Every history refresh, address refresh, connect and lock-state check rewrote the encrypted tx-history snapshot on the UI thread: a JSON document of every row, its encryption, then INSERT OR REPLACE and a prune, an estimated 40-120 ms on a 5k-transaction wallet, even when nothing had changed. Now a signature of what would be stored (about 1 ms on 5k rows) decides whether to write. Confirmations count only by the thresholds the UI reads (conflicted, pending, 1-9, 10-99, 100+); per-address scan heights, exact confirmations and the tip drift every block and form a separate part. - Routine refreshes write a content change at most every 20 s (a deferred one is released from the update loop) and the per-block drift at most every 10 min. Nothing changed means no write. - A mining-address relabel, a passphrase change, a rescan, a disconnect, a wallet lock and exit write whatever is not on disk yet, at once. - The UI thread only copies the rows and the key into a job; a single background writer (newest job wins) builds, encrypts and stores it. A failed write is retried. - Exit queues the final write so it overlaps the daemon stop, then waits up to 3 s for the writer; a disk that hangs past that keeps the previous snapshot. - A loaded snapshot counts as stored, so the first identical refresh doesn't rewrite it. An address refresh re-keys the snapshot only when the address set changed. After a hard kill the snapshot can now be up to ~20 s behind for rows and 10 min for scan heights, exact confirmations and the tip (it was one refresh); the first refresh replaces it either way. Lite builds, which never read this cache, no longer write it on Refresh or a mining relabel, and the screenshot capture mode's demo rows never reach it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
12995 lines
608 KiB
C++
12995 lines
608 KiB
C++
#ifdef _WIN32
|
||
#ifndef NOMINMAX
|
||
#define NOMINMAX
|
||
#endif
|
||
#include <windows.h>
|
||
#else
|
||
#include <signal.h>
|
||
#include <sys/wait.h>
|
||
#endif
|
||
#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/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 "rpc/connection.h"
|
||
#include "rpc/rpc_failure.h"
|
||
#include "rpc/rpc_busy_gate.h"
|
||
#include "resources/embedded_resources.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/chat_scan_policy.h"
|
||
#include "services/mining_thread_commit.h"
|
||
#include "services/network_refresh_service.h"
|
||
#include "services/refresh_scheduler.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/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/mining_pool_panel.h"
|
||
#include "ui/windows/mining_tab_helpers.h"
|
||
#include "ui/node_status_banner.h"
|
||
#include "ui/staleness_badge.h"
|
||
#include "ui/notifications.h"
|
||
#include "data/seed_migration_resume.h"
|
||
#include "util/address_validation.h"
|
||
#include "util/seed_phrase.h"
|
||
#include "daemon/daemon_startup_diagnosis.h"
|
||
#include "util/amount_format.h"
|
||
#include "util/payment_uri.h"
|
||
#include "util/platform.h"
|
||
#include "util/i18n.h"
|
||
#include "util/xmrig_updater.h"
|
||
#include "util/pool_registry.h"
|
||
#include "util/daemon_updater.h"
|
||
#include "util/lite_server_probe.h"
|
||
#include "util/wallet_file_probe.h"
|
||
#include "wallet/lite_connection_service.h"
|
||
#include "wallet/lite_diagnostics.h"
|
||
#include "wallet/lite_owned_string.h"
|
||
#include "wallet/lite_rollout_policy.h"
|
||
#include "wallet/lite_wallet_controller.h"
|
||
#include "wallet/lite_wallet_gateway.h"
|
||
#include "wallet/lite_wallet_state_mapper.h"
|
||
#include "config/settings.h"
|
||
#include "data/wallet_state.h"
|
||
#include "data/portfolio.h"
|
||
#include "data/market_series.h"
|
||
#include "data/exchange_candles.h"
|
||
#include "fake_lite_backend.h"
|
||
|
||
#include <chrono>
|
||
#include <cmath>
|
||
#include <limits>
|
||
#include <thread>
|
||
#include <atomic>
|
||
#include <cstdlib>
|
||
#include <deque>
|
||
#include <filesystem>
|
||
#include <fstream>
|
||
#include <iostream>
|
||
#include <algorithm>
|
||
#include <cstring>
|
||
#include <condition_variable>
|
||
#include <functional>
|
||
#include <future>
|
||
#include <memory>
|
||
#include <mutex>
|
||
#include <new>
|
||
#include <set>
|
||
#include <stdexcept>
|
||
#include <string>
|
||
#include <type_traits>
|
||
#include <unordered_map>
|
||
#include <unordered_set>
|
||
#include <utility>
|
||
#include <vector>
|
||
#include <sodium.h>
|
||
|
||
namespace fs = std::filesystem;
|
||
|
||
namespace {
|
||
|
||
int g_failures = 0;
|
||
|
||
void expectTrue(bool value, const char* expr, const char* file, int line)
|
||
{
|
||
if (value) return;
|
||
std::cerr << file << ":" << line << " expected true: " << expr << "\n";
|
||
++g_failures;
|
||
}
|
||
|
||
template <typename T, typename U>
|
||
void expectEqual(const T& actual, const U& expected, const char* actualExpr,
|
||
const char* expectedExpr, const char* file, int line)
|
||
{
|
||
if (actual == expected) return;
|
||
std::cerr << file << ":" << line << " expected " << actualExpr << " == "
|
||
<< expectedExpr << "\n";
|
||
++g_failures;
|
||
}
|
||
|
||
void expectNear(double actual, double expected, double epsilon, const char* file, int line)
|
||
{
|
||
if (std::abs(actual - expected) <= epsilon) return;
|
||
std::cerr << file << ":" << line << " expected near " << expected
|
||
<< " (actual=" << actual << ")\n";
|
||
++g_failures;
|
||
}
|
||
|
||
#define EXPECT_TRUE(expr) expectTrue((expr), #expr, __FILE__, __LINE__)
|
||
#define EXPECT_FALSE(expr) expectTrue(!(expr), "!(" #expr ")", __FILE__, __LINE__)
|
||
#define EXPECT_EQ(actual, expected) expectEqual((actual), (expected), #actual, #expected, __FILE__, __LINE__)
|
||
#define EXPECT_NEAR(actual, expected, epsilon) expectNear((actual), (expected), (epsilon), __FILE__, __LINE__)
|
||
|
||
fs::path makeTempDir()
|
||
{
|
||
auto now = std::chrono::steady_clock::now().time_since_epoch().count();
|
||
fs::path dir = fs::temp_directory_path() / ("obsidian_phase4_tests_" + std::to_string(now));
|
||
fs::create_directories(dir);
|
||
return dir;
|
||
}
|
||
|
||
// 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<double>(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<std::string> g_liteBridgeRuntimeTrackedFreedValues;
|
||
|
||
char* makeLiteBridgeRuntimeOwnedCString(const std::string& value)
|
||
{
|
||
char* rawValue = new char[value.size() + 1];
|
||
for (std::size_t index = 0; index < value.size(); ++index) {
|
||
rawValue[index] = value[index];
|
||
}
|
||
rawValue[value.size()] = '\0';
|
||
return rawValue;
|
||
}
|
||
|
||
void resetLiteBridgeRuntimeTrackedFree()
|
||
{
|
||
g_liteBridgeRuntimeTrackedFreeCount = 0;
|
||
g_liteBridgeRuntimeTrackedFreedValues.clear();
|
||
}
|
||
|
||
void fakeLiteBridgeRuntimeTrackedFreeString(char* value)
|
||
{
|
||
++g_liteBridgeRuntimeTrackedFreeCount;
|
||
if (value) {
|
||
g_liteBridgeRuntimeTrackedFreedValues.push_back(value);
|
||
}
|
||
delete[] value;
|
||
}
|
||
|
||
bool fakeLiteBridgeRuntimeWalletExists(const char*)
|
||
{
|
||
++g_liteBridgeRuntimeFakeCallCount;
|
||
return true;
|
||
}
|
||
|
||
char* fakeLiteBridgeRuntimeInitializeNew(bool, const char*)
|
||
{
|
||
++g_liteBridgeRuntimeFakeCallCount;
|
||
return nullptr;
|
||
}
|
||
|
||
char* fakeLiteBridgeRuntimeInitializeNewFromPhrase(bool,
|
||
const char*,
|
||
const char*,
|
||
unsigned long long,
|
||
unsigned long long,
|
||
bool)
|
||
{
|
||
++g_liteBridgeRuntimeFakeCallCount;
|
||
return nullptr;
|
||
}
|
||
|
||
char* fakeLiteBridgeRuntimeInitializeExisting(bool, const char*)
|
||
{
|
||
++g_liteBridgeRuntimeFakeCallCount;
|
||
return nullptr;
|
||
}
|
||
|
||
char* fakeLiteBridgeRuntimeExecute(const char*, const char*)
|
||
{
|
||
++g_liteBridgeRuntimeFakeCallCount;
|
||
return nullptr;
|
||
}
|
||
|
||
char* fakeLiteBridgeRuntimeOwnedExecute(const char*, const char*)
|
||
{
|
||
++g_liteBridgeRuntimeFakeCallCount;
|
||
return makeLiteBridgeRuntimeOwnedCString("bridge-runtime-ok");
|
||
}
|
||
|
||
void fakeLiteBridgeRuntimeFreeString(char*)
|
||
{
|
||
++g_liteBridgeRuntimeFakeCallCount;
|
||
}
|
||
|
||
bool fakeLiteBridgeRuntimeCheckServerOnline(const char*)
|
||
{
|
||
++g_liteBridgeRuntimeFakeCallCount;
|
||
return true;
|
||
}
|
||
|
||
void fakeLiteBridgeRuntimeShutdown()
|
||
{
|
||
++g_liteBridgeRuntimeFakeCallCount;
|
||
}
|
||
|
||
dragonx::wallet::LiteClientBridgeApi makeCompleteFakeLiteBridgeRuntimeApi()
|
||
{
|
||
return dragonx::wallet::LiteClientBridgeApi{
|
||
&fakeLiteBridgeRuntimeWalletExists,
|
||
&fakeLiteBridgeRuntimeInitializeNew,
|
||
&fakeLiteBridgeRuntimeInitializeNewFromPhrase,
|
||
&fakeLiteBridgeRuntimeInitializeExisting,
|
||
&fakeLiteBridgeRuntimeExecute,
|
||
&fakeLiteBridgeRuntimeFreeString,
|
||
&fakeLiteBridgeRuntimeCheckServerOnline,
|
||
&fakeLiteBridgeRuntimeShutdown,
|
||
};
|
||
}
|
||
|
||
class MockWalletSecurityRpc : public dragonx::services::WalletSecurityController::RpcGateway {
|
||
public:
|
||
bool encryptResult = true;
|
||
std::string encryptError;
|
||
int encryptCalls = 0;
|
||
std::string lastPassphrase;
|
||
|
||
bool encryptWallet(const std::string& passphrase, std::string& error) override
|
||
{
|
||
++encryptCalls;
|
||
lastPassphrase = passphrase;
|
||
error = encryptError;
|
||
return encryptResult;
|
||
}
|
||
|
||
bool unlockWallet(const std::string&, int, std::string&) override { return true; }
|
||
bool exportWallet(const std::string&, long, std::string&) override { return true; }
|
||
bool importWallet(const std::string&, long, std::string&) override { return true; }
|
||
};
|
||
|
||
class MockDaemonLifecycleRuntime : public dragonx::daemon::DaemonController::LifecycleRuntime {
|
||
public:
|
||
std::vector<std::string> calls;
|
||
int deletedItems = 7;
|
||
bool startResult = true;
|
||
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<dragonx::rpc::RPCWorker::WorkFn> tasks;
|
||
|
||
std::size_t pendingTaskCount() const { return reportedPending; }
|
||
void post(dragonx::rpc::RPCWorker::WorkFn work)
|
||
{
|
||
tasks.push_back(std::move(work));
|
||
}
|
||
dragonx::rpc::RPCWorker::MainCb runNext()
|
||
{
|
||
auto work = std::move(tasks.front());
|
||
tasks.erase(tasks.begin());
|
||
return work();
|
||
}
|
||
dragonx::rpc::RPCWorker::MainCb runAt(std::size_t index)
|
||
{
|
||
auto work = std::move(tasks[index]);
|
||
tasks.erase(tasks.begin() + static_cast<std::ptrdiff_t>(index));
|
||
return work();
|
||
}
|
||
};
|
||
|
||
class MockRefreshRpc : public dragonx::services::NetworkRefreshService::RefreshRpcGateway {
|
||
public:
|
||
struct RecordedCall {
|
||
std::string method;
|
||
nlohmann::json params;
|
||
};
|
||
|
||
struct Response {
|
||
nlohmann::json value;
|
||
bool throws = false;
|
||
std::string error = "mock rpc failure";
|
||
std::exception_ptr exception; // rethrown as-is when set (e.g. an RpcTransportError)
|
||
};
|
||
|
||
std::vector<RecordedCall> calls;
|
||
std::unordered_map<std::string, std::deque<Response>> responses;
|
||
|
||
void addResponse(const std::string& method, nlohmann::json value)
|
||
{
|
||
responses[method].push_back({std::move(value), false, {}});
|
||
}
|
||
|
||
void addFailure(const std::string& method, std::string error = "mock rpc failure")
|
||
{
|
||
responses[method].push_back({nlohmann::json(), true, std::move(error)});
|
||
}
|
||
|
||
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<std::string> methodNames() const
|
||
{
|
||
std::vector<std::string> names;
|
||
names.reserve(calls.size());
|
||
for (const auto& call : calls) names.push_back(call.method);
|
||
return names;
|
||
}
|
||
};
|
||
|
||
// 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, "<HTML><TITLE>503 Service Unavailable</TITLE></HTML>", 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 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 = "<HTML>\n<HEAD>\x01\x02\x7f";
|
||
while (html.size() < 300) html += "<P>junk junk</P>\n";
|
||
const std::string s = formatHttpError(502, html);
|
||
const std::string prefix = "RPC error: HTTP 502: ";
|
||
EXPECT_TRUE(s.rfind(prefix + "<HTML> <HEAD><P>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<size_t>(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<const unsigned char*>(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<kd.size();++k) w[P+490+3+k]=kd[k];
|
||
put16(P+430, (unsigned)chdValue.size()); w[P+430+2]=(char)1; // data BKEYDATA: the CHDChain value
|
||
for (std::size_t k=0;k<chdValue.size();++k) w[P+430+3+k]=chdValue[k];
|
||
return w;
|
||
};
|
||
std::string v3seed; // v3, fMnemonicSeed=1
|
||
for (int k=0;k<4;k++) v3seed += (char)((3u>>(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<dragonx::AddressInfo> addresses;
|
||
addresses.push_back({"zs-view", 50.0, "shielded", false});
|
||
addresses.push_back({"zs-low", 2.0, "shielded", true});
|
||
addresses.push_back({"R-zero", 0.0, "transparent", true});
|
||
addresses.push_back({"R-high", 5.0, "transparent", true});
|
||
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<size_t>(2));
|
||
EXPECT_EQ(indices[0], static_cast<size_t>(3));
|
||
EXPECT_EQ(indices[1], static_cast<size_t>(1));
|
||
|
||
auto includeZero = dragonx::sortedSpendableAddressIndices(addresses, false);
|
||
EXPECT_EQ(includeZero.size(), static_cast<size_t>(3));
|
||
}
|
||
|
||
// S8: deferFor() is an absolute hold, independent of the page interval (the retry-storm fix).
|
||
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));
|
||
}
|
||
}
|
||
|
||
void testRefreshScheduler()
|
||
{
|
||
using dragonx::services::RefreshScheduler;
|
||
using Timer = RefreshScheduler::Timer;
|
||
|
||
RefreshScheduler scheduler;
|
||
scheduler.applyPage(dragonx::ui::NavPage::Overview);
|
||
EXPECT_NEAR(scheduler.intervals().core, 2.0, 0.0001);
|
||
// Peers now polls on every tab (slow cadence) so the status-bar peer count stays current.
|
||
EXPECT_NEAR(scheduler.intervals().peers, 20.0, 0.0001);
|
||
|
||
scheduler.tick(1.99f);
|
||
EXPECT_FALSE(scheduler.isDue(Timer::Core));
|
||
scheduler.tick(0.02f);
|
||
EXPECT_TRUE(scheduler.consumeDue(Timer::Core));
|
||
EXPECT_FALSE(scheduler.isDue(Timer::Core));
|
||
|
||
scheduler.markWalletMutationRefresh();
|
||
EXPECT_TRUE(scheduler.isDue(Timer::Core));
|
||
EXPECT_TRUE(scheduler.isDue(Timer::Transactions));
|
||
EXPECT_TRUE(scheduler.isDue(Timer::Addresses));
|
||
EXPECT_FALSE(scheduler.isDue(Timer::Peers));
|
||
|
||
scheduler.applyPage(dragonx::ui::NavPage::Peers);
|
||
scheduler.markDue(Timer::Peers);
|
||
EXPECT_TRUE(scheduler.consumeDue(Timer::Peers));
|
||
|
||
scheduler.applyPage(dragonx::ui::NavPage::Console);
|
||
EXPECT_NEAR(scheduler.intervals().core, 10.0, 0.0001);
|
||
EXPECT_NEAR(scheduler.intervals().transactions, 30.0, 0.0001);
|
||
EXPECT_NEAR(scheduler.intervals().addresses, 30.0, 0.0001);
|
||
EXPECT_NEAR(scheduler.intervals().peers, 30.0, 0.0001);
|
||
|
||
EXPECT_FALSE(scheduler.isDue(Timer::Price));
|
||
scheduler.markDue(Timer::Price);
|
||
EXPECT_TRUE(scheduler.consumeDue(Timer::Price));
|
||
|
||
EXPECT_FALSE(scheduler.shouldRefreshTransactions(100, 100, false));
|
||
EXPECT_TRUE(scheduler.shouldRefreshTransactions(-1, 100, false));
|
||
EXPECT_TRUE(scheduler.shouldRefreshTransactions(99, 100, false));
|
||
EXPECT_TRUE(scheduler.shouldRefreshTransactions(100, 100, true));
|
||
scheduler.tick(RefreshScheduler::kTxMaxAge);
|
||
EXPECT_FALSE(scheduler.shouldRefreshTransactions(100, 100, false));
|
||
EXPECT_TRUE(scheduler.isDue(Timer::TxAge));
|
||
}
|
||
|
||
void testNetworkRefreshService()
|
||
{
|
||
using dragonx::services::NetworkRefreshService;
|
||
using Job = NetworkRefreshService::Job;
|
||
using Timer = NetworkRefreshService::Timer;
|
||
|
||
NetworkRefreshService service;
|
||
service.applyPage(dragonx::ui::NavPage::Peers);
|
||
service.markImmediateRefresh();
|
||
EXPECT_TRUE(service.consumeDue(Timer::Core));
|
||
EXPECT_TRUE(service.consumeDue(Timer::Transactions));
|
||
EXPECT_TRUE(service.consumeDue(Timer::Addresses));
|
||
EXPECT_TRUE(service.consumeDue(Timer::Peers));
|
||
|
||
EXPECT_TRUE(service.beginJob(Job::Core));
|
||
EXPECT_TRUE(service.jobInProgress(Job::Core));
|
||
EXPECT_FALSE(service.beginJob(Job::Core));
|
||
service.finishJob(Job::Core);
|
||
EXPECT_FALSE(service.jobInProgress(Job::Core));
|
||
EXPECT_TRUE(service.beginJob(Job::Core));
|
||
service.resetJobs();
|
||
EXPECT_FALSE(service.jobInProgress(Job::Core));
|
||
EXPECT_TRUE(service.beginJob(Job::ConnectionInit));
|
||
EXPECT_TRUE(service.jobInProgress(Job::ConnectionInit));
|
||
service.finishJob(Job::ConnectionInit);
|
||
EXPECT_FALSE(service.jobInProgress(Job::ConnectionInit));
|
||
|
||
auto cancelOld = service.beginDispatch(Job::Core);
|
||
EXPECT_TRUE(cancelOld.accepted);
|
||
service.resetJobs();
|
||
auto cancelNew = service.beginDispatch(Job::Core);
|
||
EXPECT_TRUE(cancelNew.accepted);
|
||
service.cancelDispatch(cancelOld);
|
||
service.cancelDispatch(cancelNew);
|
||
EXPECT_EQ(service.stats(Job::Core).staleCallbacks, static_cast<uint64_t>(1));
|
||
|
||
auto ticket = service.beginDispatch(Job::Addresses, 1, 4);
|
||
EXPECT_TRUE(ticket.accepted);
|
||
auto busyTicket = service.beginDispatch(Job::Addresses, 1, 4);
|
||
EXPECT_FALSE(busyTicket.accepted);
|
||
auto stats = service.stats(Job::Addresses);
|
||
EXPECT_EQ(stats.started, static_cast<uint64_t>(1));
|
||
EXPECT_EQ(stats.skippedInFlight, static_cast<uint64_t>(1));
|
||
EXPECT_EQ(stats.lastQueueDepth, static_cast<size_t>(1));
|
||
EXPECT_TRUE(service.completeDispatch(ticket));
|
||
stats = service.stats(Job::Addresses);
|
||
EXPECT_EQ(stats.finished, static_cast<uint64_t>(1));
|
||
|
||
auto pressureTicket = service.beginDispatch(Job::Mining, 3, 3);
|
||
EXPECT_FALSE(pressureTicket.accepted);
|
||
stats = service.stats(Job::Mining);
|
||
EXPECT_EQ(stats.skippedQueuePressure, static_cast<uint64_t>(1));
|
||
EXPECT_EQ(stats.lastQueueDepth, static_cast<size_t>(3));
|
||
|
||
auto staleTicket = service.beginDispatch(Job::Peers);
|
||
EXPECT_TRUE(staleTicket.accepted);
|
||
service.resetJobs();
|
||
auto newerTicket = service.beginDispatch(Job::Peers);
|
||
EXPECT_TRUE(newerTicket.accepted);
|
||
EXPECT_FALSE(service.completeDispatch(staleTicket));
|
||
service.cancelDispatch(newerTicket);
|
||
stats = service.stats(Job::Peers);
|
||
EXPECT_EQ(stats.started, static_cast<uint64_t>(2));
|
||
EXPECT_EQ(stats.staleCallbacks, static_cast<uint64_t>(1));
|
||
|
||
FakeRefreshWorker worker;
|
||
int workerRuns = 0;
|
||
int mainRuns = 0;
|
||
auto enqueued = service.enqueue(Job::Price, worker, [&]() -> dragonx::rpc::RPCWorker::MainCb {
|
||
++workerRuns;
|
||
return [&]() { ++mainRuns; };
|
||
}, 3);
|
||
EXPECT_TRUE(enqueued.enqueued);
|
||
EXPECT_TRUE(service.jobInProgress(Job::Price));
|
||
EXPECT_EQ(worker.tasks.size(), static_cast<size_t>(1));
|
||
auto mainCallback = worker.runNext();
|
||
EXPECT_EQ(workerRuns, 1);
|
||
mainCallback();
|
||
EXPECT_EQ(mainRuns, 1);
|
||
EXPECT_FALSE(service.jobInProgress(Job::Price));
|
||
stats = service.stats(Job::Price);
|
||
EXPECT_EQ(stats.finished, static_cast<uint64_t>(1));
|
||
|
||
auto nullMainEnqueued = service.enqueue(Job::Price, worker, []() -> dragonx::rpc::RPCWorker::MainCb {
|
||
return nullptr;
|
||
}, 3);
|
||
EXPECT_TRUE(nullMainEnqueued.enqueued);
|
||
EXPECT_TRUE(service.jobInProgress(Job::Price));
|
||
auto nullMainCallback = worker.runNext();
|
||
EXPECT_TRUE(static_cast<bool>(nullMainCallback));
|
||
nullMainCallback();
|
||
EXPECT_FALSE(service.jobInProgress(Job::Price));
|
||
stats = service.stats(Job::Price);
|
||
EXPECT_EQ(stats.finished, static_cast<uint64_t>(2));
|
||
|
||
worker.reportedPending = 5;
|
||
auto unboundedPrice = service.enqueue(Job::Price, worker, []() -> dragonx::rpc::RPCWorker::MainCb {
|
||
return []() {};
|
||
}, 0);
|
||
EXPECT_TRUE(unboundedPrice.enqueued);
|
||
EXPECT_EQ(unboundedPrice.queueDepth, static_cast<size_t>(5));
|
||
auto unboundedPriceMain = worker.runNext();
|
||
unboundedPriceMain();
|
||
EXPECT_FALSE(service.jobInProgress(Job::Price));
|
||
|
||
worker.reportedPending = 3;
|
||
auto pressured = service.enqueue(Job::Mining, worker, []() -> dragonx::rpc::RPCWorker::MainCb {
|
||
return []() {};
|
||
}, 3);
|
||
EXPECT_FALSE(pressured.enqueued);
|
||
EXPECT_EQ(service.stats(Job::Mining).skippedQueuePressure, static_cast<uint64_t>(2));
|
||
|
||
FakeRefreshWorker staleWorker;
|
||
int staleMainRuns = 0;
|
||
auto first = service.enqueue(Job::Encryption, staleWorker, [&]() -> dragonx::rpc::RPCWorker::MainCb {
|
||
return [&]() { ++staleMainRuns; };
|
||
}, 3);
|
||
EXPECT_TRUE(first.enqueued);
|
||
auto oldCallback = staleWorker.runNext();
|
||
service.resetJobs();
|
||
auto second = service.enqueue(Job::Encryption, staleWorker, [&]() -> dragonx::rpc::RPCWorker::MainCb {
|
||
return [&]() { ++staleMainRuns; };
|
||
}, 3);
|
||
EXPECT_TRUE(second.enqueued);
|
||
oldCallback();
|
||
EXPECT_EQ(staleMainRuns, 0);
|
||
auto freshCallback = staleWorker.runNext();
|
||
freshCallback();
|
||
EXPECT_EQ(staleMainRuns, 1);
|
||
EXPECT_EQ(service.stats(Job::Encryption).staleCallbacks, static_cast<uint64_t>(1));
|
||
|
||
FakeRefreshWorker orderingWorker;
|
||
std::vector<std::string> callbackOrder;
|
||
auto orderedCore = service.enqueue(Job::Core, orderingWorker, [&]() -> dragonx::rpc::RPCWorker::MainCb {
|
||
callbackOrder.push_back("core-work");
|
||
return [&]() { callbackOrder.push_back("core-main"); };
|
||
}, 3);
|
||
auto orderedPrice = service.enqueue(Job::Price, orderingWorker, [&]() -> dragonx::rpc::RPCWorker::MainCb {
|
||
callbackOrder.push_back("price-work");
|
||
return [&]() { callbackOrder.push_back("price-main"); };
|
||
}, 3);
|
||
EXPECT_TRUE(orderedCore.enqueued);
|
||
EXPECT_TRUE(orderedPrice.enqueued);
|
||
auto priceMain = orderingWorker.runAt(1);
|
||
auto coreMain = orderingWorker.runAt(0);
|
||
priceMain();
|
||
coreMain();
|
||
EXPECT_TRUE(callbackOrder == std::vector<std::string>({"price-work", "core-work", "price-main", "core-main"}));
|
||
EXPECT_FALSE(service.jobInProgress(Job::Core));
|
||
EXPECT_FALSE(service.jobInProgress(Job::Price));
|
||
|
||
FakeRefreshWorker reconnectWorker;
|
||
int reconnectMainRuns = 0;
|
||
auto staleBefore = service.stats(Job::Transactions).staleCallbacks;
|
||
auto oldTransactions = service.enqueue(Job::Transactions, reconnectWorker, [&]() -> dragonx::rpc::RPCWorker::MainCb {
|
||
return [&]() { ++reconnectMainRuns; };
|
||
}, 3);
|
||
EXPECT_TRUE(oldTransactions.enqueued);
|
||
auto oldTransactionsMain = reconnectWorker.runNext();
|
||
service.resetJobs();
|
||
auto freshTransactions = service.enqueue(Job::Transactions, reconnectWorker, [&]() -> dragonx::rpc::RPCWorker::MainCb {
|
||
return [&]() { ++reconnectMainRuns; };
|
||
}, 3);
|
||
EXPECT_TRUE(freshTransactions.enqueued);
|
||
auto freshTransactionsMain = reconnectWorker.runNext();
|
||
freshTransactionsMain();
|
||
oldTransactionsMain();
|
||
EXPECT_EQ(reconnectMainRuns, 1);
|
||
EXPECT_EQ(service.stats(Job::Transactions).staleCallbacks, staleBefore + 1);
|
||
EXPECT_FALSE(service.jobInProgress(Job::Transactions));
|
||
}
|
||
|
||
void testNetworkRefreshSnapshotHelpers()
|
||
{
|
||
using Refresh = dragonx::services::NetworkRefreshService;
|
||
using nlohmann::json;
|
||
|
||
auto shielded = Refresh::buildShieldedAddressInfo("zs-viewonly", json{{"ismine", false}}, true);
|
||
EXPECT_EQ(shielded.address, std::string("zs-viewonly"));
|
||
EXPECT_EQ(shielded.type, std::string("shielded"));
|
||
EXPECT_FALSE(shielded.has_spending_key);
|
||
|
||
auto legacyShielded = Refresh::buildShieldedAddressInfo("zs-legacy", json::object(), false);
|
||
EXPECT_TRUE(legacyShielded.has_spending_key);
|
||
|
||
auto transparentAddresses = Refresh::parseTransparentAddressList(json::array({"R-one", "R-two"}));
|
||
EXPECT_EQ(transparentAddresses.size(), static_cast<size_t>(2));
|
||
EXPECT_EQ(transparentAddresses[0].type, std::string("transparent"));
|
||
|
||
std::vector<dragonx::AddressInfo> balanceAddresses = {shielded, legacyShielded};
|
||
Refresh::applyShieldedBalancesFromUnspent(balanceAddresses, json::array({
|
||
json{{"address", "zs-viewonly"}, {"amount", 1.25}},
|
||
json{{"address", "zs-viewonly"}, {"amount", 0.75}},
|
||
json{{"address", "zs-legacy"}, {"amount", 3.0}}
|
||
}));
|
||
EXPECT_NEAR(balanceAddresses[0].balance, 2.0, 0.00000001);
|
||
EXPECT_NEAR(balanceAddresses[1].balance, 3.0, 0.00000001);
|
||
|
||
std::vector<dragonx::TransactionInfo> transactions;
|
||
std::set<std::string> knownTxids;
|
||
Refresh::appendTransparentTransactions(transactions, knownTxids, json::array({
|
||
json{{"txid", "transparent-a"}, {"category", "receive"}, {"amount", 1.5},
|
||
{"timereceived", 100}, {"confirmations", 2}, {"address", "R-one"}},
|
||
json{{"txid", "transparent-b"}, {"category", "send"}, {"amount", -0.25},
|
||
{"time", 300}, {"confirmations", 12}}
|
||
}));
|
||
EXPECT_EQ(transactions.size(), static_cast<size_t>(2));
|
||
EXPECT_EQ(knownTxids.count("transparent-a"), static_cast<size_t>(1));
|
||
EXPECT_EQ(transactions[0].timestamp, static_cast<int64_t>(100));
|
||
EXPECT_EQ(transactions[1].timestamp, static_cast<int64_t>(300));
|
||
|
||
Refresh::appendShieldedReceivedTransactions(transactions, knownTxids, "zs-viewonly", json::array({
|
||
json{{"txid", "transparent-a"}, {"amount", 9.0}, {"confirmations", 1}},
|
||
json{{"txid", "shielded-change"}, {"change", true}, {"amount", 2.0}},
|
||
json{{"txid", "shielded-receive"}, {"amount", 4.0}, {"confirmations", 5},
|
||
{"time", 200}, {"memoStr", "hello"}}
|
||
}));
|
||
EXPECT_EQ(transactions.size(), static_cast<size_t>(3));
|
||
EXPECT_EQ(knownTxids.count("shielded-receive"), static_cast<size_t>(1));
|
||
EXPECT_EQ(transactions.back().address, std::string("zs-viewonly"));
|
||
EXPECT_EQ(transactions.back().memo, std::string("hello"));
|
||
|
||
Refresh::sortTransactionsNewestFirst(transactions);
|
||
EXPECT_EQ(transactions.front().txid, std::string("transparent-b"));
|
||
EXPECT_EQ(transactions.back().txid, std::string("transparent-a"));
|
||
|
||
dragonx::WalletState state;
|
||
state.z_addresses.push_back(shielded);
|
||
dragonx::AddressInfo emptyShielded;
|
||
emptyShielded.type = "shielded";
|
||
state.z_addresses.push_back(emptyShielded);
|
||
dragonx::TransactionInfo oldTransaction;
|
||
oldTransaction.txid = "old-complete";
|
||
oldTransaction.confirmations = 7;
|
||
oldTransaction.timestamp = 1000;
|
||
state.transactions.push_back(oldTransaction);
|
||
|
||
Refresh::TransactionViewCache viewCache;
|
||
viewCache["cached-view"].from_address = "zs-from";
|
||
std::unordered_set<std::string> sendTxids{"pending-send"};
|
||
auto snapshot = Refresh::buildTransactionRefreshSnapshot(state, viewCache, sendTxids);
|
||
EXPECT_EQ(snapshot.shieldedAddresses.size(), static_cast<size_t>(1));
|
||
EXPECT_EQ(snapshot.shieldedAddresses[0], std::string("zs-viewonly"));
|
||
EXPECT_EQ(snapshot.fullyEnrichedTxids.count("cached-view"), static_cast<size_t>(1));
|
||
EXPECT_EQ(snapshot.fullyEnrichedTxids.count("old-complete"), static_cast<size_t>(1));
|
||
EXPECT_EQ(snapshot.viewTxCache.size(), static_cast<size_t>(1));
|
||
EXPECT_EQ(snapshot.sendTxids.count("pending-send"), static_cast<size_t>(1));
|
||
}
|
||
|
||
void testParseDaemonRescanOutput()
|
||
{
|
||
using dragonx::services::NetworkRefreshService;
|
||
|
||
// "Still rescanning ... Progress=<frac>" — 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... <n>%" — 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 "<digits>ms".
|
||
{
|
||
auto scan = NetworkRefreshService::parseDaemonRescanOutput(
|
||
" rescan 16760577ms\n");
|
||
EXPECT_TRUE(scan.finished);
|
||
}
|
||
|
||
// "Building Witnesses for block <h> <frac> complete, <n> 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: <done> / <total>" — 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 <hash>, <i> of <N>" — 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<size_t>(3));
|
||
std::set<std::string> distinct(scan.witnessTxids.begin(), scan.witnessTxids.end());
|
||
EXPECT_EQ(distinct.size(), static_cast<size_t>(2));
|
||
EXPECT_EQ(distinct.count("abc123"), static_cast<size_t>(1));
|
||
EXPECT_EQ(distinct.count("def456"), static_cast<size_t>(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<std::string>({"getinfo"}));
|
||
EXPECT_EQ(warmupRpc.calls[0].params, json::array());
|
||
EXPECT_TRUE(warmup.ready);
|
||
EXPECT_TRUE(warmup.info.ok);
|
||
EXPECT_EQ(*warmup.info.blocks, 12);
|
||
EXPECT_EQ(*warmup.info.longestChain, 15);
|
||
EXPECT_EQ(warmup.errorMessage, std::string());
|
||
|
||
MockRefreshRpc warmupFailureRpc;
|
||
warmupFailureRpc.addFailure("getinfo", "Loading block index");
|
||
auto warmupFailure = Refresh::collectWarmupPollResult(warmupFailureRpc);
|
||
EXPECT_TRUE(warmupFailureRpc.methodNames() == std::vector<std::string>({"getinfo"}));
|
||
EXPECT_EQ(warmupFailureRpc.calls[0].params, json::array());
|
||
EXPECT_FALSE(warmupFailure.ready);
|
||
EXPECT_FALSE(warmupFailure.info.ok);
|
||
EXPECT_EQ(warmupFailure.errorMessage, std::string("Loading block index"));
|
||
|
||
MockRefreshRpc connectionRpc;
|
||
connectionRpc.addResponse("getinfo", json{
|
||
{"version", 120001},
|
||
{"protocolversion", 170003},
|
||
{"p2pport", 21769},
|
||
{"longestchain", 250},
|
||
{"blocks", 240},
|
||
{"notarized", 230}
|
||
});
|
||
connectionRpc.addResponse("getwalletinfo", json{{"unlocked_until", 0}});
|
||
auto connection = Refresh::collectConnectionInitResult(connectionRpc);
|
||
EXPECT_TRUE(connectionRpc.methodNames() == std::vector<std::string>({
|
||
"getinfo", "getwalletinfo"
|
||
}));
|
||
EXPECT_EQ(connectionRpc.calls[0].params, json::array());
|
||
EXPECT_EQ(connectionRpc.calls[1].params, json::array());
|
||
EXPECT_TRUE(connection.info.ok);
|
||
EXPECT_EQ(*connection.info.daemonVersion, 120001);
|
||
EXPECT_TRUE(connection.encryption.ok);
|
||
EXPECT_TRUE(connection.encryption.encrypted);
|
||
EXPECT_EQ(connection.encryption.unlockedUntil, static_cast<int64_t>(0));
|
||
|
||
MockRefreshRpc connectionPrefetchRpc;
|
||
connectionPrefetchRpc.addFailure("getinfo", "daemon info unavailable");
|
||
connectionPrefetchRpc.addResponse("getwalletinfo", json{{"unlocked_until", 99}});
|
||
auto prefetch = Refresh::collectConnectionInitResult(connectionPrefetchRpc);
|
||
EXPECT_TRUE(connectionPrefetchRpc.methodNames() == std::vector<std::string>({
|
||
"getinfo", "getwalletinfo"
|
||
}));
|
||
EXPECT_FALSE(prefetch.info.ok);
|
||
EXPECT_TRUE(prefetch.encryption.ok);
|
||
EXPECT_TRUE(prefetch.encryption.encrypted);
|
||
EXPECT_EQ(prefetch.encryption.unlockedUntil, static_cast<int64_t>(99));
|
||
|
||
MockRefreshRpc connectionReuseRpc;
|
||
connectionReuseRpc.addResponse("getwalletinfo", json{{"unlocked_until", 0}});
|
||
auto reusedInfo = Refresh::parseConnectionInfoResult(json{{"version", 120002}, {"blocks", 241}});
|
||
auto reusedConnection = Refresh::collectConnectionInitResult(connectionReuseRpc, reusedInfo);
|
||
EXPECT_TRUE(connectionReuseRpc.methodNames() == std::vector<std::string>({"getwalletinfo"}));
|
||
EXPECT_TRUE(reusedConnection.info.ok);
|
||
EXPECT_EQ(*reusedConnection.info.daemonVersion, 120002);
|
||
EXPECT_TRUE(reusedConnection.encryption.ok);
|
||
|
||
MockRefreshRpc coreRpc;
|
||
coreRpc.addResponse("z_gettotalbalance", json{
|
||
{"private", "3.00000000"},
|
||
{"transparent", "1.25000000"},
|
||
{"total", "4.25000000"}
|
||
});
|
||
coreRpc.addResponse("getblockchaininfo", json{
|
||
{"blocks", 150},
|
||
{"headers", 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<std::string>({
|
||
"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<std::string>({"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<std::string>({
|
||
"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<std::string>({"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<std::string>({
|
||
"getpeerinfo", "listbanned"
|
||
}));
|
||
EXPECT_EQ(peerRpc.calls[0].params, json::array());
|
||
EXPECT_EQ(peerRpc.calls[1].params, json::array());
|
||
EXPECT_EQ(peers.peers.size(), static_cast<size_t>(1));
|
||
EXPECT_EQ(peers.peers[0].id, 42);
|
||
EXPECT_TRUE(peers.peers[0].tls_verified);
|
||
EXPECT_EQ(peers.bannedPeers.size(), static_cast<size_t>(1));
|
||
EXPECT_EQ(peers.bannedPeers[0].address, std::string("198.51.100.9"));
|
||
|
||
MockRefreshRpc peerPartialRpc;
|
||
peerPartialRpc.addFailure("getpeerinfo", "peer table unavailable");
|
||
peerPartialRpc.addResponse("listbanned", json::array({
|
||
json{{"address", "203.0.113.8"}, {"banned_until", 5555}}
|
||
}));
|
||
auto partialPeers = Refresh::collectPeerRefreshResult(peerPartialRpc);
|
||
EXPECT_TRUE(peerPartialRpc.methodNames() == std::vector<std::string>({
|
||
"getpeerinfo", "listbanned"
|
||
}));
|
||
EXPECT_EQ(partialPeers.peers.size(), static_cast<size_t>(0));
|
||
EXPECT_EQ(partialPeers.bannedPeers.size(), static_cast<size_t>(1));
|
||
|
||
MockRefreshRpc miningSlowRpc;
|
||
miningSlowRpc.addResponse("getlocalsolps", json(72.5));
|
||
miningSlowRpc.addResponse("getmininginfo", json{
|
||
{"generate", true},
|
||
{"genproclimit", 6},
|
||
{"blocks", 321},
|
||
{"difficulty", 19.75},
|
||
{"networkhashps", 1250.0},
|
||
{"chain", "main"}
|
||
});
|
||
auto miningSlow = Refresh::collectMiningRefreshResult(miningSlowRpc, 128.0, true);
|
||
EXPECT_TRUE(miningSlowRpc.methodNames() == std::vector<std::string>({
|
||
"getlocalsolps", "getmininginfo"
|
||
}));
|
||
EXPECT_EQ(miningSlowRpc.calls[0].params, json::array());
|
||
EXPECT_EQ(miningSlowRpc.calls[1].params, json::array());
|
||
EXPECT_NEAR(*miningSlow.localHashrate, 72.5, 0.00000001);
|
||
EXPECT_TRUE(miningSlow.miningOk);
|
||
EXPECT_TRUE(*miningSlow.generate);
|
||
EXPECT_EQ(*miningSlow.genproclimit, 6);
|
||
EXPECT_NEAR(miningSlow.daemonMemoryMb, 128.0, 0.00000001);
|
||
|
||
MockRefreshRpc miningFastRpc;
|
||
miningFastRpc.addResponse("getlocalsolps", json(80.25));
|
||
auto miningFast = Refresh::collectMiningRefreshResult(miningFastRpc, 64.0, false);
|
||
EXPECT_TRUE(miningFastRpc.methodNames() == std::vector<std::string>({
|
||
"getlocalsolps"
|
||
}));
|
||
EXPECT_NEAR(*miningFast.localHashrate, 80.25, 0.00000001);
|
||
EXPECT_FALSE(miningFast.miningOk);
|
||
EXPECT_NEAR(miningFast.daemonMemoryMb, 64.0, 0.00000001);
|
||
|
||
MockRefreshRpc miningPartialRpc;
|
||
miningPartialRpc.addFailure("getlocalsolps", "local solps unavailable");
|
||
miningPartialRpc.addResponse("getmininginfo", json{{"generate", false}, {"blocks", 7}});
|
||
auto miningPartial = Refresh::collectMiningRefreshResult(miningPartialRpc, 12.0, true);
|
||
EXPECT_TRUE(miningPartialRpc.methodNames() == std::vector<std::string>({
|
||
"getlocalsolps", "getmininginfo"
|
||
}));
|
||
EXPECT_FALSE(miningPartial.localHashrate.has_value());
|
||
EXPECT_TRUE(miningPartial.miningOk);
|
||
EXPECT_FALSE(*miningPartial.generate);
|
||
|
||
MockRefreshRpc miningSlowOnlyRpc;
|
||
miningSlowOnlyRpc.addResponse("getmininginfo", json{{"generate", false}, {"networkhashps", 222.0}});
|
||
auto miningSlowOnly = Refresh::collectMiningRefreshResult(miningSlowOnlyRpc, 7.0, true, false);
|
||
EXPECT_TRUE(miningSlowOnlyRpc.methodNames() == std::vector<std::string>({"getmininginfo"}));
|
||
EXPECT_FALSE(miningSlowOnly.localHashrate.has_value());
|
||
EXPECT_TRUE(miningSlowOnly.miningOk);
|
||
EXPECT_NEAR(*miningSlowOnly.networkHashrate, 222.0, 0.00000001);
|
||
|
||
MockRefreshRpc addressRpc;
|
||
addressRpc.addResponse("z_listaddresses", json::array({"zs-one", "zs-two"}));
|
||
addressRpc.addResponse("z_validateaddress", json{{"ismine", true}});
|
||
addressRpc.addResponse("z_validateaddress", json{{"ismine", false}});
|
||
addressRpc.addResponse("z_listunspent", json::array({
|
||
json{{"address", "zs-one"}, {"amount", 1.0}},
|
||
json{{"address", "zs-one"}, {"amount", 0.5}}
|
||
}));
|
||
addressRpc.addResponse("getaddressesbyaccount", json::array({"R-one"}));
|
||
addressRpc.addResponse("listunspent", json::array({
|
||
json{{"address", "R-one"}, {"amount", 2.25}}
|
||
}));
|
||
|
||
auto addresses = Refresh::collectAddressRefreshResult(addressRpc);
|
||
EXPECT_TRUE(addressRpc.methodNames() == std::vector<std::string>({
|
||
"z_listaddresses", "z_validateaddress", "z_validateaddress",
|
||
"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<size_t>(2));
|
||
EXPECT_TRUE(addresses.shieldedAddresses[0].has_spending_key);
|
||
EXPECT_FALSE(addresses.shieldedAddresses[1].has_spending_key);
|
||
EXPECT_NEAR(addresses.shieldedAddresses[0].balance, 1.5, 0.00000001);
|
||
EXPECT_EQ(addresses.transparentAddresses.size(), static_cast<size_t>(1));
|
||
EXPECT_NEAR(addresses.transparentAddresses[0].balance, 2.25, 0.00000001);
|
||
|
||
MockRefreshRpc fallbackRpc;
|
||
fallbackRpc.addResponse("z_listaddresses", json::array({"zs-fallback"}));
|
||
fallbackRpc.addFailure("z_validateaddress", "legacy daemon");
|
||
fallbackRpc.addFailure("z_listunspent", "method not found");
|
||
fallbackRpc.addResponse("z_getbalance", json(4.75));
|
||
fallbackRpc.addResponse("getaddressesbyaccount", json::array());
|
||
fallbackRpc.addResponse("listunspent", json::array());
|
||
auto fallbackAddresses = Refresh::collectAddressRefreshResult(fallbackRpc);
|
||
EXPECT_TRUE(fallbackRpc.methodNames() == std::vector<std::string>({
|
||
"z_listaddresses", "z_validateaddress", "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<std::string>({
|
||
"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<std::string>({
|
||
"listtransactions", "z_listreceivedbyaddress", "z_listreceivedbyaddress",
|
||
"z_listsentbyaddress", "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}));
|
||
EXPECT_EQ(transactionRpc.calls[4].params, json::array({"pending-send"}));
|
||
EXPECT_EQ(transactionRpc.calls[5].params, json::array({"pending-send"}));
|
||
EXPECT_EQ(transactionResult.blockHeight, 321);
|
||
EXPECT_EQ(transactionResult.newViewTxEntries.size(), static_cast<size_t>(1));
|
||
EXPECT_EQ(transactionResult.newViewTxEntries.count("pending-send"), static_cast<size_t>(1));
|
||
EXPECT_EQ(transactionResult.newViewTxEntries.at("pending-send").timestamp, static_cast<int64_t>(500));
|
||
EXPECT_EQ(transactionResult.newViewTxEntries.at("pending-send").confirmations, 1);
|
||
EXPECT_EQ(transactionResult.transactions.size(), static_cast<size_t>(4));
|
||
EXPECT_EQ(transactionResult.transactions.front().txid, std::string("pending-send"));
|
||
EXPECT_EQ(transactionResult.transactions.front().type, std::string("send"));
|
||
EXPECT_NEAR(transactionResult.transactions.front().amount, -0.40, 0.00000001);
|
||
EXPECT_EQ(transactionResult.transactions.front().timestamp, static_cast<int64_t>(500));
|
||
EXPECT_EQ(transactionResult.transactions[1].txid, std::string("transparent-a"));
|
||
|
||
// 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<std::string>({
|
||
"listtransactions", "z_listreceivedbyaddress", "z_listsentbyaddress",
|
||
"z_viewtransaction", "gettransaction"
|
||
}));
|
||
EXPECT_EQ(zzResult.transactions.size(), static_cast<size_t>(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<int64_t>(800));
|
||
EXPECT_EQ(zzResult.newViewTxEntries.count("zz-send"), static_cast<size_t>(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<std::string>({"listtransactions"}));
|
||
EXPECT_EQ(noZResult.transactions.size(), static_cast<size_t>(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<size_t>(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<std::string>({"listtransactions"}));
|
||
EXPECT_EQ(cachedOnlyResult.transactions.size(), static_cast<size_t>(1));
|
||
EXPECT_EQ(cachedOnlyResult.transactions[0].txid, std::string("cached-only-send"));
|
||
EXPECT_EQ(cachedOnlyResult.transactions[0].type, std::string("send"));
|
||
EXPECT_NEAR(cachedOnlyResult.transactions[0].amount, -0.20, 0.00000001);
|
||
EXPECT_EQ(cachedOnlyResult.transactions[0].timestamp, static_cast<int64_t>(450));
|
||
EXPECT_EQ(cachedOnlyResult.transactions[0].confirmations, 6);
|
||
|
||
Refresh::TransactionRefreshSnapshot retrySnapshot;
|
||
retrySnapshot.sendTxids = {"retry-send"};
|
||
retrySnapshot.viewTxCache["retry-send"] = Refresh::TransactionViewCacheEntry{};
|
||
retrySnapshot.fullyEnrichedTxids = {"retry-send"};
|
||
MockRefreshRpc retryRpc;
|
||
retryRpc.addResponse("listtransactions", json::array());
|
||
retryRpc.addResponse("z_viewtransaction", json{
|
||
{"spends", json::array({json{{"address", "zs-retry-from"}}})},
|
||
{"outputs", json::array({
|
||
json{{"outgoing", true}, {"address", "zs-retry-dest"}, {"value", 0.55}}
|
||
})}
|
||
});
|
||
retryRpc.addResponse("gettransaction", json{{"time", 650}, {"confirmations", 2}});
|
||
auto retryResult = Refresh::collectTransactionRefreshResult(retryRpc, retrySnapshot, 323, 4);
|
||
EXPECT_TRUE(retryRpc.methodNames() == std::vector<std::string>({
|
||
"listtransactions", "z_viewtransaction", "gettransaction"
|
||
}));
|
||
EXPECT_EQ(retryResult.transactions.size(), static_cast<size_t>(1));
|
||
EXPECT_EQ(retryResult.transactions[0].txid, std::string("retry-send"));
|
||
EXPECT_NEAR(retryResult.transactions[0].amount, -0.55, 0.00000001);
|
||
EXPECT_EQ(retryResult.newViewTxEntries.count("retry-send"), static_cast<size_t>(1));
|
||
EXPECT_EQ(retryResult.newViewTxEntries.at("retry-send").timestamp, static_cast<int64_t>(650));
|
||
EXPECT_EQ(retryResult.newViewTxEntries.at("retry-send").confirmations, 2);
|
||
|
||
Refresh::TransactionRefreshSnapshot placeholderSnapshot;
|
||
placeholderSnapshot.sendTxids = {"placeholder-send"};
|
||
MockRefreshRpc placeholderRpc;
|
||
placeholderRpc.addResponse("listtransactions", json::array());
|
||
placeholderRpc.addResponse("z_viewtransaction", json{{"spends", json::array()}, {"outputs", json::array()}});
|
||
placeholderRpc.addResponse("gettransaction", json{
|
||
{"time", 700},
|
||
{"confirmations", 0},
|
||
{"amount", -0.33},
|
||
{"details", json::array({
|
||
json{{"category", "send"}, {"address", "zs-placeholder-dest"}, {"amount", -0.33}}
|
||
})}
|
||
});
|
||
auto placeholderResult = Refresh::collectTransactionRefreshResult(placeholderRpc, placeholderSnapshot, 324, 4);
|
||
EXPECT_TRUE(placeholderRpc.methodNames() == std::vector<std::string>({
|
||
"listtransactions", "z_viewtransaction", "gettransaction"
|
||
}));
|
||
EXPECT_EQ(placeholderResult.transactions.size(), static_cast<size_t>(1));
|
||
EXPECT_EQ(placeholderResult.transactions[0].txid, std::string("placeholder-send"));
|
||
EXPECT_EQ(placeholderResult.transactions[0].type, std::string("send"));
|
||
EXPECT_NEAR(placeholderResult.transactions[0].amount, -0.33, 0.00000001);
|
||
EXPECT_EQ(placeholderResult.transactions[0].timestamp, static_cast<int64_t>(700));
|
||
EXPECT_EQ(placeholderResult.transactions[0].confirmations, 0);
|
||
EXPECT_EQ(placeholderResult.transactions[0].address, std::string("zs-placeholder-dest"));
|
||
EXPECT_EQ(placeholderResult.newViewTxEntries.count("placeholder-send"), static_cast<size_t>(0));
|
||
|
||
Refresh::TransactionRefreshSnapshot missingAddressesSnapshot;
|
||
missingAddressesSnapshot.previousTransactions.push_back(dragonx::TransactionInfo{
|
||
"shielded-fallback", "receive", 1.25, 150, 2, "zs-one", "", "memo"
|
||
});
|
||
MockRefreshRpc missingAddressesRpc;
|
||
missingAddressesRpc.addResponse("listtransactions", json::array());
|
||
auto missingAddressesResult = Refresh::collectTransactionRefreshResult(
|
||
missingAddressesRpc, missingAddressesSnapshot, 325, 4);
|
||
EXPECT_TRUE(missingAddressesRpc.methodNames() == std::vector<std::string>({"listtransactions"}));
|
||
EXPECT_EQ(missingAddressesResult.transactions.size(), static_cast<size_t>(1));
|
||
EXPECT_EQ(missingAddressesResult.transactions[0].txid, std::string("shielded-fallback"));
|
||
EXPECT_EQ(missingAddressesResult.transactions[0].type, std::string("receive"));
|
||
|
||
Refresh::TransactionRefreshSnapshot pendingOpidSnapshot;
|
||
pendingOpidSnapshot.pendingOpids = {"opid-visible-send"};
|
||
pendingOpidSnapshot.previousTransactions.push_back(dragonx::TransactionInfo{
|
||
"opid-visible-send", "send", -4.25, 170, 0, "R-destination", "R-source", ""
|
||
});
|
||
MockRefreshRpc pendingOpidRpc;
|
||
pendingOpidRpc.addResponse("listtransactions", json::array());
|
||
auto pendingOpidResult = Refresh::collectTransactionRefreshResult(
|
||
pendingOpidRpc, pendingOpidSnapshot, 326, 4);
|
||
EXPECT_TRUE(pendingOpidRpc.methodNames() == std::vector<std::string>({"listtransactions"}));
|
||
EXPECT_EQ(pendingOpidResult.transactions.size(), static_cast<size_t>(1));
|
||
EXPECT_EQ(pendingOpidResult.transactions[0].txid, std::string("opid-visible-send"));
|
||
EXPECT_EQ(pendingOpidResult.transactions[0].type, std::string("send"));
|
||
EXPECT_NEAR(pendingOpidResult.transactions[0].amount, -4.25, 0.00000001);
|
||
|
||
Refresh::TransactionRefreshSnapshot partialFailureSnapshot;
|
||
partialFailureSnapshot.shieldedAddresses = {"zs-one"};
|
||
partialFailureSnapshot.previousTransactions.push_back(dragonx::TransactionInfo{
|
||
"old-receive", "receive", 2.50, 140, 8, "zs-one", "", "old memo"
|
||
});
|
||
MockRefreshRpc partialFailureRpc;
|
||
partialFailureRpc.addResponse("listtransactions", json::array({
|
||
json{{"txid", "transparent-b"}, {"category", "receive"}, {"amount", 0.75},
|
||
{"time", 160}, {"confirmations", 2}, {"address", "R-two"}}
|
||
}));
|
||
partialFailureRpc.addFailure("z_listreceivedbyaddress", "temporary receive failure");
|
||
auto partialFailureResult = Refresh::collectTransactionRefreshResult(
|
||
partialFailureRpc, partialFailureSnapshot, 326, 4);
|
||
EXPECT_EQ(partialFailureResult.transactions.size(), static_cast<size_t>(2));
|
||
EXPECT_EQ(partialFailureResult.transactions[0].txid, std::string("transparent-b"));
|
||
EXPECT_EQ(partialFailureResult.transactions[1].txid, std::string("old-receive"));
|
||
|
||
Refresh::TransactionRefreshSnapshot pagedSnapshot;
|
||
MockRefreshRpc pagedRpc;
|
||
json firstPage = json::array();
|
||
for (int i = 0; i < 1000; ++i) {
|
||
firstPage.push_back(json{{"txid", "paged-" + std::to_string(i)}, {"category", "receive"},
|
||
{"amount", 0.01}, {"time", i}, {"confirmations", 10},
|
||
{"address", "R-page"}});
|
||
}
|
||
pagedRpc.addResponse("listtransactions", firstPage);
|
||
pagedRpc.addResponse("listtransactions", json::array({
|
||
json{{"txid", "paged-1000"}, {"category", "receive"}, {"amount", 0.02},
|
||
{"time", 2000}, {"confirmations", 11}, {"address", "R-page"}}
|
||
}));
|
||
auto pagedResult = Refresh::collectTransactionRefreshResult(pagedRpc, pagedSnapshot, 327, 0);
|
||
EXPECT_TRUE(pagedRpc.methodNames() == std::vector<std::string>({"listtransactions", "listtransactions"}));
|
||
EXPECT_EQ(pagedRpc.calls[0].params, json::array({"", 1000, 0}));
|
||
EXPECT_EQ(pagedRpc.calls[1].params, json::array({"", 1000, 1000}));
|
||
EXPECT_EQ(pagedResult.transactions.size(), static_cast<size_t>(1001));
|
||
EXPECT_EQ(pagedResult.transactions[0].txid, std::string("paged-1000"));
|
||
|
||
// 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<size_t>(2)); // both re-scanned
|
||
|
||
Refresh::TransactionRefreshSnapshot tolerantSnapshot;
|
||
tolerantSnapshot.shieldedAddresses = {"zs-a", "zs-b"};
|
||
tolerantSnapshot.shieldedScanHeights = {{"zs-a", 100}, {"zs-b", 100}};
|
||
tolerantSnapshot.maxShieldedReceiveScans = 4; // 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<size_t>(0)); // both skipped
|
||
EXPECT_TRUE(tolerantResult.shieldedScanComplete);
|
||
EXPECT_TRUE(tolerantRpc.methodNames() == std::vector<std::string>({"listtransactions", "z_listsentbyaddress"}));
|
||
}
|
||
|
||
Refresh::TransactionRefreshSnapshot recentSnapshot;
|
||
dragonx::TransactionInfo previousShielded;
|
||
previousShielded.txid = "shielded-old";
|
||
previousShielded.type = "receive";
|
||
previousShielded.address = "zs-one";
|
||
previousShielded.amount = 3.0;
|
||
previousShielded.timestamp = 10;
|
||
dragonx::TransactionInfo previousTransparent;
|
||
previousTransparent.txid = "recent-one";
|
||
previousTransparent.type = "receive";
|
||
previousTransparent.address = "R-one";
|
||
previousTransparent.amount = 1.0;
|
||
previousTransparent.timestamp = 20;
|
||
recentSnapshot.previousTransactions = {previousShielded, previousTransparent};
|
||
MockRefreshRpc recentRpc;
|
||
recentRpc.addResponse("listtransactions", json::array({
|
||
json{{"txid", "recent-one"}, {"category", "receive"}, {"address", "R-one"}, {"amount", 2.0}, {"time", 30}, {"confirmations", 0}},
|
||
json{{"txid", "recent-two"}, {"category", "send"}, {"address", "R-two"}, {"amount", -0.5}, {"time", 40}, {"confirmations", 0}}
|
||
}));
|
||
auto recent = Refresh::collectRecentTransactionRefreshResult(recentRpc, recentSnapshot, 123);
|
||
EXPECT_TRUE(recentRpc.methodNames() == std::vector<std::string>({"listtransactions"}));
|
||
EXPECT_EQ(recentRpc.calls[0].params, json::array({"", 100, 0}));
|
||
EXPECT_EQ(recent.blockHeight, 123);
|
||
EXPECT_EQ(recent.transactions.size(), static_cast<size_t>(3));
|
||
EXPECT_EQ(recent.transactions[0].txid, std::string("recent-two"));
|
||
EXPECT_EQ(recent.transactions[1].txid, std::string("recent-one"));
|
||
EXPECT_NEAR(recent.transactions[1].amount, 2.0, 0.00000001);
|
||
EXPECT_EQ(recent.transactions[2].txid, std::string("shielded-old"));
|
||
|
||
Refresh::TransactionRefreshSnapshot recentShieldedProbeSnapshot;
|
||
recentShieldedProbeSnapshot.shieldedAddresses = {"zs-probe-a", "zs-probe-b"};
|
||
recentShieldedProbeSnapshot.shieldedScanStartIndex = 1;
|
||
recentShieldedProbeSnapshot.maxShieldedReceiveScans = 1;
|
||
recentShieldedProbeSnapshot.shieldedScanHeights = {{"zs-probe-a", 600}, {"zs-probe-b", 600}};
|
||
MockRefreshRpc recentShieldedProbeRpc;
|
||
recentShieldedProbeRpc.addResponse("listtransactions", json::array());
|
||
recentShieldedProbeRpc.addResponse("z_listreceivedbyaddress", json::array({
|
||
json{{"txid", "same-tip-shielded"}, {"amount", 1.75}, {"confirmations", 0}, {"time", 170}}
|
||
}));
|
||
auto recentShieldedProbe = Refresh::collectRecentTransactionRefreshResult(
|
||
recentShieldedProbeRpc, recentShieldedProbeSnapshot, 600);
|
||
EXPECT_TRUE(recentShieldedProbeRpc.methodNames() == std::vector<std::string>({
|
||
"listtransactions", "z_listreceivedbyaddress"
|
||
}));
|
||
EXPECT_EQ(recentShieldedProbeRpc.calls[1].params, json::array({"zs-probe-b", 0}));
|
||
EXPECT_EQ(recentShieldedProbe.nextShieldedScanStartIndex, static_cast<size_t>(0));
|
||
EXPECT_EQ(recentShieldedProbe.shieldedAddressesScanned, static_cast<size_t>(1));
|
||
EXPECT_EQ(recentShieldedProbe.shieldedScanHeights.at("zs-probe-b"), 600);
|
||
EXPECT_EQ(recentShieldedProbe.transactions.size(), static_cast<size_t>(1));
|
||
EXPECT_EQ(recentShieldedProbe.transactions[0].txid, std::string("same-tip-shielded"));
|
||
EXPECT_EQ(recentShieldedProbe.transactions[0].confirmations, 0);
|
||
|
||
Refresh::TransactionRefreshSnapshot partialShieldedSnapshot;
|
||
partialShieldedSnapshot.shieldedAddresses = {"zs-zero", "zs-one", "zs-two"};
|
||
partialShieldedSnapshot.maxShieldedReceiveScans = 2;
|
||
partialShieldedSnapshot.previousTransactions.push_back(dragonx::TransactionInfo{
|
||
"old-zs-two", "receive", 1.0, 90, 10, "zs-two", "", "memo"
|
||
});
|
||
MockRefreshRpc partialShieldedRpc;
|
||
partialShieldedRpc.addResponse("listtransactions", json::array());
|
||
partialShieldedRpc.addResponse("z_listreceivedbyaddress", json::array({
|
||
json{{"txid", "new-zs-zero"}, {"amount", 0.5}, {"confirmations", 1}, {"time", 100}}
|
||
}));
|
||
partialShieldedRpc.addResponse("z_listreceivedbyaddress", json::array());
|
||
auto partialShielded = Refresh::collectTransactionRefreshResult(
|
||
partialShieldedRpc, partialShieldedSnapshot, 400, 0);
|
||
EXPECT_TRUE(partialShieldedRpc.methodNames() == std::vector<std::string>({
|
||
"listtransactions", "z_listreceivedbyaddress", "z_listreceivedbyaddress"
|
||
}));
|
||
EXPECT_EQ(partialShieldedRpc.calls[1].params, json::array({"zs-zero", 0}));
|
||
EXPECT_EQ(partialShieldedRpc.calls[2].params, json::array({"zs-one", 0}));
|
||
EXPECT_FALSE(partialShielded.shieldedScanComplete);
|
||
EXPECT_EQ(partialShielded.nextShieldedScanStartIndex, static_cast<size_t>(2));
|
||
EXPECT_EQ(partialShielded.shieldedAddressesScanned, static_cast<size_t>(2));
|
||
EXPECT_EQ(partialShielded.transactions.size(), static_cast<size_t>(2));
|
||
EXPECT_EQ(partialShielded.transactions[0].txid, std::string("new-zs-zero"));
|
||
EXPECT_EQ(partialShielded.transactions[1].txid, std::string("old-zs-two"));
|
||
|
||
partialShieldedSnapshot.shieldedScanStartIndex = partialShielded.nextShieldedScanStartIndex;
|
||
partialShieldedSnapshot.shieldedScanHeights = partialShielded.shieldedScanHeights;
|
||
MockRefreshRpc finalShieldedRpc;
|
||
finalShieldedRpc.addResponse("listtransactions", json::array());
|
||
finalShieldedRpc.addResponse("z_listreceivedbyaddress", json::array());
|
||
finalShieldedRpc.addResponse("z_listsentbyaddress", json::array());
|
||
auto finalShielded = Refresh::collectTransactionRefreshResult(
|
||
finalShieldedRpc, partialShieldedSnapshot, 400, 0);
|
||
EXPECT_TRUE(finalShieldedRpc.methodNames() == std::vector<std::string>({
|
||
"listtransactions", "z_listreceivedbyaddress", "z_listsentbyaddress"
|
||
}));
|
||
EXPECT_EQ(finalShieldedRpc.calls[1].params, json::array({"zs-two", 0}));
|
||
EXPECT_TRUE(finalShielded.shieldedScanComplete);
|
||
EXPECT_EQ(finalShielded.nextShieldedScanStartIndex, static_cast<size_t>(0));
|
||
EXPECT_EQ(finalShielded.shieldedScanHeights.at("zs-zero"), 400);
|
||
EXPECT_EQ(finalShielded.shieldedScanHeights.at("zs-one"), 400);
|
||
EXPECT_EQ(finalShielded.shieldedScanHeights.at("zs-two"), 400);
|
||
|
||
Refresh::TransactionRefreshSnapshot cachedShieldedSnapshot;
|
||
cachedShieldedSnapshot.shieldedAddresses = {"zs-cached"};
|
||
cachedShieldedSnapshot.shieldedScanHeights = {{"zs-cached", 500}};
|
||
cachedShieldedSnapshot.previousTransactions.push_back(dragonx::TransactionInfo{
|
||
"cached-shielded", "receive", 1.5, 120, 20, "zs-cached", "", "cached memo"
|
||
});
|
||
MockRefreshRpc cachedShieldedRpc;
|
||
cachedShieldedRpc.addResponse("listtransactions", json::array());
|
||
cachedShieldedRpc.addResponse("z_listsentbyaddress", json::array());
|
||
auto cachedShielded = Refresh::collectTransactionRefreshResult(
|
||
cachedShieldedRpc, cachedShieldedSnapshot, 500, 0);
|
||
EXPECT_TRUE(cachedShieldedRpc.methodNames() == std::vector<std::string>({"listtransactions", "z_listsentbyaddress"}));
|
||
EXPECT_TRUE(cachedShielded.shieldedScanComplete);
|
||
EXPECT_EQ(cachedShielded.shieldedAddressesScanned, static_cast<size_t>(0));
|
||
EXPECT_EQ(cachedShielded.transactions.size(), static_cast<size_t>(1));
|
||
EXPECT_EQ(cachedShielded.transactions[0].txid, std::string("cached-shielded"));
|
||
|
||
Refresh::TransactionRefreshSnapshot staleProgressSnapshot;
|
||
staleProgressSnapshot.shieldedAddresses = {"zs-current", "zs-stale", "zs-missing"};
|
||
staleProgressSnapshot.shieldedScanHeights = {{"zs-current", 500}, {"zs-stale", 499}};
|
||
staleProgressSnapshot.maxShieldedReceiveScans = 1;
|
||
staleProgressSnapshot.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<std::string>({
|
||
"listtransactions", "z_listreceivedbyaddress"
|
||
}));
|
||
EXPECT_EQ(staleProgressRpc.calls[1].params, json::array({"zs-stale", 0}));
|
||
EXPECT_FALSE(staleProgress.shieldedScanComplete);
|
||
EXPECT_EQ(staleProgress.nextShieldedScanStartIndex, static_cast<size_t>(2));
|
||
EXPECT_EQ(staleProgress.shieldedAddressesScanned, static_cast<size_t>(1));
|
||
EXPECT_EQ(staleProgress.shieldedScanHeights.at("zs-current"), 500);
|
||
EXPECT_EQ(staleProgress.shieldedScanHeights.at("zs-stale"), 500);
|
||
EXPECT_TRUE(staleProgress.shieldedScanHeights.find("zs-missing") == staleProgress.shieldedScanHeights.end());
|
||
EXPECT_EQ(staleProgress.transactions.size(), static_cast<size_t>(2));
|
||
EXPECT_EQ(staleProgress.transactions[0].txid, std::string("stale-shielded"));
|
||
EXPECT_EQ(staleProgress.transactions[1].txid, std::string("current-shielded"));
|
||
|
||
Refresh::TransactionRefreshSnapshot miningSnapshot;
|
||
miningSnapshot.shieldedAddresses = {"zs-mine"};
|
||
miningSnapshot.miningAddresses = {"R-mine", "zs-mine"};
|
||
MockRefreshRpc miningTxRpc;
|
||
miningTxRpc.addResponse("listtransactions", json::array({
|
||
json{{"txid", "transparent-mined"}, {"category", "receive"}, {"amount", 3.0},
|
||
{"time", 220}, {"confirmations", 101}, {"address", "R-mine"}}
|
||
}));
|
||
miningTxRpc.addResponse("z_listreceivedbyaddress", json::array({
|
||
json{{"txid", "shielded-mined"}, {"amount", 4.0}, {"confirmations", 102},
|
||
{"time", 210}, {"memoStr", "pool"}}
|
||
}));
|
||
miningTxRpc.addResponse("z_listsentbyaddress", json::array());
|
||
auto miningTxResult = Refresh::collectTransactionRefreshResult(miningTxRpc, miningSnapshot, 328, 0);
|
||
EXPECT_EQ(miningTxResult.transactions.size(), static_cast<size_t>(2));
|
||
EXPECT_EQ(miningTxResult.transactions[0].type, std::string("mined"));
|
||
EXPECT_EQ(miningTxResult.transactions[1].type, std::string("mined"));
|
||
}
|
||
|
||
void testNetworkRefreshResultModels()
|
||
{
|
||
using Refresh = dragonx::services::NetworkRefreshService;
|
||
using nlohmann::json;
|
||
|
||
dragonx::WalletState state;
|
||
|
||
auto core = Refresh::parseCoreRefreshResult(
|
||
json{{"private", "1.25000000"}, {"transparent", "0.50000000"}, {"total", "1.75000000"}}, // 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<int64_t>(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<int64_t>(0));
|
||
|
||
auto mining = Refresh::parseMiningRefreshResult(
|
||
json{{"generate", true}, {"genproclimit", 4}, {"blocks", 101},
|
||
{"difficulty", 12.5}, {"networkhashps", 900.0}, {"chain", "main"}},
|
||
true,
|
||
json(45.0),
|
||
true,
|
||
64.0);
|
||
Refresh::applyMiningRefreshResult(state, mining, 2345);
|
||
EXPECT_TRUE(state.mining.generate);
|
||
EXPECT_EQ(state.mining.genproclimit, 4);
|
||
EXPECT_NEAR(state.mining.localHashrate, 45.0, 0.0001);
|
||
EXPECT_EQ(state.mining.hashrate_history.size(), static_cast<size_t>(1));
|
||
EXPECT_NEAR(state.mining.networkHashrate, 900.0, 0.0001);
|
||
EXPECT_NEAR(state.mining.daemon_memory_mb, 64.0, 0.0001);
|
||
EXPECT_EQ(state.last_mining_update, static_cast<int64_t>(2345));
|
||
|
||
auto peers = Refresh::parsePeerRefreshResult(
|
||
json::array({json{{"id", 7}, {"addr", "127.0.0.1:8233"}, {"tls_verified", true}}}),
|
||
json::array({json{{"address", "192.0.2.1"}, {"banned_until", 3456}}}));
|
||
Refresh::applyPeerRefreshResult(state, std::move(peers), 3456);
|
||
EXPECT_EQ(state.peers.size(), static_cast<size_t>(1));
|
||
EXPECT_EQ(state.peers[0].id, 7);
|
||
EXPECT_TRUE(state.peers[0].tls_verified);
|
||
EXPECT_EQ(state.bannedPeers.size(), static_cast<size_t>(1));
|
||
EXPECT_EQ(state.bannedPeers[0].address, std::string("192.0.2.1"));
|
||
EXPECT_EQ(state.last_peer_update, static_cast<int64_t>(3456));
|
||
|
||
auto price = Refresh::parseCoinGeckoPriceResponse(
|
||
R"({"dragonx-2":{"usd":0.5,"btc":0.00001,"usd_24h_change":2.5,"usd_24h_vol":1000,"usd_market_cap":50000}})",
|
||
0);
|
||
EXPECT_TRUE(price.has_value());
|
||
if (price) {
|
||
Refresh::markPriceRefreshStarted(state);
|
||
EXPECT_TRUE(state.market.price_loading);
|
||
Refresh::applyPriceRefreshResult(state, *price, std::chrono::steady_clock::now());
|
||
EXPECT_FALSE(state.market.price_loading);
|
||
EXPECT_EQ(state.market.price_error, std::string());
|
||
EXPECT_NEAR(state.market.price_usd, 0.5, 0.00000001);
|
||
EXPECT_NEAR(state.market.price_btc, 0.00001, 0.00000001);
|
||
EXPECT_NEAR(state.market.change_24h, 2.5, 0.0001);
|
||
EXPECT_EQ(state.market.price_history.size(), static_cast<size_t>(1));
|
||
}
|
||
|
||
// 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<size_t>(3));
|
||
if (chart.size() == 3) {
|
||
EXPECT_EQ(chart[0].first, static_cast<std::time_t>(1700000000));
|
||
EXPECT_NEAR(chart[0].second, 1.5, 0.00000001);
|
||
EXPECT_EQ(chart[2].first, static_cast<std::time_t>(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<size_t>(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<size_t>(0));
|
||
EXPECT_EQ(Refresh::parseCoinGeckoMarketChart("not json").size(), static_cast<size_t>(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<size_t>(1));
|
||
EXPECT_EQ(state.t_addresses.size(), static_cast<size_t>(1));
|
||
EXPECT_EQ(state.z_addresses[0].address, std::string("zs-refresh"));
|
||
EXPECT_FALSE(state.z_addresses[0].has_spending_key);
|
||
|
||
auto viewEntry = Refresh::parseViewTransactionCacheEntry(json{
|
||
{"spends", json::array({json{{"address", "zs-from"}}})},
|
||
{"outputs", json::array({
|
||
json{{"outgoing", true}, {"address", "zs-destination"}, {"value", 0.75}, {"memoStr", "memo"}},
|
||
json{{"outgoing", false}, {"address", "zs-change"}, {"value", 0.25}}
|
||
})}
|
||
});
|
||
EXPECT_EQ(viewEntry.from_address, std::string("zs-from"));
|
||
EXPECT_EQ(viewEntry.outgoing_outputs.size(), static_cast<size_t>(1));
|
||
EXPECT_EQ(viewEntry.outgoing_outputs[0].address, std::string("zs-destination"));
|
||
|
||
std::vector<dragonx::TransactionInfo> enrichedTxs;
|
||
dragonx::TransactionInfo baseTx;
|
||
baseTx.txid = "shielded-send";
|
||
baseTx.type = "receive";
|
||
baseTx.confirmations = 7;
|
||
baseTx.timestamp = 111;
|
||
enrichedTxs.push_back(baseTx);
|
||
Refresh::appendViewTransactionOutputs(enrichedTxs, "shielded-send", viewEntry);
|
||
Refresh::appendViewTransactionOutputs(enrichedTxs, "shielded-send", viewEntry);
|
||
EXPECT_EQ(enrichedTxs.size(), static_cast<size_t>(2));
|
||
EXPECT_EQ(enrichedTxs[1].type, std::string("send"));
|
||
EXPECT_EQ(enrichedTxs[1].from_address, std::string("zs-from"));
|
||
EXPECT_NEAR(enrichedTxs[1].amount, -0.75, 0.00000001);
|
||
EXPECT_EQ(enrichedTxs[1].confirmations, 7);
|
||
EXPECT_EQ(enrichedTxs[1].timestamp, static_cast<int64_t>(111));
|
||
|
||
Refresh::TransactionRefreshResult transactionResult;
|
||
transactionResult.blockHeight = 222;
|
||
dragonx::TransactionInfo confirmedTx;
|
||
confirmedTx.txid = "confirmed";
|
||
confirmedTx.type = "receive";
|
||
confirmedTx.confirmations = 12;
|
||
confirmedTx.timestamp = 1000;
|
||
dragonx::TransactionInfo pendingTx;
|
||
pendingTx.txid = "pending";
|
||
pendingTx.type = "receive";
|
||
pendingTx.confirmations = 2;
|
||
pendingTx.timestamp = 1001;
|
||
transactionResult.transactions = {pendingTx, confirmedTx};
|
||
transactionResult.newViewTxEntries["shielded-send"] = viewEntry;
|
||
transactionResult.newViewTxEntries["pending-empty"] = Refresh::TransactionViewCacheEntry{};
|
||
|
||
Refresh::TransactionViewCache viewCache;
|
||
std::unordered_set<std::string> sendTxids{"shielded-send", "pending", "pending-empty"};
|
||
std::vector<dragonx::TransactionInfo> confirmedCache;
|
||
std::unordered_set<std::string> confirmedIds;
|
||
int confirmedBlock = -1;
|
||
int lastTxBlock = -1;
|
||
Refresh::TransactionCacheUpdate cacheUpdate{
|
||
viewCache,
|
||
sendTxids,
|
||
confirmedCache,
|
||
confirmedIds,
|
||
confirmedBlock,
|
||
lastTxBlock
|
||
};
|
||
Refresh::applyTransactionRefreshResult(state, cacheUpdate, std::move(transactionResult), 4567);
|
||
EXPECT_EQ(state.transactions.size(), static_cast<size_t>(2));
|
||
EXPECT_EQ(state.last_tx_update, static_cast<int64_t>(4567));
|
||
EXPECT_EQ(lastTxBlock, 222);
|
||
EXPECT_EQ(viewCache.size(), static_cast<size_t>(2));
|
||
EXPECT_EQ(sendTxids.count("shielded-send"), static_cast<size_t>(0));
|
||
EXPECT_EQ(sendTxids.count("pending"), static_cast<size_t>(1));
|
||
EXPECT_EQ(sendTxids.count("pending-empty"), static_cast<size_t>(1));
|
||
EXPECT_EQ(confirmedCache.size(), static_cast<size_t>(1));
|
||
EXPECT_EQ(confirmedCache[0].txid, std::string("confirmed"));
|
||
EXPECT_EQ(confirmedIds.count("confirmed"), static_cast<size_t>(1));
|
||
EXPECT_EQ(confirmedBlock, 222);
|
||
}
|
||
|
||
void testOperationStatusPollParsing()
|
||
{
|
||
using Refresh = dragonx::services::NetworkRefreshService;
|
||
using nlohmann::json;
|
||
|
||
auto parsed = Refresh::parseOperationStatusPoll(json::array({
|
||
json{{"id", "op-success"}, {"status", "success"}, {"result", json{{"txid", "tx-success"}}}},
|
||
json{{"id", "op-failed"}, {"status", "failed"}, {"error", json{{"message", "bad memo"}}}},
|
||
json{{"id", "op-running"}, {"status", "executing"}}
|
||
}), {"op-success", "op-failed", "op-running", "op-stale"});
|
||
|
||
EXPECT_TRUE(parsed.anySuccess);
|
||
EXPECT_EQ(parsed.doneOpids.size(), static_cast<size_t>(2));
|
||
EXPECT_EQ(parsed.doneOpids[0], std::string("op-success"));
|
||
EXPECT_EQ(parsed.doneOpids[1], std::string("op-failed"));
|
||
EXPECT_EQ(parsed.successTxids.size(), static_cast<size_t>(1));
|
||
EXPECT_EQ(parsed.successTxids[0], std::string("tx-success"));
|
||
EXPECT_EQ(parsed.successTxidsByOpid.size(), static_cast<size_t>(1));
|
||
EXPECT_EQ(parsed.successTxidsByOpid.at("op-success"), std::string("tx-success"));
|
||
EXPECT_EQ(parsed.failureMessages.size(), static_cast<size_t>(1));
|
||
EXPECT_EQ(parsed.failureMessages[0], std::string("bad memo"));
|
||
// Per-opid failure map drives terminal-status routing to the originating send UI.
|
||
EXPECT_EQ(parsed.failureByOpid.size(), static_cast<size_t>(1));
|
||
EXPECT_EQ(parsed.failureByOpid.at("op-failed"), std::string("bad memo"));
|
||
EXPECT_EQ(parsed.staleOpids.size(), static_cast<size_t>(1));
|
||
EXPECT_EQ(parsed.staleOpids[0], std::string("op-stale"));
|
||
|
||
auto malformed = Refresh::parseOperationStatusPoll(json{{"status", "success"}}, {"op-keep"});
|
||
EXPECT_FALSE(malformed.anySuccess);
|
||
EXPECT_TRUE(malformed.doneOpids.empty());
|
||
EXPECT_TRUE(malformed.staleOpids.empty());
|
||
|
||
// 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<size_t>(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"));
|
||
}
|
||
|
||
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.
|
||
{
|
||
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_EQ(s.detail, std::string("Peers: 0"));
|
||
}
|
||
// 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 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<std::pair<std::string, std::string>> 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<std::pair<std::string, std::string>> 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);
|
||
}
|
||
|
||
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).
|
||
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
|
||
|
||
EmbeddedDaemon d;
|
||
d.setSkipPortCheck(true); // 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);
|
||
}
|
||
|
||
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
|
||
|
||
EmbeddedDaemon d;
|
||
d.setSkipPortCheck(true);
|
||
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);
|
||
}
|
||
|
||
// 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;
|
||
}
|
||
|
||
// 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");
|
||
|
||
EmbeddedDaemon d;
|
||
d.setSkipPortCheck(true);
|
||
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);
|
||
}
|
||
|
||
// 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");
|
||
|
||
EmbeddedDaemon d;
|
||
d.setSkipPortCheck(true);
|
||
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);
|
||
}
|
||
|
||
// 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);
|
||
|
||
EmbeddedDaemon d;
|
||
d.setSkipPortCheck(true);
|
||
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<char>(in)), std::istreambuf_iterator<char>());
|
||
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);
|
||
}
|
||
|
||
// 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;
|
||
auto d = std::make_unique<EmbeddedDaemon>();
|
||
d->setSkipPortCheck(true);
|
||
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<pid_t>(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);
|
||
}
|
||
|
||
// 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<pid_t>(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);
|
||
|
||
EmbeddedDaemon d;
|
||
d.setSkipPortCheck(true);
|
||
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);
|
||
}
|
||
|
||
// 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");
|
||
EmbeddedDaemon d;
|
||
d.setSkipPortCheck(true);
|
||
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);
|
||
}
|
||
|
||
// 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);
|
||
EmbeddedDaemon d;
|
||
d.setSkipPortCheck(true);
|
||
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);
|
||
}
|
||
#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<R> 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<double(double)> cpuAt; // cumulative CPU seconds at t; empty = unknown
|
||
std::function<DC::StopUserChoice(double)> choiceAt;
|
||
bool rebuild = false;
|
||
|
||
std::vector<std::string> 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<std::string> 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());
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------------------
|
||
// 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);
|
||
}
|
||
|
||
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<Clock::duration>(seconds(36)));
|
||
EXPECT_TRUE(FetchBackoff::delayFor(1, -1.0) == std::chrono::duration_cast<Clock::duration>(seconds(24)));
|
||
for (int i = 0; i < 50; ++i) {
|
||
const double j = FetchBackoff::randomJitter();
|
||
EXPECT_TRUE(j >= -1.0 && j <= 1.0);
|
||
}
|
||
}
|
||
|
||
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<const char*>& 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<std::string>()), 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"));
|
||
}
|
||
|
||
// 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<long>(GetCurrentProcessId());
|
||
#else
|
||
const long self = static_cast<long>(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<long>(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<std::time_t>(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);
|
||
|
||
#ifdef _WIN32
|
||
const long self = static_cast<long>(GetCurrentProcessId());
|
||
#else
|
||
const long self = static_cast<long>(getpid());
|
||
#endif
|
||
const std::int64_t started = EmbeddedDaemon::processStartUtc(self);
|
||
const std::int64_t nowUtc = static_cast<std::int64_t>(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<int>(notes);
|
||
auto calls = std::make_shared<int>(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<int>(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<std::string>({"stop", "start"}));
|
||
|
||
auto rescan = DaemonController::evaluateLifecycleOperation(
|
||
DaemonController::LifecycleOperation::Rescan, true, true, true, false);
|
||
MockDaemonLifecycleRuntime rescanRuntime;
|
||
MockDaemonLifecycleTask rescanTask;
|
||
auto rescanResult = DaemonController::executeLifecycleOperation(rescan, rescanRuntime, rescanTask);
|
||
EXPECT_TRUE(rescanResult.completed);
|
||
EXPECT_EQ(rescanTask.sleeps, 30);
|
||
EXPECT_TRUE(rescanRuntime.calls == std::vector<std::string>({"stop", "reset-output", "start"}));
|
||
|
||
auto deleteData = DaemonController::evaluateLifecycleOperation(
|
||
DaemonController::LifecycleOperation::DeleteBlockchainData, true, true, false, false);
|
||
MockDaemonLifecycleRuntime deleteRuntime;
|
||
MockDaemonLifecycleTask deleteTask;
|
||
auto deleteResult = DaemonController::executeLifecycleOperation(deleteData, deleteRuntime, deleteTask);
|
||
EXPECT_TRUE(deleteResult.completed);
|
||
EXPECT_EQ(deleteResult.deletedItems, 7);
|
||
EXPECT_TRUE(deleteRuntime.calls == std::vector<std::string>({"stop", "delete", "reset-output", "start"}));
|
||
|
||
auto bootstrap = DaemonController::evaluateLifecycleOperation(
|
||
DaemonController::LifecycleOperation::BootstrapStop, true, true, true, false);
|
||
MockDaemonLifecycleRuntime bootstrapRuntime;
|
||
MockDaemonLifecycleTask bootstrapTask;
|
||
auto bootstrapResult = DaemonController::executeLifecycleOperation(bootstrap, bootstrapRuntime, bootstrapTask);
|
||
EXPECT_TRUE(bootstrapResult.completed);
|
||
EXPECT_TRUE(bootstrapResult.stopped);
|
||
EXPECT_TRUE(bootstrapRuntime.calls == std::vector<std::string>({"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<std::string>({"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<std::string>({"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<std::string>({"stop"}));
|
||
|
||
MockDaemonLifecycleRuntime shutdownRuntime;
|
||
MockDaemonLifecycleTask shutdownTask;
|
||
shutdownTask.isShuttingDown = true;
|
||
auto shutdownResult = DaemonController::executeLifecycleOperation(restart, shutdownRuntime, shutdownTask);
|
||
EXPECT_TRUE(shutdownResult.cancelled);
|
||
EXPECT_TRUE(shutdownResult.stopped);
|
||
EXPECT_FALSE(shutdownResult.started);
|
||
EXPECT_TRUE(shutdownRuntime.calls == std::vector<std::string>({"stop"}));
|
||
}
|
||
|
||
void testDaemonLifecycleAdapters()
|
||
{
|
||
using dragonx::daemon::AsyncLifecycleTaskContext;
|
||
using dragonx::daemon::BlockchainDataCleaner;
|
||
using dragonx::daemon::ImmediateLifecycleTaskContext;
|
||
using dragonx::util::AsyncTaskManager;
|
||
|
||
auto cancelled = std::make_shared<std::atomic<bool>>(false);
|
||
std::atomic<bool> shuttingDown{false};
|
||
AsyncTaskManager::Token token(cancelled);
|
||
AsyncLifecycleTaskContext asyncContext(token, shuttingDown);
|
||
EXPECT_FALSE(asyncContext.cancelled());
|
||
EXPECT_FALSE(asyncContext.shuttingDown());
|
||
cancelled->store(true);
|
||
EXPECT_TRUE(asyncContext.cancelled());
|
||
shuttingDown.store(true);
|
||
EXPECT_TRUE(asyncContext.shuttingDown());
|
||
|
||
ImmediateLifecycleTaskContext immediateContext;
|
||
EXPECT_FALSE(immediateContext.cancelled());
|
||
EXPECT_FALSE(immediateContext.shuttingDown());
|
||
immediateContext.sleepForMs(50);
|
||
|
||
fs::path dataDir = makeTempDir();
|
||
fs::create_directories(dataDir / "blocks" / "nested");
|
||
fs::create_directories(dataDir / "chainstate");
|
||
fs::create_directories(dataDir / "database");
|
||
fs::create_directories(dataDir / "notarizations");
|
||
std::ofstream(dataDir / "blocks" / "nested" / "blk00000.dat") << "block";
|
||
std::ofstream(dataDir / "chainstate" / "state.dat") << "state";
|
||
std::ofstream(dataDir / "database" / "db.dat") << "db";
|
||
std::ofstream(dataDir / "notarizations" / "notary.dat") << "notary";
|
||
std::ofstream(dataDir / "peers.dat") << "peers";
|
||
std::ofstream(dataDir / "fee_estimates.dat") << "fees";
|
||
std::ofstream(dataDir / "wallet.dat") << "wallet";
|
||
|
||
int removed = BlockchainDataCleaner::removeBlockchainData(dataDir);
|
||
EXPECT_TRUE(removed >= 6);
|
||
EXPECT_FALSE(fs::exists(dataDir / "blocks"));
|
||
EXPECT_FALSE(fs::exists(dataDir / "chainstate"));
|
||
EXPECT_FALSE(fs::exists(dataDir / "database"));
|
||
EXPECT_FALSE(fs::exists(dataDir / "notarizations"));
|
||
EXPECT_FALSE(fs::exists(dataDir / "peers.dat"));
|
||
EXPECT_FALSE(fs::exists(dataDir / "fee_estimates.dat"));
|
||
EXPECT_TRUE(fs::exists(dataDir / "wallet.dat"));
|
||
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<float>(e - b) * charW;
|
||
}
|
||
const char* wrapPosition(const char* b, const char* e, float w) const override {
|
||
int maxChars = static_cast<int>(w / charW);
|
||
if (maxChars < 1) maxChars = 1;
|
||
return (e - b) <= maxChars ? e : b + maxChars;
|
||
}
|
||
} stub;
|
||
|
||
std::vector<std::string> 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<int>(lines.size()), get, 50.0f, lineH, gap, stub);
|
||
EXPECT_EQ(L.segments.size(), static_cast<size_t>(3));
|
||
EXPECT_EQ(L.segments[0].size(), static_cast<size_t>(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<size_t>(1)); // "abc" fits
|
||
EXPECT_EQ(L.segments[1][0].byteEnd, 3);
|
||
EXPECT_EQ(L.segments[2].size(), static_cast<size_t>(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<std::string> 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<int>{0}, {0, 1}, {1, 3}), std::string("ello"));
|
||
}
|
||
|
||
// 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<std::string> 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<int>{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<size_t>(3));
|
||
EXPECT_EQ(dr.popped, static_cast<size_t>(0));
|
||
EXPECT_EQ(m.size(), static_cast<size_t>(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<size_t>(0));
|
||
EXPECT_EQ(empty.popped, static_cast<size_t>(0));
|
||
EXPECT_EQ(m.size(), static_cast<size_t>(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<size_t>(5));
|
||
EXPECT_EQ(dr.popped, static_cast<size_t>(2));
|
||
EXPECT_EQ(m.size(), static_cast<size_t>(3));
|
||
EXPECT_EQ(m[0].text, std::string("2")); // "0" and "1" evicted
|
||
EXPECT_EQ(m[2].text, std::string("4"));
|
||
}
|
||
|
||
// 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<size_t>(1));
|
||
m.ingest("b", ConsoleChannel::None); // queued, not yet visible
|
||
m.clear();
|
||
EXPECT_EQ(m.size(), static_cast<size_t>(0));
|
||
m.drain();
|
||
EXPECT_EQ(m.size(), static_cast<size_t>(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<bool> go{false};
|
||
std::vector<std::thread> 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<size_t>(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<size_t>(kThreads * kPerThread));
|
||
EXPECT_EQ(m.size(), static_cast<size_t>(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<AddressInfo> 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<std::string> 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<size_t>(2));
|
||
EXPECT_TRUE(PortfolioEntryRemove(group, "R-t1")); // removed
|
||
EXPECT_FALSE(PortfolioEntryRemove(group, "R-t1"));// gone -> no-op
|
||
EXPECT_EQ(group.size(), static_cast<size_t>(1));
|
||
EXPECT_EQ(group[0], std::string("R-t2"));
|
||
}
|
||
|
||
// 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<double> minute = {1.0, 2.0, 3.0, 4.0};
|
||
EXPECT_EQ(resampleHistory(minute, 0).size(), static_cast<size_t>(4));
|
||
EXPECT_NEAR(resampleHistory(minute, 0)[3], 4.0, 1e-9);
|
||
// interval 1 (hour = 60 minute-samples): 120 samples -> 2 averaged points.
|
||
std::vector<double> 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<size_t>(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<double>(61, 5.0), 1).size(), static_cast<size_t>(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<std::pair<std::time_t, double>> 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<size_t>(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<size_t>(2));
|
||
// ...interval 0 (minute) always uses the live buffer...
|
||
EXPECT_EQ(sparklineSeries(m, 0).size(), static_cast<size_t>(3));
|
||
// ...and with no historical series, an interval falls back to the resampled live buffer.
|
||
MarketInfo bare; bare.price_history = std::vector<double>(120, 7.0);
|
||
EXPECT_EQ(sparklineSeries(bare, 1).size(), static_cast<size_t>(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<size_t>(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<size_t>(3));
|
||
EXPECT_EQ(static_cast<long>(live.back().first), static_cast<long>(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<size_t>(2));
|
||
EXPECT_NEAR(o[0].second, 0.01395, 1e-9); // close = idx 4
|
||
EXPECT_EQ(static_cast<long>(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<size_t>(2));
|
||
EXPECT_NEAR(n[0].second, 0.031354, 1e-9);
|
||
EXPECT_EQ(static_cast<long>(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<size_t>(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<size_t>(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<Candle> 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<size_t>(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<size_t>(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<size_t>(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<size_t>(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<std::string> 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<size_t>(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<std::string> 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<std::string> empty = { "{", "}" };
|
||
EXPECT_EQ(ComputeConsoleFoldSpans(empty)[0], 0);
|
||
|
||
// Plain non-JSON lines: no openers.
|
||
std::vector<std::string> 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<std::string> 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<dragonx::AddressInfo> poolAddresses;
|
||
poolAddresses.push_back({"R-transparent", 0.0, "transparent", true});
|
||
poolAddresses.push_back({"zs-default-worker", 0.0, "shielded", true});
|
||
EXPECT_TRUE(dragonx::ui::shouldDefaultPoolWorker("", false));
|
||
EXPECT_TRUE(dragonx::ui::shouldDefaultPoolWorker("x", false));
|
||
EXPECT_FALSE(dragonx::ui::shouldDefaultPoolWorker("zs-manual", false));
|
||
EXPECT_FALSE(dragonx::ui::shouldDefaultPoolWorker("", true));
|
||
EXPECT_EQ(dragonx::ui::defaultPoolWorkerAddress(poolAddresses), std::string("zs-default-worker"));
|
||
EXPECT_TRUE(dragonx::ui::miningValueAlreadySaved({"pool-a", "pool-b"}, "pool-b"));
|
||
EXPECT_FALSE(dragonx::ui::miningValueAlreadySaved({"pool-a"}, ""));
|
||
|
||
// 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<std::string> history;
|
||
dragonx::ui::AppendConsoleHistory(history, "getinfo", 3);
|
||
dragonx::ui::AppendConsoleHistory(history, "getinfo", 3);
|
||
dragonx::ui::AppendConsoleHistory(history, "getpeerinfo", 3);
|
||
dragonx::ui::AppendConsoleHistory(history, "getblockcount", 3);
|
||
dragonx::ui::AppendConsoleHistory(history, "help", 3);
|
||
EXPECT_EQ(history.size(), static_cast<size_t>(3));
|
||
EXPECT_EQ(history.front(), std::string("getpeerinfo"));
|
||
|
||
int index = dragonx::ui::NavigateConsoleHistoryIndex(-1, history.size(), true);
|
||
EXPECT_EQ(index, 2);
|
||
EXPECT_EQ(dragonx::ui::ConsoleHistoryEntry(history, index), std::string("help"));
|
||
index = dragonx::ui::NavigateConsoleHistoryIndex(index, history.size(), true);
|
||
EXPECT_EQ(index, 1);
|
||
index = dragonx::ui::NavigateConsoleHistoryIndex(index, history.size(), false);
|
||
EXPECT_EQ(index, 2);
|
||
index = dragonx::ui::NavigateConsoleHistoryIndex(index, history.size(), false);
|
||
EXPECT_EQ(index, -1);
|
||
|
||
auto single = dragonx::ui::CompleteConsoleCommand("stop");
|
||
EXPECT_EQ(single.matches.size(), static_cast<size_t>(1));
|
||
EXPECT_EQ(single.commonPrefix, std::string("stop"));
|
||
|
||
auto multiple = dragonx::ui::CompleteConsoleCommand("getblock");
|
||
EXPECT_TRUE(multiple.matches.size() > 1);
|
||
EXPECT_EQ(multiple.commonPrefix, std::string("getblock"));
|
||
auto lines = dragonx::ui::FormatConsoleCompletionLines(multiple.matches, 40);
|
||
EXPECT_FALSE(lines.empty());
|
||
|
||
auto args = dragonx::ui::ParseConsoleCommandArgs("z_sendmany \"from addr\" [{\"address\":\"zs\",\"amount\":1.25}] true");
|
||
EXPECT_EQ(args.size(), static_cast<size_t>(4));
|
||
EXPECT_EQ(args[1], std::string("from addr"));
|
||
// 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<size_t>(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<size_t>(2));
|
||
EXPECT_TRUE(call.params[0].get<bool>());
|
||
EXPECT_EQ(call.params[1].get<long long>(), 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<size_t>(2));
|
||
EXPECT_TRUE(qcall.params[0].is_string());
|
||
EXPECT_EQ(qcall.params[0].get<std::string>(), 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<long long>(), 42LL);
|
||
EXPECT_TRUE(ncall.params[1].is_number_float());
|
||
EXPECT_TRUE(ncall.params[2].is_string());
|
||
EXPECT_EQ(ncall.params[2].get<std::string>(), 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<size_t>(4));
|
||
EXPECT_EQ(resultLines[0].role, dragonx::ui::ConsoleResultLineRole::JsonBrace);
|
||
EXPECT_EQ(resultLines[1].role, dragonx::ui::ConsoleResultLineRole::JsonKey);
|
||
auto nullLines = dragonx::ui::FormatConsoleRpcResultLines("null", false);
|
||
EXPECT_EQ(nullLines[0].text, std::string("(no result)"));
|
||
auto errorLines = dragonx::ui::FormatConsoleRpcResultLines("bad rpc", true);
|
||
EXPECT_EQ(errorLines[0].role, dragonx::ui::ConsoleResultLineRole::Error);
|
||
EXPECT_EQ(errorLines[0].text, std::string("Error: bad rpc"));
|
||
}
|
||
|
||
void 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 testMiningBenchmarkModel()
|
||
{
|
||
dragonx::ui::ThreadBenchmark benchmark;
|
||
EXPECT_FALSE(benchmark.active());
|
||
benchmark.buildCandidates(8);
|
||
EXPECT_EQ(benchmark.candidates.size(), static_cast<size_t>(5));
|
||
EXPECT_EQ(benchmark.candidates.front(), 4);
|
||
EXPECT_EQ(benchmark.candidates.back(), 8);
|
||
EXPECT_NEAR(benchmark.avgWarmupSecs(), 195.0, 0.0001);
|
||
|
||
benchmark.phase = dragonx::ui::ThreadBenchmark::Phase::WarmingUp;
|
||
EXPECT_TRUE(benchmark.active());
|
||
benchmark.current_index = 2;
|
||
benchmark.total_warmup_secs = 210.0f;
|
||
EXPECT_NEAR(benchmark.avgWarmupSecs(), 105.0, 0.0001);
|
||
EXPECT_NEAR(benchmark.perTestSecs(), 135.0, 0.0001);
|
||
benchmark.phase_timer = 15.0f;
|
||
EXPECT_TRUE(benchmark.progress() > 0.0f);
|
||
|
||
benchmark.resetStabilityTracking();
|
||
EXPECT_EQ(benchmark.window_samples, 0);
|
||
EXPECT_EQ(benchmark.consecutive_stable, 0);
|
||
|
||
benchmark.phase = dragonx::ui::ThreadBenchmark::Phase::Starting;
|
||
benchmark.candidates = {2};
|
||
benchmark.current_index = 0;
|
||
auto update = dragonx::ui::AdvanceThreadBenchmark(benchmark, 0.1f, 0.0);
|
||
EXPECT_TRUE(update.stopPoolMining);
|
||
EXPECT_TRUE(update.startPoolMining);
|
||
EXPECT_EQ(update.startThreads, 2);
|
||
EXPECT_TRUE(benchmark.phase == dragonx::ui::ThreadBenchmark::Phase::WarmingUp);
|
||
|
||
benchmark.phase_timer = dragonx::ui::ThreadBenchmark::MAX_WARMUP_SECS - 0.5f;
|
||
update = dragonx::ui::AdvanceThreadBenchmark(benchmark, 1.0f, 100.0);
|
||
EXPECT_TRUE(benchmark.phase == dragonx::ui::ThreadBenchmark::Phase::Measuring);
|
||
benchmark.phase_timer = dragonx::ui::ThreadBenchmark::MEASURE_SECS - 0.5f;
|
||
update = dragonx::ui::AdvanceThreadBenchmark(benchmark, 1.0f, 250.0);
|
||
EXPECT_TRUE(benchmark.phase == dragonx::ui::ThreadBenchmark::Phase::Advancing);
|
||
EXPECT_EQ(benchmark.optimal_threads, 2);
|
||
benchmark.was_pool_running = true;
|
||
update = dragonx::ui::AdvanceThreadBenchmark(benchmark, 0.1f, 0.0);
|
||
EXPECT_TRUE(update.stopPoolMining);
|
||
EXPECT_TRUE(update.saveOptimalThreads);
|
||
EXPECT_TRUE(update.startPoolMining);
|
||
EXPECT_EQ(update.optimalThreads, 2);
|
||
|
||
benchmark.reset();
|
||
EXPECT_FALSE(benchmark.active());
|
||
EXPECT_TRUE(benchmark.candidates.empty());
|
||
}
|
||
|
||
void testBalanceAddressListModel()
|
||
{
|
||
std::vector<dragonx::AddressInfo> addresses;
|
||
dragonx::AddressInfo zHigh;
|
||
zHigh.address = "zs-high";
|
||
zHigh.label = "Vault";
|
||
zHigh.balance = 10.0;
|
||
zHigh.type = "shielded";
|
||
addresses.push_back(zHigh);
|
||
|
||
dragonx::AddressInfo tZero;
|
||
tZero.address = "R-zero";
|
||
tZero.label = "Empty";
|
||
tZero.balance = 0.0;
|
||
tZero.type = "transparent";
|
||
addresses.push_back(tZero);
|
||
|
||
dragonx::AddressInfo tFav;
|
||
tFav.address = "R-favorite";
|
||
tFav.label = "Favorite";
|
||
tFav.balance = 0.0;
|
||
tFav.type = "transparent";
|
||
addresses.push_back(tFav);
|
||
|
||
std::vector<dragonx::ui::AddressListInput> inputs = {
|
||
{&addresses[0], true, false, false, false, "Main Vault", "", -1},
|
||
{&addresses[1], false, false, false, false, "Empty", "", -1},
|
||
{&addresses[2], false, false, true, false, "Favorite", "", -1}
|
||
};
|
||
|
||
auto rows = dragonx::ui::BuildAddressListRows(inputs, "", true, false);
|
||
EXPECT_EQ(rows.size(), static_cast<size_t>(2));
|
||
EXPECT_EQ(rows[0].info->address, std::string("R-favorite"));
|
||
EXPECT_EQ(rows[1].info->address, std::string("zs-high"));
|
||
|
||
inputs[2].sortOrder = 0;
|
||
rows = dragonx::ui::BuildAddressListRows(inputs, "", true, false);
|
||
EXPECT_EQ(rows[0].info->address, std::string("R-favorite"));
|
||
|
||
rows = dragonx::ui::BuildAddressListRows(inputs, "vault", false, false);
|
||
EXPECT_EQ(rows.size(), static_cast<size_t>(1));
|
||
EXPECT_EQ(rows[0].info->address, std::string("zs-high"));
|
||
|
||
// 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<dragonx::ui::AddressListInput> 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<size_t>(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<dragonx::ui::AddressListInput> 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<ExplorerBlockCache>(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<size_t>(2));
|
||
EXPECT_EQ(range[10].hash, std::string("hash-10"));
|
||
EXPECT_EQ(range[9].tx_count, 2);
|
||
|
||
auto sameTipValidation = cache.prepareValidation(10, "hash-10");
|
||
EXPECT_FALSE(sameTipValidation.needed);
|
||
|
||
// 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);
|
||
}
|
||
|
||
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<TransactionInfo> transactions;
|
||
transactions.push_back(TransactionInfo{
|
||
"tx-sensitive-send", "send", -1.25, 1700000100, 12,
|
||
"zs-destination-sensitive", "R-source-sensitive", "private memo"
|
||
});
|
||
transactions.push_back(TransactionInfo{
|
||
"tx-mined", "mined", 3.5, 1700000000, 104,
|
||
"R-mining-address", "", ""
|
||
});
|
||
std::unordered_map<std::string, int> shieldedScanHeights{
|
||
{"zs-beta", 120},
|
||
{"zs-archive", 118}
|
||
};
|
||
|
||
{
|
||
TransactionHistoryCache cache(databasePath.string());
|
||
EXPECT_TRUE(cache.ensureOpen());
|
||
EXPECT_TRUE(cache.unlockWithPassphrase(walletIdentity, passphrase));
|
||
EXPECT_TRUE(cache.replace(walletIdentity, 120, "tip-120", transactions, 1700000200, shieldedScanHeights));
|
||
EXPECT_EQ(cache.snapshotCount(), 1);
|
||
}
|
||
|
||
{
|
||
std::ifstream database(databasePath, std::ios::binary);
|
||
std::string bytes((std::istreambuf_iterator<char>(database)), std::istreambuf_iterator<char>());
|
||
EXPECT_TRUE(bytes.find("tx-sensitive-send") == std::string::npos);
|
||
EXPECT_TRUE(bytes.find("zs-destination-sensitive") == std::string::npos);
|
||
EXPECT_TRUE(bytes.find("zs-archive") == std::string::npos);
|
||
EXPECT_TRUE(bytes.find("private memo") == std::string::npos);
|
||
}
|
||
|
||
{
|
||
TransactionHistoryCache wrongKey(databasePath.string());
|
||
EXPECT_TRUE(wrongKey.unlockWithPassphrase(walletIdentity, "wrong passphrase"));
|
||
auto wrongLoad = wrongKey.load(walletIdentity, 120, "tip-120");
|
||
EXPECT_FALSE(wrongLoad.loaded);
|
||
}
|
||
|
||
{
|
||
TransactionHistoryCache reopened(databasePath.string());
|
||
EXPECT_TRUE(reopened.unlockWithPassphrase(walletIdentity, passphrase));
|
||
auto loaded = reopened.load(walletIdentity, 120, "tip-120");
|
||
EXPECT_TRUE(loaded.loaded);
|
||
EXPECT_FALSE(loaded.invalidated);
|
||
EXPECT_EQ(loaded.tipHeight, 120);
|
||
EXPECT_EQ(loaded.tipHash, std::string("tip-120"));
|
||
EXPECT_EQ(loaded.updatedAt, static_cast<std::time_t>(1700000200));
|
||
EXPECT_EQ(loaded.transactions.size(), static_cast<size_t>(2));
|
||
EXPECT_EQ(loaded.transactions[0].txid, std::string("tx-sensitive-send"));
|
||
EXPECT_EQ(loaded.transactions[0].memo, std::string("private memo"));
|
||
EXPECT_EQ(loaded.transactions[1].type, std::string("mined"));
|
||
EXPECT_EQ(loaded.shieldedScanHeights.size(), static_cast<size_t>(2));
|
||
EXPECT_EQ(loaded.shieldedScanHeights.at("zs-beta"), 120);
|
||
EXPECT_EQ(loaded.shieldedScanHeights.at("zs-archive"), 118);
|
||
}
|
||
|
||
{
|
||
TransactionHistoryCache cache(databasePath.string());
|
||
EXPECT_TRUE(cache.unlockWithPassphrase(walletIdentity, passphrase));
|
||
EXPECT_TRUE(cache.replace(walletIdentity, 120, "tip-120", transactions, 1700000250));
|
||
auto loaded = cache.load(walletIdentity, 120, "tip-120");
|
||
EXPECT_TRUE(loaded.loaded);
|
||
EXPECT_EQ(loaded.shieldedScanHeights.size(), static_cast<size_t>(0));
|
||
}
|
||
|
||
{
|
||
TransactionHistoryCache staleTip(databasePath.string());
|
||
EXPECT_TRUE(staleTip.unlockWithPassphrase(walletIdentity, passphrase));
|
||
auto invalidated = staleTip.load(walletIdentity, 119, "tip-119");
|
||
EXPECT_FALSE(invalidated.loaded);
|
||
EXPECT_TRUE(invalidated.invalidated);
|
||
EXPECT_EQ(staleTip.snapshotCount(), 0);
|
||
}
|
||
|
||
{
|
||
TransactionHistoryCache cache(databasePath.string());
|
||
EXPECT_TRUE(cache.unlockWithPassphrase(walletIdentity, passphrase));
|
||
EXPECT_TRUE(cache.replace(walletIdentity, 120, "tip-120", transactions, 1700000300));
|
||
auto invalidated = cache.load(walletIdentity, 120, "different-tip");
|
||
EXPECT_FALSE(invalidated.loaded);
|
||
EXPECT_TRUE(invalidated.invalidated);
|
||
EXPECT_EQ(cache.snapshotCount(), 0);
|
||
}
|
||
|
||
removeTree(dir);
|
||
}
|
||
|
||
// GC: generating a new address changes the wallet-identity hash; the prior hash's snapshot
|
||
// + salt must not accumulate forever. A save under a new identity prunes the old rows.
|
||
void testTransactionHistoryCachePrunesOldWallets()
|
||
{
|
||
using dragonx::TransactionInfo;
|
||
using dragonx::data::TransactionHistoryCache;
|
||
|
||
fs::path dir = makeTempDir();
|
||
fs::path dbPath = dir / "transaction_history.sqlite";
|
||
const std::string idA = "mainnet|R-a|zs-a";
|
||
const std::string idB = "mainnet|R-a|zs-a|zs-newly-generated"; // new address -> new identity
|
||
const std::string pass = "correct horse battery staple";
|
||
|
||
std::vector<TransactionInfo> txs;
|
||
txs.push_back(TransactionInfo{"tx1", "mined", 1.0, 1700000000, 10, "R-a", "", ""});
|
||
|
||
TransactionHistoryCache cache(dbPath.string());
|
||
EXPECT_TRUE(cache.unlockWithPassphrase(idA, pass));
|
||
EXPECT_TRUE(cache.replace(idA, 100, "tip-a", txs, 1700000000));
|
||
EXPECT_EQ(cache.snapshotCount(), 1);
|
||
|
||
EXPECT_TRUE(cache.unlockWithPassphrase(idB, pass));
|
||
EXPECT_TRUE(cache.replace(idB, 100, "tip-b", txs, 1700000001));
|
||
// Old wallet A's snapshot was pruned — only the live wallet's row remains.
|
||
EXPECT_EQ(cache.snapshotCount(), 1);
|
||
|
||
auto loadedB = cache.load(idB, 100, "tip-b");
|
||
EXPECT_TRUE(loadedB.loaded);
|
||
|
||
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<std::string> none;
|
||
const std::unordered_map<std::string, int> h{{"zs-a", 100}};
|
||
const std::vector<TransactionInfo> 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<size_t>(2));
|
||
|
||
// row order is irrelevant
|
||
const std::vector<TransactionInfo> 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<size_t>(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<TransactionInfo> txs{TransactionInfo{"tx1", "mined", 1.0, 1700000000, 10, "R-a", "", ""}};
|
||
|
||
TransactionHistoryCache cache(dbPath);
|
||
EXPECT_EQ(cache.keyId(), static_cast<std::uint64_t>(0));
|
||
EXPECT_TRUE(cache.unlockWithPassphrase(idA, pass));
|
||
const std::uint64_t idOfPass = cache.keyId();
|
||
EXPECT_TRUE(idOfPass != 0);
|
||
std::array<unsigned char, 32> 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<unsigned char, 32>{})); // 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<std::string> sendTxids;
|
||
std::vector<TransactionInfo> confirmedCache;
|
||
std::unordered_set<std::string> confirmedIds;
|
||
int confirmedBlock = -1;
|
||
int lastTxBlock = -1;
|
||
Refresh::TransactionCacheUpdate cacheUpdate{
|
||
viewCache,
|
||
sendTxids,
|
||
confirmedCache,
|
||
confirmedIds,
|
||
confirmedBlock,
|
||
lastTxBlock
|
||
};
|
||
Refresh::applyTransactionRefreshResult(state, cacheUpdate, std::move(refreshResult), 1700000500);
|
||
|
||
TransactionHistoryCache cache(databasePath.string());
|
||
EXPECT_TRUE(cache.unlockWithPassphrase(walletIdentity, passphrase));
|
||
EXPECT_TRUE(cache.replace(walletIdentity,
|
||
state.sync.blocks,
|
||
state.sync.best_blockhash,
|
||
state.transactions,
|
||
state.last_tx_update));
|
||
|
||
TransactionHistoryCache reopened(databasePath.string());
|
||
EXPECT_TRUE(reopened.unlockWithPassphrase(walletIdentity, passphrase));
|
||
auto loaded = reopened.load(walletIdentity, 333, "tip-333");
|
||
EXPECT_TRUE(loaded.loaded);
|
||
EXPECT_EQ(loaded.transactions.size(), static_cast<size_t>(1));
|
||
EXPECT_EQ(loaded.transactions[0].txid, std::string("rpc-refreshed-send"));
|
||
EXPECT_EQ(loaded.transactions[0].memo, std::string("rpc refreshed memo"));
|
||
EXPECT_EQ(loaded.updatedAt, static_cast<std::time_t>(1700000500));
|
||
EXPECT_EQ(lastTxBlock, 333);
|
||
EXPECT_EQ(confirmedIds.count("rpc-refreshed-send"), static_cast<size_t>(1));
|
||
|
||
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<std::size_t>(1));
|
||
EXPECT_EQ(g_liteBridgeRuntimeTrackedFreedValues[0], std::string("phase2-ok"));
|
||
|
||
auto secondResult = ownedString.intoResult();
|
||
EXPECT_FALSE(secondResult.ok);
|
||
EXPECT_EQ(g_liteBridgeRuntimeTrackedFreeCount, 1);
|
||
}
|
||
|
||
void testLiteBridgeOwnedStringClassifiesNullWithoutFree()
|
||
{
|
||
resetLiteBridgeRuntimeTrackedFree();
|
||
dragonx::wallet::LiteBridgeOwnedString ownedString(
|
||
nullptr,
|
||
&fakeLiteBridgeRuntimeTrackedFreeString);
|
||
|
||
auto result = ownedString.intoResult();
|
||
EXPECT_FALSE(result.ok);
|
||
EXPECT_TRUE(result.value.empty());
|
||
EXPECT_TRUE(result.error.find("null string") != std::string::npos);
|
||
EXPECT_FALSE(ownedString.rawPointerReceived());
|
||
EXPECT_FALSE(ownedString.copiedBeforeFree());
|
||
EXPECT_FALSE(ownedString.freed());
|
||
EXPECT_FALSE(ownedString.rawPointerEscaped());
|
||
EXPECT_EQ(g_liteBridgeRuntimeTrackedFreeCount, 0);
|
||
}
|
||
|
||
void testLiteBridgeOwnedStringClassifiesErrorAndFreesOnce()
|
||
{
|
||
resetLiteBridgeRuntimeTrackedFree();
|
||
dragonx::wallet::LiteBridgeOwnedString ownedString(
|
||
makeLiteBridgeRuntimeOwnedCString("Error: phase2 denied"),
|
||
&fakeLiteBridgeRuntimeTrackedFreeString);
|
||
|
||
auto result = ownedString.intoResult();
|
||
EXPECT_FALSE(result.ok);
|
||
EXPECT_TRUE(result.value.empty());
|
||
EXPECT_EQ(result.error, std::string("Error: phase2 denied"));
|
||
EXPECT_TRUE(ownedString.copiedBeforeFree());
|
||
EXPECT_TRUE(ownedString.freed());
|
||
EXPECT_EQ(g_liteBridgeRuntimeTrackedFreeCount, 1);
|
||
EXPECT_EQ(g_liteBridgeRuntimeTrackedFreedValues.size(), static_cast<std::size_t>(1));
|
||
EXPECT_EQ(g_liteBridgeRuntimeTrackedFreedValues[0], std::string("Error: phase2 denied"));
|
||
}
|
||
|
||
void testLiteBridgeOwnedStringMovePreventsDoubleFree()
|
||
{
|
||
resetLiteBridgeRuntimeTrackedFree();
|
||
{
|
||
dragonx::wallet::LiteBridgeOwnedString original(
|
||
makeLiteBridgeRuntimeOwnedCString("phase2-move"),
|
||
&fakeLiteBridgeRuntimeTrackedFreeString);
|
||
dragonx::wallet::LiteBridgeOwnedString moved(std::move(original));
|
||
|
||
EXPECT_FALSE(original.rawPointerReceived());
|
||
EXPECT_TRUE(moved.rawPointerReceived());
|
||
EXPECT_FALSE(moved.rawPointerEscaped());
|
||
}
|
||
|
||
EXPECT_EQ(g_liteBridgeRuntimeTrackedFreeCount, 1);
|
||
EXPECT_EQ(g_liteBridgeRuntimeTrackedFreedValues.size(), static_cast<std::size_t>(1));
|
||
EXPECT_EQ(g_liteBridgeRuntimeTrackedFreedValues[0], std::string("phase2-move"));
|
||
}
|
||
|
||
void testLiteClientBridgeUsesRuntimeOwnedStringCleanup()
|
||
{
|
||
resetLiteBridgeRuntimeTrackedFree();
|
||
g_liteBridgeRuntimeFakeCallCount = 0;
|
||
auto bridgeApi = makeCompleteFakeLiteBridgeRuntimeApi();
|
||
bridgeApi.execute = &fakeLiteBridgeRuntimeOwnedExecute;
|
||
bridgeApi.freeString = &fakeLiteBridgeRuntimeTrackedFreeString;
|
||
|
||
auto bridge = dragonx::wallet::LiteClientBridge::fromApi(bridgeApi);
|
||
auto result = bridge.execute("phase2", "{}");
|
||
|
||
EXPECT_TRUE(result.ok);
|
||
EXPECT_EQ(result.value, std::string("bridge-runtime-ok"));
|
||
EXPECT_TRUE(result.error.empty());
|
||
EXPECT_EQ(g_liteBridgeRuntimeTrackedFreeCount, 1);
|
||
EXPECT_EQ(g_liteBridgeRuntimeTrackedFreedValues.size(), static_cast<std::size_t>(1));
|
||
EXPECT_EQ(g_liteBridgeRuntimeTrackedFreedValues[0], std::string("bridge-runtime-ok"));
|
||
EXPECT_EQ(g_liteBridgeRuntimeFakeCallCount, 1);
|
||
}
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
void testGeneratedResourceBehavior()
|
||
{
|
||
const auto* themes = dragonx::resources::getEmbeddedThemes();
|
||
EXPECT_TRUE(themes != nullptr);
|
||
EXPECT_TRUE(dragonx::resources::getEmbeddedResource("__missing_resource__") == nullptr);
|
||
if (!dragonx::resources::hasEmbeddedResources()) {
|
||
EXPECT_TRUE(themes[0].data == nullptr);
|
||
EXPECT_EQ(dragonx::resources::extractBundledThemes(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<LiteWalletController>(
|
||
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 (begins with K) -> "timport"
|
||
{
|
||
auto c = openController();
|
||
const auto r = c->importKey("Kx1faketransparentwif");
|
||
EXPECT_TRUE(r.ok);
|
||
}
|
||
|
||
// import with no wallet open -> rejected (and key still wiped)
|
||
{
|
||
LiteWalletController c(liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
||
const auto r = c.importKey("secret-extended-key-main1abc");
|
||
EXPECT_FALSE(r.ok);
|
||
}
|
||
}
|
||
|
||
// M5: persistence. The backend auto-saves on new-address/import but NOT after sync/send/shield,
|
||
// so the controller must trigger `save` at those points (otherwise a ~30-min scan and sent txs
|
||
// are lost on restart). Drives the fake's save counter to prove saves happen exactly when needed.
|
||
void testLiteWalletControllerM5Persistence()
|
||
{
|
||
using namespace dragonx::wallet;
|
||
const auto liteCaps = makeWalletCapabilities(WalletBuildKind::Lite, false, true);
|
||
const LiteConnectionSettings conn = defaultLiteConnectionSettings();
|
||
|
||
auto openController = [&]() {
|
||
auto c = std::make_unique<LiteWalletController>(
|
||
liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
||
LiteWalletCreateRequest req;
|
||
(void)c->createWallet(req);
|
||
return c;
|
||
};
|
||
// Open and wait until the background sync (and its persist) has completed, so the save count
|
||
// is stable before a sub-test captures its baseline.
|
||
auto openSynced = [&]() {
|
||
auto c = openController();
|
||
for (int i = 0; i < 400 && !c->syncComplete(); ++i)
|
||
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||
return c;
|
||
};
|
||
|
||
// sync persists: completing a sync saves the freshly-scanned wallet.
|
||
{
|
||
dragonx::test::g_liteFakeSyncBlock = false;
|
||
dragonx::test::g_liteFakeSaveCount = 0;
|
||
auto c = openSynced();
|
||
EXPECT_TRUE(c->syncComplete());
|
||
EXPECT_TRUE(dragonx::test::g_liteFakeSaveCount.load() >= 1);
|
||
}
|
||
|
||
// send persists the new transaction (backend does not auto-save sends)
|
||
{
|
||
dragonx::test::g_liteFakeSendFails = false;
|
||
auto c = openSynced();
|
||
const long before = dragonx::test::g_liteFakeSaveCount.load();
|
||
LiteSendRequest req;
|
||
req.recipients.push_back({"zs1dest", 1ULL, ""});
|
||
EXPECT_TRUE(c->sendTransactionBlocking(req).ok);
|
||
EXPECT_EQ(dragonx::test::g_liteFakeSaveCount.load(), before + 1);
|
||
}
|
||
|
||
// a FAILED send does NOT save
|
||
{
|
||
dragonx::test::g_liteFakeSendFails = true;
|
||
auto c = openSynced();
|
||
const long before = dragonx::test::g_liteFakeSaveCount.load();
|
||
LiteSendRequest req;
|
||
req.recipients.push_back({"zs1dest", 1ULL, ""});
|
||
EXPECT_FALSE(c->sendTransactionBlocking(req).ok);
|
||
EXPECT_EQ(dragonx::test::g_liteFakeSaveCount.load(), before);
|
||
dragonx::test::g_liteFakeSendFails = false;
|
||
}
|
||
|
||
// shield persists
|
||
{
|
||
auto c = openSynced();
|
||
const long before = dragonx::test::g_liteFakeSaveCount.load();
|
||
EXPECT_TRUE(c->shieldFundsBlocking().ok);
|
||
EXPECT_EQ(dragonx::test::g_liteFakeSaveCount.load(), before + 1);
|
||
}
|
||
|
||
// explicit saveWallet() persists; with no wallet open it is a no-op
|
||
{
|
||
auto c = openSynced();
|
||
const long before = dragonx::test::g_liteFakeSaveCount.load();
|
||
EXPECT_TRUE(c->saveWallet());
|
||
EXPECT_EQ(dragonx::test::g_liteFakeSaveCount.load(), before + 1);
|
||
}
|
||
{
|
||
LiteWalletController c(liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
||
const long before = dragonx::test::g_liteFakeSaveCount.load();
|
||
EXPECT_FALSE(c.saveWallet());
|
||
EXPECT_EQ(dragonx::test::g_liteFakeSaveCount.load(), before);
|
||
}
|
||
}
|
||
|
||
// Encryption: the controller drives encrypt/unlock/lock/decrypt + encryptionstatus through the
|
||
// (injected fake) backend, wipes passphrases, and folds status into WalletState (encrypted/locked).
|
||
void testLiteWalletControllerEncryption()
|
||
{
|
||
using namespace dragonx::wallet;
|
||
const auto liteCaps = makeWalletCapabilities(WalletBuildKind::Lite, false, true);
|
||
const LiteConnectionSettings conn = defaultLiteConnectionSettings();
|
||
|
||
auto open = [&]() {
|
||
dragonx::test::g_liteFakeEncrypted = false;
|
||
dragonx::test::g_liteFakeLocked = false;
|
||
auto c = std::make_unique<LiteWalletController>(
|
||
liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
||
LiteWalletCreateRequest req;
|
||
(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);
|
||
}
|
||
|
||
// "<command> <args>" splits into the command token + the remainder as the single arg string
|
||
// (new zs -> the shielded-address path of the fake backend).
|
||
{
|
||
EXPECT_TRUE(controller.runConsoleCommand("new zs"));
|
||
LiteConsoleResult res;
|
||
drainConsole(res);
|
||
EXPECT_TRUE(res.ok);
|
||
EXPECT_TRUE(res.response.find("zs1fakenew") != std::string::npos);
|
||
}
|
||
|
||
// A blank line is rejected (nothing to run).
|
||
EXPECT_FALSE(controller.runConsoleCommand(" "));
|
||
}
|
||
|
||
// Async FULL lifecycle (Settings-page create/open/restore WITH passphrase/restore params) also
|
||
// fails over: the request runs off the UI thread against the preferred server, then the other
|
||
// usable defaults, finalized by pumpLifecycleResult() on the main thread.
|
||
// 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<LiteWalletController>(
|
||
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();
|
||
}
|
||
|
||
// M2: a parsed lite refresh bundle maps through to the app's WalletState (the last hop
|
||
// the Balance/Receive/Transactions tabs read), with zatoshi->DRGX conversion, z/t address
|
||
// split, transaction typing, confirmations, and sync progress.
|
||
void testLiteRefreshModelAppliesToWalletState()
|
||
{
|
||
using namespace dragonx::wallet;
|
||
|
||
LiteWalletRefreshBundle bundle;
|
||
bundle.complete = true;
|
||
bundle.successfulCommandCount = 4;
|
||
|
||
bundle.hasBalance = true;
|
||
bundle.balance.shieldedBalance = 200000000; // 2 DRGX
|
||
bundle.balance.transparentBalance = 100000000; // 1 DRGX
|
||
bundle.balance.unconfirmedBalance = 50000000; // 0.5 DRGX
|
||
|
||
bundle.hasAddresses = true;
|
||
bundle.addresses.zAddresses = {"zs1shielded"};
|
||
bundle.addresses.tAddresses = {"t1transparent"};
|
||
|
||
bundle.hasTransactions = true;
|
||
LiteTransactionRecord rec;
|
||
rec.txid = "deadbeef";
|
||
rec.datetime = 1700000000;
|
||
rec.blockHeight = 990;
|
||
rec.unconfirmed = false;
|
||
rec.direction = LiteTransactionDirection::Receive;
|
||
rec.address = "zs1shielded";
|
||
rec.amount = 150000000; // 1.5 DRGX
|
||
bundle.transactions.transactions.push_back(rec);
|
||
|
||
bundle.hasSyncStatus = true;
|
||
bundle.syncStatus.syncedBlocks = 1000;
|
||
bundle.syncStatus.totalBlocks = 1000;
|
||
bundle.syncStatus.progress = 1.0;
|
||
bundle.syncStatus.complete = true;
|
||
|
||
const auto mapped = mapLiteWalletRefreshBundle(bundle);
|
||
EXPECT_TRUE(mapped.ok);
|
||
|
||
dragonx::WalletState state;
|
||
applyLiteRefreshModelToWalletState(mapped.model, state);
|
||
|
||
EXPECT_NEAR(state.privateBalance, 2.0, 1e-9);
|
||
EXPECT_NEAR(state.transparentBalance, 1.0, 1e-9);
|
||
EXPECT_NEAR(state.totalBalance, 3.0, 1e-9);
|
||
EXPECT_NEAR(state.unconfirmedBalance, 0.5, 1e-9);
|
||
|
||
EXPECT_EQ(static_cast<int>(state.addresses.size()), 2);
|
||
EXPECT_EQ(static_cast<int>(state.z_addresses.size()), 1);
|
||
EXPECT_EQ(static_cast<int>(state.t_addresses.size()), 1);
|
||
EXPECT_EQ(state.z_addresses[0].type, std::string("shielded"));
|
||
EXPECT_EQ(state.t_addresses[0].type, std::string("transparent"));
|
||
|
||
EXPECT_EQ(static_cast<int>(state.transactions.size()), 1);
|
||
EXPECT_EQ(state.transactions[0].type, std::string("receive"));
|
||
EXPECT_NEAR(state.transactions[0].amount, 1.5, 1e-9);
|
||
EXPECT_EQ(state.transactions[0].confirmations, 11); // chain 1000 - block 990 + 1
|
||
|
||
EXPECT_EQ(state.sync.blocks, 1000);
|
||
EXPECT_EQ(state.sync.headers, 1000);
|
||
EXPECT_FALSE(state.sync.syncing);
|
||
}
|
||
|
||
// A Send record carries its recipient in outgoing_metadata, not the top-level address/memo —
|
||
// the transactions list must surface the destination + memo instead of blanks.
|
||
void testLiteSendShowsRecipientFromOutgoing()
|
||
{
|
||
using namespace dragonx::wallet;
|
||
LiteWalletRefreshBundle bundle;
|
||
bundle.complete = true;
|
||
bundle.hasTransactions = true;
|
||
LiteTransactionRecord rec;
|
||
rec.txid = "sendtx";
|
||
rec.datetime = 1700000000;
|
||
rec.unconfirmed = true;
|
||
rec.direction = LiteTransactionDirection::Send;
|
||
rec.address = ""; // sends have no top-level address
|
||
rec.memo = "";
|
||
LiteTransactionOutput out;
|
||
out.address = "zs1recipient";
|
||
out.value = 75000000; // 0.75 DRGX
|
||
out.memo = "thanks";
|
||
rec.outgoingMetadata.push_back(out);
|
||
bundle.transactions.transactions.push_back(rec);
|
||
|
||
const auto mapped = mapLiteWalletRefreshBundle(bundle);
|
||
dragonx::WalletState state;
|
||
applyLiteRefreshModelToWalletState(mapped.model, state);
|
||
|
||
EXPECT_EQ(static_cast<int>(state.transactions.size()), 1);
|
||
EXPECT_EQ(state.transactions[0].type, std::string("send"));
|
||
EXPECT_EQ(state.transactions[0].address, std::string("zs1recipient"));
|
||
EXPECT_EQ(state.transactions[0].memo, std::string("thanks"));
|
||
}
|
||
|
||
// A tolerated partial refresh where the notes/utxo command failed (hasAddresses but
|
||
// !hasSpendableOutputs) must NOT zero every per-address balance — it keeps the last-known
|
||
// values so a correct total isn't accompanied by a misleading all-zero breakdown.
|
||
void testLitePartialRefreshKeepsPriorAddressBalances()
|
||
{
|
||
using namespace dragonx::wallet;
|
||
|
||
// First, a full refresh establishes a per-address balance.
|
||
LiteWalletRefreshBundle full;
|
||
full.complete = true;
|
||
full.hasAddresses = true;
|
||
full.addresses.zAddresses = {"zs1keep"};
|
||
full.hasNotes = true;
|
||
LiteSpendableOutput note;
|
||
note.address = "zs1keep";
|
||
note.value = 120000000; // 1.2 DRGX
|
||
note.spent = false;
|
||
full.notes.unspentNotes.push_back(note);
|
||
|
||
dragonx::WalletState state;
|
||
applyLiteRefreshModelToWalletState(mapLiteWalletRefreshBundle(full).model, state);
|
||
EXPECT_EQ(static_cast<int>(state.z_addresses.size()), 1);
|
||
EXPECT_NEAR(state.z_addresses[0].balance, 1.2, 1e-9);
|
||
|
||
// Now a partial refresh: addresses present, notes command failed (hasNotes=false).
|
||
LiteWalletRefreshBundle partial;
|
||
partial.complete = true;
|
||
partial.hasAddresses = true;
|
||
partial.addresses.zAddresses = {"zs1keep"};
|
||
partial.hasNotes = false; // notes/utxo failed this cycle
|
||
|
||
applyLiteRefreshModelToWalletState(mapLiteWalletRefreshBundle(partial).model, state);
|
||
EXPECT_EQ(static_cast<int>(state.z_addresses.size()), 1);
|
||
EXPECT_NEAR(state.z_addresses[0].balance, 1.2, 1e-9); // preserved, not zeroed
|
||
}
|
||
|
||
// M2b: the controller, after a wallet is ready, auto-starts sync and refreshWalletState()
|
||
// pulls balance/addresses/transactions/syncstatus through the shared bridge into WalletState.
|
||
void testLiteWalletControllerRefreshPopulatesState()
|
||
{
|
||
using namespace dragonx::wallet;
|
||
const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, /*embeddedDaemon*/ false, /*liteBackendLinked*/ true);
|
||
const auto conn = defaultLiteConnectionSettings();
|
||
|
||
LiteWalletController controller(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
||
EXPECT_TRUE(controller.createWallet(LiteWalletCreateRequest{}).walletReady);
|
||
EXPECT_TRUE(controller.walletOpen());
|
||
EXPECT_TRUE(controller.syncStarted()); // auto-started when the wallet became ready
|
||
|
||
// Sync runs on a detached thread; the full refresh (balance/addresses) only runs once it
|
||
// completes. Wait for it (instant with the fake) so the refresh is deterministic.
|
||
for (int i = 0; i < 500 && !controller.syncComplete(); ++i)
|
||
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||
EXPECT_TRUE(controller.syncComplete());
|
||
|
||
dragonx::WalletState state;
|
||
EXPECT_TRUE(controller.refreshWalletState(state));
|
||
EXPECT_NEAR(state.privateBalance, 2.0, 1e-9);
|
||
EXPECT_NEAR(state.transparentBalance, 1.0, 1e-9);
|
||
EXPECT_EQ(static_cast<int>(state.z_addresses.size()), 1);
|
||
EXPECT_EQ(static_cast<int>(state.t_addresses.size()), 1);
|
||
EXPECT_EQ(static_cast<int>(state.transactions.size()), 1);
|
||
EXPECT_EQ(state.transactions[0].type, std::string("receive"));
|
||
EXPECT_NEAR(state.transactions[0].amount, 1.5, 1e-9);
|
||
EXPECT_EQ(state.sync.headers, 1000);
|
||
|
||
// No wallet open -> refresh is a safe no-op
|
||
LiteWalletController fresh(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
||
dragonx::WalletState empty;
|
||
EXPECT_FALSE(fresh.refreshWalletState(empty));
|
||
}
|
||
|
||
// syncstatus parser must accept both real backend shapes: idle {"syncing":"false"} and
|
||
// in-progress {"syncing":"true","synced_blocks":N,"total_blocks":M} ("syncing" is a string).
|
||
void testLiteSyncStatusParserRealShapes()
|
||
{
|
||
using namespace dragonx::wallet;
|
||
|
||
// Idle: no block counts, syncing is the string "false".
|
||
{
|
||
const auto p = parseLiteSyncStatusResponse(std::string("{\"syncing\":\"false\"}"));
|
||
EXPECT_TRUE(p.ok);
|
||
EXPECT_EQ(static_cast<long long>(p.syncStatus.syncedBlocks), 0LL);
|
||
EXPECT_TRUE(p.syncStatus.complete);
|
||
}
|
||
// In-progress: block counts present.
|
||
{
|
||
const auto p = parseLiteSyncStatusResponse(
|
||
std::string("{\"syncing\":\"true\",\"synced_blocks\":900,\"total_blocks\":1000}"));
|
||
EXPECT_TRUE(p.ok);
|
||
EXPECT_EQ(static_cast<long long>(p.syncStatus.syncedBlocks), 900LL);
|
||
EXPECT_EQ(static_cast<long long>(p.syncStatus.totalBlocks), 1000LL);
|
||
EXPECT_FALSE(p.syncStatus.complete);
|
||
EXPECT_NEAR(p.syncStatus.progress, 0.9, 1e-9);
|
||
}
|
||
// While syncing, missing block counts is still an error.
|
||
{
|
||
const auto p = parseLiteSyncStatusResponse(std::string("{\"syncing\":\"true\"}"));
|
||
EXPECT_FALSE(p.ok);
|
||
}
|
||
}
|
||
|
||
// Per-address balances: unspent notes/utxos are summed per address (spent ones excluded)
|
||
// into the WalletState address list, instead of aggregate-only zeros.
|
||
void testLitePerAddressBalances()
|
||
{
|
||
using namespace dragonx::wallet;
|
||
|
||
LiteWalletRefreshBundle bundle;
|
||
bundle.complete = true;
|
||
bundle.hasAddresses = true;
|
||
bundle.addresses.zAddresses = {"zs1aaa"};
|
||
bundle.addresses.tAddresses = {"t1bbb"};
|
||
bundle.hasNotes = true;
|
||
|
||
LiteSpendableOutput note; note.address = "zs1aaa"; note.value = 150000000; note.createdInTxid = "n1";
|
||
LiteSpendableOutput utxo; utxo.address = "t1bbb"; utxo.value = 50000000; utxo.createdInTxid = "u1";
|
||
LiteSpendableOutput spentNote; spentNote.address = "zs1aaa"; spentNote.value = 999; spentNote.spent = true; spentNote.createdInTxid = "n2";
|
||
bundle.notes.unspentNotes.push_back(note);
|
||
bundle.notes.unspentNotes.push_back(spentNote); // excluded: spent
|
||
bundle.notes.utxos.push_back(utxo);
|
||
|
||
const auto mapped = mapLiteWalletRefreshBundle(bundle);
|
||
EXPECT_TRUE(mapped.ok);
|
||
|
||
dragonx::WalletState state;
|
||
applyLiteRefreshModelToWalletState(mapped.model, state);
|
||
EXPECT_EQ(static_cast<int>(state.z_addresses.size()), 1);
|
||
EXPECT_EQ(static_cast<int>(state.t_addresses.size()), 1);
|
||
EXPECT_NEAR(state.z_addresses[0].balance, 1.5, 1e-9); // 150000000; the spent note is excluded
|
||
EXPECT_NEAR(state.t_addresses[0].balance, 0.5, 1e-9); // 50000000
|
||
}
|
||
|
||
// M3: new-address generation via the controller (backend "new" zs/R) returns the address.
|
||
void testLiteWalletControllerNewAddress()
|
||
{
|
||
using namespace dragonx::wallet;
|
||
const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, /*embeddedDaemon*/ false, /*liteBackendLinked*/ true);
|
||
const auto conn = defaultLiteConnectionSettings();
|
||
|
||
LiteWalletController controller(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
||
EXPECT_TRUE(controller.createWallet(LiteWalletCreateRequest{}).walletReady);
|
||
|
||
const auto z = controller.newAddress(/*shielded*/ true);
|
||
EXPECT_TRUE(z.ok);
|
||
EXPECT_TRUE(z.address.rfind("zs1", 0) == 0);
|
||
|
||
const auto t = controller.newAddress(/*shielded*/ false);
|
||
EXPECT_TRUE(t.ok);
|
||
EXPECT_TRUE(t.address.rfind("R1", 0) == 0);
|
||
|
||
// No wallet open -> error, no address.
|
||
LiteWalletController idle(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
||
const auto none = idle.newAddress(true);
|
||
EXPECT_FALSE(none.ok);
|
||
}
|
||
|
||
// Gateway hardening: one command's parse failure must not abort the whole refresh — the
|
||
// other commands still populate the bundle (graceful degradation against real-shape drift).
|
||
void testLiteWalletGatewayRefreshSkipsFailedCommand()
|
||
{
|
||
using namespace dragonx::wallet;
|
||
const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, /*embeddedDaemon*/ false, /*liteBackendLinked*/ true);
|
||
const auto conn = defaultLiteConnectionSettings();
|
||
|
||
auto bridge = LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi());
|
||
LiteWalletGateway gateway(caps, conn, &bridge, LiteWalletGatewayOptions{/*allowBridgeCalls*/ true});
|
||
|
||
dragonx::test::g_liteFakeBadBalance.store(true); // make only the balance command fail to parse
|
||
const auto result = gateway.refresh(LiteWalletRefreshRequest{});
|
||
dragonx::test::g_liteFakeBadBalance.store(false);
|
||
|
||
EXPECT_TRUE(result.ok); // partial success, not a total failure
|
||
EXPECT_FALSE(result.bundle.complete); // not all commands succeeded
|
||
EXPECT_FALSE(result.bundle.hasBalance); // the failed command is skipped
|
||
EXPECT_TRUE(result.bundle.hasAddresses); // the others still populate
|
||
EXPECT_TRUE(result.bundle.hasTransactions);
|
||
EXPECT_TRUE(result.bundle.hasInfo);
|
||
}
|
||
|
||
// M2b-3: opening a wallet auto-starts the background worker, which produces a refresh model
|
||
// the main thread can pick up via takeRefreshedModel() and apply to WalletState.
|
||
void testLiteWalletControllerWorkerProducesModel()
|
||
{
|
||
using namespace dragonx::wallet;
|
||
const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, /*embeddedDaemon*/ false, /*liteBackendLinked*/ true);
|
||
const auto conn = defaultLiteConnectionSettings();
|
||
|
||
LiteWalletController controller(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
||
EXPECT_TRUE(controller.createWallet(LiteWalletCreateRequest{}).walletReady); // auto-starts the worker
|
||
|
||
// The worker publishes progress-only models while syncing, then full models once synced.
|
||
// Poll until a full (balance-bearing) model arrives (sync is instant with the fake).
|
||
LiteWalletAppRefreshModel model;
|
||
bool gotFull = false;
|
||
for (int i = 0; i < 500 && !gotFull; ++i) {
|
||
LiteWalletAppRefreshModel m;
|
||
if (controller.takeRefreshedModel(m) && m.hasBalance) {
|
||
model = m;
|
||
gotFull = true;
|
||
break;
|
||
}
|
||
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
||
}
|
||
EXPECT_TRUE(gotFull);
|
||
EXPECT_TRUE(model.hasBalance);
|
||
EXPECT_TRUE(model.hasAddresses);
|
||
|
||
dragonx::WalletState state;
|
||
applyLiteRefreshModelToWalletState(model, state);
|
||
EXPECT_NEAR(state.privateBalance, 2.0, 1e-9);
|
||
|
||
// Idle controller (no wallet -> no worker) has nothing pending.
|
||
LiteWalletController idle(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
||
LiteWalletAppRefreshModel none;
|
||
EXPECT_FALSE(idle.takeRefreshedModel(none));
|
||
}
|
||
|
||
// M2b-3 hardening: the backend `sync` is a blocking, uninterruptible full scan. Destroying the
|
||
// controller while a sync is in flight must NOT hang (the sync thread is detached, not joined).
|
||
void testLiteWalletControllerShutdownDoesNotHangDuringSync()
|
||
{
|
||
using namespace dragonx::wallet;
|
||
const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, /*embeddedDaemon*/ false, /*liteBackendLinked*/ true);
|
||
const auto conn = defaultLiteConnectionSettings();
|
||
|
||
dragonx::test::g_liteFakeSyncBlock.store(true); // make the backend "sync" block indefinitely
|
||
const auto start = std::chrono::steady_clock::now();
|
||
{
|
||
LiteWalletController controller(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
||
controller.createWallet(LiteWalletCreateRequest{}); // launches the (now-blocked) sync thread
|
||
EXPECT_TRUE(controller.syncStarted());
|
||
EXPECT_FALSE(controller.syncComplete());
|
||
// controller destructs here with the sync thread still blocked -> must return promptly.
|
||
}
|
||
const auto elapsedMs = std::chrono::duration_cast<std::chrono::milliseconds>(
|
||
std::chrono::steady_clock::now() - start).count();
|
||
EXPECT_TRUE(elapsedMs < 1500); // did not wait for the (blocked) sync to finish
|
||
|
||
dragonx::test::g_liteFakeSyncBlock.store(false); // release the detached sync thread
|
||
std::this_thread::sleep_for(std::chrono::milliseconds(50)); // let it unwind cleanly
|
||
}
|
||
|
||
// M5b: lite-wallet rollout / kill-switch policy (wallet/lite_rollout_policy.h).
|
||
void testLiteRolloutVersionCompare()
|
||
{
|
||
using namespace dragonx::wallet;
|
||
EXPECT_EQ(compareLiteVersions("1.2.0", "1.2.0"), 0);
|
||
EXPECT_EQ(compareLiteVersions("1.2.0", "1.2"), 0); // missing components treated as 0
|
||
EXPECT_EQ(compareLiteVersions("1.2.0-rc1", "1.2.0"), 0); // pre-release suffix ignored
|
||
EXPECT_EQ(compareLiteVersions("1.2.0", "1.3.0"), -1);
|
||
EXPECT_EQ(compareLiteVersions("2.0.0", "1.9.9"), 1);
|
||
EXPECT_EQ(compareLiteVersions("1.2.10", "1.2.9"), 1); // numeric, not lexical
|
||
}
|
||
|
||
void testLiteRolloutBucketAndOverrideHelpers()
|
||
{
|
||
using namespace dragonx::wallet;
|
||
const int a = liteRolloutBucketFromInstallId("install-abc");
|
||
EXPECT_EQ(a, liteRolloutBucketFromInstallId("install-abc")); // deterministic
|
||
EXPECT_TRUE(a >= 0 && a < 1000); // in range
|
||
EXPECT_EQ(liteRolloutBucketFromInstallId(""), 0); // empty -> 0
|
||
EXPECT_TRUE(liteRolloutBucketFromInstallId("install-abc")
|
||
!= liteRolloutBucketFromInstallId("install-xyz"));
|
||
|
||
EXPECT_EQ(std::string(liteRolloutOverrideToString(liteRolloutOverrideFromString("force_on"))),
|
||
std::string("force_on"));
|
||
EXPECT_EQ(std::string(liteRolloutOverrideToString(liteRolloutOverrideFromString("force_off"))),
|
||
std::string("force_off"));
|
||
EXPECT_EQ(std::string(liteRolloutOverrideToString(liteRolloutOverrideFromString("auto"))),
|
||
std::string("auto"));
|
||
EXPECT_EQ(std::string(liteRolloutOverrideToString(liteRolloutOverrideFromString("garbage"))),
|
||
std::string("auto")); // unknown -> auto
|
||
}
|
||
|
||
void testLiteRolloutPolicyOverridesAndKillSwitch()
|
||
{
|
||
using namespace dragonx::wallet;
|
||
LiteRolloutInputs in;
|
||
in.appVersion = "1.2.0";
|
||
|
||
// No manifest, auto -> allowed (fail-open).
|
||
EXPECT_TRUE(evaluateLiteRollout(in).allowed);
|
||
|
||
// force_off -> disabled by local override.
|
||
in.override = LiteRolloutOverride::ForceOff;
|
||
auto off = evaluateLiteRollout(in);
|
||
EXPECT_FALSE(off.allowed);
|
||
EXPECT_TRUE(off.status == LiteRolloutStatus::DisabledByLocalOverride);
|
||
|
||
// force_on -> allowed, even with a disabling manifest.
|
||
in.override = LiteRolloutOverride::ForceOn;
|
||
in.manifest.present = true; in.manifest.valid = true; in.manifest.globalEnabled = false;
|
||
EXPECT_TRUE(evaluateLiteRollout(in).allowed);
|
||
|
||
// env kill-switch is absolute: disables even with force_on.
|
||
in.killSwitchEnv = true;
|
||
auto killed = evaluateLiteRollout(in);
|
||
EXPECT_FALSE(killed.allowed);
|
||
EXPECT_TRUE(killed.status == LiteRolloutStatus::DisabledByKillSwitchEnv);
|
||
}
|
||
|
||
void testLiteRolloutPolicyManifestGates()
|
||
{
|
||
using namespace dragonx::wallet;
|
||
LiteRolloutInputs in;
|
||
in.appVersion = "1.2.0";
|
||
in.manifest.present = true;
|
||
in.manifest.valid = true;
|
||
|
||
// Global disable carries the manifest message through to the decision.
|
||
in.manifest.globalEnabled = false;
|
||
in.manifest.message = "paused for maintenance";
|
||
{
|
||
auto d = evaluateLiteRollout(in);
|
||
EXPECT_FALSE(d.allowed);
|
||
EXPECT_TRUE(d.status == LiteRolloutStatus::DisabledByManifest);
|
||
EXPECT_EQ(d.message, std::string("paused for maintenance"));
|
||
}
|
||
in.manifest.globalEnabled = true;
|
||
in.manifest.message.clear();
|
||
|
||
// Version floor / ceiling / blocklist.
|
||
in.manifest.minVersion = "1.3.0";
|
||
EXPECT_TRUE(evaluateLiteRollout(in).status == LiteRolloutStatus::DisabledUnsupportedVersion);
|
||
in.manifest.minVersion.clear();
|
||
in.manifest.maxVersion = "1.1.0";
|
||
EXPECT_TRUE(evaluateLiteRollout(in).status == LiteRolloutStatus::DisabledUnsupportedVersion);
|
||
in.manifest.maxVersion.clear();
|
||
in.manifest.blockedVersions = {"1.2.0"};
|
||
EXPECT_TRUE(evaluateLiteRollout(in).status == LiteRolloutStatus::DisabledBlockedVersion);
|
||
in.manifest.blockedVersions.clear();
|
||
|
||
// Within range and not blocked -> allowed.
|
||
in.manifest.minVersion = "1.0.0";
|
||
in.manifest.maxVersion = "2.0.0";
|
||
EXPECT_TRUE(evaluateLiteRollout(in).allowed);
|
||
}
|
||
|
||
void testLiteRolloutPolicyStagedRollout()
|
||
{
|
||
using namespace dragonx::wallet;
|
||
LiteRolloutInputs in;
|
||
in.appVersion = "1.2.0";
|
||
in.manifest.present = true;
|
||
in.manifest.valid = true;
|
||
in.manifest.globalEnabled = true;
|
||
|
||
in.manifest.rolloutPermille = 0; // nobody (bucket 0 >= 0)
|
||
in.installBucket = 0;
|
||
EXPECT_FALSE(evaluateLiteRollout(in).allowed);
|
||
|
||
in.manifest.rolloutPermille = 500; // bucket < permille is in
|
||
in.installBucket = 499;
|
||
EXPECT_TRUE(evaluateLiteRollout(in).allowed);
|
||
in.installBucket = 500;
|
||
auto out = evaluateLiteRollout(in);
|
||
EXPECT_FALSE(out.allowed);
|
||
EXPECT_TRUE(out.status == LiteRolloutStatus::DisabledByStagedRollout);
|
||
|
||
in.manifest.rolloutPermille = 1000; // everyone (bucket 999 < 1000)
|
||
in.installBucket = 999;
|
||
EXPECT_TRUE(evaluateLiteRollout(in).allowed);
|
||
}
|
||
|
||
void testLiteRolloutManifestLoader()
|
||
{
|
||
using namespace dragonx::wallet;
|
||
// Missing file -> not present -> fail-open allowed.
|
||
auto missing = loadLiteRolloutManifestFromFile(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<char>(f)), std::istreambuf_iterator<char>());
|
||
}
|
||
|
||
void testXmrigReleaseParsing()
|
||
{
|
||
using namespace dragonx::util;
|
||
const auto rel = parseXmrigRelease(readXmrigFixture());
|
||
EXPECT_TRUE(rel.ok);
|
||
EXPECT_EQ(rel.tag, std::string("v1.0.0"));
|
||
EXPECT_EQ(rel.assets.size(), static_cast<std::size_t>(2));
|
||
// Both assets carry a name + a usable download URL.
|
||
for (const auto& a : rel.assets) {
|
||
EXPECT_TRUE(!a.name.empty());
|
||
EXPECT_TRUE(a.downloadUrl.rfind("https://", 0) == 0);
|
||
EXPECT_TRUE(a.size > 0);
|
||
}
|
||
// Garbage input fails closed.
|
||
EXPECT_FALSE(parseXmrigRelease("not json at all").ok);
|
||
EXPECT_FALSE(parseXmrigRelease("{}").ok); // no tag_name
|
||
}
|
||
|
||
void testXmrigAssetSelection()
|
||
{
|
||
using namespace dragonx::util;
|
||
const auto rel = parseXmrigRelease(readXmrigFixture());
|
||
const int linux = selectXmrigAsset(rel, "linux-x64");
|
||
const int win = selectXmrigAsset(rel, "win-x64");
|
||
EXPECT_TRUE(linux >= 0);
|
||
EXPECT_TRUE(win >= 0);
|
||
EXPECT_TRUE(linux != win);
|
||
// 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<std::size_t>(1));
|
||
EXPECT_EQ(linuxNames.front(), std::string("xmrig"));
|
||
const auto winNames = xmrigExtractBasenames("win-x64");
|
||
EXPECT_EQ(winNames.size(), static_cast<std::size_t>(2));
|
||
EXPECT_EQ(winNames.front(), std::string("xmrig.exe")); // miner binary is always first
|
||
EXPECT_TRUE(std::find(winNames.begin(), winNames.end(), std::string("WinRing0x64.sys"))
|
||
!= winNames.end());
|
||
}
|
||
|
||
void testXmrigSignatureAssetSelection()
|
||
{
|
||
using namespace dragonx::util;
|
||
XmrigRelease rel; rel.ok = true; rel.tag = "v1";
|
||
rel.assets.push_back({"drg-xmrig-linux-x64.zip", "https://x/zip", 100});
|
||
EXPECT_EQ(selectXmrigSignatureAsset(rel, "drg-xmrig-linux-x64.zip"), -1); // none published
|
||
rel.assets.push_back({"drg-xmrig-linux-x64.zip.sig", "https://x/sig", 64});
|
||
EXPECT_TRUE(selectXmrigSignatureAsset(rel, "drg-xmrig-linux-x64.zip") >= 0);
|
||
EXPECT_EQ(selectXmrigSignatureAsset(rel, "other.zip"), -1);
|
||
}
|
||
|
||
void testXmrigPinnedKeyValidity()
|
||
{
|
||
using namespace dragonx::util;
|
||
const std::string pin = kXmrigSignaturePublicKeyBase64;
|
||
if (pin.empty()) return; // signing disabled -> nothing to validate
|
||
// A non-empty pinned key MUST decode to exactly a 32-byte ed25519 public key, or signature
|
||
// verification would silently never succeed. Guards against a malformed/truncated paste.
|
||
EXPECT_TRUE(sodium_init() >= 0);
|
||
unsigned char pk[crypto_sign_PUBLICKEYBYTES];
|
||
std::size_t n = 0; const char* end = nullptr;
|
||
const int rc = sodium_base642bin(pk, sizeof(pk), pin.data(), pin.size(), " \t\r\n",
|
||
&n, &end, sodium_base64_VARIANT_ORIGINAL);
|
||
EXPECT_EQ(rc, 0);
|
||
EXPECT_EQ(n, static_cast<std::size_t>(crypto_sign_PUBLICKEYBYTES));
|
||
}
|
||
|
||
// Lite Network tab pure helpers.
|
||
void testLiteServerHostParsing()
|
||
{
|
||
using dragonx::util::liteServerHost;
|
||
EXPECT_EQ(liteServerHost("https://lite.dragonx.is"), std::string("lite.dragonx.is"));
|
||
EXPECT_EQ(liteServerHost("https://lite.dragonx.is:443/path"), std::string("lite.dragonx.is:443"));
|
||
EXPECT_EQ(liteServerHost("http://1.2.3.4:9067"), std::string("1.2.3.4:9067"));
|
||
EXPECT_EQ(liteServerHost("https://user@host.example/x?y"), std::string("host.example"));
|
||
EXPECT_EQ(liteServerHost("lite.dragonx.is"), std::string("lite.dragonx.is")); // no scheme
|
||
}
|
||
|
||
void testLiteOfficialServerDetection()
|
||
{
|
||
using dragonx::wallet::isOfficialLiteServer;
|
||
EXPECT_TRUE(isOfficialLiteServer("https://lite.dragonx.is"));
|
||
EXPECT_TRUE(isOfficialLiteServer("https://lite3.dragonx.is"));
|
||
EXPECT_TRUE(isOfficialLiteServer(" https://lite.dragonx.is ")); // trimmed
|
||
EXPECT_FALSE(isOfficialLiteServer("https://my-custom-server.example"));
|
||
EXPECT_FALSE(isOfficialLiteServer(""));
|
||
}
|
||
|
||
// 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<int> 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<std::mutex> l(m); started = true; }
|
||
cv.notify_all();
|
||
std::unique_lock<std::mutex> l(m);
|
||
cv.wait(l, [&] { return release; });
|
||
ran.push_back(1);
|
||
return true;
|
||
});
|
||
{ std::unique_lock<std::mutex> 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<std::mutex> l(m); release = true; }
|
||
cv.notify_all();
|
||
EXPECT_TRUE(w.flush(std::chrono::seconds(5)));
|
||
EXPECT_EQ(ran.size(), static_cast<size_t>(2));
|
||
EXPECT_EQ(ran.back(), 4);
|
||
EXPECT_EQ(w.supersededCount(), static_cast<size_t>(2));
|
||
}
|
||
{ // distinct keys all run; stop() drains; failure reported once; a throw is contained
|
||
CoalescingWriter w;
|
||
std::atomic<int> 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<size_t>(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<std::pair<std::string, std::string>> calls;
|
||
bool hold = false; // jobs block until released
|
||
int entered = 0; // jobs that have started
|
||
};
|
||
auto r = std::make_shared<Rec>();
|
||
// 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<std::mutex> 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<std::mutex> lk(r->m);
|
||
r->calls.clear(); r->hold = hold; r->entered = 0;
|
||
};
|
||
auto release = [r] {
|
||
{ std::lock_guard<std::mutex> lk(r->m); r->hold = false; }
|
||
r->cv.notify_all();
|
||
};
|
||
auto waitEntered = [r](int n) {
|
||
std::unique_lock<std::mutex> lk(r->m);
|
||
return r->cv.wait_for(lk, ms(3000), [&] { return r->entered >= n; });
|
||
};
|
||
using Call = std::pair<std::string, std::string>;
|
||
|
||
{ // 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<std::mutex> 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<std::mutex> lk(r->m);
|
||
const std::vector<Call> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<char>(in)), std::istreambuf_iterator<char>());
|
||
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<char>(in)), std::istreambuf_iterator<char>());
|
||
EXPECT_EQ(body, std::string("{\"a\":1}"));
|
||
}
|
||
|
||
// Overwrites in place (atomic replace), and leaves no stray .tmp behind.
|
||
EXPECT_TRUE(Platform::writeFileAtomically(target, "{\"a\":2,\"b\":3}"));
|
||
{
|
||
std::ifstream in(target);
|
||
std::string body((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
|
||
EXPECT_EQ(body, std::string("{\"a\":2,\"b\":3}"));
|
||
}
|
||
EXPECT_FALSE(fs::exists(target + ".tmp"));
|
||
|
||
#ifndef _WIN32
|
||
// restrictPermissions -> owner-only (0600).
|
||
const std::string secret = (dir / "vault.bin").string();
|
||
EXPECT_TRUE(Platform::writeFileAtomically(secret, "shh", /*restrictPermissions=*/true));
|
||
auto perms = fs::status(secret).permissions();
|
||
EXPECT_TRUE((perms & (fs::perms::group_all | fs::perms::others_all)) == fs::perms::none);
|
||
#endif
|
||
|
||
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<std::uint8_t> 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(""));
|
||
}
|
||
|
||
// 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.<unixtime>.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.<digits>.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<const unsigned char*>(data.data()), data.size(), sk);
|
||
auto b64 = [](const unsigned char* b, std::size_t n) {
|
||
std::vector<char> buf(sodium_base64_encoded_len(n, sodium_base64_VARIANT_ORIGINAL));
|
||
sodium_bin2base64(buf.data(), buf.size(), b, n, sodium_base64_VARIANT_ORIGINAL);
|
||
return std::string(buf.data());
|
||
};
|
||
const std::string pkB64 = b64(pk, sizeof(pk));
|
||
const std::string sigB64 = b64(sig, sizeof(sig));
|
||
|
||
// Valid base64 signature verifies; a raw 64-byte signature is also accepted.
|
||
EXPECT_TRUE(verifyXmrigSignature(data, sigB64, pkB64));
|
||
EXPECT_TRUE(verifyXmrigSignature(data, std::string(reinterpret_cast<const char*>(sig),
|
||
crypto_sign_BYTES), pkB64));
|
||
// Whitespace around the base64 signature is tolerated.
|
||
EXPECT_TRUE(verifyXmrigSignature(data, " " + sigB64 + "\n", pkB64));
|
||
|
||
// Regression: a RAW 64-byte signature whose final byte equals a whitespace value (~1.6% of
|
||
// signatures) must still verify — it must not be whitespace-trimmed. Force that case.
|
||
{
|
||
unsigned char rpk[crypto_sign_PUBLICKEYBYTES], rsk[crypto_sign_SECRETKEYBYTES],
|
||
rsig[crypto_sign_BYTES];
|
||
bool found = false;
|
||
for (int i = 0; i < 20000 && !found; ++i) {
|
||
crypto_sign_keypair(rpk, rsk);
|
||
const std::string m = "raw-ws-regression-" + std::to_string(i);
|
||
crypto_sign_detached(rsig, nullptr,
|
||
reinterpret_cast<const unsigned char*>(m.data()), m.size(), rsk);
|
||
const unsigned char last = rsig[crypto_sign_BYTES - 1];
|
||
if (last == '\n' || last == '\r' || last == ' ' || last == '\t') {
|
||
found = true;
|
||
const std::string rawSig(reinterpret_cast<const char*>(rsig), crypto_sign_BYTES);
|
||
EXPECT_TRUE(verifyXmrigSignature(m, rawSig, b64(rpk, sizeof(rpk))));
|
||
}
|
||
}
|
||
EXPECT_TRUE(found);
|
||
}
|
||
|
||
// Fails closed: tampered data, wrong key, malformed/empty inputs.
|
||
EXPECT_FALSE(verifyXmrigSignature(data + "x", sigB64, pkB64));
|
||
unsigned char pk2[crypto_sign_PUBLICKEYBYTES], sk2[crypto_sign_SECRETKEYBYTES];
|
||
crypto_sign_keypair(pk2, sk2);
|
||
EXPECT_FALSE(verifyXmrigSignature(data, sigB64, b64(pk2, sizeof(pk2))));
|
||
EXPECT_FALSE(verifyXmrigSignature(data, "not valid base64 !!!", pkB64));
|
||
EXPECT_FALSE(verifyXmrigSignature(data, sigB64, ""));
|
||
EXPECT_FALSE(verifyXmrigSignature(data, "", pkB64));
|
||
}
|
||
|
||
// 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<char>(f)), std::istreambuf_iterator<char>());
|
||
}
|
||
|
||
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<std::size_t>(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<std::size_t>(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 ( "| <archive>.zip | `<sha256>` |" ) 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<std::size_t>(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-<commit>" 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<std::size_t>(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<std::size_t>(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<std::size_t>(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
|
||
}
|
||
|
||
// 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<PoolHashrate> 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<PoolHashrate> one = {{"a", 5.0, true}, {"b", 1.0, false}};
|
||
EXPECT_EQ(chooseWeightedPool(one, "", rng), std::string("a"));
|
||
std::vector<PoolHashrate> 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<PoolHashrate> 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<HushChatTransactionMetadata> 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<ChatMessage> 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<HushChatTransactionMetadata> 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<ChatMessage> 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<HushChatTransactionMetadata> 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<ChatMessage> 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<HushChatTransactionMetadata> batch{m};
|
||
std::unordered_map<std::string, std::int64_t> 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<ChatMessage> 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<ChatMessage> loaded = db.load();
|
||
EXPECT_EQ((int)loaded.size(), 1);
|
||
EXPECT_TRUE(loaded[0].delivery == ChatDelivery::Failed);
|
||
}
|
||
|
||
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<HushChatTransactionMetadata> convX{ mkMeta("dx1", "conv-x", "x-one"),
|
||
mkMeta("dx2", "conv-x", "x-two") };
|
||
std::vector<HushChatTransactionMetadata> 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<HushChatTransactionMetadata> 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::int64_t>(std::time(nullptr))), 1);
|
||
std::vector<ChatMessage> 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::int64_t>(std::time(nullptr)) + 10;
|
||
echo.txid = "out:local1";
|
||
echo.payload_position = 0;
|
||
EXPECT_TRUE(bobSvc.recordOutgoing(echo));
|
||
std::vector<ChatMessage> 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::int64_t>(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<ChatSendOutput, 2> 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<ChatSendOutput, 2> 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
|
||
}
|
||
|
||
// 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<size_t>(1));
|
||
EXPECT_EQ(mined.newViewTxEntries.count("self-mined"), static_cast<size_t>(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<size_t>(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<size_t>(0));
|
||
|
||
// Applying the mined result drops only the retired id.
|
||
dragonx::WalletState state;
|
||
Refresh::TransactionViewCache viewCache;
|
||
std::unordered_set<std::string> sendTxids{"self-mined", "self-pending"};
|
||
std::vector<dragonx::TransactionInfo> confirmedCache;
|
||
std::unordered_set<std::string> confirmedIds;
|
||
int confirmedBlock = -1;
|
||
int lastTxBlock = -1;
|
||
Refresh::TransactionCacheUpdate cacheUpdate{viewCache, sendTxids, confirmedCache, confirmedIds,
|
||
confirmedBlock, lastTxBlock};
|
||
Refresh::applyTransactionRefreshResult(state, cacheUpdate, std::move(mined), 999);
|
||
EXPECT_EQ(sendTxids.count("self-mined"), static_cast<size_t>(0));
|
||
EXPECT_EQ(sendTxids.count("self-pending"), static_cast<size_t>(1));
|
||
EXPECT_EQ(viewCache.count("self-mined"), static_cast<size_t>(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<std::string>({"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<double>::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<std::string> oneCall = {"getblockchaininfo", "z_gettotalbalance"};
|
||
const std::vector<std::string> 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<std::int64_t>(425000000));
|
||
EXPECT_TRUE(Refresh::nextTotalBaseline(result, 425000000) == std::optional<std::int64_t>(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<std::string>({"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<std::string>({"getblockchaininfo"}));
|
||
EXPECT_FALSE(skipped.balanceAttempted);
|
||
EXPECT_TRUE(Refresh::nextTotalBaseline(skipped, 425000000) == std::optional<std::int64_t>(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<std::int64_t>(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<std::int64_t>;
|
||
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<size_t>(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<double>::quiet_NaN()), 8);
|
||
EXPECT_EQ(historyStaleAfterBlocks(30, std::numeric_limits<double>::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<size_t>(1));
|
||
EXPECT_EQ(oldestScannedAddressIndex({"a", "b", "c"}, {{"a", 50}, {"c", 60}}), static_cast<size_t>(1));
|
||
EXPECT_EQ(oldestScannedAddressIndex({}, {}), static_cast<size_t>(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<std::string> 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<std::string> addrs;
|
||
for (int i = 0; i < kAddresses; ++i) addrs.push_back("zs-d" + std::to_string(i));
|
||
std::unordered_map<std::string, int> 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<int>(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<std::string> 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<size_t>(3));
|
||
EXPECT_EQ(work.missing, static_cast<size_t>(3));
|
||
EXPECT_EQ(work.pending, static_cast<size_t>(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<std::string, int> heights{{"z1", 100}, {"z2", 100}, {"z3", 100}};
|
||
work = pass.evaluate(105, addrs, heights, 8, true);
|
||
EXPECT_EQ(work.pending, static_cast<size_t>(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<size_t>(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<size_t>(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<std::string, int> 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<std::string> addrs;
|
||
std::unordered_map<std::string, int> 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<size_t>(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<std::string> many;
|
||
std::unordered_map<std::string, int> 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<std::string>({"listtransactions", "z_listreceivedbyaddress"}));
|
||
EXPECT_EQ(newAddrRpc.calls[1].params, json::array({many[2], 0}));
|
||
EXPECT_EQ(newAddr.shieldedAddressesScanned, static_cast<size_t>(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<int>(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<std::string>({"listtransactions", "z_listsentbyaddress"}));
|
||
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, given the long timeout.
|
||
auto snap = freshSnapshot();
|
||
MockRefreshRpc rpc;
|
||
rpc.addResponse("listtransactions", json::array());
|
||
rpc.addResponse("z_listsentbyaddress", json::array());
|
||
auto result = Refresh::collectTransactionRefreshResult(rpc, snap, 110, 4);
|
||
EXPECT_EQ(rpc.calls[1].params.dump(), std::string("[\"zs-one\",0]"));
|
||
EXPECT_EQ(rpc.longCalls, 1);
|
||
EXPECT_EQ(rpc.lastLongTimeout, 120L);
|
||
EXPECT_TRUE(result.sentSweepFull && result.sentSweepOk);
|
||
}
|
||
{ // 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<std::string>({"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<std::string>({"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<size_t>(2));
|
||
EXPECT_EQ(result.unenrichedTxids.count("t2"), static_cast<size_t>(1));
|
||
EXPECT_EQ(result.unenrichedTxids.count("b1"), static_cast<size_t>(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<dragonx::TransactionInfo> 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<std::string>({
|
||
"listtransactions", "z_listreceivedbyaddress", "z_viewtransaction"}));
|
||
EXPECT_EQ(result.newViewTxEntries.count("zz-old"), static_cast<size_t>(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<std::int64_t>(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<size_t>(0));
|
||
EXPECT_EQ(result.unenrichedTxids.count("zz-pending"), static_cast<size_t>(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<std::int64_t>(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<size_t>(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<size_t>(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<size_t>(0));
|
||
EXPECT_EQ(result.unenrichedTxids.count("zz-old"), static_cast<size_t>(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<size_t>(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);
|
||
EXPECT_EQ(rpc.calls.back().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<std::string>({"listtransactions"}));
|
||
EXPECT_EQ(recent.shieldedAddressesScanned, static_cast<size_t>(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<bool>(t));
|
||
EXPECT_FALSE(static_cast<bool>(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<bool>(gov.tryAdmit(ScanStream::Balance, true, sig, 6.5))); // quiet
|
||
EXPECT_FALSE(static_cast<bool>(gov.tryAdmit(ScanStream::Balance, true, sig, 6.7))); // Addresses waited longer
|
||
EXPECT_TRUE(static_cast<bool>(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<bool>(gov.tryAdmit(ScanStream::History, false, sig, 3.5)));
|
||
EXPECT_TRUE(static_cast<bool>(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<bool>(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<bool>(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<bool>(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<bool>(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<bool>(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<bool>(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<bool>(gov.tryAdmit(ScanStream::Addresses, true, sig, 1.0))); // Balance is ahead
|
||
EXPECT_TRUE(static_cast<bool>(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<bool>(stuck));
|
||
EXPECT_FALSE(static_cast<bool>(lease.tryAdmit(ScanStream::Balance, true, sig, 599.0)));
|
||
EXPECT_TRUE(static_cast<bool>(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<bool>(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<bool>(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<bool>(gov.tryAdmit(ScanStream::Addresses, true, sig, 712.0))); // History is older
|
||
EXPECT_TRUE(static_cast<bool>(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<bool>(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<bool>(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<bool>(b));
|
||
fifo.complete(b, 0.0, ScanOutcome::Skipped, 11.0); // the node was behind: nothing sent
|
||
EXPECT_FALSE(static_cast<bool>(fifo.tryAdmit(ScanStream::Addresses, true, sig, 12.0))); // Balance kept its place
|
||
EXPECT_TRUE(static_cast<bool>(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<bool>(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<bool>(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<std::string> addresses{"za", "zb", "zc"};
|
||
std::unordered_map<std::string, int> 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<std::string> 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
|
||
|
||
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<std::string>({"getblockchaininfo", "getmempoolinfo", "z_gettotalbalance"}));
|
||
EXPECT_EQ(rpc.calls[1].params, json::array());
|
||
EXPECT_TRUE(result.mempoolFetched && result.mempoolOk);
|
||
EXPECT_EQ(result.mempoolTxs, static_cast<std::int64_t>(3));
|
||
EXPECT_EQ(result.mempoolBytes, static_cast<std::int64_t>(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<std::string>({"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<std::string>({"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<std::string>({"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<std::string>({"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<bool>(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<Clock::duration>(std::chrono::duration<double>(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<std::string>({"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<size_t>(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<std::string>({"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<std::string>({"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<std::string>({"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<std::string, Origin> origins{{"a1", Origin::Auto}, {"c1", Origin::Chat},
|
||
{"c2", Origin::Chat}, {"c3", Origin::Chat}};
|
||
auto plan = [&](const std::vector<std::string>& 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<std::int64_t>(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<bool>(bal));
|
||
EXPECT_FALSE(static_cast<bool>(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<bool>(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<bool>(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<bool>(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<std::uint64_t>(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<size_t>(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<const char*, MergeFailure> 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<double, double>{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<OpReport> 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<OpReport> 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<std::string, int> unlocked{{"A", 240}, {"B", 60}, {"C", 3}, {"D", 1}};
|
||
std::vector<FakeOp> ops;
|
||
ConsolidationRun run;
|
||
std::vector<NoteGroup> 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<size_t>(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<OpReport> 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);
|
||
}
|
||
|
||
int main()
|
||
{
|
||
testConnectionConfig();
|
||
testClassifyRpcFailure();
|
||
testFormatHttpError();
|
||
testRpcFailureKindOf();
|
||
testPaymentUri();
|
||
testAmountFormatting();
|
||
testSpendableFiltering();
|
||
testRefreshScheduler();
|
||
testRefreshSchedulerDeferFor();
|
||
testRpcBusyGate();
|
||
testRpcKindClasses();
|
||
testConnectRetryBackoff();
|
||
testFetchBackoff();
|
||
testNetworkRefreshService();
|
||
testNetworkRefreshSnapshotHelpers();
|
||
testParseDaemonRescanOutput();
|
||
testNetworkRefreshRpcCollectors();
|
||
testCoreRefreshBalancePlan();
|
||
testParseMoneyZat();
|
||
testSpendableBalanceNeeded();
|
||
testOperationFilterParams();
|
||
testOperationBatchPlan();
|
||
testHistoryScanPolicy();
|
||
testShieldedHistoryPass();
|
||
testHistoryPassWithCollector();
|
||
testHistoryCollectorSweepAndEnrichment();
|
||
testHistoryDutyModel();
|
||
testWalletScanGovernor();
|
||
testWalletScanGovernorOverload();
|
||
testProbeRotation();
|
||
testBalancePollDecisionAndVisibility();
|
||
testCoreRefreshMempool();
|
||
testRefreshSchedulerRetrySoon();
|
||
testRefreshProfileSelection();
|
||
testConnectionInfoSeeding();
|
||
testConnectScanGate();
|
||
testScanPolicy();
|
||
testChatScanPolicy();
|
||
testChooseChatPayer();
|
||
testChatScanGovernorStreams();
|
||
testAddressListOnlyCollector();
|
||
testNetworkRefreshResultModels();
|
||
testTrackedSendRetirement();
|
||
testBalanceDisplayLerp();
|
||
testOperationStatusPollParsing();
|
||
testSecureVaultScope();
|
||
testWalletSecurityController();
|
||
testWalletSecurityWorkflow();
|
||
testWalletSecurityWorkflowExecutor();
|
||
testDaemonShutdownPolicy();
|
||
testDatadirLockGate();
|
||
testDaemonProcessIdentity();
|
||
testConfSetsKey();
|
||
testOwnershipRecord();
|
||
testDebugLogTailRules();
|
||
testNoteConsolidation();
|
||
testScopeScanAndLockedNotes();
|
||
testMergeCallResult();
|
||
testOpReports();
|
||
testMergeFailureClassification();
|
||
testConsolidationRun();
|
||
testConsolidationRunSimulation();
|
||
testStopDeliveryPolicy();
|
||
testProcStatParse();
|
||
testStopProgressTracker();
|
||
testEvaluateStopWait();
|
||
testDaemonStopExecution();
|
||
testParseDaemonLogUtc();
|
||
testDaemonBusyTracker();
|
||
testShouldConfirmStop();
|
||
testOutputSinceAcrossTrim();
|
||
testChooseDaemonStatus();
|
||
testDaemonStopStringsTranslated();
|
||
testMiningAddressStringsTranslated();
|
||
testConsolidationStringsTranslated();
|
||
#ifdef _WIN32
|
||
testWindowsStopNeverKillsForeignDaemon();
|
||
#endif
|
||
#ifndef _WIN32
|
||
testExecFailureReported();
|
||
testDaemonCrashDetected();
|
||
testPosixStopReturnsAfterCleanExit();
|
||
testPosixRequestedStopIsNotACrash();
|
||
testPosixStopSigtermIsGraceful();
|
||
testPosixDestructorLeavesProcessRunning();
|
||
testPosixStopSessionDetach();
|
||
testPosixStopSessionSigterm();
|
||
testPosixStopSessionGiveUpKeepsWatching();
|
||
#endif
|
||
testPlatformEnsureDirectory();
|
||
testVerifySaplingParams();
|
||
testConnectHasStalled();
|
||
testIsLocalHost();
|
||
testAllowsPlaintextRemote();
|
||
testConsoleSecretRedaction();
|
||
testNodeStatusBanner();
|
||
testStalenessBadge();
|
||
testNotificationHistory();
|
||
testSeedMigrationResume();
|
||
testLoggerFileSink();
|
||
testDaemonLifecycleExecution();
|
||
testDaemonLifecycleAdapters();
|
||
testConsoleTextLayout();
|
||
testConsoleModel();
|
||
testPortfolioHelpers();
|
||
testMarketSeries();
|
||
testExchangeCandles();
|
||
testWalletStateAddressListRebuild();
|
||
testConsoleFoldSpans();
|
||
testConsoleScrollController();
|
||
testConsoleSelectionController();
|
||
testRendererHelpers();
|
||
testConsoleInputModel();
|
||
testMiningBenchmarkModel();
|
||
testMiningThreadCommit();
|
||
testBalanceAddressListModel();
|
||
testExplorerBlockCache();
|
||
testTransactionHistoryCache();
|
||
testTransactionHistoryCachePrunesOldWallets();
|
||
testHistorySnapshotSignature();
|
||
testHistoryStorePolicy();
|
||
testTransactionHistoryCacheBackgroundWrite();
|
||
testAddressBookScope();
|
||
testWalletIndex();
|
||
testTransactionHistoryCacheRefreshApply();
|
||
testLiteBridgeOwnedStringCopiesBeforeFreeOnSuccess();
|
||
testLiteBridgeOwnedStringClassifiesNullWithoutFree();
|
||
testLiteBridgeOwnedStringClassifiesErrorAndFreesOnce();
|
||
testLiteBridgeOwnedStringMovePreventsDoubleFree();
|
||
testLiteClientBridgeUsesRuntimeOwnedStringCleanup();
|
||
testLiteBackendInjectableFakeBridge();
|
||
testLiteWalletControllerLifecycle();
|
||
testLiteWalletControllerOpenFailover();
|
||
testLiteWalletControllerAsyncLifecycleFailover();
|
||
testLiteWalletControllerConsoleCommand();
|
||
testLiteWalletControllerM4();
|
||
testLiteWalletControllerM5Persistence();
|
||
testLiteWalletControllerEncryption();
|
||
testLiteWalletControllerCreateEncryptsWithPassphrase();
|
||
testLiteWalletControllerAsyncAppliesPassphrase();
|
||
testLiteChainNameMigration();
|
||
testLiteRefreshModelAppliesToWalletState();
|
||
testLiteSendShowsRecipientFromOutgoing();
|
||
testLitePartialRefreshKeepsPriorAddressBalances();
|
||
testLitePerAddressBalances();
|
||
testLiteWalletControllerNewAddress();
|
||
testLiteSyncStatusParserRealShapes();
|
||
testLiteWalletControllerRefreshPopulatesState();
|
||
testLiteWalletGatewayRefreshSkipsFailedCommand();
|
||
testLiteWalletControllerWorkerProducesModel();
|
||
testLiteWalletControllerShutdownDoesNotHangDuringSync();
|
||
testLiteRolloutVersionCompare();
|
||
testLiteRolloutBucketAndOverrideHelpers();
|
||
testLiteRolloutPolicyOverridesAndKillSwitch();
|
||
testLiteRolloutPolicyManifestGates();
|
||
testLiteRolloutPolicyStagedRollout();
|
||
testLiteRolloutManifestLoader();
|
||
testLiteWalletControllerRolloutGate();
|
||
testXmrigReleaseParsing();
|
||
testXmrigAssetSelection();
|
||
testXmrigChecksumParsing();
|
||
testXmrigSha256AndBasenames();
|
||
testXmrigSignatureAssetSelection();
|
||
testXmrigPinnedKeyValidity();
|
||
testXmrigSignatureVerify();
|
||
testXmrigReleaseListParsing();
|
||
testDaemonReleaseParsing();
|
||
testDaemonAssetSelection();
|
||
testDaemonChecksumParsing();
|
||
testDaemonBasenamesAndVersionCore();
|
||
testDaemonSignatureAssetSelection();
|
||
testDaemonPinnedKeyValidity();
|
||
testDaemonSignatureInterop();
|
||
testDaemonReleaseListParsing();
|
||
testLiteServerHostParsing();
|
||
testLiteOfficialServerDetection();
|
||
testPoolRegistryLookup();
|
||
testPoolHashrateParsing();
|
||
testPoolFeeParsing();
|
||
testEffectivePools();
|
||
testFormatFeePercent();
|
||
testPoolWeightedSelection();
|
||
testCoalescingWriter();
|
||
testCoalescingWriterOrdering();
|
||
testAtomicFileWrite();
|
||
#ifndef _WIN32
|
||
testSettingsBackgroundSave();
|
||
testOpenerDoesNotBlock();
|
||
#endif
|
||
testHushChatCrypto();
|
||
testHushChatReceivePath();
|
||
testHushChatService();
|
||
testHushChatDatabase();
|
||
testHushChatDeleteConversation();
|
||
testHushChatOutgoing();
|
||
testHushChatTransport();
|
||
testHushChatShuffledReceive();
|
||
testWalletFileProbe();
|
||
testAddressChecksumValidation();
|
||
testPrivateKeyImportRecognition();
|
||
testSeedPhraseHelpers();
|
||
testBlockDbOutputDiagnosis();
|
||
testLiteServerProbeLive();
|
||
testXmrigLiveInstall();
|
||
testGeneratedResourceBehavior();
|
||
|
||
if (g_failures != 0) {
|
||
std::cerr << g_failures << " test assertion(s) failed\n";
|
||
return 1;
|
||
}
|
||
std::cout << "Focused service tests passed\n";
|
||
return 0;
|
||
}
|