Opening an existing lite wallet ran synchronously on the UI thread and used a
single server, so a dead/unreachable lightwalletd server froze startup for the
connect timeout and then stranded the wallet ("disconnected" spinner) — and the
DragonX lite servers are flaky (often several down at once).
Add LiteWalletController::beginOpenExisting() / pumpAsyncOpen(): the open runs on
a background thread (mirroring the sync/broadcast shared-lifetime pattern — it
captures only shared_ptrs + value copies, never `this`), trying the preferred
server first and then every other usable default until one succeeds. The main
thread finalizes the result (flips walletOpen, starts sync) or records the reason.
The rollout gate is still checked up-front on the main thread.
App: auto-open now calls beginOpenExisting() and pumps it each tick, retrying on
a 20s interval so a transient outage self-heals once a server returns; a failed
open surfaces its reason (notification + Network tab) instead of a silent spinner.
Tested: a fake bridge that fails specific servers exercises both
preferred-dead -> fallback-opens and all-dead -> fails-with-reason. Built clean
for full-node, lite, and Windows cross-compile.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4518 lines
180 KiB
C++
4518 lines
180 KiB
C++
#include "daemon/daemon_controller.h"
|
|
#include "data/transaction_history_cache.h"
|
|
#include "daemon/lifecycle_adapters.h"
|
|
#include "data/wallet_state.h"
|
|
#include "rpc/connection.h"
|
|
#include "resources/embedded_resources.h"
|
|
#include "services/network_refresh_service.h"
|
|
#include "services/refresh_scheduler.h"
|
|
#include "services/wallet_security_controller.h"
|
|
#include "services/wallet_security_workflow.h"
|
|
#include "services/wallet_security_workflow_executor.h"
|
|
#include "ui/explorer/explorer_block_cache.h"
|
|
#include "ui/windows/balance_address_list.h"
|
|
#include "ui/windows/balance_recent_tx.h"
|
|
#include "ui/windows/console_input_model.h"
|
|
#include "ui/windows/console_output_model.h"
|
|
#include "ui/windows/console_tab_helpers.h"
|
|
#include "ui/windows/mining_benchmark.h"
|
|
#include "ui/windows/mining_pool_panel.h"
|
|
#include "ui/windows/mining_tab_helpers.h"
|
|
#include "util/address_validation.h"
|
|
#include "util/amount_format.h"
|
|
#include "util/payment_uri.h"
|
|
#include "util/platform.h"
|
|
#include "util/xmrig_updater.h"
|
|
#include "util/lite_server_probe.h"
|
|
#include "wallet/lite_connection_service.h"
|
|
#include "wallet/lite_owned_string.h"
|
|
#include "wallet/lite_rollout_policy.h"
|
|
#include "wallet/lite_wallet_controller.h"
|
|
#include "wallet/lite_wallet_gateway.h"
|
|
#include "wallet/lite_wallet_state_mapper.h"
|
|
#include "config/settings.h"
|
|
#include "data/wallet_state.h"
|
|
#include "fake_lite_backend.h"
|
|
|
|
#include <chrono>
|
|
#include <cmath>
|
|
#include <thread>
|
|
#include <atomic>
|
|
#include <deque>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <iostream>
|
|
#include <memory>
|
|
#include <new>
|
|
#include <set>
|
|
#include <stdexcept>
|
|
#include <string>
|
|
#include <type_traits>
|
|
#include <unordered_map>
|
|
#include <unordered_set>
|
|
#include <utility>
|
|
#include <vector>
|
|
#include <sodium.h>
|
|
|
|
namespace fs = std::filesystem;
|
|
|
|
namespace {
|
|
|
|
int g_failures = 0;
|
|
|
|
void expectTrue(bool value, const char* expr, const char* file, int line)
|
|
{
|
|
if (value) return;
|
|
std::cerr << file << ":" << line << " expected true: " << expr << "\n";
|
|
++g_failures;
|
|
}
|
|
|
|
template <typename T, typename U>
|
|
void expectEqual(const T& actual, const U& expected, const char* actualExpr,
|
|
const char* expectedExpr, const char* file, int line)
|
|
{
|
|
if (actual == expected) return;
|
|
std::cerr << file << ":" << line << " expected " << actualExpr << " == "
|
|
<< expectedExpr << "\n";
|
|
++g_failures;
|
|
}
|
|
|
|
void expectNear(double actual, double expected, double epsilon, const char* file, int line)
|
|
{
|
|
if (std::abs(actual - expected) <= epsilon) return;
|
|
std::cerr << file << ":" << line << " expected near " << expected
|
|
<< " (actual=" << actual << ")\n";
|
|
++g_failures;
|
|
}
|
|
|
|
#define EXPECT_TRUE(expr) expectTrue((expr), #expr, __FILE__, __LINE__)
|
|
#define EXPECT_FALSE(expr) expectTrue(!(expr), "!(" #expr ")", __FILE__, __LINE__)
|
|
#define EXPECT_EQ(actual, expected) expectEqual((actual), (expected), #actual, #expected, __FILE__, __LINE__)
|
|
#define EXPECT_NEAR(actual, expected, epsilon) expectNear((actual), (expected), (epsilon), __FILE__, __LINE__)
|
|
|
|
fs::path makeTempDir()
|
|
{
|
|
auto now = std::chrono::steady_clock::now().time_since_epoch().count();
|
|
fs::path dir = fs::temp_directory_path() / ("obsidian_phase4_tests_" + std::to_string(now));
|
|
fs::create_directories(dir);
|
|
return dir;
|
|
}
|
|
|
|
void writeTestFile(const fs::path& path, const std::string& content)
|
|
{
|
|
std::ofstream output(path, std::ios::binary);
|
|
output << content;
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
int g_liteBridgeRuntimeFakeCallCount = 0;
|
|
int g_liteBridgeRuntimeTrackedFreeCount = 0;
|
|
std::vector<std::string> g_liteBridgeRuntimeTrackedFreedValues;
|
|
|
|
char* makeLiteBridgeRuntimeOwnedCString(const std::string& value)
|
|
{
|
|
char* rawValue = new char[value.size() + 1];
|
|
for (std::size_t index = 0; index < value.size(); ++index) {
|
|
rawValue[index] = value[index];
|
|
}
|
|
rawValue[value.size()] = '\0';
|
|
return rawValue;
|
|
}
|
|
|
|
void resetLiteBridgeRuntimeTrackedFree()
|
|
{
|
|
g_liteBridgeRuntimeTrackedFreeCount = 0;
|
|
g_liteBridgeRuntimeTrackedFreedValues.clear();
|
|
}
|
|
|
|
void fakeLiteBridgeRuntimeTrackedFreeString(char* value)
|
|
{
|
|
++g_liteBridgeRuntimeTrackedFreeCount;
|
|
if (value) {
|
|
g_liteBridgeRuntimeTrackedFreedValues.push_back(value);
|
|
}
|
|
delete[] value;
|
|
}
|
|
|
|
bool fakeLiteBridgeRuntimeWalletExists(const char*)
|
|
{
|
|
++g_liteBridgeRuntimeFakeCallCount;
|
|
return true;
|
|
}
|
|
|
|
char* fakeLiteBridgeRuntimeInitializeNew(bool, const char*)
|
|
{
|
|
++g_liteBridgeRuntimeFakeCallCount;
|
|
return nullptr;
|
|
}
|
|
|
|
char* fakeLiteBridgeRuntimeInitializeNewFromPhrase(bool,
|
|
const char*,
|
|
const char*,
|
|
unsigned long long,
|
|
unsigned long long,
|
|
bool)
|
|
{
|
|
++g_liteBridgeRuntimeFakeCallCount;
|
|
return nullptr;
|
|
}
|
|
|
|
char* fakeLiteBridgeRuntimeInitializeExisting(bool, const char*)
|
|
{
|
|
++g_liteBridgeRuntimeFakeCallCount;
|
|
return nullptr;
|
|
}
|
|
|
|
char* fakeLiteBridgeRuntimeExecute(const char*, const char*)
|
|
{
|
|
++g_liteBridgeRuntimeFakeCallCount;
|
|
return nullptr;
|
|
}
|
|
|
|
char* fakeLiteBridgeRuntimeOwnedExecute(const char*, const char*)
|
|
{
|
|
++g_liteBridgeRuntimeFakeCallCount;
|
|
return makeLiteBridgeRuntimeOwnedCString("bridge-runtime-ok");
|
|
}
|
|
|
|
void fakeLiteBridgeRuntimeFreeString(char*)
|
|
{
|
|
++g_liteBridgeRuntimeFakeCallCount;
|
|
}
|
|
|
|
bool fakeLiteBridgeRuntimeCheckServerOnline(const char*)
|
|
{
|
|
++g_liteBridgeRuntimeFakeCallCount;
|
|
return true;
|
|
}
|
|
|
|
void fakeLiteBridgeRuntimeShutdown()
|
|
{
|
|
++g_liteBridgeRuntimeFakeCallCount;
|
|
}
|
|
|
|
dragonx::wallet::LiteClientBridgeApi makeCompleteFakeLiteBridgeRuntimeApi()
|
|
{
|
|
return dragonx::wallet::LiteClientBridgeApi{
|
|
&fakeLiteBridgeRuntimeWalletExists,
|
|
&fakeLiteBridgeRuntimeInitializeNew,
|
|
&fakeLiteBridgeRuntimeInitializeNewFromPhrase,
|
|
&fakeLiteBridgeRuntimeInitializeExisting,
|
|
&fakeLiteBridgeRuntimeExecute,
|
|
&fakeLiteBridgeRuntimeFreeString,
|
|
&fakeLiteBridgeRuntimeCheckServerOnline,
|
|
&fakeLiteBridgeRuntimeShutdown,
|
|
};
|
|
}
|
|
|
|
class MockWalletSecurityRpc : public dragonx::services::WalletSecurityController::RpcGateway {
|
|
public:
|
|
bool encryptResult = true;
|
|
std::string encryptError;
|
|
int encryptCalls = 0;
|
|
std::string lastPassphrase;
|
|
|
|
bool encryptWallet(const std::string& passphrase, std::string& error) override
|
|
{
|
|
++encryptCalls;
|
|
lastPassphrase = passphrase;
|
|
error = encryptError;
|
|
return encryptResult;
|
|
}
|
|
|
|
bool unlockWallet(const std::string&, int, std::string&) override { return true; }
|
|
bool exportWallet(const std::string&, long, std::string&) override { return true; }
|
|
bool importWallet(const std::string&, long, std::string&) override { return true; }
|
|
};
|
|
|
|
class MockDaemonLifecycleRuntime : public dragonx::daemon::DaemonController::LifecycleRuntime {
|
|
public:
|
|
std::vector<std::string> calls;
|
|
int deletedItems = 7;
|
|
bool startResult = true;
|
|
std::string rpcStopContext;
|
|
std::string disconnectReason;
|
|
|
|
void stopDaemonWithPolicy() override { calls.push_back("stop"); }
|
|
bool startDaemon() override
|
|
{
|
|
calls.push_back("start");
|
|
return startResult;
|
|
}
|
|
int deleteBlockchainData() override
|
|
{
|
|
calls.push_back("delete");
|
|
return deletedItems;
|
|
}
|
|
void resetOutputOffset() override { calls.push_back("reset-output"); }
|
|
void requestRpcStopAndDisconnect(const char* context, const char* reason) override
|
|
{
|
|
calls.push_back("rpc-stop-disconnect");
|
|
rpcStopContext = context ? context : "";
|
|
disconnectReason = reason ? reason : "";
|
|
}
|
|
};
|
|
|
|
class MockDaemonLifecycleTask : public dragonx::daemon::DaemonController::LifecycleTaskContext {
|
|
public:
|
|
bool isCancelled = false;
|
|
bool isShuttingDown = false;
|
|
int cancelAfterSleeps = 0;
|
|
int sleeps = 0;
|
|
int sleptMs = 0;
|
|
|
|
bool cancelled() const override { return isCancelled; }
|
|
bool shuttingDown() const override { return isShuttingDown; }
|
|
void sleepForMs(int milliseconds) override
|
|
{
|
|
++sleeps;
|
|
sleptMs += milliseconds;
|
|
if (cancelAfterSleeps > 0 && sleeps >= cancelAfterSleeps) {
|
|
isCancelled = true;
|
|
}
|
|
}
|
|
};
|
|
|
|
class MockWalletSecurityVault : public dragonx::services::WalletSecurityController::VaultGateway {
|
|
public:
|
|
bool storeResult = true;
|
|
int storeCalls = 0;
|
|
std::string lastPin;
|
|
std::string lastPassphrase;
|
|
|
|
bool storePin(const std::string& pin, const std::string& passphrase) override
|
|
{
|
|
++storeCalls;
|
|
lastPin = pin;
|
|
lastPassphrase = passphrase;
|
|
return storeResult;
|
|
}
|
|
};
|
|
|
|
class MockWorkflowRpc : public dragonx::services::WalletSecurityWorkflowExecutor::RpcGateway {
|
|
public:
|
|
bool unlockResult = true;
|
|
bool exportResult = true;
|
|
bool stopResult = true;
|
|
int probeSuccessOnCall = 1;
|
|
std::string unlockError;
|
|
std::string exportError;
|
|
std::string stopError;
|
|
std::string probeError;
|
|
int unlockCalls = 0;
|
|
int exportCalls = 0;
|
|
int stopCalls = 0;
|
|
int probeCalls = 0;
|
|
std::string lastPassphrase;
|
|
std::string lastExportFile;
|
|
|
|
bool unlockWallet(const std::string& passphrase, int, std::string& error) override
|
|
{
|
|
++unlockCalls;
|
|
lastPassphrase = passphrase;
|
|
error = unlockError;
|
|
return unlockResult;
|
|
}
|
|
|
|
bool exportWallet(const std::string& fileName, long, std::string& error) override
|
|
{
|
|
++exportCalls;
|
|
lastExportFile = fileName;
|
|
error = exportError;
|
|
return exportResult;
|
|
}
|
|
|
|
bool requestDaemonStop(std::string& error) override
|
|
{
|
|
++stopCalls;
|
|
error = stopError;
|
|
return stopResult;
|
|
}
|
|
|
|
bool probeDaemon(std::string& error) override
|
|
{
|
|
++probeCalls;
|
|
error = probeError;
|
|
return probeCalls >= probeSuccessOnCall;
|
|
}
|
|
};
|
|
|
|
class MockWorkflowFiles : public dragonx::services::WalletSecurityWorkflowExecutor::FileGateway {
|
|
public:
|
|
std::string dir = "/tmp/dragonx/";
|
|
bool backupResult = true;
|
|
std::string backupError;
|
|
int backupCalls = 0;
|
|
|
|
std::string dataDir() override { return dir; }
|
|
bool backupEncryptedWallet(const dragonx::services::WalletSecurityWorkflowExecutor::WalletFilePlan&,
|
|
std::string& error) override
|
|
{
|
|
++backupCalls;
|
|
error = backupError;
|
|
return backupResult;
|
|
}
|
|
};
|
|
|
|
class MockWorkflowDaemon : public dragonx::services::WalletSecurityWorkflowExecutor::DaemonGateway {
|
|
public:
|
|
bool usingEmbedded = true;
|
|
bool isCancelled = false;
|
|
bool isShuttingDown = false;
|
|
int cancelAfterSleeps = 0;
|
|
int sleepCalls = 0;
|
|
int sleptMs = 0;
|
|
int stopCalls = 0;
|
|
int startCalls = 0;
|
|
|
|
bool isUsingEmbeddedDaemon() const override { return usingEmbedded; }
|
|
void stopEmbeddedDaemon() override { ++stopCalls; }
|
|
bool startEmbeddedDaemon() override { ++startCalls; return true; }
|
|
bool cancelled() const override { return isCancelled; }
|
|
bool shuttingDown() const override { return isShuttingDown; }
|
|
void sleepForMs(int milliseconds) override
|
|
{
|
|
++sleepCalls;
|
|
sleptMs += milliseconds;
|
|
if (cancelAfterSleeps > 0 && sleepCalls >= cancelAfterSleeps) {
|
|
isCancelled = true;
|
|
}
|
|
}
|
|
};
|
|
|
|
class MockWorkflowImporter : public dragonx::services::WalletSecurityWorkflowExecutor::ImportGateway {
|
|
public:
|
|
bool result = true;
|
|
std::string importError;
|
|
int importCalls = 0;
|
|
std::string lastExportPath;
|
|
|
|
bool importWallet(const std::string& exportPath, long, std::string& error) override
|
|
{
|
|
++importCalls;
|
|
lastExportPath = exportPath;
|
|
error = importError;
|
|
return result;
|
|
}
|
|
};
|
|
|
|
class FakeRefreshWorker {
|
|
public:
|
|
std::size_t reportedPending = 0;
|
|
std::vector<dragonx::rpc::RPCWorker::WorkFn> tasks;
|
|
|
|
std::size_t pendingTaskCount() const { return reportedPending; }
|
|
void post(dragonx::rpc::RPCWorker::WorkFn work)
|
|
{
|
|
tasks.push_back(std::move(work));
|
|
}
|
|
dragonx::rpc::RPCWorker::MainCb runNext()
|
|
{
|
|
auto work = std::move(tasks.front());
|
|
tasks.erase(tasks.begin());
|
|
return work();
|
|
}
|
|
dragonx::rpc::RPCWorker::MainCb runAt(std::size_t index)
|
|
{
|
|
auto work = std::move(tasks[index]);
|
|
tasks.erase(tasks.begin() + static_cast<std::ptrdiff_t>(index));
|
|
return work();
|
|
}
|
|
};
|
|
|
|
class MockRefreshRpc : public dragonx::services::NetworkRefreshService::RefreshRpcGateway {
|
|
public:
|
|
struct RecordedCall {
|
|
std::string method;
|
|
nlohmann::json params;
|
|
};
|
|
|
|
struct Response {
|
|
nlohmann::json value;
|
|
bool throws = false;
|
|
std::string error = "mock rpc failure";
|
|
};
|
|
|
|
std::vector<RecordedCall> calls;
|
|
std::unordered_map<std::string, std::deque<Response>> responses;
|
|
|
|
void addResponse(const std::string& method, nlohmann::json value)
|
|
{
|
|
responses[method].push_back({std::move(value), false, {}});
|
|
}
|
|
|
|
void addFailure(const std::string& method, std::string error = "mock rpc failure")
|
|
{
|
|
responses[method].push_back({nlohmann::json(), true, std::move(error)});
|
|
}
|
|
|
|
nlohmann::json call(const std::string& method, const nlohmann::json& params) override
|
|
{
|
|
calls.push_back({method, params});
|
|
auto& queue = responses[method];
|
|
if (queue.empty()) {
|
|
throw std::runtime_error("unexpected rpc call: " + method);
|
|
}
|
|
auto response = std::move(queue.front());
|
|
queue.pop_front();
|
|
if (response.throws) throw std::runtime_error(response.error);
|
|
return response.value;
|
|
}
|
|
|
|
std::vector<std::string> methodNames() const
|
|
{
|
|
std::vector<std::string> names;
|
|
names.reserve(calls.size());
|
|
for (const auto& call : calls) names.push_back(call.method);
|
|
return names;
|
|
}
|
|
};
|
|
|
|
void testConnectionConfig()
|
|
{
|
|
using dragonx::rpc::AuthSource;
|
|
using dragonx::rpc::Connection;
|
|
|
|
fs::path dir = makeTempDir();
|
|
fs::path conf = dir / "DRAGONX.conf";
|
|
{
|
|
std::ofstream out(conf);
|
|
out << "rpcuser=alice\n"
|
|
<< "rpcpassword = secret \r\n"
|
|
<< "rpcconnect=rpc.example.test\n"
|
|
<< "rpcport=12345\n"
|
|
<< "rpctls=yes\n";
|
|
}
|
|
|
|
auto config = Connection::parseConfFile(conf.string());
|
|
EXPECT_EQ(config.rpcuser, std::string("alice"));
|
|
EXPECT_EQ(config.rpcpassword, std::string("secret"));
|
|
EXPECT_EQ(config.host, std::string("rpc.example.test"));
|
|
EXPECT_EQ(config.port, std::string("12345"));
|
|
EXPECT_TRUE(config.use_tls);
|
|
EXPECT_EQ(config.auth_source, AuthSource::ConfigFile);
|
|
EXPECT_FALSE(Connection::usesPlaintextRemote(config));
|
|
|
|
fs::path tlsConf = dir / "tls.conf";
|
|
{
|
|
std::ofstream out(tlsConf);
|
|
out << "rpcuser=bob\n"
|
|
<< "rpcpassword=remote-secret\n"
|
|
<< "rpcconnect=198.51.100.10\n"
|
|
<< "rpcssl=1\n";
|
|
}
|
|
auto tlsConfig = Connection::parseConfFile(tlsConf.string());
|
|
EXPECT_TRUE(tlsConfig.use_tls);
|
|
EXPECT_FALSE(Connection::usesPlaintextRemote(tlsConfig));
|
|
|
|
config.use_tls = false;
|
|
EXPECT_TRUE(Connection::usesPlaintextRemote(config));
|
|
config.host = "127.4.5.6";
|
|
EXPECT_FALSE(Connection::usesPlaintextRemote(config));
|
|
config.host = "[::1]";
|
|
EXPECT_FALSE(Connection::usesPlaintextRemote(config));
|
|
|
|
fs::path cookieDir = dir / "cookie";
|
|
fs::create_directories(cookieDir);
|
|
{
|
|
std::ofstream out(cookieDir / ".cookie");
|
|
out << "__cookie__:cookie-secret\r\n";
|
|
}
|
|
|
|
dragonx::rpc::ConnectionConfig cookieBase;
|
|
cookieBase.host = "rpc.example.test";
|
|
cookieBase.hush_dir = cookieDir.string();
|
|
cookieBase.auth_source = AuthSource::ConfigFile;
|
|
cookieBase.use_tls = true;
|
|
|
|
dragonx::rpc::ConnectionConfig cookieConfig;
|
|
EXPECT_TRUE(Connection::buildCookieAuthConfig(cookieBase, cookieConfig));
|
|
EXPECT_EQ(cookieConfig.rpcuser, std::string("__cookie__"));
|
|
EXPECT_EQ(cookieConfig.rpcpassword, std::string("cookie-secret"));
|
|
EXPECT_EQ(cookieConfig.auth_source, AuthSource::Cookie);
|
|
EXPECT_TRUE(cookieConfig.use_tls);
|
|
|
|
fs::remove_all(dir);
|
|
}
|
|
|
|
void testPaymentUri()
|
|
{
|
|
std::string taddr = "R" + std::string(33, 'a');
|
|
auto parsed = dragonx::util::parsePaymentURI(
|
|
"drgx:" + taddr + "?amount=1.25000000&label=Main+Wallet&memo=hello%20there&message=thanks");
|
|
|
|
EXPECT_TRUE(parsed.valid);
|
|
EXPECT_EQ(parsed.address, taddr);
|
|
EXPECT_NEAR(parsed.amount, 1.25, 0.00000001);
|
|
EXPECT_EQ(parsed.label, std::string("Main Wallet"));
|
|
EXPECT_EQ(parsed.memo, std::string("hello there"));
|
|
EXPECT_EQ(parsed.message, std::string("thanks"));
|
|
|
|
std::string zaddr = "zs" + std::string(76, 'b');
|
|
auto zparsed = dragonx::util::parsePaymentURI("hush://" + zaddr + "?amt=0.5");
|
|
EXPECT_TRUE(zparsed.valid);
|
|
EXPECT_NEAR(zparsed.amount, 0.5, 0.00000001);
|
|
|
|
auto invalid = dragonx::util::parsePaymentURI("drgx:" + taddr + "?amount=-1");
|
|
EXPECT_FALSE(invalid.valid);
|
|
EXPECT_EQ(invalid.error, std::string("Invalid negative amount"));
|
|
}
|
|
|
|
void testAmountFormatting()
|
|
{
|
|
EXPECT_EQ(dragonx::util::formatAmountFixed(1.0), std::string("1.00000000"));
|
|
EXPECT_EQ(dragonx::util::formatAmountFixed(0.1), std::string("0.10000000"));
|
|
EXPECT_EQ(dragonx::util::formatAmountFixed(0.000000019), std::string("0.00000002"));
|
|
EXPECT_EQ(dragonx::util::formatAmountFixed(12.3456, 2), std::string("12.35"));
|
|
}
|
|
|
|
void testSpendableFiltering()
|
|
{
|
|
std::vector<dragonx::AddressInfo> addresses;
|
|
addresses.push_back({"zs-view", 50.0, "shielded", false});
|
|
addresses.push_back({"zs-low", 2.0, "shielded", true});
|
|
addresses.push_back({"R-zero", 0.0, "transparent", true});
|
|
addresses.push_back({"R-high", 5.0, "transparent", true});
|
|
|
|
EXPECT_EQ(dragonx::bestSpendableAddressIndex(addresses), 3);
|
|
|
|
auto indices = dragonx::sortedSpendableAddressIndices(addresses);
|
|
EXPECT_EQ(indices.size(), static_cast<size_t>(2));
|
|
EXPECT_EQ(indices[0], static_cast<size_t>(3));
|
|
EXPECT_EQ(indices[1], static_cast<size_t>(1));
|
|
|
|
auto includeZero = dragonx::sortedSpendableAddressIndices(addresses, false);
|
|
EXPECT_EQ(includeZero.size(), static_cast<size_t>(3));
|
|
}
|
|
|
|
void testRefreshScheduler()
|
|
{
|
|
using dragonx::services::RefreshScheduler;
|
|
using Timer = RefreshScheduler::Timer;
|
|
|
|
RefreshScheduler scheduler;
|
|
scheduler.applyPage(dragonx::ui::NavPage::Overview);
|
|
EXPECT_NEAR(scheduler.intervals().core, 2.0, 0.0001);
|
|
EXPECT_NEAR(scheduler.intervals().peers, 0.0, 0.0001);
|
|
|
|
scheduler.tick(1.99f);
|
|
EXPECT_FALSE(scheduler.isDue(Timer::Core));
|
|
scheduler.tick(0.02f);
|
|
EXPECT_TRUE(scheduler.consumeDue(Timer::Core));
|
|
EXPECT_FALSE(scheduler.isDue(Timer::Core));
|
|
|
|
scheduler.markWalletMutationRefresh();
|
|
EXPECT_TRUE(scheduler.isDue(Timer::Core));
|
|
EXPECT_TRUE(scheduler.isDue(Timer::Transactions));
|
|
EXPECT_TRUE(scheduler.isDue(Timer::Addresses));
|
|
EXPECT_FALSE(scheduler.isDue(Timer::Peers));
|
|
|
|
scheduler.applyPage(dragonx::ui::NavPage::Peers);
|
|
scheduler.markDue(Timer::Peers);
|
|
EXPECT_TRUE(scheduler.consumeDue(Timer::Peers));
|
|
|
|
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, 0.0, 0.0001);
|
|
|
|
EXPECT_FALSE(scheduler.isDue(Timer::Price));
|
|
scheduler.markDue(Timer::Price);
|
|
EXPECT_TRUE(scheduler.consumeDue(Timer::Price));
|
|
|
|
EXPECT_FALSE(scheduler.shouldRefreshTransactions(100, 100, false));
|
|
EXPECT_TRUE(scheduler.shouldRefreshTransactions(-1, 100, false));
|
|
EXPECT_TRUE(scheduler.shouldRefreshTransactions(99, 100, false));
|
|
EXPECT_TRUE(scheduler.shouldRefreshTransactions(100, 100, true));
|
|
scheduler.tick(RefreshScheduler::kTxMaxAge);
|
|
EXPECT_FALSE(scheduler.shouldRefreshTransactions(100, 100, false));
|
|
EXPECT_TRUE(scheduler.isDue(Timer::TxAge));
|
|
}
|
|
|
|
void testNetworkRefreshService()
|
|
{
|
|
using dragonx::services::NetworkRefreshService;
|
|
using Job = NetworkRefreshService::Job;
|
|
using Timer = NetworkRefreshService::Timer;
|
|
|
|
NetworkRefreshService service;
|
|
service.applyPage(dragonx::ui::NavPage::Peers);
|
|
service.markImmediateRefresh();
|
|
EXPECT_TRUE(service.consumeDue(Timer::Core));
|
|
EXPECT_TRUE(service.consumeDue(Timer::Transactions));
|
|
EXPECT_TRUE(service.consumeDue(Timer::Addresses));
|
|
EXPECT_TRUE(service.consumeDue(Timer::Peers));
|
|
|
|
EXPECT_TRUE(service.beginJob(Job::Core));
|
|
EXPECT_TRUE(service.jobInProgress(Job::Core));
|
|
EXPECT_FALSE(service.beginJob(Job::Core));
|
|
service.finishJob(Job::Core);
|
|
EXPECT_FALSE(service.jobInProgress(Job::Core));
|
|
EXPECT_TRUE(service.beginJob(Job::Core));
|
|
service.resetJobs();
|
|
EXPECT_FALSE(service.jobInProgress(Job::Core));
|
|
EXPECT_TRUE(service.beginJob(Job::ConnectionInit));
|
|
EXPECT_TRUE(service.jobInProgress(Job::ConnectionInit));
|
|
service.finishJob(Job::ConnectionInit);
|
|
EXPECT_FALSE(service.jobInProgress(Job::ConnectionInit));
|
|
|
|
auto cancelOld = service.beginDispatch(Job::Core);
|
|
EXPECT_TRUE(cancelOld.accepted);
|
|
service.resetJobs();
|
|
auto cancelNew = service.beginDispatch(Job::Core);
|
|
EXPECT_TRUE(cancelNew.accepted);
|
|
service.cancelDispatch(cancelOld);
|
|
service.cancelDispatch(cancelNew);
|
|
EXPECT_EQ(service.stats(Job::Core).staleCallbacks, static_cast<uint64_t>(1));
|
|
|
|
auto ticket = service.beginDispatch(Job::Addresses, 1, 4);
|
|
EXPECT_TRUE(ticket.accepted);
|
|
auto busyTicket = service.beginDispatch(Job::Addresses, 1, 4);
|
|
EXPECT_FALSE(busyTicket.accepted);
|
|
auto stats = service.stats(Job::Addresses);
|
|
EXPECT_EQ(stats.started, static_cast<uint64_t>(1));
|
|
EXPECT_EQ(stats.skippedInFlight, static_cast<uint64_t>(1));
|
|
EXPECT_EQ(stats.lastQueueDepth, static_cast<size_t>(1));
|
|
EXPECT_TRUE(service.completeDispatch(ticket));
|
|
stats = service.stats(Job::Addresses);
|
|
EXPECT_EQ(stats.finished, static_cast<uint64_t>(1));
|
|
|
|
auto pressureTicket = service.beginDispatch(Job::Mining, 3, 3);
|
|
EXPECT_FALSE(pressureTicket.accepted);
|
|
stats = service.stats(Job::Mining);
|
|
EXPECT_EQ(stats.skippedQueuePressure, static_cast<uint64_t>(1));
|
|
EXPECT_EQ(stats.lastQueueDepth, static_cast<size_t>(3));
|
|
|
|
auto staleTicket = service.beginDispatch(Job::Peers);
|
|
EXPECT_TRUE(staleTicket.accepted);
|
|
service.resetJobs();
|
|
auto newerTicket = service.beginDispatch(Job::Peers);
|
|
EXPECT_TRUE(newerTicket.accepted);
|
|
EXPECT_FALSE(service.completeDispatch(staleTicket));
|
|
service.cancelDispatch(newerTicket);
|
|
stats = service.stats(Job::Peers);
|
|
EXPECT_EQ(stats.started, static_cast<uint64_t>(2));
|
|
EXPECT_EQ(stats.staleCallbacks, static_cast<uint64_t>(1));
|
|
|
|
FakeRefreshWorker worker;
|
|
int workerRuns = 0;
|
|
int mainRuns = 0;
|
|
auto enqueued = service.enqueue(Job::Price, worker, [&]() -> dragonx::rpc::RPCWorker::MainCb {
|
|
++workerRuns;
|
|
return [&]() { ++mainRuns; };
|
|
}, 3);
|
|
EXPECT_TRUE(enqueued.enqueued);
|
|
EXPECT_TRUE(service.jobInProgress(Job::Price));
|
|
EXPECT_EQ(worker.tasks.size(), static_cast<size_t>(1));
|
|
auto mainCallback = worker.runNext();
|
|
EXPECT_EQ(workerRuns, 1);
|
|
mainCallback();
|
|
EXPECT_EQ(mainRuns, 1);
|
|
EXPECT_FALSE(service.jobInProgress(Job::Price));
|
|
stats = service.stats(Job::Price);
|
|
EXPECT_EQ(stats.finished, static_cast<uint64_t>(1));
|
|
|
|
auto nullMainEnqueued = service.enqueue(Job::Price, worker, []() -> dragonx::rpc::RPCWorker::MainCb {
|
|
return nullptr;
|
|
}, 3);
|
|
EXPECT_TRUE(nullMainEnqueued.enqueued);
|
|
EXPECT_TRUE(service.jobInProgress(Job::Price));
|
|
auto nullMainCallback = worker.runNext();
|
|
EXPECT_TRUE(static_cast<bool>(nullMainCallback));
|
|
nullMainCallback();
|
|
EXPECT_FALSE(service.jobInProgress(Job::Price));
|
|
stats = service.stats(Job::Price);
|
|
EXPECT_EQ(stats.finished, static_cast<uint64_t>(2));
|
|
|
|
worker.reportedPending = 5;
|
|
auto unboundedPrice = service.enqueue(Job::Price, worker, []() -> dragonx::rpc::RPCWorker::MainCb {
|
|
return []() {};
|
|
}, 0);
|
|
EXPECT_TRUE(unboundedPrice.enqueued);
|
|
EXPECT_EQ(unboundedPrice.queueDepth, static_cast<size_t>(5));
|
|
auto unboundedPriceMain = worker.runNext();
|
|
unboundedPriceMain();
|
|
EXPECT_FALSE(service.jobInProgress(Job::Price));
|
|
|
|
worker.reportedPending = 3;
|
|
auto pressured = service.enqueue(Job::Mining, worker, []() -> dragonx::rpc::RPCWorker::MainCb {
|
|
return []() {};
|
|
}, 3);
|
|
EXPECT_FALSE(pressured.enqueued);
|
|
EXPECT_EQ(service.stats(Job::Mining).skippedQueuePressure, static_cast<uint64_t>(2));
|
|
|
|
FakeRefreshWorker staleWorker;
|
|
int staleMainRuns = 0;
|
|
auto first = service.enqueue(Job::Encryption, staleWorker, [&]() -> dragonx::rpc::RPCWorker::MainCb {
|
|
return [&]() { ++staleMainRuns; };
|
|
}, 3);
|
|
EXPECT_TRUE(first.enqueued);
|
|
auto oldCallback = staleWorker.runNext();
|
|
service.resetJobs();
|
|
auto second = service.enqueue(Job::Encryption, staleWorker, [&]() -> dragonx::rpc::RPCWorker::MainCb {
|
|
return [&]() { ++staleMainRuns; };
|
|
}, 3);
|
|
EXPECT_TRUE(second.enqueued);
|
|
oldCallback();
|
|
EXPECT_EQ(staleMainRuns, 0);
|
|
auto freshCallback = staleWorker.runNext();
|
|
freshCallback();
|
|
EXPECT_EQ(staleMainRuns, 1);
|
|
EXPECT_EQ(service.stats(Job::Encryption).staleCallbacks, static_cast<uint64_t>(1));
|
|
|
|
FakeRefreshWorker orderingWorker;
|
|
std::vector<std::string> callbackOrder;
|
|
auto orderedCore = service.enqueue(Job::Core, orderingWorker, [&]() -> dragonx::rpc::RPCWorker::MainCb {
|
|
callbackOrder.push_back("core-work");
|
|
return [&]() { callbackOrder.push_back("core-main"); };
|
|
}, 3);
|
|
auto orderedPrice = service.enqueue(Job::Price, orderingWorker, [&]() -> dragonx::rpc::RPCWorker::MainCb {
|
|
callbackOrder.push_back("price-work");
|
|
return [&]() { callbackOrder.push_back("price-main"); };
|
|
}, 3);
|
|
EXPECT_TRUE(orderedCore.enqueued);
|
|
EXPECT_TRUE(orderedPrice.enqueued);
|
|
auto priceMain = orderingWorker.runAt(1);
|
|
auto coreMain = orderingWorker.runAt(0);
|
|
priceMain();
|
|
coreMain();
|
|
EXPECT_TRUE(callbackOrder == std::vector<std::string>({"price-work", "core-work", "price-main", "core-main"}));
|
|
EXPECT_FALSE(service.jobInProgress(Job::Core));
|
|
EXPECT_FALSE(service.jobInProgress(Job::Price));
|
|
|
|
FakeRefreshWorker reconnectWorker;
|
|
int reconnectMainRuns = 0;
|
|
auto staleBefore = service.stats(Job::Transactions).staleCallbacks;
|
|
auto oldTransactions = service.enqueue(Job::Transactions, reconnectWorker, [&]() -> dragonx::rpc::RPCWorker::MainCb {
|
|
return [&]() { ++reconnectMainRuns; };
|
|
}, 3);
|
|
EXPECT_TRUE(oldTransactions.enqueued);
|
|
auto oldTransactionsMain = reconnectWorker.runNext();
|
|
service.resetJobs();
|
|
auto freshTransactions = service.enqueue(Job::Transactions, reconnectWorker, [&]() -> dragonx::rpc::RPCWorker::MainCb {
|
|
return [&]() { ++reconnectMainRuns; };
|
|
}, 3);
|
|
EXPECT_TRUE(freshTransactions.enqueued);
|
|
auto freshTransactionsMain = reconnectWorker.runNext();
|
|
freshTransactionsMain();
|
|
oldTransactionsMain();
|
|
EXPECT_EQ(reconnectMainRuns, 1);
|
|
EXPECT_EQ(service.stats(Job::Transactions).staleCallbacks, staleBefore + 1);
|
|
EXPECT_FALSE(service.jobInProgress(Job::Transactions));
|
|
}
|
|
|
|
void testNetworkRefreshSnapshotHelpers()
|
|
{
|
|
using Refresh = dragonx::services::NetworkRefreshService;
|
|
using nlohmann::json;
|
|
|
|
auto shielded = Refresh::buildShieldedAddressInfo("zs-viewonly", json{{"ismine", false}}, true);
|
|
EXPECT_EQ(shielded.address, std::string("zs-viewonly"));
|
|
EXPECT_EQ(shielded.type, std::string("shielded"));
|
|
EXPECT_FALSE(shielded.has_spending_key);
|
|
|
|
auto legacyShielded = Refresh::buildShieldedAddressInfo("zs-legacy", json::object(), false);
|
|
EXPECT_TRUE(legacyShielded.has_spending_key);
|
|
|
|
auto transparentAddresses = Refresh::parseTransparentAddressList(json::array({"R-one", "R-two"}));
|
|
EXPECT_EQ(transparentAddresses.size(), static_cast<size_t>(2));
|
|
EXPECT_EQ(transparentAddresses[0].type, std::string("transparent"));
|
|
|
|
std::vector<dragonx::AddressInfo> balanceAddresses = {shielded, legacyShielded};
|
|
Refresh::applyShieldedBalancesFromUnspent(balanceAddresses, json::array({
|
|
json{{"address", "zs-viewonly"}, {"amount", 1.25}},
|
|
json{{"address", "zs-viewonly"}, {"amount", 0.75}},
|
|
json{{"address", "zs-legacy"}, {"amount", 3.0}}
|
|
}));
|
|
EXPECT_NEAR(balanceAddresses[0].balance, 2.0, 0.00000001);
|
|
EXPECT_NEAR(balanceAddresses[1].balance, 3.0, 0.00000001);
|
|
|
|
std::vector<dragonx::TransactionInfo> transactions;
|
|
std::set<std::string> knownTxids;
|
|
Refresh::appendTransparentTransactions(transactions, knownTxids, json::array({
|
|
json{{"txid", "transparent-a"}, {"category", "receive"}, {"amount", 1.5},
|
|
{"timereceived", 100}, {"confirmations", 2}, {"address", "R-one"}},
|
|
json{{"txid", "transparent-b"}, {"category", "send"}, {"amount", -0.25},
|
|
{"time", 300}, {"confirmations", 12}}
|
|
}));
|
|
EXPECT_EQ(transactions.size(), static_cast<size_t>(2));
|
|
EXPECT_EQ(knownTxids.count("transparent-a"), static_cast<size_t>(1));
|
|
EXPECT_EQ(transactions[0].timestamp, static_cast<int64_t>(100));
|
|
EXPECT_EQ(transactions[1].timestamp, static_cast<int64_t>(300));
|
|
|
|
Refresh::appendShieldedReceivedTransactions(transactions, knownTxids, "zs-viewonly", json::array({
|
|
json{{"txid", "transparent-a"}, {"amount", 9.0}, {"confirmations", 1}},
|
|
json{{"txid", "shielded-change"}, {"change", true}, {"amount", 2.0}},
|
|
json{{"txid", "shielded-receive"}, {"amount", 4.0}, {"confirmations", 5},
|
|
{"time", 200}, {"memoStr", "hello"}}
|
|
}));
|
|
EXPECT_EQ(transactions.size(), static_cast<size_t>(3));
|
|
EXPECT_EQ(knownTxids.count("shielded-receive"), static_cast<size_t>(1));
|
|
EXPECT_EQ(transactions.back().address, std::string("zs-viewonly"));
|
|
EXPECT_EQ(transactions.back().memo, std::string("hello"));
|
|
|
|
Refresh::sortTransactionsNewestFirst(transactions);
|
|
EXPECT_EQ(transactions.front().txid, std::string("transparent-b"));
|
|
EXPECT_EQ(transactions.back().txid, std::string("transparent-a"));
|
|
|
|
dragonx::WalletState state;
|
|
state.z_addresses.push_back(shielded);
|
|
dragonx::AddressInfo emptyShielded;
|
|
emptyShielded.type = "shielded";
|
|
state.z_addresses.push_back(emptyShielded);
|
|
dragonx::TransactionInfo oldTransaction;
|
|
oldTransaction.txid = "old-complete";
|
|
oldTransaction.confirmations = 7;
|
|
oldTransaction.timestamp = 1000;
|
|
state.transactions.push_back(oldTransaction);
|
|
|
|
Refresh::TransactionViewCache viewCache;
|
|
viewCache["cached-view"].from_address = "zs-from";
|
|
std::unordered_set<std::string> sendTxids{"pending-send"};
|
|
auto snapshot = Refresh::buildTransactionRefreshSnapshot(state, viewCache, sendTxids);
|
|
EXPECT_EQ(snapshot.shieldedAddresses.size(), static_cast<size_t>(1));
|
|
EXPECT_EQ(snapshot.shieldedAddresses[0], std::string("zs-viewonly"));
|
|
EXPECT_EQ(snapshot.fullyEnrichedTxids.count("cached-view"), static_cast<size_t>(1));
|
|
EXPECT_EQ(snapshot.fullyEnrichedTxids.count("old-complete"), static_cast<size_t>(1));
|
|
EXPECT_EQ(snapshot.viewTxCache.size(), static_cast<size_t>(1));
|
|
EXPECT_EQ(snapshot.sendTxids.count("pending-send"), static_cast<size_t>(1));
|
|
}
|
|
|
|
void testNetworkRefreshRpcCollectors()
|
|
{
|
|
using Refresh = dragonx::services::NetworkRefreshService;
|
|
using nlohmann::json;
|
|
|
|
MockRefreshRpc warmupRpc;
|
|
warmupRpc.addResponse("getinfo", json{
|
|
{"version", 120000},
|
|
{"protocolversion", 170002},
|
|
{"blocks", 12},
|
|
{"longestchain", 15},
|
|
{"notarized", 9}
|
|
});
|
|
auto warmup = Refresh::collectWarmupPollResult(warmupRpc);
|
|
EXPECT_TRUE(warmupRpc.methodNames() == std::vector<std::string>({"getinfo"}));
|
|
EXPECT_EQ(warmupRpc.calls[0].params, json::array());
|
|
EXPECT_TRUE(warmup.ready);
|
|
EXPECT_TRUE(warmup.info.ok);
|
|
EXPECT_EQ(*warmup.info.blocks, 12);
|
|
EXPECT_EQ(*warmup.info.longestChain, 15);
|
|
EXPECT_EQ(warmup.errorMessage, std::string());
|
|
|
|
MockRefreshRpc warmupFailureRpc;
|
|
warmupFailureRpc.addFailure("getinfo", "Loading block index");
|
|
auto warmupFailure = Refresh::collectWarmupPollResult(warmupFailureRpc);
|
|
EXPECT_TRUE(warmupFailureRpc.methodNames() == std::vector<std::string>({"getinfo"}));
|
|
EXPECT_EQ(warmupFailureRpc.calls[0].params, json::array());
|
|
EXPECT_FALSE(warmupFailure.ready);
|
|
EXPECT_FALSE(warmupFailure.info.ok);
|
|
EXPECT_EQ(warmupFailure.errorMessage, std::string("Loading block index"));
|
|
|
|
MockRefreshRpc connectionRpc;
|
|
connectionRpc.addResponse("getinfo", json{
|
|
{"version", 120001},
|
|
{"protocolversion", 170003},
|
|
{"p2pport", 21769},
|
|
{"longestchain", 250},
|
|
{"blocks", 240},
|
|
{"notarized", 230}
|
|
});
|
|
connectionRpc.addResponse("getwalletinfo", json{{"unlocked_until", 0}});
|
|
auto connection = Refresh::collectConnectionInitResult(connectionRpc);
|
|
EXPECT_TRUE(connectionRpc.methodNames() == std::vector<std::string>({
|
|
"getinfo", "getwalletinfo"
|
|
}));
|
|
EXPECT_EQ(connectionRpc.calls[0].params, json::array());
|
|
EXPECT_EQ(connectionRpc.calls[1].params, json::array());
|
|
EXPECT_TRUE(connection.info.ok);
|
|
EXPECT_EQ(*connection.info.daemonVersion, 120001);
|
|
EXPECT_TRUE(connection.encryption.ok);
|
|
EXPECT_TRUE(connection.encryption.encrypted);
|
|
EXPECT_EQ(connection.encryption.unlockedUntil, static_cast<int64_t>(0));
|
|
|
|
MockRefreshRpc connectionPrefetchRpc;
|
|
connectionPrefetchRpc.addFailure("getinfo", "daemon info unavailable");
|
|
connectionPrefetchRpc.addResponse("getwalletinfo", json{{"unlocked_until", 99}});
|
|
auto prefetch = Refresh::collectConnectionInitResult(connectionPrefetchRpc);
|
|
EXPECT_TRUE(connectionPrefetchRpc.methodNames() == std::vector<std::string>({
|
|
"getinfo", "getwalletinfo"
|
|
}));
|
|
EXPECT_FALSE(prefetch.info.ok);
|
|
EXPECT_TRUE(prefetch.encryption.ok);
|
|
EXPECT_TRUE(prefetch.encryption.encrypted);
|
|
EXPECT_EQ(prefetch.encryption.unlockedUntil, static_cast<int64_t>(99));
|
|
|
|
MockRefreshRpc connectionReuseRpc;
|
|
connectionReuseRpc.addResponse("getwalletinfo", json{{"unlocked_until", 0}});
|
|
auto reusedInfo = Refresh::parseConnectionInfoResult(json{{"version", 120002}, {"blocks", 241}});
|
|
auto reusedConnection = Refresh::collectConnectionInitResult(connectionReuseRpc, reusedInfo);
|
|
EXPECT_TRUE(connectionReuseRpc.methodNames() == std::vector<std::string>({"getwalletinfo"}));
|
|
EXPECT_TRUE(reusedConnection.info.ok);
|
|
EXPECT_EQ(*reusedConnection.info.daemonVersion, 120002);
|
|
EXPECT_TRUE(reusedConnection.encryption.ok);
|
|
|
|
MockRefreshRpc coreRpc;
|
|
coreRpc.addResponse("z_gettotalbalance", json{
|
|
{"private", "3.00000000"},
|
|
{"transparent", "1.25000000"},
|
|
{"total", "4.25000000"}
|
|
});
|
|
coreRpc.addResponse("getblockchaininfo", json{
|
|
{"blocks", 150},
|
|
{"headers", 155},
|
|
{"bestblockhash", "core-best-150"},
|
|
{"verificationprogress", 0.80},
|
|
{"longestchain", 160},
|
|
{"notarized", 145}
|
|
});
|
|
auto core = Refresh::collectCoreRefreshResult(coreRpc);
|
|
EXPECT_TRUE(coreRpc.methodNames() == std::vector<std::string>({
|
|
"z_gettotalbalance", "getblockchaininfo"
|
|
}));
|
|
EXPECT_EQ(coreRpc.calls[0].params, json::array());
|
|
EXPECT_EQ(coreRpc.calls[1].params, json::array());
|
|
EXPECT_TRUE(core.balanceOk);
|
|
EXPECT_TRUE(core.blockchainOk);
|
|
EXPECT_NEAR(*core.totalBalance, 4.25, 0.00000001);
|
|
EXPECT_EQ(*core.blocks, 150);
|
|
EXPECT_EQ(*core.bestBlockHash, std::string("core-best-150"));
|
|
EXPECT_EQ(*core.longestChain, 160);
|
|
|
|
MockRefreshRpc coreFallbackRpc;
|
|
coreFallbackRpc.addFailure("z_gettotalbalance", "wallet warming up");
|
|
coreFallbackRpc.addResponse("getblockchaininfo", json{{"blocks", 8}, {"headers", 9}});
|
|
auto partialCore = Refresh::collectCoreRefreshResult(coreFallbackRpc);
|
|
EXPECT_TRUE(coreFallbackRpc.methodNames() == std::vector<std::string>({
|
|
"z_gettotalbalance", "getblockchaininfo"
|
|
}));
|
|
EXPECT_FALSE(partialCore.balanceOk);
|
|
EXPECT_TRUE(partialCore.blockchainOk);
|
|
EXPECT_EQ(*partialCore.blocks, 8);
|
|
|
|
MockRefreshRpc peerRpc;
|
|
peerRpc.addResponse("getpeerinfo", json::array({
|
|
json{{"id", 42}, {"addr", "203.0.113.7:21769"}, {"tls_verified", true}}
|
|
}));
|
|
peerRpc.addResponse("listbanned", json::array({
|
|
json{{"address", "198.51.100.9"}, {"banned_until", 4444}}
|
|
}));
|
|
auto peers = Refresh::collectPeerRefreshResult(peerRpc);
|
|
EXPECT_TRUE(peerRpc.methodNames() == std::vector<std::string>({
|
|
"getpeerinfo", "listbanned"
|
|
}));
|
|
EXPECT_EQ(peerRpc.calls[0].params, json::array());
|
|
EXPECT_EQ(peerRpc.calls[1].params, json::array());
|
|
EXPECT_EQ(peers.peers.size(), static_cast<size_t>(1));
|
|
EXPECT_EQ(peers.peers[0].id, 42);
|
|
EXPECT_TRUE(peers.peers[0].tls_verified);
|
|
EXPECT_EQ(peers.bannedPeers.size(), static_cast<size_t>(1));
|
|
EXPECT_EQ(peers.bannedPeers[0].address, std::string("198.51.100.9"));
|
|
|
|
MockRefreshRpc peerPartialRpc;
|
|
peerPartialRpc.addFailure("getpeerinfo", "peer table unavailable");
|
|
peerPartialRpc.addResponse("listbanned", json::array({
|
|
json{{"address", "203.0.113.8"}, {"banned_until", 5555}}
|
|
}));
|
|
auto partialPeers = Refresh::collectPeerRefreshResult(peerPartialRpc);
|
|
EXPECT_TRUE(peerPartialRpc.methodNames() == std::vector<std::string>({
|
|
"getpeerinfo", "listbanned"
|
|
}));
|
|
EXPECT_EQ(partialPeers.peers.size(), static_cast<size_t>(0));
|
|
EXPECT_EQ(partialPeers.bannedPeers.size(), static_cast<size_t>(1));
|
|
|
|
MockRefreshRpc miningSlowRpc;
|
|
miningSlowRpc.addResponse("getlocalsolps", json(72.5));
|
|
miningSlowRpc.addResponse("getmininginfo", json{
|
|
{"generate", true},
|
|
{"genproclimit", 6},
|
|
{"blocks", 321},
|
|
{"difficulty", 19.75},
|
|
{"networkhashps", 1250.0},
|
|
{"chain", "main"}
|
|
});
|
|
auto miningSlow = Refresh::collectMiningRefreshResult(miningSlowRpc, 128.0, true);
|
|
EXPECT_TRUE(miningSlowRpc.methodNames() == std::vector<std::string>({
|
|
"getlocalsolps", "getmininginfo"
|
|
}));
|
|
EXPECT_EQ(miningSlowRpc.calls[0].params, json::array());
|
|
EXPECT_EQ(miningSlowRpc.calls[1].params, json::array());
|
|
EXPECT_NEAR(*miningSlow.localHashrate, 72.5, 0.00000001);
|
|
EXPECT_TRUE(miningSlow.miningOk);
|
|
EXPECT_TRUE(*miningSlow.generate);
|
|
EXPECT_EQ(*miningSlow.genproclimit, 6);
|
|
EXPECT_NEAR(miningSlow.daemonMemoryMb, 128.0, 0.00000001);
|
|
|
|
MockRefreshRpc miningFastRpc;
|
|
miningFastRpc.addResponse("getlocalsolps", json(80.25));
|
|
auto miningFast = Refresh::collectMiningRefreshResult(miningFastRpc, 64.0, false);
|
|
EXPECT_TRUE(miningFastRpc.methodNames() == std::vector<std::string>({
|
|
"getlocalsolps"
|
|
}));
|
|
EXPECT_NEAR(*miningFast.localHashrate, 80.25, 0.00000001);
|
|
EXPECT_FALSE(miningFast.miningOk);
|
|
EXPECT_NEAR(miningFast.daemonMemoryMb, 64.0, 0.00000001);
|
|
|
|
MockRefreshRpc miningPartialRpc;
|
|
miningPartialRpc.addFailure("getlocalsolps", "local solps unavailable");
|
|
miningPartialRpc.addResponse("getmininginfo", json{{"generate", false}, {"blocks", 7}});
|
|
auto miningPartial = Refresh::collectMiningRefreshResult(miningPartialRpc, 12.0, true);
|
|
EXPECT_TRUE(miningPartialRpc.methodNames() == std::vector<std::string>({
|
|
"getlocalsolps", "getmininginfo"
|
|
}));
|
|
EXPECT_FALSE(miningPartial.localHashrate.has_value());
|
|
EXPECT_TRUE(miningPartial.miningOk);
|
|
EXPECT_FALSE(*miningPartial.generate);
|
|
|
|
MockRefreshRpc miningSlowOnlyRpc;
|
|
miningSlowOnlyRpc.addResponse("getmininginfo", json{{"generate", false}, {"networkhashps", 222.0}});
|
|
auto miningSlowOnly = Refresh::collectMiningRefreshResult(miningSlowOnlyRpc, 7.0, true, false);
|
|
EXPECT_TRUE(miningSlowOnlyRpc.methodNames() == std::vector<std::string>({"getmininginfo"}));
|
|
EXPECT_FALSE(miningSlowOnly.localHashrate.has_value());
|
|
EXPECT_TRUE(miningSlowOnly.miningOk);
|
|
EXPECT_NEAR(*miningSlowOnly.networkHashrate, 222.0, 0.00000001);
|
|
|
|
MockRefreshRpc addressRpc;
|
|
addressRpc.addResponse("z_listaddresses", json::array({"zs-one", "zs-two"}));
|
|
addressRpc.addResponse("z_validateaddress", json{{"ismine", true}});
|
|
addressRpc.addResponse("z_validateaddress", json{{"ismine", false}});
|
|
addressRpc.addResponse("z_listunspent", json::array({
|
|
json{{"address", "zs-one"}, {"amount", 1.0}},
|
|
json{{"address", "zs-one"}, {"amount", 0.5}}
|
|
}));
|
|
addressRpc.addResponse("getaddressesbyaccount", json::array({"R-one"}));
|
|
addressRpc.addResponse("listunspent", json::array({
|
|
json{{"address", "R-one"}, {"amount", 2.25}}
|
|
}));
|
|
|
|
auto addresses = Refresh::collectAddressRefreshResult(addressRpc);
|
|
EXPECT_TRUE(addressRpc.methodNames() == std::vector<std::string>({
|
|
"z_listaddresses", "z_validateaddress", "z_validateaddress",
|
|
"z_listunspent", "getaddressesbyaccount", "listunspent"
|
|
}));
|
|
EXPECT_EQ(addressRpc.calls[1].params, json::array({"zs-one"}));
|
|
EXPECT_EQ(addressRpc.calls[2].params, json::array({"zs-two"}));
|
|
EXPECT_EQ(addressRpc.calls[4].params, json::array({""}));
|
|
EXPECT_EQ(addresses.shieldedAddresses.size(), static_cast<size_t>(2));
|
|
EXPECT_TRUE(addresses.shieldedAddresses[0].has_spending_key);
|
|
EXPECT_FALSE(addresses.shieldedAddresses[1].has_spending_key);
|
|
EXPECT_NEAR(addresses.shieldedAddresses[0].balance, 1.5, 0.00000001);
|
|
EXPECT_EQ(addresses.transparentAddresses.size(), static_cast<size_t>(1));
|
|
EXPECT_NEAR(addresses.transparentAddresses[0].balance, 2.25, 0.00000001);
|
|
|
|
MockRefreshRpc fallbackRpc;
|
|
fallbackRpc.addResponse("z_listaddresses", json::array({"zs-fallback"}));
|
|
fallbackRpc.addFailure("z_validateaddress", "legacy daemon");
|
|
fallbackRpc.addFailure("z_listunspent", "method not found");
|
|
fallbackRpc.addResponse("z_getbalance", json(4.75));
|
|
fallbackRpc.addResponse("getaddressesbyaccount", json::array());
|
|
fallbackRpc.addResponse("listunspent", json::array());
|
|
auto fallbackAddresses = Refresh::collectAddressRefreshResult(fallbackRpc);
|
|
EXPECT_TRUE(fallbackRpc.methodNames() == std::vector<std::string>({
|
|
"z_listaddresses", "z_validateaddress", "z_listunspent",
|
|
"z_getbalance", "getaddressesbyaccount", "listunspent"
|
|
}));
|
|
EXPECT_TRUE(fallbackAddresses.shieldedAddresses[0].has_spending_key);
|
|
EXPECT_NEAR(fallbackAddresses.shieldedAddresses[0].balance, 4.75, 0.00000001);
|
|
|
|
dragonx::WalletState cachedAddressState;
|
|
cachedAddressState.z_addresses.push_back({"zs-cached", 0.0, "shielded", false});
|
|
auto addressSnapshot = Refresh::buildAddressRefreshSnapshot(cachedAddressState);
|
|
MockRefreshRpc cachedAddressRpc;
|
|
cachedAddressRpc.addResponse("z_listaddresses", json::array({"zs-cached", "zs-new"}));
|
|
cachedAddressRpc.addResponse("z_validateaddress", json{{"ismine", true}});
|
|
cachedAddressRpc.addResponse("z_listunspent", json::array());
|
|
cachedAddressRpc.addResponse("getaddressesbyaccount", json::array());
|
|
cachedAddressRpc.addResponse("listunspent", json::array());
|
|
auto cachedAddresses = Refresh::collectAddressRefreshResult(cachedAddressRpc, addressSnapshot);
|
|
EXPECT_TRUE(cachedAddressRpc.methodNames() == std::vector<std::string>({
|
|
"z_listaddresses", "z_validateaddress", "z_listunspent",
|
|
"getaddressesbyaccount", "listunspent"
|
|
}));
|
|
EXPECT_FALSE(cachedAddresses.shieldedAddresses[0].has_spending_key);
|
|
EXPECT_TRUE(cachedAddresses.shieldedAddresses[1].has_spending_key);
|
|
|
|
Refresh::TransactionRefreshSnapshot snapshot;
|
|
snapshot.shieldedAddresses = {"zs-one", "zs-two"};
|
|
snapshot.sendTxids = {"cached-send", "pending-send"};
|
|
snapshot.fullyEnrichedTxids = {"shielded-receive", "transparent-a"};
|
|
Refresh::TransactionViewCacheEntry cachedEntry;
|
|
cachedEntry.from_address = "zs-cache-from";
|
|
cachedEntry.outgoing_outputs.push_back({"zs-cache-dest", 0.20, "cached memo"});
|
|
snapshot.viewTxCache["cached-send"] = cachedEntry;
|
|
|
|
MockRefreshRpc transactionRpc;
|
|
transactionRpc.addResponse("listtransactions", json::array({
|
|
json{{"txid", "transparent-a"}, {"category", "receive"}, {"amount", 1.0},
|
|
{"time", 100}, {"confirmations", 3}, {"address", "R-one"}}
|
|
}));
|
|
transactionRpc.addResponse("z_listreceivedbyaddress", json::array({
|
|
json{{"txid", "shielded-receive"}, {"amount", 2.0}, {"confirmations", 4},
|
|
{"time", 50}, {"memoStr", "shielded memo"}}
|
|
}));
|
|
transactionRpc.addResponse("z_listreceivedbyaddress", json::array());
|
|
transactionRpc.addResponse("z_viewtransaction", json{
|
|
{"spends", json::array({json{{"address", "zs-from"}}})},
|
|
{"outputs", json::array({
|
|
json{{"outgoing", true}, {"address", "zs-dest"}, {"value", 0.40}, {"memoStr", "fresh memo"}}
|
|
})}
|
|
});
|
|
transactionRpc.addResponse("gettransaction", json{{"time", 500}, {"confirmations", 1}});
|
|
|
|
auto transactionResult = Refresh::collectTransactionRefreshResult(transactionRpc, snapshot, 321, 4);
|
|
EXPECT_TRUE(transactionRpc.methodNames() == std::vector<std::string>({
|
|
"listtransactions", "z_listreceivedbyaddress", "z_listreceivedbyaddress",
|
|
"z_viewtransaction", "gettransaction"
|
|
}));
|
|
EXPECT_EQ(transactionRpc.calls[1].params, json::array({"zs-one", 0}));
|
|
EXPECT_EQ(transactionRpc.calls[2].params, json::array({"zs-two", 0}));
|
|
EXPECT_EQ(transactionRpc.calls[3].params, json::array({"pending-send"}));
|
|
EXPECT_EQ(transactionRpc.calls[4].params, json::array({"pending-send"}));
|
|
EXPECT_EQ(transactionResult.blockHeight, 321);
|
|
EXPECT_EQ(transactionResult.newViewTxEntries.size(), static_cast<size_t>(1));
|
|
EXPECT_EQ(transactionResult.newViewTxEntries.count("pending-send"), static_cast<size_t>(1));
|
|
EXPECT_EQ(transactionResult.newViewTxEntries.at("pending-send").timestamp, static_cast<int64_t>(500));
|
|
EXPECT_EQ(transactionResult.newViewTxEntries.at("pending-send").confirmations, 1);
|
|
EXPECT_EQ(transactionResult.transactions.size(), static_cast<size_t>(4));
|
|
EXPECT_EQ(transactionResult.transactions.front().txid, std::string("pending-send"));
|
|
EXPECT_EQ(transactionResult.transactions.front().type, std::string("send"));
|
|
EXPECT_NEAR(transactionResult.transactions.front().amount, -0.40, 0.00000001);
|
|
EXPECT_EQ(transactionResult.transactions.front().timestamp, static_cast<int64_t>(500));
|
|
EXPECT_EQ(transactionResult.transactions[1].txid, std::string("transparent-a"));
|
|
|
|
Refresh::TransactionRefreshSnapshot cachedOnlySnapshot;
|
|
auto cachedOnlyEntry = cachedEntry;
|
|
cachedOnlyEntry.timestamp = 450;
|
|
cachedOnlyEntry.confirmations = 6;
|
|
cachedOnlySnapshot.viewTxCache["cached-only-send"] = cachedOnlyEntry;
|
|
cachedOnlySnapshot.fullyEnrichedTxids = {"cached-only-send"};
|
|
MockRefreshRpc cachedOnlyRpc;
|
|
cachedOnlyRpc.addResponse("listtransactions", json::array());
|
|
auto cachedOnlyResult = Refresh::collectTransactionRefreshResult(cachedOnlyRpc, cachedOnlySnapshot, 322, 4);
|
|
EXPECT_TRUE(cachedOnlyRpc.methodNames() == std::vector<std::string>({"listtransactions"}));
|
|
EXPECT_EQ(cachedOnlyResult.transactions.size(), static_cast<size_t>(1));
|
|
EXPECT_EQ(cachedOnlyResult.transactions[0].txid, std::string("cached-only-send"));
|
|
EXPECT_EQ(cachedOnlyResult.transactions[0].type, std::string("send"));
|
|
EXPECT_NEAR(cachedOnlyResult.transactions[0].amount, -0.20, 0.00000001);
|
|
EXPECT_EQ(cachedOnlyResult.transactions[0].timestamp, static_cast<int64_t>(450));
|
|
EXPECT_EQ(cachedOnlyResult.transactions[0].confirmations, 6);
|
|
|
|
Refresh::TransactionRefreshSnapshot retrySnapshot;
|
|
retrySnapshot.sendTxids = {"retry-send"};
|
|
retrySnapshot.viewTxCache["retry-send"] = Refresh::TransactionViewCacheEntry{};
|
|
retrySnapshot.fullyEnrichedTxids = {"retry-send"};
|
|
MockRefreshRpc retryRpc;
|
|
retryRpc.addResponse("listtransactions", json::array());
|
|
retryRpc.addResponse("z_viewtransaction", json{
|
|
{"spends", json::array({json{{"address", "zs-retry-from"}}})},
|
|
{"outputs", json::array({
|
|
json{{"outgoing", true}, {"address", "zs-retry-dest"}, {"value", 0.55}}
|
|
})}
|
|
});
|
|
retryRpc.addResponse("gettransaction", json{{"time", 650}, {"confirmations", 2}});
|
|
auto retryResult = Refresh::collectTransactionRefreshResult(retryRpc, retrySnapshot, 323, 4);
|
|
EXPECT_TRUE(retryRpc.methodNames() == std::vector<std::string>({
|
|
"listtransactions", "z_viewtransaction", "gettransaction"
|
|
}));
|
|
EXPECT_EQ(retryResult.transactions.size(), static_cast<size_t>(1));
|
|
EXPECT_EQ(retryResult.transactions[0].txid, std::string("retry-send"));
|
|
EXPECT_NEAR(retryResult.transactions[0].amount, -0.55, 0.00000001);
|
|
EXPECT_EQ(retryResult.newViewTxEntries.count("retry-send"), static_cast<size_t>(1));
|
|
EXPECT_EQ(retryResult.newViewTxEntries.at("retry-send").timestamp, static_cast<int64_t>(650));
|
|
EXPECT_EQ(retryResult.newViewTxEntries.at("retry-send").confirmations, 2);
|
|
|
|
Refresh::TransactionRefreshSnapshot placeholderSnapshot;
|
|
placeholderSnapshot.sendTxids = {"placeholder-send"};
|
|
MockRefreshRpc placeholderRpc;
|
|
placeholderRpc.addResponse("listtransactions", json::array());
|
|
placeholderRpc.addResponse("z_viewtransaction", json{{"spends", json::array()}, {"outputs", json::array()}});
|
|
placeholderRpc.addResponse("gettransaction", json{
|
|
{"time", 700},
|
|
{"confirmations", 0},
|
|
{"amount", -0.33},
|
|
{"details", json::array({
|
|
json{{"category", "send"}, {"address", "zs-placeholder-dest"}, {"amount", -0.33}}
|
|
})}
|
|
});
|
|
auto placeholderResult = Refresh::collectTransactionRefreshResult(placeholderRpc, placeholderSnapshot, 324, 4);
|
|
EXPECT_TRUE(placeholderRpc.methodNames() == std::vector<std::string>({
|
|
"listtransactions", "z_viewtransaction", "gettransaction"
|
|
}));
|
|
EXPECT_EQ(placeholderResult.transactions.size(), static_cast<size_t>(1));
|
|
EXPECT_EQ(placeholderResult.transactions[0].txid, std::string("placeholder-send"));
|
|
EXPECT_EQ(placeholderResult.transactions[0].type, std::string("send"));
|
|
EXPECT_NEAR(placeholderResult.transactions[0].amount, -0.33, 0.00000001);
|
|
EXPECT_EQ(placeholderResult.transactions[0].timestamp, static_cast<int64_t>(700));
|
|
EXPECT_EQ(placeholderResult.transactions[0].confirmations, 0);
|
|
EXPECT_EQ(placeholderResult.transactions[0].address, std::string("zs-placeholder-dest"));
|
|
EXPECT_EQ(placeholderResult.newViewTxEntries.count("placeholder-send"), static_cast<size_t>(0));
|
|
|
|
Refresh::TransactionRefreshSnapshot missingAddressesSnapshot;
|
|
missingAddressesSnapshot.previousTransactions.push_back(dragonx::TransactionInfo{
|
|
"shielded-fallback", "receive", 1.25, 150, 2, "zs-one", "", "memo"
|
|
});
|
|
MockRefreshRpc missingAddressesRpc;
|
|
missingAddressesRpc.addResponse("listtransactions", json::array());
|
|
auto missingAddressesResult = Refresh::collectTransactionRefreshResult(
|
|
missingAddressesRpc, missingAddressesSnapshot, 325, 4);
|
|
EXPECT_TRUE(missingAddressesRpc.methodNames() == std::vector<std::string>({"listtransactions"}));
|
|
EXPECT_EQ(missingAddressesResult.transactions.size(), static_cast<size_t>(1));
|
|
EXPECT_EQ(missingAddressesResult.transactions[0].txid, std::string("shielded-fallback"));
|
|
EXPECT_EQ(missingAddressesResult.transactions[0].type, std::string("receive"));
|
|
|
|
Refresh::TransactionRefreshSnapshot pendingOpidSnapshot;
|
|
pendingOpidSnapshot.pendingOpids = {"opid-visible-send"};
|
|
pendingOpidSnapshot.previousTransactions.push_back(dragonx::TransactionInfo{
|
|
"opid-visible-send", "send", -4.25, 170, 0, "R-destination", "R-source", ""
|
|
});
|
|
MockRefreshRpc pendingOpidRpc;
|
|
pendingOpidRpc.addResponse("listtransactions", json::array());
|
|
auto pendingOpidResult = Refresh::collectTransactionRefreshResult(
|
|
pendingOpidRpc, pendingOpidSnapshot, 326, 4);
|
|
EXPECT_TRUE(pendingOpidRpc.methodNames() == std::vector<std::string>({"listtransactions"}));
|
|
EXPECT_EQ(pendingOpidResult.transactions.size(), static_cast<size_t>(1));
|
|
EXPECT_EQ(pendingOpidResult.transactions[0].txid, std::string("opid-visible-send"));
|
|
EXPECT_EQ(pendingOpidResult.transactions[0].type, std::string("send"));
|
|
EXPECT_NEAR(pendingOpidResult.transactions[0].amount, -4.25, 0.00000001);
|
|
|
|
Refresh::TransactionRefreshSnapshot partialFailureSnapshot;
|
|
partialFailureSnapshot.shieldedAddresses = {"zs-one"};
|
|
partialFailureSnapshot.previousTransactions.push_back(dragonx::TransactionInfo{
|
|
"old-receive", "receive", 2.50, 140, 8, "zs-one", "", "old memo"
|
|
});
|
|
MockRefreshRpc partialFailureRpc;
|
|
partialFailureRpc.addResponse("listtransactions", json::array({
|
|
json{{"txid", "transparent-b"}, {"category", "receive"}, {"amount", 0.75},
|
|
{"time", 160}, {"confirmations", 2}, {"address", "R-two"}}
|
|
}));
|
|
partialFailureRpc.addFailure("z_listreceivedbyaddress", "temporary receive failure");
|
|
auto partialFailureResult = Refresh::collectTransactionRefreshResult(
|
|
partialFailureRpc, partialFailureSnapshot, 326, 4);
|
|
EXPECT_EQ(partialFailureResult.transactions.size(), static_cast<size_t>(2));
|
|
EXPECT_EQ(partialFailureResult.transactions[0].txid, std::string("transparent-b"));
|
|
EXPECT_EQ(partialFailureResult.transactions[1].txid, std::string("old-receive"));
|
|
|
|
Refresh::TransactionRefreshSnapshot pagedSnapshot;
|
|
MockRefreshRpc pagedRpc;
|
|
json firstPage = json::array();
|
|
for (int i = 0; i < 1000; ++i) {
|
|
firstPage.push_back(json{{"txid", "paged-" + std::to_string(i)}, {"category", "receive"},
|
|
{"amount", 0.01}, {"time", i}, {"confirmations", 10},
|
|
{"address", "R-page"}});
|
|
}
|
|
pagedRpc.addResponse("listtransactions", firstPage);
|
|
pagedRpc.addResponse("listtransactions", json::array({
|
|
json{{"txid", "paged-1000"}, {"category", "receive"}, {"amount", 0.02},
|
|
{"time", 2000}, {"confirmations", 11}, {"address", "R-page"}}
|
|
}));
|
|
auto pagedResult = Refresh::collectTransactionRefreshResult(pagedRpc, pagedSnapshot, 327, 0);
|
|
EXPECT_TRUE(pagedRpc.methodNames() == std::vector<std::string>({"listtransactions", "listtransactions"}));
|
|
EXPECT_EQ(pagedRpc.calls[0].params, json::array({"", 1000, 0}));
|
|
EXPECT_EQ(pagedRpc.calls[1].params, json::array({"", 1000, 1000}));
|
|
EXPECT_EQ(pagedResult.transactions.size(), static_cast<size_t>(1001));
|
|
EXPECT_EQ(pagedResult.transactions[0].txid, std::string("paged-1000"));
|
|
|
|
Refresh::TransactionRefreshSnapshot recentSnapshot;
|
|
dragonx::TransactionInfo previousShielded;
|
|
previousShielded.txid = "shielded-old";
|
|
previousShielded.type = "receive";
|
|
previousShielded.address = "zs-one";
|
|
previousShielded.amount = 3.0;
|
|
previousShielded.timestamp = 10;
|
|
dragonx::TransactionInfo previousTransparent;
|
|
previousTransparent.txid = "recent-one";
|
|
previousTransparent.type = "receive";
|
|
previousTransparent.address = "R-one";
|
|
previousTransparent.amount = 1.0;
|
|
previousTransparent.timestamp = 20;
|
|
recentSnapshot.previousTransactions = {previousShielded, previousTransparent};
|
|
MockRefreshRpc recentRpc;
|
|
recentRpc.addResponse("listtransactions", json::array({
|
|
json{{"txid", "recent-one"}, {"category", "receive"}, {"address", "R-one"}, {"amount", 2.0}, {"time", 30}, {"confirmations", 0}},
|
|
json{{"txid", "recent-two"}, {"category", "send"}, {"address", "R-two"}, {"amount", -0.5}, {"time", 40}, {"confirmations", 0}}
|
|
}));
|
|
auto recent = Refresh::collectRecentTransactionRefreshResult(recentRpc, recentSnapshot, 123);
|
|
EXPECT_TRUE(recentRpc.methodNames() == std::vector<std::string>({"listtransactions"}));
|
|
EXPECT_EQ(recentRpc.calls[0].params, json::array({"", 100, 0}));
|
|
EXPECT_EQ(recent.blockHeight, 123);
|
|
EXPECT_EQ(recent.transactions.size(), static_cast<size_t>(3));
|
|
EXPECT_EQ(recent.transactions[0].txid, std::string("recent-two"));
|
|
EXPECT_EQ(recent.transactions[1].txid, std::string("recent-one"));
|
|
EXPECT_NEAR(recent.transactions[1].amount, 2.0, 0.00000001);
|
|
EXPECT_EQ(recent.transactions[2].txid, std::string("shielded-old"));
|
|
|
|
Refresh::TransactionRefreshSnapshot recentShieldedProbeSnapshot;
|
|
recentShieldedProbeSnapshot.shieldedAddresses = {"zs-probe-a", "zs-probe-b"};
|
|
recentShieldedProbeSnapshot.shieldedScanStartIndex = 1;
|
|
recentShieldedProbeSnapshot.maxShieldedReceiveScans = 1;
|
|
recentShieldedProbeSnapshot.shieldedScanHeights = {{"zs-probe-a", 600}, {"zs-probe-b", 600}};
|
|
MockRefreshRpc recentShieldedProbeRpc;
|
|
recentShieldedProbeRpc.addResponse("listtransactions", json::array());
|
|
recentShieldedProbeRpc.addResponse("z_listreceivedbyaddress", json::array({
|
|
json{{"txid", "same-tip-shielded"}, {"amount", 1.75}, {"confirmations", 0}, {"time", 170}}
|
|
}));
|
|
auto recentShieldedProbe = Refresh::collectRecentTransactionRefreshResult(
|
|
recentShieldedProbeRpc, recentShieldedProbeSnapshot, 600);
|
|
EXPECT_TRUE(recentShieldedProbeRpc.methodNames() == std::vector<std::string>({
|
|
"listtransactions", "z_listreceivedbyaddress"
|
|
}));
|
|
EXPECT_EQ(recentShieldedProbeRpc.calls[1].params, json::array({"zs-probe-b", 0}));
|
|
EXPECT_EQ(recentShieldedProbe.nextShieldedScanStartIndex, static_cast<size_t>(0));
|
|
EXPECT_EQ(recentShieldedProbe.shieldedAddressesScanned, static_cast<size_t>(1));
|
|
EXPECT_EQ(recentShieldedProbe.shieldedScanHeights.at("zs-probe-b"), 600);
|
|
EXPECT_EQ(recentShieldedProbe.transactions.size(), static_cast<size_t>(1));
|
|
EXPECT_EQ(recentShieldedProbe.transactions[0].txid, std::string("same-tip-shielded"));
|
|
EXPECT_EQ(recentShieldedProbe.transactions[0].confirmations, 0);
|
|
|
|
Refresh::TransactionRefreshSnapshot partialShieldedSnapshot;
|
|
partialShieldedSnapshot.shieldedAddresses = {"zs-zero", "zs-one", "zs-two"};
|
|
partialShieldedSnapshot.maxShieldedReceiveScans = 2;
|
|
partialShieldedSnapshot.previousTransactions.push_back(dragonx::TransactionInfo{
|
|
"old-zs-two", "receive", 1.0, 90, 10, "zs-two", "", "memo"
|
|
});
|
|
MockRefreshRpc partialShieldedRpc;
|
|
partialShieldedRpc.addResponse("listtransactions", json::array());
|
|
partialShieldedRpc.addResponse("z_listreceivedbyaddress", json::array({
|
|
json{{"txid", "new-zs-zero"}, {"amount", 0.5}, {"confirmations", 1}, {"time", 100}}
|
|
}));
|
|
partialShieldedRpc.addResponse("z_listreceivedbyaddress", json::array());
|
|
auto partialShielded = Refresh::collectTransactionRefreshResult(
|
|
partialShieldedRpc, partialShieldedSnapshot, 400, 0);
|
|
EXPECT_TRUE(partialShieldedRpc.methodNames() == std::vector<std::string>({
|
|
"listtransactions", "z_listreceivedbyaddress", "z_listreceivedbyaddress"
|
|
}));
|
|
EXPECT_EQ(partialShieldedRpc.calls[1].params, json::array({"zs-zero", 0}));
|
|
EXPECT_EQ(partialShieldedRpc.calls[2].params, json::array({"zs-one", 0}));
|
|
EXPECT_FALSE(partialShielded.shieldedScanComplete);
|
|
EXPECT_EQ(partialShielded.nextShieldedScanStartIndex, static_cast<size_t>(2));
|
|
EXPECT_EQ(partialShielded.shieldedAddressesScanned, static_cast<size_t>(2));
|
|
EXPECT_EQ(partialShielded.transactions.size(), static_cast<size_t>(2));
|
|
EXPECT_EQ(partialShielded.transactions[0].txid, std::string("new-zs-zero"));
|
|
EXPECT_EQ(partialShielded.transactions[1].txid, std::string("old-zs-two"));
|
|
|
|
partialShieldedSnapshot.shieldedScanStartIndex = partialShielded.nextShieldedScanStartIndex;
|
|
partialShieldedSnapshot.shieldedScanHeights = partialShielded.shieldedScanHeights;
|
|
MockRefreshRpc finalShieldedRpc;
|
|
finalShieldedRpc.addResponse("listtransactions", json::array());
|
|
finalShieldedRpc.addResponse("z_listreceivedbyaddress", json::array());
|
|
auto finalShielded = Refresh::collectTransactionRefreshResult(
|
|
finalShieldedRpc, partialShieldedSnapshot, 400, 0);
|
|
EXPECT_TRUE(finalShieldedRpc.methodNames() == std::vector<std::string>({
|
|
"listtransactions", "z_listreceivedbyaddress"
|
|
}));
|
|
EXPECT_EQ(finalShieldedRpc.calls[1].params, json::array({"zs-two", 0}));
|
|
EXPECT_TRUE(finalShielded.shieldedScanComplete);
|
|
EXPECT_EQ(finalShielded.nextShieldedScanStartIndex, static_cast<size_t>(0));
|
|
EXPECT_EQ(finalShielded.shieldedScanHeights.at("zs-zero"), 400);
|
|
EXPECT_EQ(finalShielded.shieldedScanHeights.at("zs-one"), 400);
|
|
EXPECT_EQ(finalShielded.shieldedScanHeights.at("zs-two"), 400);
|
|
|
|
Refresh::TransactionRefreshSnapshot cachedShieldedSnapshot;
|
|
cachedShieldedSnapshot.shieldedAddresses = {"zs-cached"};
|
|
cachedShieldedSnapshot.shieldedScanHeights = {{"zs-cached", 500}};
|
|
cachedShieldedSnapshot.previousTransactions.push_back(dragonx::TransactionInfo{
|
|
"cached-shielded", "receive", 1.5, 120, 20, "zs-cached", "", "cached memo"
|
|
});
|
|
MockRefreshRpc cachedShieldedRpc;
|
|
cachedShieldedRpc.addResponse("listtransactions", json::array());
|
|
auto cachedShielded = Refresh::collectTransactionRefreshResult(
|
|
cachedShieldedRpc, cachedShieldedSnapshot, 500, 0);
|
|
EXPECT_TRUE(cachedShieldedRpc.methodNames() == std::vector<std::string>({"listtransactions"}));
|
|
EXPECT_TRUE(cachedShielded.shieldedScanComplete);
|
|
EXPECT_EQ(cachedShielded.shieldedAddressesScanned, static_cast<size_t>(0));
|
|
EXPECT_EQ(cachedShielded.transactions.size(), static_cast<size_t>(1));
|
|
EXPECT_EQ(cachedShielded.transactions[0].txid, std::string("cached-shielded"));
|
|
|
|
Refresh::TransactionRefreshSnapshot staleProgressSnapshot;
|
|
staleProgressSnapshot.shieldedAddresses = {"zs-current", "zs-stale", "zs-missing"};
|
|
staleProgressSnapshot.shieldedScanHeights = {{"zs-current", 500}, {"zs-stale", 499}};
|
|
staleProgressSnapshot.maxShieldedReceiveScans = 1;
|
|
staleProgressSnapshot.previousTransactions.push_back(dragonx::TransactionInfo{
|
|
"current-shielded", "receive", 0.75, 110, 12, "zs-current", "", ""
|
|
});
|
|
MockRefreshRpc staleProgressRpc;
|
|
staleProgressRpc.addResponse("listtransactions", json::array());
|
|
staleProgressRpc.addResponse("z_listreceivedbyaddress", json::array({
|
|
json{{"txid", "stale-shielded"}, {"amount", 2.0}, {"confirmations", 1}, {"time", 130}}
|
|
}));
|
|
auto staleProgress = Refresh::collectTransactionRefreshResult(
|
|
staleProgressRpc, staleProgressSnapshot, 500, 0);
|
|
EXPECT_TRUE(staleProgressRpc.methodNames() == std::vector<std::string>({
|
|
"listtransactions", "z_listreceivedbyaddress"
|
|
}));
|
|
EXPECT_EQ(staleProgressRpc.calls[1].params, json::array({"zs-stale", 0}));
|
|
EXPECT_FALSE(staleProgress.shieldedScanComplete);
|
|
EXPECT_EQ(staleProgress.nextShieldedScanStartIndex, static_cast<size_t>(2));
|
|
EXPECT_EQ(staleProgress.shieldedAddressesScanned, static_cast<size_t>(1));
|
|
EXPECT_EQ(staleProgress.shieldedScanHeights.at("zs-current"), 500);
|
|
EXPECT_EQ(staleProgress.shieldedScanHeights.at("zs-stale"), 500);
|
|
EXPECT_TRUE(staleProgress.shieldedScanHeights.find("zs-missing") == staleProgress.shieldedScanHeights.end());
|
|
EXPECT_EQ(staleProgress.transactions.size(), static_cast<size_t>(2));
|
|
EXPECT_EQ(staleProgress.transactions[0].txid, std::string("stale-shielded"));
|
|
EXPECT_EQ(staleProgress.transactions[1].txid, std::string("current-shielded"));
|
|
|
|
Refresh::TransactionRefreshSnapshot miningSnapshot;
|
|
miningSnapshot.shieldedAddresses = {"zs-mine"};
|
|
miningSnapshot.miningAddresses = {"R-mine", "zs-mine"};
|
|
MockRefreshRpc miningTxRpc;
|
|
miningTxRpc.addResponse("listtransactions", json::array({
|
|
json{{"txid", "transparent-mined"}, {"category", "receive"}, {"amount", 3.0},
|
|
{"time", 220}, {"confirmations", 101}, {"address", "R-mine"}}
|
|
}));
|
|
miningTxRpc.addResponse("z_listreceivedbyaddress", json::array({
|
|
json{{"txid", "shielded-mined"}, {"amount", 4.0}, {"confirmations", 102},
|
|
{"time", 210}, {"memoStr", "pool"}}
|
|
}));
|
|
auto miningTxResult = Refresh::collectTransactionRefreshResult(miningTxRpc, miningSnapshot, 328, 0);
|
|
EXPECT_EQ(miningTxResult.transactions.size(), static_cast<size_t>(2));
|
|
EXPECT_EQ(miningTxResult.transactions[0].type, std::string("mined"));
|
|
EXPECT_EQ(miningTxResult.transactions[1].type, std::string("mined"));
|
|
}
|
|
|
|
void testNetworkRefreshResultModels()
|
|
{
|
|
using Refresh = dragonx::services::NetworkRefreshService;
|
|
using nlohmann::json;
|
|
|
|
dragonx::WalletState state;
|
|
|
|
auto core = Refresh::parseCoreRefreshResult(
|
|
json{{"private", "1.25000000"}, {"transparent", "0.50000000"}, {"total", "1.75000000"}},
|
|
true,
|
|
json{{"blocks", 100}, {"headers", 105}, {"bestblockhash", "apply-best-100"}, {"verificationprogress", 0.75},
|
|
{"longestchain", 110}, {"notarized", 90}},
|
|
true);
|
|
Refresh::applyCoreRefreshResult(state, core, 1234);
|
|
EXPECT_NEAR(state.shielded_balance, 1.25, 0.00000001);
|
|
EXPECT_NEAR(state.transparent_balance, 0.5, 0.00000001);
|
|
EXPECT_NEAR(state.total_balance, 1.75, 0.00000001);
|
|
EXPECT_EQ(state.sync.blocks, 100);
|
|
EXPECT_EQ(state.sync.headers, 105);
|
|
EXPECT_EQ(state.sync.best_blockhash, std::string("apply-best-100"));
|
|
EXPECT_TRUE(state.sync.syncing);
|
|
EXPECT_EQ(state.longestchain, 110);
|
|
EXPECT_EQ(state.notarized, 90);
|
|
EXPECT_EQ(state.last_balance_update, static_cast<int64_t>(1234));
|
|
|
|
auto connectionInfo = Refresh::parseConnectionInfoResult(
|
|
json{{"version", 120000}, {"protocolversion", 170002}, {"p2pport", 8233},
|
|
{"longestchain", 0}, {"blocks", 120}, {"notarized", 118}});
|
|
Refresh::applyConnectionInfoResult(state, connectionInfo);
|
|
EXPECT_EQ(state.daemon_version, 120000);
|
|
EXPECT_EQ(state.protocol_version, 170002);
|
|
EXPECT_EQ(state.p2p_port, 8233);
|
|
EXPECT_EQ(state.sync.blocks, 120);
|
|
EXPECT_EQ(state.longestchain, 120);
|
|
EXPECT_EQ(state.notarized, 118);
|
|
|
|
auto encrypted = Refresh::parseWalletEncryptionResult(json{{"unlocked_until", 0}});
|
|
Refresh::applyWalletEncryptionResult(state, encrypted);
|
|
EXPECT_TRUE(state.encrypted);
|
|
EXPECT_TRUE(state.locked);
|
|
EXPECT_TRUE(state.encryption_state_known);
|
|
auto unencrypted = Refresh::parseWalletEncryptionResult(json::object());
|
|
Refresh::applyWalletEncryptionResult(state, unencrypted);
|
|
EXPECT_FALSE(state.encrypted);
|
|
EXPECT_FALSE(state.locked);
|
|
EXPECT_EQ(state.unlocked_until, static_cast<int64_t>(0));
|
|
|
|
auto mining = Refresh::parseMiningRefreshResult(
|
|
json{{"generate", true}, {"genproclimit", 4}, {"blocks", 101},
|
|
{"difficulty", 12.5}, {"networkhashps", 900.0}, {"chain", "main"}},
|
|
true,
|
|
json(45.0),
|
|
true,
|
|
64.0);
|
|
Refresh::applyMiningRefreshResult(state, mining, 2345);
|
|
EXPECT_TRUE(state.mining.generate);
|
|
EXPECT_EQ(state.mining.genproclimit, 4);
|
|
EXPECT_NEAR(state.mining.localHashrate, 45.0, 0.0001);
|
|
EXPECT_EQ(state.mining.hashrate_history.size(), static_cast<size_t>(1));
|
|
EXPECT_NEAR(state.mining.networkHashrate, 900.0, 0.0001);
|
|
EXPECT_NEAR(state.mining.daemon_memory_mb, 64.0, 0.0001);
|
|
EXPECT_EQ(state.last_mining_update, static_cast<int64_t>(2345));
|
|
|
|
auto peers = Refresh::parsePeerRefreshResult(
|
|
json::array({json{{"id", 7}, {"addr", "127.0.0.1:8233"}, {"tls_verified", true}}}),
|
|
json::array({json{{"address", "192.0.2.1"}, {"banned_until", 3456}}}));
|
|
Refresh::applyPeerRefreshResult(state, std::move(peers), 3456);
|
|
EXPECT_EQ(state.peers.size(), static_cast<size_t>(1));
|
|
EXPECT_EQ(state.peers[0].id, 7);
|
|
EXPECT_TRUE(state.peers[0].tls_verified);
|
|
EXPECT_EQ(state.bannedPeers.size(), static_cast<size_t>(1));
|
|
EXPECT_EQ(state.bannedPeers[0].address, std::string("192.0.2.1"));
|
|
EXPECT_EQ(state.last_peer_update, static_cast<int64_t>(3456));
|
|
|
|
auto price = Refresh::parseCoinGeckoPriceResponse(
|
|
R"({"dragonx-2":{"usd":0.5,"btc":0.00001,"usd_24h_change":2.5,"usd_24h_vol":1000,"usd_market_cap":50000}})",
|
|
0);
|
|
EXPECT_TRUE(price.has_value());
|
|
if (price) {
|
|
Refresh::markPriceRefreshStarted(state);
|
|
EXPECT_TRUE(state.market.price_loading);
|
|
Refresh::applyPriceRefreshResult(state, *price, std::chrono::steady_clock::now());
|
|
EXPECT_FALSE(state.market.price_loading);
|
|
EXPECT_EQ(state.market.price_error, std::string());
|
|
EXPECT_NEAR(state.market.price_usd, 0.5, 0.00000001);
|
|
EXPECT_NEAR(state.market.price_btc, 0.00001, 0.00000001);
|
|
EXPECT_NEAR(state.market.change_24h, 2.5, 0.0001);
|
|
EXPECT_EQ(state.market.price_history.size(), static_cast<size_t>(1));
|
|
}
|
|
|
|
Refresh::markPriceRefreshStarted(state);
|
|
Refresh::applyPriceRefreshFailure(state, "timeout");
|
|
EXPECT_FALSE(state.market.price_loading);
|
|
EXPECT_EQ(state.market.price_error, std::string("timeout"));
|
|
EXPECT_NEAR(state.market.price_usd, 0.5, 0.00000001);
|
|
|
|
Refresh::PriceHttpResponse priceHttpOk;
|
|
priceHttpOk.transportOk = true;
|
|
priceHttpOk.httpStatus = 200;
|
|
priceHttpOk.body = R"({"dragonx-2":{"usd":0.75,"btc":0.00002,"usd_24h_change":1.5,"usd_24h_vol":2000,"usd_market_cap":75000}})";
|
|
auto priceHttp = Refresh::parsePriceHttpResponse(priceHttpOk, 0);
|
|
EXPECT_TRUE(priceHttp.price.has_value());
|
|
if (priceHttp.price) {
|
|
EXPECT_NEAR(priceHttp.price->market.price_usd, 0.75, 0.00000001);
|
|
EXPECT_NEAR(priceHttp.price->market.price_btc, 0.00002, 0.00000001);
|
|
}
|
|
EXPECT_EQ(priceHttp.errorMessage, std::string());
|
|
|
|
Refresh::PriceHttpResponse priceHttpStatus;
|
|
priceHttpStatus.transportOk = true;
|
|
priceHttpStatus.httpStatus = 429;
|
|
auto priceHttpStatusResult = Refresh::parsePriceHttpResponse(priceHttpStatus, 0);
|
|
EXPECT_FALSE(priceHttpStatusResult.price.has_value());
|
|
EXPECT_EQ(priceHttpStatusResult.errorMessage, std::string("Price fetch failed: OK (HTTP 429)"));
|
|
|
|
Refresh::PriceHttpResponse priceHttpTransport;
|
|
priceHttpTransport.transportOk = false;
|
|
priceHttpTransport.httpStatus = 0;
|
|
priceHttpTransport.transportError = "timeout";
|
|
auto priceHttpTransportResult = Refresh::parsePriceHttpResponse(priceHttpTransport, 0);
|
|
EXPECT_FALSE(priceHttpTransportResult.price.has_value());
|
|
EXPECT_EQ(priceHttpTransportResult.errorMessage, std::string("Price fetch failed: timeout (HTTP 0)"));
|
|
|
|
Refresh::PriceHttpResponse priceHttpParse;
|
|
priceHttpParse.transportOk = true;
|
|
priceHttpParse.httpStatus = 200;
|
|
priceHttpParse.body = R"({"other-coin":{"usd":1.0}})";
|
|
auto priceHttpParseResult = Refresh::parsePriceHttpResponse(priceHttpParse, 0);
|
|
EXPECT_FALSE(priceHttpParseResult.price.has_value());
|
|
EXPECT_EQ(priceHttpParseResult.errorMessage, std::string("Price fetch returned an unrecognized response"));
|
|
|
|
Refresh::AddressRefreshResult addresses;
|
|
dragonx::AddressInfo zAddr;
|
|
zAddr.address = "zs-refresh";
|
|
zAddr.type = "shielded";
|
|
zAddr.balance = 2.0;
|
|
zAddr.has_spending_key = false;
|
|
dragonx::AddressInfo tAddr;
|
|
tAddr.address = "R-refresh";
|
|
tAddr.type = "transparent";
|
|
tAddr.balance = 3.0;
|
|
addresses.shieldedAddresses.push_back(zAddr);
|
|
addresses.transparentAddresses.push_back(tAddr);
|
|
Refresh::applyAddressRefreshResult(state, std::move(addresses));
|
|
EXPECT_EQ(state.z_addresses.size(), static_cast<size_t>(1));
|
|
EXPECT_EQ(state.t_addresses.size(), static_cast<size_t>(1));
|
|
EXPECT_EQ(state.z_addresses[0].address, std::string("zs-refresh"));
|
|
EXPECT_FALSE(state.z_addresses[0].has_spending_key);
|
|
|
|
auto viewEntry = Refresh::parseViewTransactionCacheEntry(json{
|
|
{"spends", json::array({json{{"address", "zs-from"}}})},
|
|
{"outputs", json::array({
|
|
json{{"outgoing", true}, {"address", "zs-destination"}, {"value", 0.75}, {"memoStr", "memo"}},
|
|
json{{"outgoing", false}, {"address", "zs-change"}, {"value", 0.25}}
|
|
})}
|
|
});
|
|
EXPECT_EQ(viewEntry.from_address, std::string("zs-from"));
|
|
EXPECT_EQ(viewEntry.outgoing_outputs.size(), static_cast<size_t>(1));
|
|
EXPECT_EQ(viewEntry.outgoing_outputs[0].address, std::string("zs-destination"));
|
|
|
|
std::vector<dragonx::TransactionInfo> enrichedTxs;
|
|
dragonx::TransactionInfo baseTx;
|
|
baseTx.txid = "shielded-send";
|
|
baseTx.type = "receive";
|
|
baseTx.confirmations = 7;
|
|
baseTx.timestamp = 111;
|
|
enrichedTxs.push_back(baseTx);
|
|
Refresh::appendViewTransactionOutputs(enrichedTxs, "shielded-send", viewEntry);
|
|
Refresh::appendViewTransactionOutputs(enrichedTxs, "shielded-send", viewEntry);
|
|
EXPECT_EQ(enrichedTxs.size(), static_cast<size_t>(2));
|
|
EXPECT_EQ(enrichedTxs[1].type, std::string("send"));
|
|
EXPECT_EQ(enrichedTxs[1].from_address, std::string("zs-from"));
|
|
EXPECT_NEAR(enrichedTxs[1].amount, -0.75, 0.00000001);
|
|
EXPECT_EQ(enrichedTxs[1].confirmations, 7);
|
|
EXPECT_EQ(enrichedTxs[1].timestamp, static_cast<int64_t>(111));
|
|
|
|
Refresh::TransactionRefreshResult transactionResult;
|
|
transactionResult.blockHeight = 222;
|
|
dragonx::TransactionInfo confirmedTx;
|
|
confirmedTx.txid = "confirmed";
|
|
confirmedTx.type = "receive";
|
|
confirmedTx.confirmations = 12;
|
|
confirmedTx.timestamp = 1000;
|
|
dragonx::TransactionInfo pendingTx;
|
|
pendingTx.txid = "pending";
|
|
pendingTx.type = "receive";
|
|
pendingTx.confirmations = 2;
|
|
pendingTx.timestamp = 1001;
|
|
transactionResult.transactions = {pendingTx, confirmedTx};
|
|
transactionResult.newViewTxEntries["shielded-send"] = viewEntry;
|
|
transactionResult.newViewTxEntries["pending-empty"] = Refresh::TransactionViewCacheEntry{};
|
|
|
|
Refresh::TransactionViewCache viewCache;
|
|
std::unordered_set<std::string> sendTxids{"shielded-send", "pending", "pending-empty"};
|
|
std::vector<dragonx::TransactionInfo> confirmedCache;
|
|
std::unordered_set<std::string> confirmedIds;
|
|
int confirmedBlock = -1;
|
|
int lastTxBlock = -1;
|
|
Refresh::TransactionCacheUpdate cacheUpdate{
|
|
viewCache,
|
|
sendTxids,
|
|
confirmedCache,
|
|
confirmedIds,
|
|
confirmedBlock,
|
|
lastTxBlock
|
|
};
|
|
Refresh::applyTransactionRefreshResult(state, cacheUpdate, std::move(transactionResult), 4567);
|
|
EXPECT_EQ(state.transactions.size(), static_cast<size_t>(2));
|
|
EXPECT_EQ(state.last_tx_update, static_cast<int64_t>(4567));
|
|
EXPECT_EQ(lastTxBlock, 222);
|
|
EXPECT_EQ(viewCache.size(), static_cast<size_t>(2));
|
|
EXPECT_EQ(sendTxids.count("shielded-send"), static_cast<size_t>(0));
|
|
EXPECT_EQ(sendTxids.count("pending"), static_cast<size_t>(1));
|
|
EXPECT_EQ(sendTxids.count("pending-empty"), static_cast<size_t>(1));
|
|
EXPECT_EQ(confirmedCache.size(), static_cast<size_t>(1));
|
|
EXPECT_EQ(confirmedCache[0].txid, std::string("confirmed"));
|
|
EXPECT_EQ(confirmedIds.count("confirmed"), static_cast<size_t>(1));
|
|
EXPECT_EQ(confirmedBlock, 222);
|
|
}
|
|
|
|
void testOperationStatusPollParsing()
|
|
{
|
|
using Refresh = dragonx::services::NetworkRefreshService;
|
|
using nlohmann::json;
|
|
|
|
auto parsed = Refresh::parseOperationStatusPoll(json::array({
|
|
json{{"id", "op-success"}, {"status", "success"}, {"result", json{{"txid", "tx-success"}}}},
|
|
json{{"id", "op-failed"}, {"status", "failed"}, {"error", json{{"message", "bad memo"}}}},
|
|
json{{"id", "op-running"}, {"status", "executing"}}
|
|
}), {"op-success", "op-failed", "op-running", "op-stale"});
|
|
|
|
EXPECT_TRUE(parsed.anySuccess);
|
|
EXPECT_EQ(parsed.doneOpids.size(), static_cast<size_t>(2));
|
|
EXPECT_EQ(parsed.doneOpids[0], std::string("op-success"));
|
|
EXPECT_EQ(parsed.doneOpids[1], std::string("op-failed"));
|
|
EXPECT_EQ(parsed.successTxids.size(), static_cast<size_t>(1));
|
|
EXPECT_EQ(parsed.successTxids[0], std::string("tx-success"));
|
|
EXPECT_EQ(parsed.successTxidsByOpid.size(), static_cast<size_t>(1));
|
|
EXPECT_EQ(parsed.successTxidsByOpid.at("op-success"), std::string("tx-success"));
|
|
EXPECT_EQ(parsed.failureMessages.size(), static_cast<size_t>(1));
|
|
EXPECT_EQ(parsed.failureMessages[0], std::string("bad memo"));
|
|
// Per-opid failure map drives terminal-status routing to the originating send UI.
|
|
EXPECT_EQ(parsed.failureByOpid.size(), static_cast<size_t>(1));
|
|
EXPECT_EQ(parsed.failureByOpid.at("op-failed"), std::string("bad memo"));
|
|
EXPECT_EQ(parsed.staleOpids.size(), static_cast<size_t>(1));
|
|
EXPECT_EQ(parsed.staleOpids[0], std::string("op-stale"));
|
|
|
|
auto malformed = Refresh::parseOperationStatusPoll(json{{"status", "success"}}, {"op-keep"});
|
|
EXPECT_FALSE(malformed.anySuccess);
|
|
EXPECT_TRUE(malformed.doneOpids.empty());
|
|
EXPECT_TRUE(malformed.staleOpids.empty());
|
|
}
|
|
|
|
void testWalletSecurityController()
|
|
{
|
|
using dragonx::services::WalletSecurityController;
|
|
using PinError = WalletSecurityController::PinValidationError;
|
|
|
|
WalletSecurityController controller;
|
|
EXPECT_FALSE(controller.hasDeferredEncryption());
|
|
controller.beginDeferredEncryption("secret-passphrase", "1234");
|
|
EXPECT_TRUE(controller.hasDeferredEncryption());
|
|
|
|
auto snapshot = controller.deferredEncryption();
|
|
EXPECT_EQ(snapshot.passphrase, std::string("secret-passphrase"));
|
|
EXPECT_EQ(snapshot.pin, std::string("1234"));
|
|
|
|
EXPECT_TRUE(controller.shouldAttemptDeferredConnect(0.0));
|
|
EXPECT_FALSE(controller.shouldAttemptDeferredConnect(1.0));
|
|
EXPECT_TRUE(controller.shouldAttemptDeferredConnect(3.1));
|
|
|
|
controller.clearDeferredEncryption();
|
|
EXPECT_FALSE(controller.hasDeferredEncryption());
|
|
EXPECT_FALSE(controller.shouldAttemptDeferredConnect(10.0));
|
|
|
|
auto valid = WalletSecurityController::validatePinSetup("1234", "1234");
|
|
EXPECT_TRUE(valid.ok);
|
|
auto emptyAllowed = WalletSecurityController::validatePinSetup("", "", true);
|
|
EXPECT_TRUE(emptyAllowed.ok);
|
|
auto mismatch = WalletSecurityController::validatePinSetup("1234", "4321");
|
|
EXPECT_EQ(mismatch.error, PinError::Mismatch);
|
|
auto shortPin = WalletSecurityController::validatePinSetup("123", "123");
|
|
EXPECT_EQ(shortPin.error, PinError::TooShort);
|
|
auto nondigit = WalletSecurityController::validatePinSetup("12a4", "12a4");
|
|
EXPECT_EQ(nondigit.error, PinError::NonDigit);
|
|
|
|
EXPECT_EQ(WalletSecurityController::classifyAddress("zs123"), WalletSecurityController::KeyKind::Shielded);
|
|
EXPECT_EQ(WalletSecurityController::classifyAddress("R123"), WalletSecurityController::KeyKind::Transparent);
|
|
EXPECT_EQ(WalletSecurityController::classifyPrivateKey("secret-key"), WalletSecurityController::KeyKind::Shielded);
|
|
EXPECT_EQ(WalletSecurityController::classifyPrivateKey("Kx123"), WalletSecurityController::KeyKind::Transparent);
|
|
EXPECT_EQ(std::string(WalletSecurityController::importSuccessMessage(WalletSecurityController::KeyKind::Shielded)),
|
|
std::string("Z-address key imported successfully. Wallet is rescanning."));
|
|
EXPECT_EQ(WalletSecurityController::decryptExportFileName(42), std::string("obsidiandecryptexport42"));
|
|
|
|
MockWalletSecurityRpc rpc;
|
|
MockWalletSecurityVault vault;
|
|
auto encrypted = controller.runDeferredEncryption({"passphrase", "9876"}, rpc, &vault);
|
|
EXPECT_TRUE(encrypted.encrypted);
|
|
EXPECT_TRUE(encrypted.pinProvided);
|
|
EXPECT_TRUE(encrypted.pinStored);
|
|
EXPECT_TRUE(encrypted.restartRequired);
|
|
EXPECT_EQ(rpc.encryptCalls, 1);
|
|
EXPECT_EQ(rpc.lastPassphrase, std::string("passphrase"));
|
|
EXPECT_EQ(vault.storeCalls, 1);
|
|
EXPECT_EQ(vault.lastPin, std::string("9876"));
|
|
EXPECT_EQ(vault.lastPassphrase, std::string("passphrase"));
|
|
|
|
MockWalletSecurityRpc failingRpc;
|
|
MockWalletSecurityVault unusedVault;
|
|
failingRpc.encryptResult = false;
|
|
failingRpc.encryptError = "rpc unavailable";
|
|
auto failed = controller.runDeferredEncryption({"bad-passphrase", "1111"}, failingRpc, &unusedVault);
|
|
EXPECT_FALSE(failed.encrypted);
|
|
EXPECT_TRUE(failed.pinProvided);
|
|
EXPECT_FALSE(failed.pinStored);
|
|
EXPECT_FALSE(failed.restartRequired);
|
|
EXPECT_EQ(failed.error, std::string("rpc unavailable"));
|
|
EXPECT_EQ(unusedVault.storeCalls, 0);
|
|
}
|
|
|
|
void testWalletSecurityWorkflow()
|
|
{
|
|
using dragonx::services::WalletSecurityWorkflow;
|
|
using DecryptPhase = WalletSecurityWorkflow::DecryptPhase;
|
|
using DecryptStep = WalletSecurityWorkflow::DecryptStep;
|
|
|
|
WalletSecurityWorkflow workflow;
|
|
auto start = std::chrono::steady_clock::now();
|
|
EXPECT_EQ(workflow.phase(), DecryptPhase::PassphraseEntry);
|
|
EXPECT_TRUE(workflow.canClose());
|
|
|
|
workflow.start(start);
|
|
EXPECT_EQ(workflow.phase(), DecryptPhase::Working);
|
|
EXPECT_EQ(workflow.step(), DecryptStep::Unlock);
|
|
EXPECT_FALSE(workflow.canClose());
|
|
EXPECT_TRUE(workflow.inProgress());
|
|
|
|
workflow.advanceTo(DecryptStep::BackupWallet,
|
|
WalletSecurityWorkflow::stepStatus(DecryptStep::BackupWallet),
|
|
start + std::chrono::seconds(2));
|
|
EXPECT_EQ(workflow.step(), DecryptStep::BackupWallet);
|
|
EXPECT_EQ(workflow.status(), std::string("Backing up encrypted wallet..."));
|
|
EXPECT_TRUE(WalletSecurityWorkflow::stepIsComplete(workflow.step(), DecryptStep::StopDaemon));
|
|
EXPECT_FALSE(WalletSecurityWorkflow::stepIsComplete(workflow.step(), DecryptStep::RestartDaemon));
|
|
|
|
workflow.closeDialogForImport();
|
|
EXPECT_FALSE(workflow.inProgress());
|
|
EXPECT_TRUE(workflow.importActive());
|
|
workflow.finishImport();
|
|
EXPECT_FALSE(workflow.importActive());
|
|
|
|
workflow.fail("Daemon failed to restart");
|
|
EXPECT_EQ(workflow.phase(), DecryptPhase::Error);
|
|
EXPECT_TRUE(workflow.canClose());
|
|
EXPECT_EQ(workflow.status(), std::string("Daemon failed to restart"));
|
|
|
|
workflow.failEntry("Incorrect passphrase");
|
|
EXPECT_EQ(workflow.phase(), DecryptPhase::PassphraseEntry);
|
|
EXPECT_FALSE(workflow.inProgress());
|
|
EXPECT_EQ(workflow.status(), std::string("Incorrect passphrase"));
|
|
|
|
auto plan = WalletSecurityWorkflow::planWalletFiles("/tmp/dragonx/", 1234);
|
|
EXPECT_EQ(plan.exportFile, std::string("obsidiandecryptexport1234"));
|
|
EXPECT_EQ(plan.exportPath, std::string("/tmp/dragonx/obsidiandecryptexport1234"));
|
|
EXPECT_EQ(plan.walletPath, std::string("/tmp/dragonx/wallet.dat"));
|
|
EXPECT_EQ(plan.backupPath, std::string("/tmp/dragonx/wallet.dat.encrypted.bak"));
|
|
}
|
|
|
|
void testWalletSecurityWorkflowExecutor()
|
|
{
|
|
using Executor = dragonx::services::WalletSecurityWorkflowExecutor;
|
|
|
|
MockWorkflowRpc rpc;
|
|
auto unlock = Executor::unlockWallet("passphrase", rpc);
|
|
EXPECT_TRUE(unlock.ok);
|
|
EXPECT_EQ(rpc.unlockCalls, 1);
|
|
EXPECT_EQ(rpc.lastPassphrase, std::string("passphrase"));
|
|
|
|
rpc.unlockResult = false;
|
|
rpc.unlockError = "bad passphrase";
|
|
auto failedUnlock = Executor::unlockWallet("bad", rpc);
|
|
EXPECT_FALSE(failedUnlock.ok);
|
|
EXPECT_TRUE(failedUnlock.passphraseRejected);
|
|
EXPECT_EQ(failedUnlock.error, std::string("bad passphrase"));
|
|
|
|
MockWorkflowFiles files;
|
|
auto exported = Executor::exportWallet(rpc, files, 77);
|
|
EXPECT_TRUE(exported.ok);
|
|
EXPECT_EQ(exported.filePlan.exportFile, std::string("obsidiandecryptexport77"));
|
|
EXPECT_EQ(exported.filePlan.exportPath, std::string("/tmp/dragonx/obsidiandecryptexport77"));
|
|
EXPECT_EQ(rpc.lastExportFile, std::string("obsidiandecryptexport77"));
|
|
|
|
rpc.exportResult = false;
|
|
rpc.exportError = "disk full";
|
|
auto failedExport = Executor::exportWallet(rpc, files, 78);
|
|
EXPECT_FALSE(failedExport.ok);
|
|
EXPECT_EQ(failedExport.error, std::string("Export failed: disk full"));
|
|
|
|
rpc.stopResult = false;
|
|
auto stop = Executor::stopDaemon(rpc);
|
|
EXPECT_TRUE(stop.ok);
|
|
EXPECT_EQ(rpc.stopCalls, 1);
|
|
|
|
auto backup = Executor::backupEncryptedWallet(files, exported.filePlan);
|
|
EXPECT_TRUE(backup.ok);
|
|
EXPECT_EQ(files.backupCalls, 1);
|
|
files.backupResult = false;
|
|
files.backupError = "permission denied";
|
|
auto failedBackup = Executor::backupEncryptedWallet(files, exported.filePlan);
|
|
EXPECT_FALSE(failedBackup.ok);
|
|
EXPECT_EQ(failedBackup.error, std::string("Failed to rename wallet.dat: permission denied"));
|
|
|
|
MockWorkflowDaemon daemon;
|
|
rpc.probeCalls = 0;
|
|
rpc.probeSuccessOnCall = 3;
|
|
auto restart = Executor::restartDaemonAndWait(daemon, rpc, 200, 100, 5);
|
|
EXPECT_TRUE(restart.ok);
|
|
EXPECT_EQ(daemon.stopCalls, 1);
|
|
EXPECT_EQ(daemon.startCalls, 1);
|
|
EXPECT_EQ(rpc.probeCalls, 3);
|
|
|
|
MockWorkflowDaemon cancelledDaemon;
|
|
cancelledDaemon.cancelAfterSleeps = 1;
|
|
rpc.probeCalls = 0;
|
|
auto cancelled = Executor::restartDaemonAndWait(cancelledDaemon, rpc, 200, 100, 5);
|
|
EXPECT_FALSE(cancelled.ok);
|
|
EXPECT_TRUE(cancelled.error.empty());
|
|
|
|
MockWorkflowDaemon failedDaemon;
|
|
rpc.probeCalls = 0;
|
|
rpc.probeSuccessOnCall = 99;
|
|
auto failedRestart = Executor::restartDaemonAndWait(failedDaemon, rpc, 0, 0, 2);
|
|
EXPECT_FALSE(failedRestart.ok);
|
|
EXPECT_EQ(failedRestart.error, std::string("Daemon failed to restart"));
|
|
|
|
MockWorkflowImporter importer;
|
|
auto imported = Executor::importWallet(importer, exported.filePlan.exportPath);
|
|
EXPECT_TRUE(imported.ok);
|
|
EXPECT_EQ(importer.importCalls, 1);
|
|
EXPECT_EQ(importer.lastExportPath, exported.filePlan.exportPath);
|
|
importer.result = false;
|
|
importer.importError = "rescan failed";
|
|
auto failedImport = Executor::importWallet(importer, exported.filePlan.exportPath);
|
|
EXPECT_FALSE(failedImport.ok);
|
|
EXPECT_EQ(failedImport.error, std::string("Key import failed: rescan failed"));
|
|
|
|
int cleanupCalls = 0;
|
|
Executor::cleanupVaultAndPin([&]() { ++cleanupCalls; });
|
|
EXPECT_EQ(cleanupCalls, 1);
|
|
}
|
|
|
|
void testDaemonShutdownPolicy()
|
|
{
|
|
using dragonx::daemon::DaemonController;
|
|
using ShutdownAction = DaemonController::ShutdownAction;
|
|
|
|
auto noDaemon = DaemonController::evaluateShutdownPolicy(false, false, false, false);
|
|
EXPECT_EQ(noDaemon.action, ShutdownAction::DisconnectOnly);
|
|
|
|
auto keepRunning = DaemonController::evaluateShutdownPolicy(true, false, true, false);
|
|
EXPECT_EQ(keepRunning.action, ShutdownAction::DisconnectOnly);
|
|
EXPECT_EQ(std::string(keepRunning.logReason), std::string("keep_daemon_running enabled"));
|
|
|
|
auto externalPreserved = DaemonController::evaluateShutdownPolicy(true, true, false, false);
|
|
EXPECT_EQ(externalPreserved.action, ShutdownAction::DisconnectOnly);
|
|
EXPECT_EQ(std::string(externalPreserved.logReason), std::string("external daemon (not ours to stop)"));
|
|
|
|
auto externalStopAllowed = DaemonController::evaluateShutdownPolicy(true, true, false, true);
|
|
EXPECT_EQ(externalStopAllowed.action, ShutdownAction::StopDaemon);
|
|
|
|
auto ownedDaemon = DaemonController::evaluateShutdownPolicy(true, false, false, false);
|
|
EXPECT_EQ(ownedDaemon.action, ShutdownAction::StopDaemon);
|
|
|
|
auto restart = DaemonController::evaluateLifecycleOperation(
|
|
DaemonController::LifecycleOperation::ManualRestart, true, true, true, false);
|
|
EXPECT_TRUE(restart.allowed);
|
|
EXPECT_TRUE(restart.wasRunning);
|
|
EXPECT_TRUE(restart.resetCrashCount);
|
|
EXPECT_TRUE(restart.disconnectRpc);
|
|
EXPECT_EQ(restart.restartDelayMs, 500);
|
|
EXPECT_EQ(std::string(restart.taskName), std::string("daemon-restart"));
|
|
|
|
auto blockedRestart = DaemonController::evaluateLifecycleOperation(
|
|
DaemonController::LifecycleOperation::ManualRestart, true, true, true, true);
|
|
EXPECT_FALSE(blockedRestart.allowed);
|
|
|
|
auto externalRescan = DaemonController::evaluateLifecycleOperation(
|
|
DaemonController::LifecycleOperation::Rescan, false, false, false, false);
|
|
EXPECT_FALSE(externalRescan.allowed);
|
|
EXPECT_EQ(std::string(externalRescan.warning),
|
|
std::string("Rescan requires embedded daemon. Restart your daemon with -rescan manually."));
|
|
|
|
auto deleteData = DaemonController::evaluateLifecycleOperation(
|
|
DaemonController::LifecycleOperation::DeleteBlockchainData, true, true, false, false);
|
|
EXPECT_TRUE(deleteData.allowed);
|
|
EXPECT_FALSE(deleteData.wasRunning);
|
|
EXPECT_EQ(deleteData.restartDelayMs, 3000);
|
|
|
|
auto bootstrap = DaemonController::evaluateLifecycleOperation(
|
|
DaemonController::LifecycleOperation::BootstrapStop, false, false, true, false);
|
|
EXPECT_TRUE(bootstrap.allowed);
|
|
EXPECT_TRUE(bootstrap.wasRunning);
|
|
EXPECT_TRUE(bootstrap.disconnectRpc);
|
|
}
|
|
|
|
void testDaemonLifecycleExecution()
|
|
{
|
|
using dragonx::daemon::DaemonController;
|
|
|
|
auto restart = DaemonController::evaluateLifecycleOperation(
|
|
DaemonController::LifecycleOperation::ManualRestart, true, true, true, false);
|
|
MockDaemonLifecycleRuntime restartRuntime;
|
|
MockDaemonLifecycleTask restartTask;
|
|
auto restartResult = DaemonController::executeLifecycleOperation(restart, restartRuntime, restartTask);
|
|
EXPECT_TRUE(restartResult.completed);
|
|
EXPECT_TRUE(restartResult.stopped);
|
|
EXPECT_TRUE(restartResult.started);
|
|
EXPECT_EQ(restartTask.sleeps, 5);
|
|
EXPECT_TRUE(restartRuntime.calls == std::vector<std::string>({"stop", "start"}));
|
|
|
|
auto rescan = DaemonController::evaluateLifecycleOperation(
|
|
DaemonController::LifecycleOperation::Rescan, true, true, true, false);
|
|
MockDaemonLifecycleRuntime rescanRuntime;
|
|
MockDaemonLifecycleTask rescanTask;
|
|
auto rescanResult = DaemonController::executeLifecycleOperation(rescan, rescanRuntime, rescanTask);
|
|
EXPECT_TRUE(rescanResult.completed);
|
|
EXPECT_EQ(rescanTask.sleeps, 30);
|
|
EXPECT_TRUE(rescanRuntime.calls == std::vector<std::string>({"stop", "reset-output", "start"}));
|
|
|
|
auto deleteData = DaemonController::evaluateLifecycleOperation(
|
|
DaemonController::LifecycleOperation::DeleteBlockchainData, true, true, false, false);
|
|
MockDaemonLifecycleRuntime deleteRuntime;
|
|
MockDaemonLifecycleTask deleteTask;
|
|
auto deleteResult = DaemonController::executeLifecycleOperation(deleteData, deleteRuntime, deleteTask);
|
|
EXPECT_TRUE(deleteResult.completed);
|
|
EXPECT_EQ(deleteResult.deletedItems, 7);
|
|
EXPECT_TRUE(deleteRuntime.calls == std::vector<std::string>({"stop", "delete", "reset-output", "start"}));
|
|
|
|
auto bootstrap = DaemonController::evaluateLifecycleOperation(
|
|
DaemonController::LifecycleOperation::BootstrapStop, true, true, true, false);
|
|
MockDaemonLifecycleRuntime bootstrapRuntime;
|
|
MockDaemonLifecycleTask bootstrapTask;
|
|
auto bootstrapResult = DaemonController::executeLifecycleOperation(bootstrap, bootstrapRuntime, bootstrapTask);
|
|
EXPECT_TRUE(bootstrapResult.completed);
|
|
EXPECT_TRUE(bootstrapResult.stopped);
|
|
EXPECT_TRUE(bootstrapRuntime.calls == std::vector<std::string>({"rpc-stop-disconnect"}));
|
|
EXPECT_EQ(bootstrapRuntime.rpcStopContext, std::string("Bootstrap daemon stop"));
|
|
EXPECT_EQ(bootstrapRuntime.disconnectReason, std::string("Bootstrap"));
|
|
|
|
MockDaemonLifecycleRuntime cancelRuntime;
|
|
MockDaemonLifecycleTask cancelTask;
|
|
cancelTask.cancelAfterSleeps = 1;
|
|
auto cancelResult = DaemonController::executeLifecycleOperation(rescan, cancelRuntime, cancelTask);
|
|
EXPECT_TRUE(cancelResult.cancelled);
|
|
EXPECT_FALSE(cancelResult.started);
|
|
EXPECT_TRUE(cancelRuntime.calls == std::vector<std::string>({"stop"}));
|
|
|
|
MockDaemonLifecycleRuntime shutdownRuntime;
|
|
MockDaemonLifecycleTask shutdownTask;
|
|
shutdownTask.isShuttingDown = true;
|
|
auto shutdownResult = DaemonController::executeLifecycleOperation(restart, shutdownRuntime, shutdownTask);
|
|
EXPECT_TRUE(shutdownResult.cancelled);
|
|
EXPECT_TRUE(shutdownResult.stopped);
|
|
EXPECT_FALSE(shutdownResult.started);
|
|
EXPECT_TRUE(shutdownRuntime.calls == std::vector<std::string>({"stop"}));
|
|
}
|
|
|
|
void testDaemonLifecycleAdapters()
|
|
{
|
|
using dragonx::daemon::AsyncLifecycleTaskContext;
|
|
using dragonx::daemon::BlockchainDataCleaner;
|
|
using dragonx::daemon::ImmediateLifecycleTaskContext;
|
|
using dragonx::util::AsyncTaskManager;
|
|
|
|
auto cancelled = std::make_shared<std::atomic<bool>>(false);
|
|
std::atomic<bool> shuttingDown{false};
|
|
AsyncTaskManager::Token token(cancelled);
|
|
AsyncLifecycleTaskContext asyncContext(token, shuttingDown);
|
|
EXPECT_FALSE(asyncContext.cancelled());
|
|
EXPECT_FALSE(asyncContext.shuttingDown());
|
|
cancelled->store(true);
|
|
EXPECT_TRUE(asyncContext.cancelled());
|
|
shuttingDown.store(true);
|
|
EXPECT_TRUE(asyncContext.shuttingDown());
|
|
|
|
ImmediateLifecycleTaskContext immediateContext;
|
|
EXPECT_FALSE(immediateContext.cancelled());
|
|
EXPECT_FALSE(immediateContext.shuttingDown());
|
|
immediateContext.sleepForMs(50);
|
|
|
|
fs::path dataDir = makeTempDir();
|
|
fs::create_directories(dataDir / "blocks" / "nested");
|
|
fs::create_directories(dataDir / "chainstate");
|
|
fs::create_directories(dataDir / "database");
|
|
fs::create_directories(dataDir / "notarizations");
|
|
std::ofstream(dataDir / "blocks" / "nested" / "blk00000.dat") << "block";
|
|
std::ofstream(dataDir / "chainstate" / "state.dat") << "state";
|
|
std::ofstream(dataDir / "database" / "db.dat") << "db";
|
|
std::ofstream(dataDir / "notarizations" / "notary.dat") << "notary";
|
|
std::ofstream(dataDir / "peers.dat") << "peers";
|
|
std::ofstream(dataDir / "fee_estimates.dat") << "fees";
|
|
std::ofstream(dataDir / "wallet.dat") << "wallet";
|
|
|
|
int removed = BlockchainDataCleaner::removeBlockchainData(dataDir);
|
|
EXPECT_TRUE(removed >= 6);
|
|
EXPECT_FALSE(fs::exists(dataDir / "blocks"));
|
|
EXPECT_FALSE(fs::exists(dataDir / "chainstate"));
|
|
EXPECT_FALSE(fs::exists(dataDir / "database"));
|
|
EXPECT_FALSE(fs::exists(dataDir / "notarizations"));
|
|
EXPECT_FALSE(fs::exists(dataDir / "peers.dat"));
|
|
EXPECT_FALSE(fs::exists(dataDir / "fee_estimates.dat"));
|
|
EXPECT_TRUE(fs::exists(dataDir / "wallet.dat"));
|
|
fs::remove_all(dataDir);
|
|
}
|
|
|
|
void testRendererHelpers()
|
|
{
|
|
EXPECT_NEAR(dragonx::ui::ComputeConsoleInputHeight(20.0f, 4.0f, 8.0f, 2.0f, 1.0f),
|
|
43.0, 0.0001);
|
|
EXPECT_NEAR(dragonx::ui::ComputeConsoleOutputHeight(200.0f, 50.0f, 120.0f, 0.75f),
|
|
150.0, 0.0001);
|
|
EXPECT_NEAR(dragonx::ui::ComputeConsoleOutputHeight(120.0f, 80.0f, 70.0f, 0.5f),
|
|
70.0, 0.0001);
|
|
EXPECT_NEAR(dragonx::ui::ClampConsoleWrapWidth(40.0f, 8.0f), 50.0, 0.0001);
|
|
EXPECT_NEAR(dragonx::ui::ClampConsoleWrapWidth(200.0f, 12.0f), 176.0, 0.0001);
|
|
|
|
EXPECT_EQ(dragonx::ui::ClampMiningThreads(0, 8), 1);
|
|
EXPECT_EQ(dragonx::ui::ClampMiningThreads(12, 8), 8);
|
|
EXPECT_EQ(dragonx::ui::ClampMiningThreads(4, 8), 4);
|
|
EXPECT_TRUE(dragonx::ui::IsPoolMiningActive(true, true, false));
|
|
EXPECT_FALSE(dragonx::ui::IsPoolMiningActive(true, false, true));
|
|
EXPECT_TRUE(dragonx::ui::IsPoolMiningActive(false, false, true));
|
|
|
|
dragonx::ui::ConsoleOutputFilter filter{"error", false, false, false, 10, 20, 30};
|
|
EXPECT_TRUE(dragonx::ui::consoleLinePassesFilter("RPC Error", 20, filter));
|
|
EXPECT_FALSE(dragonx::ui::consoleLinePassesFilter("daemon line", 10, filter));
|
|
filter.text.clear();
|
|
EXPECT_FALSE(dragonx::ui::consoleLinePassesFilter("[rpc] History -> listtransactions", 30, filter));
|
|
filter.rpcTraceEnabled = true;
|
|
EXPECT_TRUE(dragonx::ui::consoleLinePassesFilter("[rpc] History -> listtransactions", 30, filter));
|
|
filter.daemonMessagesEnabled = true;
|
|
filter.errorsOnly = true;
|
|
filter.text.clear();
|
|
EXPECT_TRUE(dragonx::ui::consoleLinePassesFilter("error line", 20, filter));
|
|
EXPECT_FALSE(dragonx::ui::consoleLinePassesFilter("info line", 30, filter));
|
|
|
|
std::vector<dragonx::AddressInfo> poolAddresses;
|
|
poolAddresses.push_back({"R-transparent", 0.0, "transparent", true});
|
|
poolAddresses.push_back({"zs-default-worker", 0.0, "shielded", true});
|
|
EXPECT_TRUE(dragonx::ui::shouldDefaultPoolWorker("", false));
|
|
EXPECT_TRUE(dragonx::ui::shouldDefaultPoolWorker("x", false));
|
|
EXPECT_FALSE(dragonx::ui::shouldDefaultPoolWorker("zs-manual", false));
|
|
EXPECT_FALSE(dragonx::ui::shouldDefaultPoolWorker("", true));
|
|
EXPECT_EQ(dragonx::ui::defaultPoolWorkerAddress(poolAddresses), std::string("zs-default-worker"));
|
|
EXPECT_TRUE(dragonx::ui::miningValueAlreadySaved({"pool-a", "pool-b"}, "pool-b"));
|
|
EXPECT_FALSE(dragonx::ui::miningValueAlreadySaved({"pool-a"}, ""));
|
|
EXPECT_EQ(std::string(dragonx::ui::defaultPoolUrl()), std::string("pool.dragonx.is:3433"));
|
|
|
|
dragonx::TransactionInfo tx;
|
|
tx.type = "send";
|
|
tx.amount = -1.25;
|
|
tx.address = "zsabcdefghijklmnopqrstuvwxyz";
|
|
auto txDisplay = dragonx::ui::buildRecentTxDisplay(tx, 12);
|
|
EXPECT_EQ(txDisplay.typeText, std::string("Sent"));
|
|
EXPECT_EQ(txDisplay.addressText, std::string("zsab...wxyz"));
|
|
EXPECT_EQ(txDisplay.amountText, std::string("-1.2500 DRGX"));
|
|
EXPECT_EQ(txDisplay.timeText, std::string(""));
|
|
}
|
|
|
|
|
|
void testConsoleInputModel()
|
|
{
|
|
std::vector<std::string> history;
|
|
dragonx::ui::AppendConsoleHistory(history, "getinfo", 3);
|
|
dragonx::ui::AppendConsoleHistory(history, "getinfo", 3);
|
|
dragonx::ui::AppendConsoleHistory(history, "getpeerinfo", 3);
|
|
dragonx::ui::AppendConsoleHistory(history, "getblockcount", 3);
|
|
dragonx::ui::AppendConsoleHistory(history, "help", 3);
|
|
EXPECT_EQ(history.size(), static_cast<size_t>(3));
|
|
EXPECT_EQ(history.front(), std::string("getpeerinfo"));
|
|
|
|
int index = dragonx::ui::NavigateConsoleHistoryIndex(-1, history.size(), true);
|
|
EXPECT_EQ(index, 2);
|
|
EXPECT_EQ(dragonx::ui::ConsoleHistoryEntry(history, index), std::string("help"));
|
|
index = dragonx::ui::NavigateConsoleHistoryIndex(index, history.size(), true);
|
|
EXPECT_EQ(index, 1);
|
|
index = dragonx::ui::NavigateConsoleHistoryIndex(index, history.size(), false);
|
|
EXPECT_EQ(index, 2);
|
|
index = dragonx::ui::NavigateConsoleHistoryIndex(index, history.size(), false);
|
|
EXPECT_EQ(index, -1);
|
|
|
|
auto single = dragonx::ui::CompleteConsoleCommand("stop");
|
|
EXPECT_EQ(single.matches.size(), static_cast<size_t>(1));
|
|
EXPECT_EQ(single.commonPrefix, std::string("stop"));
|
|
|
|
auto multiple = dragonx::ui::CompleteConsoleCommand("getblock");
|
|
EXPECT_TRUE(multiple.matches.size() > 1);
|
|
EXPECT_EQ(multiple.commonPrefix, std::string("getblock"));
|
|
auto lines = dragonx::ui::FormatConsoleCompletionLines(multiple.matches, 40);
|
|
EXPECT_FALSE(lines.empty());
|
|
|
|
auto args = dragonx::ui::ParseConsoleCommandArgs("z_sendmany \"from addr\" [{\"address\":\"zs\",\"amount\":1.25}] true");
|
|
EXPECT_EQ(args.size(), static_cast<size_t>(4));
|
|
EXPECT_EQ(args[1], std::string("from addr"));
|
|
auto call = dragonx::ui::BuildConsoleRpcCall("setgenerate true 4");
|
|
EXPECT_TRUE(call.valid);
|
|
EXPECT_EQ(call.method, std::string("setgenerate"));
|
|
EXPECT_EQ(call.params.size(), static_cast<size_t>(2));
|
|
EXPECT_TRUE(call.params[0].get<bool>());
|
|
EXPECT_EQ(call.params[1].get<long long>(), 4LL);
|
|
|
|
auto resultLines = dragonx::ui::FormatConsoleRpcResultLines("{\n \"balance\": 12,\n \"ok\": true\n}", false);
|
|
EXPECT_EQ(resultLines.size(), static_cast<size_t>(4));
|
|
EXPECT_EQ(resultLines[0].role, dragonx::ui::ConsoleResultLineRole::JsonBrace);
|
|
EXPECT_EQ(resultLines[1].role, dragonx::ui::ConsoleResultLineRole::JsonKey);
|
|
auto nullLines = dragonx::ui::FormatConsoleRpcResultLines("null", false);
|
|
EXPECT_EQ(nullLines[0].text, std::string("(no result)"));
|
|
auto errorLines = dragonx::ui::FormatConsoleRpcResultLines("bad rpc", true);
|
|
EXPECT_EQ(errorLines[0].role, dragonx::ui::ConsoleResultLineRole::Error);
|
|
EXPECT_EQ(errorLines[0].text, std::string("Error: bad rpc"));
|
|
}
|
|
|
|
void testMiningBenchmarkModel()
|
|
{
|
|
dragonx::ui::ThreadBenchmark benchmark;
|
|
EXPECT_FALSE(benchmark.active());
|
|
benchmark.buildCandidates(8);
|
|
EXPECT_EQ(benchmark.candidates.size(), static_cast<size_t>(5));
|
|
EXPECT_EQ(benchmark.candidates.front(), 4);
|
|
EXPECT_EQ(benchmark.candidates.back(), 8);
|
|
EXPECT_NEAR(benchmark.avgWarmupSecs(), 195.0, 0.0001);
|
|
|
|
benchmark.phase = dragonx::ui::ThreadBenchmark::Phase::WarmingUp;
|
|
EXPECT_TRUE(benchmark.active());
|
|
benchmark.current_index = 2;
|
|
benchmark.total_warmup_secs = 210.0f;
|
|
EXPECT_NEAR(benchmark.avgWarmupSecs(), 105.0, 0.0001);
|
|
EXPECT_NEAR(benchmark.perTestSecs(), 135.0, 0.0001);
|
|
benchmark.phase_timer = 15.0f;
|
|
EXPECT_TRUE(benchmark.progress() > 0.0f);
|
|
|
|
benchmark.resetStabilityTracking();
|
|
EXPECT_EQ(benchmark.window_samples, 0);
|
|
EXPECT_EQ(benchmark.consecutive_stable, 0);
|
|
|
|
benchmark.phase = dragonx::ui::ThreadBenchmark::Phase::Starting;
|
|
benchmark.candidates = {2};
|
|
benchmark.current_index = 0;
|
|
auto update = dragonx::ui::AdvanceThreadBenchmark(benchmark, 0.1f, 0.0);
|
|
EXPECT_TRUE(update.stopPoolMining);
|
|
EXPECT_TRUE(update.startPoolMining);
|
|
EXPECT_EQ(update.startThreads, 2);
|
|
EXPECT_TRUE(benchmark.phase == dragonx::ui::ThreadBenchmark::Phase::WarmingUp);
|
|
|
|
benchmark.phase_timer = dragonx::ui::ThreadBenchmark::MAX_WARMUP_SECS - 0.5f;
|
|
update = dragonx::ui::AdvanceThreadBenchmark(benchmark, 1.0f, 100.0);
|
|
EXPECT_TRUE(benchmark.phase == dragonx::ui::ThreadBenchmark::Phase::Measuring);
|
|
benchmark.phase_timer = dragonx::ui::ThreadBenchmark::MEASURE_SECS - 0.5f;
|
|
update = dragonx::ui::AdvanceThreadBenchmark(benchmark, 1.0f, 250.0);
|
|
EXPECT_TRUE(benchmark.phase == dragonx::ui::ThreadBenchmark::Phase::Advancing);
|
|
EXPECT_EQ(benchmark.optimal_threads, 2);
|
|
benchmark.was_pool_running = true;
|
|
update = dragonx::ui::AdvanceThreadBenchmark(benchmark, 0.1f, 0.0);
|
|
EXPECT_TRUE(update.stopPoolMining);
|
|
EXPECT_TRUE(update.saveOptimalThreads);
|
|
EXPECT_TRUE(update.startPoolMining);
|
|
EXPECT_EQ(update.optimalThreads, 2);
|
|
|
|
benchmark.reset();
|
|
EXPECT_FALSE(benchmark.active());
|
|
EXPECT_TRUE(benchmark.candidates.empty());
|
|
}
|
|
|
|
void testBalanceAddressListModel()
|
|
{
|
|
std::vector<dragonx::AddressInfo> addresses;
|
|
dragonx::AddressInfo zHigh;
|
|
zHigh.address = "zs-high";
|
|
zHigh.label = "Vault";
|
|
zHigh.balance = 10.0;
|
|
zHigh.type = "shielded";
|
|
addresses.push_back(zHigh);
|
|
|
|
dragonx::AddressInfo tZero;
|
|
tZero.address = "R-zero";
|
|
tZero.label = "Empty";
|
|
tZero.balance = 0.0;
|
|
tZero.type = "transparent";
|
|
addresses.push_back(tZero);
|
|
|
|
dragonx::AddressInfo tFav;
|
|
tFav.address = "R-favorite";
|
|
tFav.label = "Favorite";
|
|
tFav.balance = 0.0;
|
|
tFav.type = "transparent";
|
|
addresses.push_back(tFav);
|
|
|
|
std::vector<dragonx::ui::AddressListInput> inputs = {
|
|
{&addresses[0], true, false, false, false, "Main Vault", "", -1},
|
|
{&addresses[1], false, false, false, false, "Empty", "", -1},
|
|
{&addresses[2], false, false, true, false, "Favorite", "", -1}
|
|
};
|
|
|
|
auto rows = dragonx::ui::BuildAddressListRows(inputs, "", true, false);
|
|
EXPECT_EQ(rows.size(), static_cast<size_t>(2));
|
|
EXPECT_EQ(rows[0].info->address, std::string("R-favorite"));
|
|
EXPECT_EQ(rows[1].info->address, std::string("zs-high"));
|
|
|
|
inputs[2].sortOrder = 0;
|
|
rows = dragonx::ui::BuildAddressListRows(inputs, "", true, false);
|
|
EXPECT_EQ(rows[0].info->address, std::string("R-favorite"));
|
|
|
|
rows = dragonx::ui::BuildAddressListRows(inputs, "vault", false, false);
|
|
EXPECT_EQ(rows.size(), static_cast<size_t>(1));
|
|
EXPECT_EQ(rows[0].info->address, std::string("zs-high"));
|
|
|
|
auto layout = dragonx::ui::ComputeAddressRowLayout(10.0f, 20.0f, 300.0f, 50.0f,
|
|
12.0f, 16.0f, 6.0f, 4.0f, 2.0f);
|
|
EXPECT_NEAR(layout.contentStartX, 22.0, 0.0001);
|
|
EXPECT_NEAR(layout.contentStartY, 26.0, 0.0001);
|
|
EXPECT_NEAR(layout.buttonSize, 38.0, 0.0001);
|
|
EXPECT_NEAR(layout.favoriteButton.x, 270.0, 0.0001);
|
|
EXPECT_NEAR(layout.visibilityButton.x, 228.0, 0.0001);
|
|
EXPECT_NEAR(layout.contentRight, 224.0, 0.0001);
|
|
EXPECT_EQ(dragonx::ui::FormatAddressUsdValue(2.0, 3.5), std::string("$7.00"));
|
|
EXPECT_EQ(dragonx::ui::FormatAddressUsdValue(0.001, 2.0), std::string("$0.002000"));
|
|
EXPECT_EQ(dragonx::ui::FormatAddressUsdValue(0.0, 2.0), std::string(""));
|
|
}
|
|
|
|
void testExplorerBlockCache()
|
|
{
|
|
using dragonx::ui::ExplorerBlockCache;
|
|
using dragonx::ui::ExplorerBlockSummary;
|
|
using nlohmann::json;
|
|
|
|
fs::path dir = makeTempDir();
|
|
fs::path databasePath = dir / "explorer_blocks.sqlite";
|
|
fs::path legacyPath = dir / "explorer_blocks_cache.json";
|
|
|
|
json legacy = {
|
|
{"version", 1},
|
|
{"tip_height", 10},
|
|
{"tip_hash", "hash-10"},
|
|
{"blocks", json::array({
|
|
json{{"height", 10}, {"hash", "hash-10"}, {"tx_count", 3}, {"size", 1000}, {"time", 5000}, {"difficulty", 1.25}},
|
|
json{{"height", 9}, {"hash", "hash-9"}, {"tx_count", 2}, {"size", 900}, {"time", 4900}, {"difficulty", 1.20}}
|
|
})}
|
|
};
|
|
{
|
|
std::ofstream file(legacyPath);
|
|
file << legacy.dump(2);
|
|
}
|
|
|
|
ExplorerBlockCache cache(databasePath.string(), legacyPath.string());
|
|
EXPECT_TRUE(cache.ensureOpen());
|
|
EXPECT_EQ(cache.cachedBlockCount(), 2);
|
|
|
|
auto range = cache.loadRange(9, 10);
|
|
EXPECT_EQ(range.size(), static_cast<size_t>(2));
|
|
EXPECT_EQ(range[10].hash, std::string("hash-10"));
|
|
EXPECT_EQ(range[9].tx_count, 2);
|
|
|
|
auto sameTipValidation = cache.prepareValidation(10, "hash-10");
|
|
EXPECT_FALSE(sameTipValidation.needed);
|
|
|
|
auto advancedValidation = cache.prepareValidation(12, "hash-12");
|
|
EXPECT_TRUE(advancedValidation.needed);
|
|
EXPECT_EQ(advancedValidation.height, 10);
|
|
EXPECT_EQ(advancedValidation.expectedHash, std::string("hash-10"));
|
|
cache.applySavedTipValidation(advancedValidation, "hash-10", 12, "hash-12");
|
|
|
|
ExplorerBlockSummary block12;
|
|
block12.height = 12;
|
|
block12.hash = "hash-12";
|
|
block12.tx_count = 5;
|
|
block12.size = 1200;
|
|
block12.time = 5200;
|
|
block12.difficulty = 1.40;
|
|
EXPECT_TRUE(cache.storeBlock(block12));
|
|
EXPECT_EQ(cache.loadRange(12, 12)[12].tx_count, 5);
|
|
|
|
{
|
|
ExplorerBlockCache reopened(databasePath.string(), legacyPath.string());
|
|
EXPECT_TRUE(reopened.ensureOpen());
|
|
EXPECT_EQ(reopened.cachedBlockCount(), 3);
|
|
auto noRepeatValidation = reopened.prepareValidation(12, "hash-12");
|
|
EXPECT_FALSE(noRepeatValidation.needed);
|
|
}
|
|
|
|
cache.prepareValidation(12, "different-tip");
|
|
EXPECT_EQ(cache.cachedBlockCount(), 0);
|
|
|
|
{
|
|
ExplorerBlockCache reopened(databasePath.string(), legacyPath.string());
|
|
EXPECT_TRUE(reopened.ensureOpen());
|
|
EXPECT_EQ(reopened.cachedBlockCount(), 0);
|
|
}
|
|
|
|
fs::remove_all(dir);
|
|
}
|
|
|
|
void testTransactionHistoryCache()
|
|
{
|
|
using dragonx::TransactionInfo;
|
|
using dragonx::data::TransactionHistoryCache;
|
|
|
|
std::string identityA = TransactionHistoryCache::walletIdentityFromAddresses(
|
|
{"zs-beta", "zs-alpha"}, {"R-one"});
|
|
std::string identityB = TransactionHistoryCache::walletIdentityFromAddresses(
|
|
{"zs-alpha", "zs-beta"}, {"R-one"});
|
|
std::string identityWithSameText = TransactionHistoryCache::walletIdentityFromAddresses(
|
|
{"same-address-text"}, {"same-address-text"});
|
|
EXPECT_EQ(identityA, identityB);
|
|
EXPECT_TRUE(identityA.find("protocol=") == std::string::npos);
|
|
EXPECT_TRUE(identityA.find("p2p_port=") == std::string::npos);
|
|
EXPECT_TRUE(identityA.find("z:zs-alpha") != std::string::npos);
|
|
EXPECT_TRUE(identityA.find("t:R-one") != std::string::npos);
|
|
EXPECT_TRUE(identityWithSameText.find("z:same-address-text") != std::string::npos);
|
|
EXPECT_TRUE(identityWithSameText.find("t:same-address-text") != std::string::npos);
|
|
|
|
fs::path dir = makeTempDir();
|
|
fs::path databasePath = dir / "transaction_history.sqlite";
|
|
std::string walletIdentity = "mainnet|R-alpha|zs-beta";
|
|
std::string passphrase = "correct horse battery staple";
|
|
|
|
std::vector<TransactionInfo> transactions;
|
|
transactions.push_back(TransactionInfo{
|
|
"tx-sensitive-send", "send", -1.25, 1700000100, 12,
|
|
"zs-destination-sensitive", "R-source-sensitive", "private memo"
|
|
});
|
|
transactions.push_back(TransactionInfo{
|
|
"tx-mined", "mined", 3.5, 1700000000, 104,
|
|
"R-mining-address", "", ""
|
|
});
|
|
std::unordered_map<std::string, int> shieldedScanHeights{
|
|
{"zs-beta", 120},
|
|
{"zs-archive", 118}
|
|
};
|
|
|
|
{
|
|
TransactionHistoryCache cache(databasePath.string());
|
|
EXPECT_TRUE(cache.ensureOpen());
|
|
EXPECT_TRUE(cache.unlockWithPassphrase(walletIdentity, passphrase));
|
|
EXPECT_TRUE(cache.replace(walletIdentity, 120, "tip-120", transactions, 1700000200, shieldedScanHeights));
|
|
EXPECT_EQ(cache.snapshotCount(), 1);
|
|
}
|
|
|
|
{
|
|
std::ifstream database(databasePath, std::ios::binary);
|
|
std::string bytes((std::istreambuf_iterator<char>(database)), std::istreambuf_iterator<char>());
|
|
EXPECT_TRUE(bytes.find("tx-sensitive-send") == std::string::npos);
|
|
EXPECT_TRUE(bytes.find("zs-destination-sensitive") == std::string::npos);
|
|
EXPECT_TRUE(bytes.find("zs-archive") == std::string::npos);
|
|
EXPECT_TRUE(bytes.find("private memo") == std::string::npos);
|
|
}
|
|
|
|
{
|
|
TransactionHistoryCache wrongKey(databasePath.string());
|
|
EXPECT_TRUE(wrongKey.unlockWithPassphrase(walletIdentity, "wrong passphrase"));
|
|
auto wrongLoad = wrongKey.load(walletIdentity, 120, "tip-120");
|
|
EXPECT_FALSE(wrongLoad.loaded);
|
|
}
|
|
|
|
{
|
|
TransactionHistoryCache reopened(databasePath.string());
|
|
EXPECT_TRUE(reopened.unlockWithPassphrase(walletIdentity, passphrase));
|
|
auto loaded = reopened.load(walletIdentity, 120, "tip-120");
|
|
EXPECT_TRUE(loaded.loaded);
|
|
EXPECT_FALSE(loaded.invalidated);
|
|
EXPECT_EQ(loaded.tipHeight, 120);
|
|
EXPECT_EQ(loaded.tipHash, std::string("tip-120"));
|
|
EXPECT_EQ(loaded.updatedAt, static_cast<std::time_t>(1700000200));
|
|
EXPECT_EQ(loaded.transactions.size(), static_cast<size_t>(2));
|
|
EXPECT_EQ(loaded.transactions[0].txid, std::string("tx-sensitive-send"));
|
|
EXPECT_EQ(loaded.transactions[0].memo, std::string("private memo"));
|
|
EXPECT_EQ(loaded.transactions[1].type, std::string("mined"));
|
|
EXPECT_EQ(loaded.shieldedScanHeights.size(), static_cast<size_t>(2));
|
|
EXPECT_EQ(loaded.shieldedScanHeights.at("zs-beta"), 120);
|
|
EXPECT_EQ(loaded.shieldedScanHeights.at("zs-archive"), 118);
|
|
}
|
|
|
|
{
|
|
TransactionHistoryCache cache(databasePath.string());
|
|
EXPECT_TRUE(cache.unlockWithPassphrase(walletIdentity, passphrase));
|
|
EXPECT_TRUE(cache.replace(walletIdentity, 120, "tip-120", transactions, 1700000250));
|
|
auto loaded = cache.load(walletIdentity, 120, "tip-120");
|
|
EXPECT_TRUE(loaded.loaded);
|
|
EXPECT_EQ(loaded.shieldedScanHeights.size(), static_cast<size_t>(0));
|
|
}
|
|
|
|
{
|
|
TransactionHistoryCache staleTip(databasePath.string());
|
|
EXPECT_TRUE(staleTip.unlockWithPassphrase(walletIdentity, passphrase));
|
|
auto invalidated = staleTip.load(walletIdentity, 119, "tip-119");
|
|
EXPECT_FALSE(invalidated.loaded);
|
|
EXPECT_TRUE(invalidated.invalidated);
|
|
EXPECT_EQ(staleTip.snapshotCount(), 0);
|
|
}
|
|
|
|
{
|
|
TransactionHistoryCache cache(databasePath.string());
|
|
EXPECT_TRUE(cache.unlockWithPassphrase(walletIdentity, passphrase));
|
|
EXPECT_TRUE(cache.replace(walletIdentity, 120, "tip-120", transactions, 1700000300));
|
|
auto invalidated = cache.load(walletIdentity, 120, "different-tip");
|
|
EXPECT_FALSE(invalidated.loaded);
|
|
EXPECT_TRUE(invalidated.invalidated);
|
|
EXPECT_EQ(cache.snapshotCount(), 0);
|
|
}
|
|
|
|
fs::remove_all(dir);
|
|
}
|
|
|
|
// GC: generating a new address changes the wallet-identity hash; the prior hash's snapshot
|
|
// + salt must not accumulate forever. A save under a new identity prunes the old rows.
|
|
void testTransactionHistoryCachePrunesOldWallets()
|
|
{
|
|
using dragonx::TransactionInfo;
|
|
using dragonx::data::TransactionHistoryCache;
|
|
|
|
fs::path dir = makeTempDir();
|
|
fs::path dbPath = dir / "transaction_history.sqlite";
|
|
const std::string idA = "mainnet|R-a|zs-a";
|
|
const std::string idB = "mainnet|R-a|zs-a|zs-newly-generated"; // new address -> new identity
|
|
const std::string pass = "correct horse battery staple";
|
|
|
|
std::vector<TransactionInfo> txs;
|
|
txs.push_back(TransactionInfo{"tx1", "mined", 1.0, 1700000000, 10, "R-a", "", ""});
|
|
|
|
TransactionHistoryCache cache(dbPath.string());
|
|
EXPECT_TRUE(cache.unlockWithPassphrase(idA, pass));
|
|
EXPECT_TRUE(cache.replace(idA, 100, "tip-a", txs, 1700000000));
|
|
EXPECT_EQ(cache.snapshotCount(), 1);
|
|
|
|
EXPECT_TRUE(cache.unlockWithPassphrase(idB, pass));
|
|
EXPECT_TRUE(cache.replace(idB, 100, "tip-b", txs, 1700000001));
|
|
// Old wallet A's snapshot was pruned — only the live wallet's row remains.
|
|
EXPECT_EQ(cache.snapshotCount(), 1);
|
|
|
|
auto loadedB = cache.load(idB, 100, "tip-b");
|
|
EXPECT_TRUE(loadedB.loaded);
|
|
|
|
fs::remove_all(dir);
|
|
}
|
|
|
|
void testTransactionHistoryCacheRefreshApply()
|
|
{
|
|
using Refresh = dragonx::services::NetworkRefreshService;
|
|
using dragonx::TransactionInfo;
|
|
using dragonx::data::TransactionHistoryCache;
|
|
|
|
fs::path dir = makeTempDir();
|
|
fs::path databasePath = dir / "transaction_history_refresh.sqlite";
|
|
std::string walletIdentity = "mainnet|R-refresh-cache|zs-refresh-cache";
|
|
std::string passphrase = "refresh cache passphrase";
|
|
|
|
dragonx::WalletState state;
|
|
state.sync.blocks = 333;
|
|
state.sync.best_blockhash = "tip-333";
|
|
|
|
Refresh::TransactionRefreshResult refreshResult;
|
|
refreshResult.blockHeight = 333;
|
|
TransactionInfo refreshedTx;
|
|
refreshedTx.txid = "rpc-refreshed-send";
|
|
refreshedTx.type = "send";
|
|
refreshedTx.amount = -2.75;
|
|
refreshedTx.timestamp = 1700000400;
|
|
refreshedTx.confirmations = 14;
|
|
refreshedTx.address = "zs-rpc-destination";
|
|
refreshedTx.from_address = "R-refresh-cache";
|
|
refreshedTx.memo = "rpc refreshed memo";
|
|
refreshResult.transactions = {refreshedTx};
|
|
|
|
Refresh::TransactionViewCache viewCache;
|
|
std::unordered_set<std::string> sendTxids;
|
|
std::vector<TransactionInfo> confirmedCache;
|
|
std::unordered_set<std::string> confirmedIds;
|
|
int confirmedBlock = -1;
|
|
int lastTxBlock = -1;
|
|
Refresh::TransactionCacheUpdate cacheUpdate{
|
|
viewCache,
|
|
sendTxids,
|
|
confirmedCache,
|
|
confirmedIds,
|
|
confirmedBlock,
|
|
lastTxBlock
|
|
};
|
|
Refresh::applyTransactionRefreshResult(state, cacheUpdate, std::move(refreshResult), 1700000500);
|
|
|
|
TransactionHistoryCache cache(databasePath.string());
|
|
EXPECT_TRUE(cache.unlockWithPassphrase(walletIdentity, passphrase));
|
|
EXPECT_TRUE(cache.replace(walletIdentity,
|
|
state.sync.blocks,
|
|
state.sync.best_blockhash,
|
|
state.transactions,
|
|
state.last_tx_update));
|
|
|
|
TransactionHistoryCache reopened(databasePath.string());
|
|
EXPECT_TRUE(reopened.unlockWithPassphrase(walletIdentity, passphrase));
|
|
auto loaded = reopened.load(walletIdentity, 333, "tip-333");
|
|
EXPECT_TRUE(loaded.loaded);
|
|
EXPECT_EQ(loaded.transactions.size(), static_cast<size_t>(1));
|
|
EXPECT_EQ(loaded.transactions[0].txid, std::string("rpc-refreshed-send"));
|
|
EXPECT_EQ(loaded.transactions[0].memo, std::string("rpc refreshed memo"));
|
|
EXPECT_EQ(loaded.updatedAt, static_cast<std::time_t>(1700000500));
|
|
EXPECT_EQ(lastTxBlock, 333);
|
|
EXPECT_EQ(confirmedIds.count("rpc-refreshed-send"), static_cast<size_t>(1));
|
|
|
|
fs::remove_all(dir);
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
void testLiteBridgeOwnedStringCopiesBeforeFreeOnSuccess()
|
|
{
|
|
resetLiteBridgeRuntimeTrackedFree();
|
|
dragonx::wallet::LiteBridgeOwnedString ownedString(
|
|
makeLiteBridgeRuntimeOwnedCString("phase2-ok"),
|
|
&fakeLiteBridgeRuntimeTrackedFreeString);
|
|
|
|
auto result = ownedString.intoResult();
|
|
EXPECT_TRUE(result.ok);
|
|
EXPECT_EQ(result.value, std::string("phase2-ok"));
|
|
EXPECT_TRUE(result.error.empty());
|
|
EXPECT_TRUE(ownedString.rawPointerReceived());
|
|
EXPECT_TRUE(ownedString.copiedBeforeFree());
|
|
EXPECT_TRUE(ownedString.freed());
|
|
EXPECT_FALSE(ownedString.rawPointerEscaped());
|
|
EXPECT_EQ(g_liteBridgeRuntimeTrackedFreeCount, 1);
|
|
EXPECT_EQ(g_liteBridgeRuntimeTrackedFreedValues.size(), static_cast<std::size_t>(1));
|
|
EXPECT_EQ(g_liteBridgeRuntimeTrackedFreedValues[0], std::string("phase2-ok"));
|
|
|
|
auto secondResult = ownedString.intoResult();
|
|
EXPECT_FALSE(secondResult.ok);
|
|
EXPECT_EQ(g_liteBridgeRuntimeTrackedFreeCount, 1);
|
|
}
|
|
|
|
void testLiteBridgeOwnedStringClassifiesNullWithoutFree()
|
|
{
|
|
resetLiteBridgeRuntimeTrackedFree();
|
|
dragonx::wallet::LiteBridgeOwnedString ownedString(
|
|
nullptr,
|
|
&fakeLiteBridgeRuntimeTrackedFreeString);
|
|
|
|
auto result = ownedString.intoResult();
|
|
EXPECT_FALSE(result.ok);
|
|
EXPECT_TRUE(result.value.empty());
|
|
EXPECT_TRUE(result.error.find("null string") != std::string::npos);
|
|
EXPECT_FALSE(ownedString.rawPointerReceived());
|
|
EXPECT_FALSE(ownedString.copiedBeforeFree());
|
|
EXPECT_FALSE(ownedString.freed());
|
|
EXPECT_FALSE(ownedString.rawPointerEscaped());
|
|
EXPECT_EQ(g_liteBridgeRuntimeTrackedFreeCount, 0);
|
|
}
|
|
|
|
void testLiteBridgeOwnedStringClassifiesErrorAndFreesOnce()
|
|
{
|
|
resetLiteBridgeRuntimeTrackedFree();
|
|
dragonx::wallet::LiteBridgeOwnedString ownedString(
|
|
makeLiteBridgeRuntimeOwnedCString("Error: phase2 denied"),
|
|
&fakeLiteBridgeRuntimeTrackedFreeString);
|
|
|
|
auto result = ownedString.intoResult();
|
|
EXPECT_FALSE(result.ok);
|
|
EXPECT_TRUE(result.value.empty());
|
|
EXPECT_EQ(result.error, std::string("Error: phase2 denied"));
|
|
EXPECT_TRUE(ownedString.copiedBeforeFree());
|
|
EXPECT_TRUE(ownedString.freed());
|
|
EXPECT_EQ(g_liteBridgeRuntimeTrackedFreeCount, 1);
|
|
EXPECT_EQ(g_liteBridgeRuntimeTrackedFreedValues.size(), static_cast<std::size_t>(1));
|
|
EXPECT_EQ(g_liteBridgeRuntimeTrackedFreedValues[0], std::string("Error: phase2 denied"));
|
|
}
|
|
|
|
void testLiteBridgeOwnedStringMovePreventsDoubleFree()
|
|
{
|
|
resetLiteBridgeRuntimeTrackedFree();
|
|
{
|
|
dragonx::wallet::LiteBridgeOwnedString original(
|
|
makeLiteBridgeRuntimeOwnedCString("phase2-move"),
|
|
&fakeLiteBridgeRuntimeTrackedFreeString);
|
|
dragonx::wallet::LiteBridgeOwnedString moved(std::move(original));
|
|
|
|
EXPECT_FALSE(original.rawPointerReceived());
|
|
EXPECT_TRUE(moved.rawPointerReceived());
|
|
EXPECT_FALSE(moved.rawPointerEscaped());
|
|
}
|
|
|
|
EXPECT_EQ(g_liteBridgeRuntimeTrackedFreeCount, 1);
|
|
EXPECT_EQ(g_liteBridgeRuntimeTrackedFreedValues.size(), static_cast<std::size_t>(1));
|
|
EXPECT_EQ(g_liteBridgeRuntimeTrackedFreedValues[0], std::string("phase2-move"));
|
|
}
|
|
|
|
void testLiteClientBridgeUsesRuntimeOwnedStringCleanup()
|
|
{
|
|
resetLiteBridgeRuntimeTrackedFree();
|
|
g_liteBridgeRuntimeFakeCallCount = 0;
|
|
auto bridgeApi = makeCompleteFakeLiteBridgeRuntimeApi();
|
|
bridgeApi.execute = &fakeLiteBridgeRuntimeOwnedExecute;
|
|
bridgeApi.freeString = &fakeLiteBridgeRuntimeTrackedFreeString;
|
|
|
|
auto bridge = dragonx::wallet::LiteClientBridge::fromApi(bridgeApi);
|
|
auto result = bridge.execute("phase2", "{}");
|
|
|
|
EXPECT_TRUE(result.ok);
|
|
EXPECT_EQ(result.value, std::string("bridge-runtime-ok"));
|
|
EXPECT_TRUE(result.error.empty());
|
|
EXPECT_EQ(g_liteBridgeRuntimeTrackedFreeCount, 1);
|
|
EXPECT_EQ(g_liteBridgeRuntimeTrackedFreedValues.size(), static_cast<std::size_t>(1));
|
|
EXPECT_EQ(g_liteBridgeRuntimeTrackedFreedValues[0], std::string("bridge-runtime-ok"));
|
|
EXPECT_EQ(g_liteBridgeRuntimeFakeCallCount, 1);
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
void testGeneratedResourceBehavior()
|
|
{
|
|
const auto* themes = dragonx::resources::getEmbeddedThemes();
|
|
EXPECT_TRUE(themes != nullptr);
|
|
EXPECT_TRUE(dragonx::resources::getEmbeddedResource("__missing_resource__") == nullptr);
|
|
if (!dragonx::resources::hasEmbeddedResources()) {
|
|
EXPECT_TRUE(themes[0].data == nullptr);
|
|
EXPECT_EQ(dragonx::resources::extractBundledThemes("/tmp/obsidian-dragon-empty-themes"), 0);
|
|
}
|
|
}
|
|
|
|
// M0: deterministic injectable fake backend driving the real LiteClientBridge
|
|
// (ungated fromApi path). This is the harness M1 service tests reuse.
|
|
void testLiteBackendInjectableFakeBridge()
|
|
{
|
|
using dragonx::wallet::LiteClientBridge;
|
|
|
|
// available() + bool round-trips
|
|
{
|
|
dragonx::test::resetLiteFakeCounters();
|
|
auto bridge = LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi());
|
|
EXPECT_TRUE(bridge.available());
|
|
EXPECT_TRUE(bridge.walletExists("DRGX"));
|
|
EXPECT_TRUE(bridge.checkServerOnline("https://lite.example"));
|
|
}
|
|
|
|
// execute() round-trips the canned value and frees the owned string exactly once
|
|
{
|
|
dragonx::test::resetLiteFakeCounters();
|
|
auto bridge = LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi());
|
|
const auto result = bridge.execute("ping", ""); // unknown command -> default fake response
|
|
EXPECT_TRUE(result.ok);
|
|
EXPECT_EQ(result.value, std::string("{\"version\":\"sdxl-fake\"}"));
|
|
EXPECT_EQ(dragonx::test::g_liteFakeAlloc, 1L);
|
|
EXPECT_EQ(dragonx::test::g_liteFakeFreed, 1L);
|
|
}
|
|
|
|
// lifecycle calls round-trip
|
|
{
|
|
dragonx::test::resetLiteFakeCounters();
|
|
auto bridge = LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi());
|
|
EXPECT_TRUE(bridge.initializeNew(false, "https://lite.example").ok);
|
|
EXPECT_TRUE(bridge.initializeExisting(false, "https://lite.example").ok);
|
|
EXPECT_TRUE(bridge.initializeNewFromPhrase(false, "https://lite.example", "seed words", 0, 0, false).ok);
|
|
EXPECT_EQ(dragonx::test::g_liteFakeAlloc, dragonx::test::g_liteFakeFreed);
|
|
}
|
|
|
|
// an "Error:"-prefixed backend response maps to ok=false with the message propagated
|
|
{
|
|
dragonx::test::resetLiteFakeCounters();
|
|
auto bridge = LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi());
|
|
const auto result = bridge.execute("boom", "");
|
|
EXPECT_FALSE(result.ok);
|
|
EXPECT_TRUE(result.error.rfind("Error:", 0) == 0);
|
|
EXPECT_EQ(dragonx::test::g_liteFakeAlloc, dragonx::test::g_liteFakeFreed);
|
|
}
|
|
|
|
// empty command is rejected before reaching the backend (no allocation)
|
|
{
|
|
dragonx::test::resetLiteFakeCounters();
|
|
auto bridge = LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi());
|
|
EXPECT_FALSE(bridge.execute("", "").ok);
|
|
EXPECT_EQ(dragonx::test::g_liteFakeAlloc, 0L);
|
|
}
|
|
|
|
// an unavailable bridge fails closed (never fakes success)
|
|
{
|
|
dragonx::test::resetLiteFakeCounters();
|
|
auto bridge = LiteClientBridge::unavailable("lite backend is not linked");
|
|
EXPECT_FALSE(bridge.available());
|
|
EXPECT_FALSE(bridge.execute("info", "").ok);
|
|
EXPECT_FALSE(bridge.walletExists("DRGX"));
|
|
EXPECT_EQ(dragonx::test::g_liteFakeAlloc, 0L);
|
|
}
|
|
|
|
// shutdown is invoked (via destructor) and no owned string leaks or double-frees
|
|
{
|
|
dragonx::test::resetLiteFakeCounters();
|
|
{
|
|
auto bridge = LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi());
|
|
(void)bridge.execute("info", "");
|
|
}
|
|
EXPECT_TRUE(dragonx::test::g_liteFakeShutdownCalled);
|
|
EXPECT_EQ(dragonx::test::g_liteFakeAlloc, dragonx::test::g_liteFakeFreed);
|
|
}
|
|
}
|
|
|
|
// M1: the App-owned LiteWalletController drives real create/open/restore through an
|
|
// (injected fake) bridge, reports wallet-ready, persists, wipes secrets, and fails closed.
|
|
void testLiteWalletControllerLifecycle()
|
|
{
|
|
using namespace dragonx::wallet;
|
|
const auto liteCaps = makeWalletCapabilities(WalletBuildKind::Lite, /*embeddedDaemon*/ false, /*liteBackendLinked*/ true);
|
|
const LiteConnectionSettings conn = defaultLiteConnectionSettings();
|
|
|
|
// create -> wallet ready, walletOpen() true, persist callback fires once.
|
|
// (No alloc==freed leak assert here: a ready wallet launches the detached sync thread +
|
|
// refresh worker, which are still in flight at end-of-scope and legitimately hold owned
|
|
// strings. The leak/double-free invariant is checked in the thread-free bridge test.)
|
|
{
|
|
dragonx::test::resetLiteFakeCounters();
|
|
int persistCount = 0;
|
|
LiteWalletController controller(liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
|
controller.setPersistCallback([&persistCount]() { ++persistCount; });
|
|
EXPECT_FALSE(controller.walletOpen());
|
|
|
|
LiteWalletCreateRequest req;
|
|
req.passphrase = "hunter2";
|
|
const auto result = controller.createWallet(req);
|
|
EXPECT_TRUE(result.ok);
|
|
EXPECT_TRUE(result.walletReady);
|
|
EXPECT_TRUE(result.operation == LiteWalletLifecycleOperation::CreateNew);
|
|
EXPECT_TRUE(controller.walletOpen());
|
|
EXPECT_EQ(persistCount, 1);
|
|
}
|
|
|
|
// open existing -> ready. Regression guard: the real backend's open path
|
|
// (litelib_initialize_existing) returns the bare string "OK", which is NOT valid JSON, so
|
|
// walletReady must key off a non-empty success response, not JSON validity.
|
|
{
|
|
dragonx::test::resetLiteFakeCounters();
|
|
dragonx::test::g_liteFakeWalletExists = true;
|
|
LiteWalletController controller(liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
|
LiteWalletOpenRequest req;
|
|
req.passphrase = "hunter2";
|
|
const auto result = controller.openWallet(req);
|
|
EXPECT_TRUE(result.ok);
|
|
EXPECT_TRUE(result.walletReady);
|
|
EXPECT_TRUE(result.operation == LiteWalletLifecycleOperation::OpenExisting);
|
|
EXPECT_TRUE(controller.walletOpen());
|
|
}
|
|
|
|
// restore-from-seed round-trips to ready
|
|
{
|
|
dragonx::test::resetLiteFakeCounters();
|
|
LiteWalletController controller(liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
|
LiteWalletRestoreRequest req;
|
|
req.seedPhrase = "abandon abandon abandon ... art";
|
|
const auto result = controller.restoreWallet(req);
|
|
EXPECT_TRUE(result.walletReady);
|
|
EXPECT_TRUE(result.operation == LiteWalletLifecycleOperation::RestoreFromSeed);
|
|
EXPECT_TRUE(controller.walletOpen());
|
|
}
|
|
|
|
// empty restore seed is rejected before the backend is touched
|
|
{
|
|
dragonx::test::resetLiteFakeCounters();
|
|
LiteWalletController controller(liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
|
const auto result = controller.restoreWallet(LiteWalletRestoreRequest{});
|
|
EXPECT_FALSE(result.ok);
|
|
EXPECT_FALSE(result.walletReady);
|
|
EXPECT_FALSE(controller.walletOpen());
|
|
EXPECT_EQ(dragonx::test::g_liteFakeAlloc, 0L);
|
|
}
|
|
|
|
// bridge calls disabled -> blocked, backend never reached
|
|
{
|
|
dragonx::test::resetLiteFakeCounters();
|
|
LiteWalletController controller(liteCaps, conn,
|
|
LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()),
|
|
LiteWalletControllerOptions{/*allowBridgeCalls*/ false});
|
|
const auto result = controller.createWallet(LiteWalletCreateRequest{});
|
|
EXPECT_FALSE(result.walletReady);
|
|
EXPECT_FALSE(controller.walletOpen());
|
|
EXPECT_EQ(dragonx::test::g_liteFakeAlloc, 0L);
|
|
}
|
|
|
|
// full-node capabilities -> unsupported, fails closed
|
|
{
|
|
dragonx::test::resetLiteFakeCounters();
|
|
const auto fullNodeCaps = makeWalletCapabilities(WalletBuildKind::FullNode, true, false);
|
|
LiteWalletController controller(fullNodeCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
|
const auto result = controller.createWallet(LiteWalletCreateRequest{});
|
|
EXPECT_FALSE(result.walletReady);
|
|
EXPECT_EQ(dragonx::test::g_liteFakeAlloc, 0L);
|
|
}
|
|
|
|
// secret-wipe helper zeroes and clears
|
|
{
|
|
std::string secret = "super-secret-seed-phrase";
|
|
secureWipeLiteSecret(secret);
|
|
EXPECT_TRUE(secret.empty());
|
|
}
|
|
}
|
|
|
|
// M4: spend & backup. The controller drives send/shield/import/export/seed through the
|
|
// (injected fake) bridge with the real backend's arg/response contracts: send uses the
|
|
// JSON-array form (litelib_execute takes one arg), failures arrive as {"error":..} in the
|
|
// body, and the async send/shield path delivers a txid via takeBroadcastResult().
|
|
void testLiteWalletControllerM4()
|
|
{
|
|
using namespace dragonx::wallet;
|
|
const auto liteCaps = makeWalletCapabilities(WalletBuildKind::Lite, false, true);
|
|
const LiteConnectionSettings conn = defaultLiteConnectionSettings();
|
|
|
|
auto openController = [&]() {
|
|
auto c = std::make_unique<LiteWalletController>(
|
|
liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
|
LiteWalletCreateRequest req;
|
|
req.passphrase = "hunter2";
|
|
(void)c->createWallet(req);
|
|
return c;
|
|
};
|
|
|
|
// send (blocking core): JSON-array payload -> {"txid":..}
|
|
{
|
|
dragonx::test::g_liteFakeSendFails = false;
|
|
auto c = openController();
|
|
LiteSendRequest req;
|
|
req.recipients.push_back({"zs1dest", 150000000ULL, "hello memo with spaces"});
|
|
const auto r = c->sendTransactionBlocking(req);
|
|
EXPECT_TRUE(r.ok);
|
|
EXPECT_EQ(r.txid, std::string("faketxid123"));
|
|
EXPECT_TRUE(r.error.empty());
|
|
}
|
|
|
|
// send with no recipients -> error, backend never reached
|
|
{
|
|
auto c = openController();
|
|
const auto r = c->sendTransactionBlocking(LiteSendRequest{});
|
|
EXPECT_FALSE(r.ok);
|
|
EXPECT_FALSE(r.error.empty());
|
|
}
|
|
|
|
// send failure surfaces the backend's {"error":..} body (not an "Error:" prefix)
|
|
{
|
|
dragonx::test::g_liteFakeSendFails = true;
|
|
auto c = openController();
|
|
LiteSendRequest req;
|
|
req.recipients.push_back({"zs1dest", 1ULL, ""});
|
|
const auto r = c->sendTransactionBlocking(req);
|
|
EXPECT_FALSE(r.ok);
|
|
EXPECT_EQ(r.error, std::string("insufficient funds"));
|
|
dragonx::test::g_liteFakeSendFails = false;
|
|
}
|
|
|
|
// shield (blocking core) -> txid
|
|
{
|
|
dragonx::test::g_liteFakeSendFails = false;
|
|
auto c = openController();
|
|
const auto r = c->shieldFundsBlocking();
|
|
EXPECT_TRUE(r.ok);
|
|
EXPECT_EQ(r.txid, std::string("fakeshieldtxid"));
|
|
}
|
|
|
|
// async send: delivers the result to the main-thread slot
|
|
{
|
|
dragonx::test::g_liteFakeSendFails = false;
|
|
auto c = openController();
|
|
LiteSendRequest req;
|
|
req.recipients.push_back({"zs1dest", 42ULL, ""});
|
|
EXPECT_TRUE(c->sendTransaction(req));
|
|
LiteBroadcastResult r;
|
|
bool got = false;
|
|
for (int i = 0; i < 400 && !got; ++i) { // bounded wait (<= ~2s)
|
|
got = c->takeBroadcastResult(r);
|
|
if (!got) std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
|
}
|
|
EXPECT_TRUE(got);
|
|
EXPECT_TRUE(r.ok);
|
|
EXPECT_EQ(r.txid, std::string("faketxid123"));
|
|
}
|
|
|
|
// send before a wallet is open -> rejected
|
|
{
|
|
LiteWalletController c(liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
|
LiteSendRequest req;
|
|
req.recipients.push_back({"zs1dest", 1ULL, ""});
|
|
EXPECT_FALSE(c.sendTransaction(req));
|
|
const auto r = c.sendTransactionBlocking(req);
|
|
EXPECT_FALSE(r.ok);
|
|
}
|
|
|
|
// export private keys (SECRET) -> parsed, non-empty
|
|
{
|
|
auto c = openController();
|
|
const auto r = c->exportPrivateKeys();
|
|
EXPECT_TRUE(r.ok);
|
|
EXPECT_TRUE(r.privateKeysJson.find("private_key") != std::string::npos);
|
|
}
|
|
|
|
// export seed (SECRET) -> seed phrase + birthday
|
|
{
|
|
auto c = openController();
|
|
const auto r = c->exportSeed();
|
|
EXPECT_TRUE(r.ok);
|
|
EXPECT_EQ(r.seedPhrase, std::string("fake seed phrase words"));
|
|
EXPECT_EQ(r.birthday, 0ULL);
|
|
}
|
|
|
|
// import shielded key -> "import"; the key argument is wiped on return
|
|
{
|
|
auto c = openController();
|
|
std::string key = "secret-extended-key-main1fakeshieldedkey";
|
|
const auto r = c->importKey(std::move(key));
|
|
EXPECT_TRUE(r.ok);
|
|
EXPECT_TRUE(r.detail.find("zs1imported") != std::string::npos);
|
|
}
|
|
|
|
// import transparent WIF (begins with K) -> "timport"
|
|
{
|
|
auto c = openController();
|
|
const auto r = c->importKey("Kx1faketransparentwif");
|
|
EXPECT_TRUE(r.ok);
|
|
}
|
|
|
|
// import with no wallet open -> rejected (and key still wiped)
|
|
{
|
|
LiteWalletController c(liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
|
const auto r = c.importKey("secret-extended-key-main1abc");
|
|
EXPECT_FALSE(r.ok);
|
|
}
|
|
}
|
|
|
|
// M5: persistence. The backend auto-saves on new-address/import but NOT after sync/send/shield,
|
|
// so the controller must trigger `save` at those points (otherwise a ~30-min scan and sent txs
|
|
// are lost on restart). Drives the fake's save counter to prove saves happen exactly when needed.
|
|
void testLiteWalletControllerM5Persistence()
|
|
{
|
|
using namespace dragonx::wallet;
|
|
const auto liteCaps = makeWalletCapabilities(WalletBuildKind::Lite, false, true);
|
|
const LiteConnectionSettings conn = defaultLiteConnectionSettings();
|
|
|
|
auto openController = [&]() {
|
|
auto c = std::make_unique<LiteWalletController>(
|
|
liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
|
LiteWalletCreateRequest req;
|
|
req.passphrase = "hunter2";
|
|
(void)c->createWallet(req);
|
|
return c;
|
|
};
|
|
// Open and wait until the background sync (and its persist) has completed, so the save count
|
|
// is stable before a sub-test captures its baseline.
|
|
auto openSynced = [&]() {
|
|
auto c = openController();
|
|
for (int i = 0; i < 400 && !c->syncComplete(); ++i)
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
|
return c;
|
|
};
|
|
|
|
// sync persists: completing a sync saves the freshly-scanned wallet.
|
|
{
|
|
dragonx::test::g_liteFakeSyncBlock = false;
|
|
dragonx::test::g_liteFakeSaveCount = 0;
|
|
auto c = openSynced();
|
|
EXPECT_TRUE(c->syncComplete());
|
|
EXPECT_TRUE(dragonx::test::g_liteFakeSaveCount.load() >= 1);
|
|
}
|
|
|
|
// send persists the new transaction (backend does not auto-save sends)
|
|
{
|
|
dragonx::test::g_liteFakeSendFails = false;
|
|
auto c = openSynced();
|
|
const long before = dragonx::test::g_liteFakeSaveCount.load();
|
|
LiteSendRequest req;
|
|
req.recipients.push_back({"zs1dest", 1ULL, ""});
|
|
EXPECT_TRUE(c->sendTransactionBlocking(req).ok);
|
|
EXPECT_EQ(dragonx::test::g_liteFakeSaveCount.load(), before + 1);
|
|
}
|
|
|
|
// a FAILED send does NOT save
|
|
{
|
|
dragonx::test::g_liteFakeSendFails = true;
|
|
auto c = openSynced();
|
|
const long before = dragonx::test::g_liteFakeSaveCount.load();
|
|
LiteSendRequest req;
|
|
req.recipients.push_back({"zs1dest", 1ULL, ""});
|
|
EXPECT_FALSE(c->sendTransactionBlocking(req).ok);
|
|
EXPECT_EQ(dragonx::test::g_liteFakeSaveCount.load(), before);
|
|
dragonx::test::g_liteFakeSendFails = false;
|
|
}
|
|
|
|
// shield persists
|
|
{
|
|
auto c = openSynced();
|
|
const long before = dragonx::test::g_liteFakeSaveCount.load();
|
|
EXPECT_TRUE(c->shieldFundsBlocking().ok);
|
|
EXPECT_EQ(dragonx::test::g_liteFakeSaveCount.load(), before + 1);
|
|
}
|
|
|
|
// explicit saveWallet() persists; with no wallet open it is a no-op
|
|
{
|
|
auto c = openSynced();
|
|
const long before = dragonx::test::g_liteFakeSaveCount.load();
|
|
EXPECT_TRUE(c->saveWallet());
|
|
EXPECT_EQ(dragonx::test::g_liteFakeSaveCount.load(), before + 1);
|
|
}
|
|
{
|
|
LiteWalletController c(liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
|
const long before = dragonx::test::g_liteFakeSaveCount.load();
|
|
EXPECT_FALSE(c.saveWallet());
|
|
EXPECT_EQ(dragonx::test::g_liteFakeSaveCount.load(), before);
|
|
}
|
|
}
|
|
|
|
// Encryption: the controller drives encrypt/unlock/lock/decrypt + encryptionstatus through the
|
|
// (injected fake) backend, wipes passphrases, and folds status into WalletState (encrypted/locked).
|
|
void testLiteWalletControllerEncryption()
|
|
{
|
|
using namespace dragonx::wallet;
|
|
const auto liteCaps = makeWalletCapabilities(WalletBuildKind::Lite, false, true);
|
|
const LiteConnectionSettings conn = defaultLiteConnectionSettings();
|
|
|
|
auto open = [&]() {
|
|
dragonx::test::g_liteFakeEncrypted = false;
|
|
dragonx::test::g_liteFakeLocked = false;
|
|
auto c = std::make_unique<LiteWalletController>(
|
|
liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
|
LiteWalletCreateRequest req;
|
|
req.passphrase = "hunter2";
|
|
(void)c->createWallet(req);
|
|
return c;
|
|
};
|
|
|
|
// encrypt -> unlock -> lock -> decrypt, observed via encryptionStatus(). NOTE: encrypt LOCKS
|
|
// immediately (the real backend removes keys after encrypting — verified via lite_smoke).
|
|
{
|
|
auto c = open();
|
|
const auto s0 = c->encryptionStatus();
|
|
EXPECT_TRUE(s0.ok);
|
|
EXPECT_FALSE(s0.encrypted);
|
|
EXPECT_FALSE(s0.locked);
|
|
|
|
EXPECT_TRUE(c->encryptWallet("walletpass").ok);
|
|
const auto s1 = c->encryptionStatus();
|
|
EXPECT_TRUE(s1.encrypted);
|
|
EXPECT_TRUE(s1.locked); // encrypt locks immediately
|
|
|
|
EXPECT_TRUE(c->unlockWallet("walletpass"));
|
|
const auto s2 = c->encryptionStatus();
|
|
EXPECT_TRUE(s2.encrypted);
|
|
EXPECT_FALSE(s2.locked);
|
|
|
|
EXPECT_TRUE(c->lockWallet());
|
|
const auto s3 = c->encryptionStatus();
|
|
EXPECT_TRUE(s3.encrypted);
|
|
EXPECT_TRUE(s3.locked);
|
|
|
|
EXPECT_TRUE(c->decryptWallet("walletpass").ok);
|
|
const auto s4 = c->encryptionStatus();
|
|
EXPECT_FALSE(s4.encrypted);
|
|
EXPECT_FALSE(s4.locked);
|
|
}
|
|
|
|
// empty passphrase rejected; operations on a closed wallet fail closed
|
|
{
|
|
auto c = open();
|
|
EXPECT_FALSE(c->encryptWallet("").ok);
|
|
|
|
LiteWalletController closed(liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
|
EXPECT_FALSE(closed.unlockWallet("x"));
|
|
EXPECT_FALSE(closed.lockWallet());
|
|
EXPECT_FALSE(closed.encryptionStatus().ok);
|
|
}
|
|
|
|
// encryption status folds into WalletState (gates the locked UI)
|
|
{
|
|
LiteWalletAppRefreshModel model;
|
|
model.hasEncryptionStatus = true;
|
|
model.encrypted = true;
|
|
model.locked = true;
|
|
dragonx::WalletState state;
|
|
applyLiteRefreshModelToWalletState(model, state);
|
|
EXPECT_TRUE(state.isEncrypted());
|
|
EXPECT_TRUE(state.isLocked());
|
|
}
|
|
}
|
|
|
|
// Migration: a saved lite chain_name outside {main,test,regtest} (e.g. the legacy
|
|
// "DRAGONX" ticker) is rewritten to "main" on load, since the backend hard-panics
|
|
// on unknown chains. Valid values are preserved.
|
|
void testLiteChainNameMigration()
|
|
{
|
|
const fs::path dir = fs::temp_directory_path() / "dragonx_lite_chain_migration_test";
|
|
fs::create_directories(dir);
|
|
|
|
{
|
|
const fs::path p = dir / "legacy.json";
|
|
writeTestFile(p, "{\"lite_wallet\":{\"chain_name\":\"DRAGONX\"}}");
|
|
dragonx::config::Settings settings;
|
|
EXPECT_TRUE(settings.load(p.string()));
|
|
EXPECT_EQ(settings.getLiteChainName(), std::string("main"));
|
|
}
|
|
{
|
|
const fs::path p = dir / "valid.json";
|
|
writeTestFile(p, "{\"lite_wallet\":{\"chain_name\":\"test\"}}");
|
|
dragonx::config::Settings settings;
|
|
EXPECT_TRUE(settings.load(p.string()));
|
|
EXPECT_EQ(settings.getLiteChainName(), std::string("test"));
|
|
}
|
|
|
|
std::error_code ec;
|
|
fs::remove_all(dir, ec);
|
|
}
|
|
|
|
// Async open with server failover: when the preferred lite server is unreachable, the controller
|
|
// transparently opens against the next usable one; only an all-servers-down case fails (and then
|
|
// surfaces a reason). Open runs off the UI thread, finalized by pumpAsyncOpen() on the main thread.
|
|
void testLiteWalletControllerOpenFailover()
|
|
{
|
|
using namespace dragonx::wallet;
|
|
const auto liteCaps = makeWalletCapabilities(WalletBuildKind::Lite, false, true);
|
|
|
|
LiteConnectionSettings conn;
|
|
conn.chainName = "main";
|
|
conn.servers = {
|
|
LiteServerEndpoint{"https://dead.example", "Dead", true},
|
|
LiteServerEndpoint{"https://good.example", "Good", true},
|
|
};
|
|
conn.selectionMode = LiteServerSelectionMode::Sticky;
|
|
conn.stickyServerUrl = "https://dead.example"; // the PREFERRED server is the dead one
|
|
|
|
// Preferred server dead, fallback good -> the wallet opens via the fallback.
|
|
{
|
|
dragonx::test::resetLiteFakeCounters();
|
|
dragonx::test::g_liteFakeWalletExists = true;
|
|
dragonx::test::g_liteFakeDeadServerSubstr = "dead.example";
|
|
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());
|
|
}
|
|
|
|
// 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());
|
|
}
|
|
|
|
dragonx::test::g_liteFakeWalletExists = false;
|
|
dragonx::test::g_liteFakeDeadServerSubstr.clear();
|
|
}
|
|
|
|
// M2: a parsed lite refresh bundle maps through to the app's WalletState (the last hop
|
|
// the Balance/Receive/Transactions tabs read), with zatoshi->DRGX conversion, z/t address
|
|
// split, transaction typing, confirmations, and sync progress.
|
|
void testLiteRefreshModelAppliesToWalletState()
|
|
{
|
|
using namespace dragonx::wallet;
|
|
|
|
LiteWalletRefreshBundle bundle;
|
|
bundle.complete = true;
|
|
bundle.successfulCommandCount = 4;
|
|
|
|
bundle.hasBalance = true;
|
|
bundle.balance.shieldedBalance = 200000000; // 2 DRGX
|
|
bundle.balance.transparentBalance = 100000000; // 1 DRGX
|
|
bundle.balance.unconfirmedBalance = 50000000; // 0.5 DRGX
|
|
|
|
bundle.hasAddresses = true;
|
|
bundle.addresses.zAddresses = {"zs1shielded"};
|
|
bundle.addresses.tAddresses = {"t1transparent"};
|
|
|
|
bundle.hasTransactions = true;
|
|
LiteTransactionRecord rec;
|
|
rec.txid = "deadbeef";
|
|
rec.datetime = 1700000000;
|
|
rec.blockHeight = 990;
|
|
rec.unconfirmed = false;
|
|
rec.direction = LiteTransactionDirection::Receive;
|
|
rec.address = "zs1shielded";
|
|
rec.amount = 150000000; // 1.5 DRGX
|
|
bundle.transactions.transactions.push_back(rec);
|
|
|
|
bundle.hasSyncStatus = true;
|
|
bundle.syncStatus.syncedBlocks = 1000;
|
|
bundle.syncStatus.totalBlocks = 1000;
|
|
bundle.syncStatus.progress = 1.0;
|
|
bundle.syncStatus.complete = true;
|
|
|
|
const auto mapped = mapLiteWalletRefreshBundle(bundle);
|
|
EXPECT_TRUE(mapped.ok);
|
|
|
|
dragonx::WalletState state;
|
|
applyLiteRefreshModelToWalletState(mapped.model, state);
|
|
|
|
EXPECT_NEAR(state.privateBalance, 2.0, 1e-9);
|
|
EXPECT_NEAR(state.transparentBalance, 1.0, 1e-9);
|
|
EXPECT_NEAR(state.totalBalance, 3.0, 1e-9);
|
|
EXPECT_NEAR(state.unconfirmedBalance, 0.5, 1e-9);
|
|
|
|
EXPECT_EQ(static_cast<int>(state.addresses.size()), 2);
|
|
EXPECT_EQ(static_cast<int>(state.z_addresses.size()), 1);
|
|
EXPECT_EQ(static_cast<int>(state.t_addresses.size()), 1);
|
|
EXPECT_EQ(state.z_addresses[0].type, std::string("shielded"));
|
|
EXPECT_EQ(state.t_addresses[0].type, std::string("transparent"));
|
|
|
|
EXPECT_EQ(static_cast<int>(state.transactions.size()), 1);
|
|
EXPECT_EQ(state.transactions[0].type, std::string("receive"));
|
|
EXPECT_NEAR(state.transactions[0].amount, 1.5, 1e-9);
|
|
EXPECT_EQ(state.transactions[0].confirmations, 11); // chain 1000 - block 990 + 1
|
|
|
|
EXPECT_EQ(state.sync.blocks, 1000);
|
|
EXPECT_EQ(state.sync.headers, 1000);
|
|
EXPECT_FALSE(state.sync.syncing);
|
|
}
|
|
|
|
// A Send record carries its recipient in outgoing_metadata, not the top-level address/memo —
|
|
// the transactions list must surface the destination + memo instead of blanks.
|
|
void testLiteSendShowsRecipientFromOutgoing()
|
|
{
|
|
using namespace dragonx::wallet;
|
|
LiteWalletRefreshBundle bundle;
|
|
bundle.complete = true;
|
|
bundle.hasTransactions = true;
|
|
LiteTransactionRecord rec;
|
|
rec.txid = "sendtx";
|
|
rec.datetime = 1700000000;
|
|
rec.unconfirmed = true;
|
|
rec.direction = LiteTransactionDirection::Send;
|
|
rec.address = ""; // sends have no top-level address
|
|
rec.memo = "";
|
|
LiteTransactionOutput out;
|
|
out.address = "zs1recipient";
|
|
out.value = 75000000; // 0.75 DRGX
|
|
out.memo = "thanks";
|
|
rec.outgoingMetadata.push_back(out);
|
|
bundle.transactions.transactions.push_back(rec);
|
|
|
|
const auto mapped = mapLiteWalletRefreshBundle(bundle);
|
|
dragonx::WalletState state;
|
|
applyLiteRefreshModelToWalletState(mapped.model, state);
|
|
|
|
EXPECT_EQ(static_cast<int>(state.transactions.size()), 1);
|
|
EXPECT_EQ(state.transactions[0].type, std::string("send"));
|
|
EXPECT_EQ(state.transactions[0].address, std::string("zs1recipient"));
|
|
EXPECT_EQ(state.transactions[0].memo, std::string("thanks"));
|
|
}
|
|
|
|
// A tolerated partial refresh where the notes/utxo command failed (hasAddresses but
|
|
// !hasSpendableOutputs) must NOT zero every per-address balance — it keeps the last-known
|
|
// values so a correct total isn't accompanied by a misleading all-zero breakdown.
|
|
void testLitePartialRefreshKeepsPriorAddressBalances()
|
|
{
|
|
using namespace dragonx::wallet;
|
|
|
|
// First, a full refresh establishes a per-address balance.
|
|
LiteWalletRefreshBundle full;
|
|
full.complete = true;
|
|
full.hasAddresses = true;
|
|
full.addresses.zAddresses = {"zs1keep"};
|
|
full.hasNotes = true;
|
|
LiteSpendableOutput note;
|
|
note.address = "zs1keep";
|
|
note.value = 120000000; // 1.2 DRGX
|
|
note.spent = false;
|
|
full.notes.unspentNotes.push_back(note);
|
|
|
|
dragonx::WalletState state;
|
|
applyLiteRefreshModelToWalletState(mapLiteWalletRefreshBundle(full).model, state);
|
|
EXPECT_EQ(static_cast<int>(state.z_addresses.size()), 1);
|
|
EXPECT_NEAR(state.z_addresses[0].balance, 1.2, 1e-9);
|
|
|
|
// Now a partial refresh: addresses present, notes command failed (hasNotes=false).
|
|
LiteWalletRefreshBundle partial;
|
|
partial.complete = true;
|
|
partial.hasAddresses = true;
|
|
partial.addresses.zAddresses = {"zs1keep"};
|
|
partial.hasNotes = false; // notes/utxo failed this cycle
|
|
|
|
applyLiteRefreshModelToWalletState(mapLiteWalletRefreshBundle(partial).model, state);
|
|
EXPECT_EQ(static_cast<int>(state.z_addresses.size()), 1);
|
|
EXPECT_NEAR(state.z_addresses[0].balance, 1.2, 1e-9); // preserved, not zeroed
|
|
}
|
|
|
|
// M2b: the controller, after a wallet is ready, auto-starts sync and refreshWalletState()
|
|
// pulls balance/addresses/transactions/syncstatus through the shared bridge into WalletState.
|
|
void testLiteWalletControllerRefreshPopulatesState()
|
|
{
|
|
using namespace dragonx::wallet;
|
|
const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, /*embeddedDaemon*/ false, /*liteBackendLinked*/ true);
|
|
const auto conn = defaultLiteConnectionSettings();
|
|
|
|
LiteWalletController controller(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
|
EXPECT_TRUE(controller.createWallet(LiteWalletCreateRequest{}).walletReady);
|
|
EXPECT_TRUE(controller.walletOpen());
|
|
EXPECT_TRUE(controller.syncStarted()); // auto-started when the wallet became ready
|
|
|
|
// Sync runs on a detached thread; the full refresh (balance/addresses) only runs once it
|
|
// completes. Wait for it (instant with the fake) so the refresh is deterministic.
|
|
for (int i = 0; i < 500 && !controller.syncComplete(); ++i)
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
|
EXPECT_TRUE(controller.syncComplete());
|
|
|
|
dragonx::WalletState state;
|
|
EXPECT_TRUE(controller.refreshWalletState(state));
|
|
EXPECT_NEAR(state.privateBalance, 2.0, 1e-9);
|
|
EXPECT_NEAR(state.transparentBalance, 1.0, 1e-9);
|
|
EXPECT_EQ(static_cast<int>(state.z_addresses.size()), 1);
|
|
EXPECT_EQ(static_cast<int>(state.t_addresses.size()), 1);
|
|
EXPECT_EQ(static_cast<int>(state.transactions.size()), 1);
|
|
EXPECT_EQ(state.transactions[0].type, std::string("receive"));
|
|
EXPECT_NEAR(state.transactions[0].amount, 1.5, 1e-9);
|
|
EXPECT_EQ(state.sync.headers, 1000);
|
|
|
|
// No wallet open -> refresh is a safe no-op
|
|
LiteWalletController fresh(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
|
dragonx::WalletState empty;
|
|
EXPECT_FALSE(fresh.refreshWalletState(empty));
|
|
}
|
|
|
|
// syncstatus parser must accept both real backend shapes: idle {"syncing":"false"} and
|
|
// in-progress {"syncing":"true","synced_blocks":N,"total_blocks":M} ("syncing" is a string).
|
|
void testLiteSyncStatusParserRealShapes()
|
|
{
|
|
using namespace dragonx::wallet;
|
|
|
|
// Idle: no block counts, syncing is the string "false".
|
|
{
|
|
const auto p = parseLiteSyncStatusResponse(std::string("{\"syncing\":\"false\"}"));
|
|
EXPECT_TRUE(p.ok);
|
|
EXPECT_EQ(static_cast<long long>(p.syncStatus.syncedBlocks), 0LL);
|
|
EXPECT_TRUE(p.syncStatus.complete);
|
|
}
|
|
// In-progress: block counts present.
|
|
{
|
|
const auto p = parseLiteSyncStatusResponse(
|
|
std::string("{\"syncing\":\"true\",\"synced_blocks\":900,\"total_blocks\":1000}"));
|
|
EXPECT_TRUE(p.ok);
|
|
EXPECT_EQ(static_cast<long long>(p.syncStatus.syncedBlocks), 900LL);
|
|
EXPECT_EQ(static_cast<long long>(p.syncStatus.totalBlocks), 1000LL);
|
|
EXPECT_FALSE(p.syncStatus.complete);
|
|
EXPECT_NEAR(p.syncStatus.progress, 0.9, 1e-9);
|
|
}
|
|
// While syncing, missing block counts is still an error.
|
|
{
|
|
const auto p = parseLiteSyncStatusResponse(std::string("{\"syncing\":\"true\"}"));
|
|
EXPECT_FALSE(p.ok);
|
|
}
|
|
}
|
|
|
|
// Per-address balances: unspent notes/utxos are summed per address (spent ones excluded)
|
|
// into the WalletState address list, instead of aggregate-only zeros.
|
|
void testLitePerAddressBalances()
|
|
{
|
|
using namespace dragonx::wallet;
|
|
|
|
LiteWalletRefreshBundle bundle;
|
|
bundle.complete = true;
|
|
bundle.hasAddresses = true;
|
|
bundle.addresses.zAddresses = {"zs1aaa"};
|
|
bundle.addresses.tAddresses = {"t1bbb"};
|
|
bundle.hasNotes = true;
|
|
|
|
LiteSpendableOutput note; note.address = "zs1aaa"; note.value = 150000000; note.createdInTxid = "n1";
|
|
LiteSpendableOutput utxo; utxo.address = "t1bbb"; utxo.value = 50000000; utxo.createdInTxid = "u1";
|
|
LiteSpendableOutput spentNote; spentNote.address = "zs1aaa"; spentNote.value = 999; spentNote.spent = true; spentNote.createdInTxid = "n2";
|
|
bundle.notes.unspentNotes.push_back(note);
|
|
bundle.notes.unspentNotes.push_back(spentNote); // excluded: spent
|
|
bundle.notes.utxos.push_back(utxo);
|
|
|
|
const auto mapped = mapLiteWalletRefreshBundle(bundle);
|
|
EXPECT_TRUE(mapped.ok);
|
|
|
|
dragonx::WalletState state;
|
|
applyLiteRefreshModelToWalletState(mapped.model, state);
|
|
EXPECT_EQ(static_cast<int>(state.z_addresses.size()), 1);
|
|
EXPECT_EQ(static_cast<int>(state.t_addresses.size()), 1);
|
|
EXPECT_NEAR(state.z_addresses[0].balance, 1.5, 1e-9); // 150000000; the spent note is excluded
|
|
EXPECT_NEAR(state.t_addresses[0].balance, 0.5, 1e-9); // 50000000
|
|
}
|
|
|
|
// M3: new-address generation via the controller (backend "new" zs/R) returns the address.
|
|
void testLiteWalletControllerNewAddress()
|
|
{
|
|
using namespace dragonx::wallet;
|
|
const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, /*embeddedDaemon*/ false, /*liteBackendLinked*/ true);
|
|
const auto conn = defaultLiteConnectionSettings();
|
|
|
|
LiteWalletController controller(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
|
EXPECT_TRUE(controller.createWallet(LiteWalletCreateRequest{}).walletReady);
|
|
|
|
const auto z = controller.newAddress(/*shielded*/ true);
|
|
EXPECT_TRUE(z.ok);
|
|
EXPECT_TRUE(z.address.rfind("zs1", 0) == 0);
|
|
|
|
const auto t = controller.newAddress(/*shielded*/ false);
|
|
EXPECT_TRUE(t.ok);
|
|
EXPECT_TRUE(t.address.rfind("R1", 0) == 0);
|
|
|
|
// No wallet open -> error, no address.
|
|
LiteWalletController idle(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
|
const auto none = idle.newAddress(true);
|
|
EXPECT_FALSE(none.ok);
|
|
}
|
|
|
|
// Gateway hardening: one command's parse failure must not abort the whole refresh — the
|
|
// other commands still populate the bundle (graceful degradation against real-shape drift).
|
|
void testLiteWalletGatewayRefreshSkipsFailedCommand()
|
|
{
|
|
using namespace dragonx::wallet;
|
|
const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, /*embeddedDaemon*/ false, /*liteBackendLinked*/ true);
|
|
const auto conn = defaultLiteConnectionSettings();
|
|
|
|
auto bridge = LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi());
|
|
LiteWalletGateway gateway(caps, conn, &bridge, LiteWalletGatewayOptions{/*allowBridgeCalls*/ true});
|
|
|
|
dragonx::test::g_liteFakeBadBalance.store(true); // make only the balance command fail to parse
|
|
const auto result = gateway.refresh(LiteWalletRefreshRequest{});
|
|
dragonx::test::g_liteFakeBadBalance.store(false);
|
|
|
|
EXPECT_TRUE(result.ok); // partial success, not a total failure
|
|
EXPECT_FALSE(result.bundle.complete); // not all commands succeeded
|
|
EXPECT_FALSE(result.bundle.hasBalance); // the failed command is skipped
|
|
EXPECT_TRUE(result.bundle.hasAddresses); // the others still populate
|
|
EXPECT_TRUE(result.bundle.hasTransactions);
|
|
EXPECT_TRUE(result.bundle.hasInfo);
|
|
}
|
|
|
|
// M2b-3: opening a wallet auto-starts the background worker, which produces a refresh model
|
|
// the main thread can pick up via takeRefreshedModel() and apply to WalletState.
|
|
void testLiteWalletControllerWorkerProducesModel()
|
|
{
|
|
using namespace dragonx::wallet;
|
|
const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, /*embeddedDaemon*/ false, /*liteBackendLinked*/ true);
|
|
const auto conn = defaultLiteConnectionSettings();
|
|
|
|
LiteWalletController controller(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
|
EXPECT_TRUE(controller.createWallet(LiteWalletCreateRequest{}).walletReady); // auto-starts the worker
|
|
|
|
// The worker publishes progress-only models while syncing, then full models once synced.
|
|
// Poll until a full (balance-bearing) model arrives (sync is instant with the fake).
|
|
LiteWalletAppRefreshModel model;
|
|
bool gotFull = false;
|
|
for (int i = 0; i < 500 && !gotFull; ++i) {
|
|
LiteWalletAppRefreshModel m;
|
|
if (controller.takeRefreshedModel(m) && m.hasBalance) {
|
|
model = m;
|
|
gotFull = true;
|
|
break;
|
|
}
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
|
}
|
|
EXPECT_TRUE(gotFull);
|
|
EXPECT_TRUE(model.hasBalance);
|
|
EXPECT_TRUE(model.hasAddresses);
|
|
|
|
dragonx::WalletState state;
|
|
applyLiteRefreshModelToWalletState(model, state);
|
|
EXPECT_NEAR(state.privateBalance, 2.0, 1e-9);
|
|
|
|
// Idle controller (no wallet -> no worker) has nothing pending.
|
|
LiteWalletController idle(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
|
LiteWalletAppRefreshModel none;
|
|
EXPECT_FALSE(idle.takeRefreshedModel(none));
|
|
}
|
|
|
|
// M2b-3 hardening: the backend `sync` is a blocking, uninterruptible full scan. Destroying the
|
|
// controller while a sync is in flight must NOT hang (the sync thread is detached, not joined).
|
|
void testLiteWalletControllerShutdownDoesNotHangDuringSync()
|
|
{
|
|
using namespace dragonx::wallet;
|
|
const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, /*embeddedDaemon*/ false, /*liteBackendLinked*/ true);
|
|
const auto conn = defaultLiteConnectionSettings();
|
|
|
|
dragonx::test::g_liteFakeSyncBlock.store(true); // make the backend "sync" block indefinitely
|
|
const auto start = std::chrono::steady_clock::now();
|
|
{
|
|
LiteWalletController controller(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
|
controller.createWallet(LiteWalletCreateRequest{}); // launches the (now-blocked) sync thread
|
|
EXPECT_TRUE(controller.syncStarted());
|
|
EXPECT_FALSE(controller.syncComplete());
|
|
// controller destructs here with the sync thread still blocked -> must return promptly.
|
|
}
|
|
const auto elapsedMs = std::chrono::duration_cast<std::chrono::milliseconds>(
|
|
std::chrono::steady_clock::now() - start).count();
|
|
EXPECT_TRUE(elapsedMs < 1500); // did not wait for the (blocked) sync to finish
|
|
|
|
dragonx::test::g_liteFakeSyncBlock.store(false); // release the detached sync thread
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(50)); // let it unwind cleanly
|
|
}
|
|
|
|
// M5b: lite-wallet rollout / kill-switch policy (wallet/lite_rollout_policy.h).
|
|
void testLiteRolloutVersionCompare()
|
|
{
|
|
using namespace dragonx::wallet;
|
|
EXPECT_EQ(compareLiteVersions("1.2.0", "1.2.0"), 0);
|
|
EXPECT_EQ(compareLiteVersions("1.2.0", "1.2"), 0); // missing components treated as 0
|
|
EXPECT_EQ(compareLiteVersions("1.2.0-rc1", "1.2.0"), 0); // pre-release suffix ignored
|
|
EXPECT_EQ(compareLiteVersions("1.2.0", "1.3.0"), -1);
|
|
EXPECT_EQ(compareLiteVersions("2.0.0", "1.9.9"), 1);
|
|
EXPECT_EQ(compareLiteVersions("1.2.10", "1.2.9"), 1); // numeric, not lexical
|
|
}
|
|
|
|
void testLiteRolloutBucketAndOverrideHelpers()
|
|
{
|
|
using namespace dragonx::wallet;
|
|
const int a = liteRolloutBucketFromInstallId("install-abc");
|
|
EXPECT_EQ(a, liteRolloutBucketFromInstallId("install-abc")); // deterministic
|
|
EXPECT_TRUE(a >= 0 && a < 1000); // in range
|
|
EXPECT_EQ(liteRolloutBucketFromInstallId(""), 0); // empty -> 0
|
|
EXPECT_TRUE(liteRolloutBucketFromInstallId("install-abc")
|
|
!= liteRolloutBucketFromInstallId("install-xyz"));
|
|
|
|
EXPECT_EQ(std::string(liteRolloutOverrideToString(liteRolloutOverrideFromString("force_on"))),
|
|
std::string("force_on"));
|
|
EXPECT_EQ(std::string(liteRolloutOverrideToString(liteRolloutOverrideFromString("force_off"))),
|
|
std::string("force_off"));
|
|
EXPECT_EQ(std::string(liteRolloutOverrideToString(liteRolloutOverrideFromString("auto"))),
|
|
std::string("auto"));
|
|
EXPECT_EQ(std::string(liteRolloutOverrideToString(liteRolloutOverrideFromString("garbage"))),
|
|
std::string("auto")); // unknown -> auto
|
|
}
|
|
|
|
void testLiteRolloutPolicyOverridesAndKillSwitch()
|
|
{
|
|
using namespace dragonx::wallet;
|
|
LiteRolloutInputs in;
|
|
in.appVersion = "1.2.0";
|
|
|
|
// No manifest, auto -> allowed (fail-open).
|
|
EXPECT_TRUE(evaluateLiteRollout(in).allowed);
|
|
|
|
// force_off -> disabled by local override.
|
|
in.override = LiteRolloutOverride::ForceOff;
|
|
auto off = evaluateLiteRollout(in);
|
|
EXPECT_FALSE(off.allowed);
|
|
EXPECT_TRUE(off.status == LiteRolloutStatus::DisabledByLocalOverride);
|
|
|
|
// force_on -> allowed, even with a disabling manifest.
|
|
in.override = LiteRolloutOverride::ForceOn;
|
|
in.manifest.present = true; in.manifest.valid = true; in.manifest.globalEnabled = false;
|
|
EXPECT_TRUE(evaluateLiteRollout(in).allowed);
|
|
|
|
// env kill-switch is absolute: disables even with force_on.
|
|
in.killSwitchEnv = true;
|
|
auto killed = evaluateLiteRollout(in);
|
|
EXPECT_FALSE(killed.allowed);
|
|
EXPECT_TRUE(killed.status == LiteRolloutStatus::DisabledByKillSwitchEnv);
|
|
}
|
|
|
|
void testLiteRolloutPolicyManifestGates()
|
|
{
|
|
using namespace dragonx::wallet;
|
|
LiteRolloutInputs in;
|
|
in.appVersion = "1.2.0";
|
|
in.manifest.present = true;
|
|
in.manifest.valid = true;
|
|
|
|
// Global disable carries the manifest message through to the decision.
|
|
in.manifest.globalEnabled = false;
|
|
in.manifest.message = "paused for maintenance";
|
|
{
|
|
auto d = evaluateLiteRollout(in);
|
|
EXPECT_FALSE(d.allowed);
|
|
EXPECT_TRUE(d.status == LiteRolloutStatus::DisabledByManifest);
|
|
EXPECT_EQ(d.message, std::string("paused for maintenance"));
|
|
}
|
|
in.manifest.globalEnabled = true;
|
|
in.manifest.message.clear();
|
|
|
|
// Version floor / ceiling / blocklist.
|
|
in.manifest.minVersion = "1.3.0";
|
|
EXPECT_TRUE(evaluateLiteRollout(in).status == LiteRolloutStatus::DisabledUnsupportedVersion);
|
|
in.manifest.minVersion.clear();
|
|
in.manifest.maxVersion = "1.1.0";
|
|
EXPECT_TRUE(evaluateLiteRollout(in).status == LiteRolloutStatus::DisabledUnsupportedVersion);
|
|
in.manifest.maxVersion.clear();
|
|
in.manifest.blockedVersions = {"1.2.0"};
|
|
EXPECT_TRUE(evaluateLiteRollout(in).status == LiteRolloutStatus::DisabledBlockedVersion);
|
|
in.manifest.blockedVersions.clear();
|
|
|
|
// Within range and not blocked -> allowed.
|
|
in.manifest.minVersion = "1.0.0";
|
|
in.manifest.maxVersion = "2.0.0";
|
|
EXPECT_TRUE(evaluateLiteRollout(in).allowed);
|
|
}
|
|
|
|
void testLiteRolloutPolicyStagedRollout()
|
|
{
|
|
using namespace dragonx::wallet;
|
|
LiteRolloutInputs in;
|
|
in.appVersion = "1.2.0";
|
|
in.manifest.present = true;
|
|
in.manifest.valid = true;
|
|
in.manifest.globalEnabled = true;
|
|
|
|
in.manifest.rolloutPermille = 0; // nobody (bucket 0 >= 0)
|
|
in.installBucket = 0;
|
|
EXPECT_FALSE(evaluateLiteRollout(in).allowed);
|
|
|
|
in.manifest.rolloutPermille = 500; // bucket < permille is in
|
|
in.installBucket = 499;
|
|
EXPECT_TRUE(evaluateLiteRollout(in).allowed);
|
|
in.installBucket = 500;
|
|
auto out = evaluateLiteRollout(in);
|
|
EXPECT_FALSE(out.allowed);
|
|
EXPECT_TRUE(out.status == LiteRolloutStatus::DisabledByStagedRollout);
|
|
|
|
in.manifest.rolloutPermille = 1000; // everyone (bucket 999 < 1000)
|
|
in.installBucket = 999;
|
|
EXPECT_TRUE(evaluateLiteRollout(in).allowed);
|
|
}
|
|
|
|
void testLiteRolloutManifestLoader()
|
|
{
|
|
using namespace dragonx::wallet;
|
|
// Missing file -> not present -> fail-open allowed.
|
|
auto missing = loadLiteRolloutManifestFromFile("/tmp/obsidian-rollout-missing-xyz.json");
|
|
EXPECT_FALSE(missing.present);
|
|
{
|
|
LiteRolloutInputs in; in.appVersion = "1.2.0"; in.manifest = missing;
|
|
EXPECT_TRUE(evaluateLiteRollout(in).allowed);
|
|
}
|
|
|
|
// A valid manifest file parses into the expected fields.
|
|
const std::string path = "/tmp/obsidian-rollout-test.json";
|
|
{
|
|
std::ofstream f(path);
|
|
f << R"({"global_enabled":false,"min_version":"1.0.0","rollout_permille":250,"message":"hold"})";
|
|
}
|
|
auto m = loadLiteRolloutManifestFromFile(path);
|
|
EXPECT_TRUE(m.present);
|
|
EXPECT_TRUE(m.valid);
|
|
EXPECT_FALSE(m.globalEnabled);
|
|
EXPECT_EQ(m.minVersion, std::string("1.0.0"));
|
|
EXPECT_EQ(m.rolloutPermille, 250);
|
|
EXPECT_EQ(m.message, std::string("hold"));
|
|
std::remove(path.c_str());
|
|
|
|
// Malformed JSON -> present but invalid -> fail-open allowed.
|
|
const std::string badPath = "/tmp/obsidian-rollout-bad.json";
|
|
{ std::ofstream f(badPath); f << "{ this is not json"; }
|
|
auto bad = loadLiteRolloutManifestFromFile(badPath);
|
|
EXPECT_TRUE(bad.present);
|
|
EXPECT_FALSE(bad.valid);
|
|
{
|
|
LiteRolloutInputs in; in.appVersion = "1.2.0"; in.manifest = bad;
|
|
EXPECT_TRUE(evaluateLiteRollout(in).allowed);
|
|
}
|
|
std::remove(badPath.c_str());
|
|
}
|
|
|
|
// M5b: the rollout gate threads through the controller -> lifecycle availability() and blocks
|
|
// lifecycle execution with the gate's user-facing message.
|
|
void testLiteWalletControllerRolloutGate()
|
|
{
|
|
using namespace dragonx::wallet;
|
|
const auto liteCaps = makeWalletCapabilities(WalletBuildKind::Lite, /*embeddedDaemon*/ false, /*liteBackendLinked*/ true);
|
|
const auto conn = defaultLiteConnectionSettings();
|
|
|
|
// Gated OFF: availability() reports RolloutDisabled, create is blocked, wallet stays closed.
|
|
{
|
|
LiteWalletController controller(
|
|
liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()),
|
|
LiteWalletControllerOptions{/*allowBridgeCalls*/ true, /*rolloutBlocked*/ true,
|
|
"lite paused for maintenance"});
|
|
EXPECT_TRUE(controller.availability() == LiteWalletLifecycleAvailability::RolloutDisabled);
|
|
const auto result = controller.createWallet(LiteWalletCreateRequest{});
|
|
EXPECT_FALSE(result.walletReady);
|
|
EXPECT_TRUE(result.status.message.find("maintenance") != std::string::npos);
|
|
EXPECT_FALSE(controller.walletOpen());
|
|
}
|
|
|
|
// Not gated: availability() is Ready (matches the other lifecycle tests).
|
|
{
|
|
LiteWalletController controller(
|
|
liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()),
|
|
LiteWalletControllerOptions{/*allowBridgeCalls*/ true, /*rolloutBlocked*/ false, ""});
|
|
EXPECT_TRUE(controller.availability() == LiteWalletLifecycleAvailability::Ready);
|
|
}
|
|
}
|
|
|
|
// ── xmrig updater pure core (util/xmrig_updater.h) — driven by the real release fixture ──
|
|
static std::string readXmrigFixture()
|
|
{
|
|
const std::string path = std::string(DRAGONX_TEST_FIXTURE_DIR) + "/xmrig/release_latest.json";
|
|
std::ifstream f(path, std::ios::binary);
|
|
return std::string((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>());
|
|
}
|
|
|
|
void testXmrigReleaseParsing()
|
|
{
|
|
using namespace dragonx::util;
|
|
const auto rel = parseXmrigRelease(readXmrigFixture());
|
|
EXPECT_TRUE(rel.ok);
|
|
EXPECT_EQ(rel.tag, std::string("v1.0.0"));
|
|
EXPECT_EQ(rel.assets.size(), static_cast<std::size_t>(2));
|
|
// Both assets carry a name + a usable download URL.
|
|
for (const auto& a : rel.assets) {
|
|
EXPECT_TRUE(!a.name.empty());
|
|
EXPECT_TRUE(a.downloadUrl.rfind("https://", 0) == 0);
|
|
EXPECT_TRUE(a.size > 0);
|
|
}
|
|
// Garbage input fails closed.
|
|
EXPECT_FALSE(parseXmrigRelease("not json at all").ok);
|
|
EXPECT_FALSE(parseXmrigRelease("{}").ok); // no tag_name
|
|
}
|
|
|
|
void testXmrigAssetSelection()
|
|
{
|
|
using namespace dragonx::util;
|
|
const auto rel = parseXmrigRelease(readXmrigFixture());
|
|
const int linux = selectXmrigAsset(rel, "linux-x64");
|
|
const int win = selectXmrigAsset(rel, "win-x64");
|
|
EXPECT_TRUE(linux >= 0);
|
|
EXPECT_TRUE(win >= 0);
|
|
EXPECT_TRUE(linux != win);
|
|
EXPECT_TRUE(rel.assets[linux].name.find("linux-x64.zip") != std::string::npos);
|
|
EXPECT_TRUE(rel.assets[win].name.find("win-x64.zip") != std::string::npos);
|
|
// No macOS build in this fixture -> graceful "not found".
|
|
EXPECT_EQ(selectXmrigAsset(rel, "macos-x86_64"), -1);
|
|
EXPECT_EQ(selectXmrigAsset(rel, "macos-arm64"), -1);
|
|
EXPECT_EQ(selectXmrigAsset(rel, ""), -1);
|
|
|
|
// The matcher handles the macOS asset naming ("macos-x86_64", not "macos-x64").
|
|
XmrigRelease mac; mac.ok = true; mac.tag = "v6.25.3";
|
|
mac.assets.push_back({"drg-xmrig-6.25.3-macos-x86_64.zip", "https://x/m", 100});
|
|
EXPECT_TRUE(selectXmrigAsset(mac, "macos-x86_64") >= 0);
|
|
EXPECT_EQ(selectXmrigAsset(mac, "macos-x64"), -1); // the wrong token must NOT match
|
|
}
|
|
|
|
void testXmrigChecksumParsing()
|
|
{
|
|
using namespace dragonx::util;
|
|
const auto rel = parseXmrigRelease(readXmrigFixture());
|
|
const auto sums = parseXmrigChecksums(rel.body);
|
|
// Inner-binary checksums (what we verify before making the binary executable).
|
|
EXPECT_EQ(sums.at("xmrig"),
|
|
std::string("37c178f743c269c1d9e18302cead0ed117ded2b5fe30910e836896b4abc20e57"));
|
|
EXPECT_EQ(sums.at("xmrig.exe"),
|
|
std::string("01223711eddea347eee394c4b6d265b9a3e5c13fe93204bc041c4399c9c758f8"));
|
|
// Archive checksum, keyed by the asset filename.
|
|
EXPECT_EQ(sums.at("drg-xmrig-6.25.1-drg1-linux-x64.zip"),
|
|
std::string("c121a078ee46943584aa6148cac26583409c832d28668cf38ba908e10214c9a6"));
|
|
// Non-checksum prose lines are ignored.
|
|
EXPECT_TRUE(parseXmrigChecksums("just some text\nno hashes here").empty());
|
|
}
|
|
|
|
void testXmrigSha256AndBasenames()
|
|
{
|
|
using namespace dragonx::util;
|
|
// Known-answer: SHA-256("abc").
|
|
const std::string abc = "abc";
|
|
EXPECT_EQ(sha256Hex(abc.data(), abc.size()),
|
|
std::string("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"));
|
|
// The selected asset's published archive checksum matches what the verifier will compute over
|
|
// the bytes (closes the loop between parse + hash). Empty input is the SHA-256 of "".
|
|
EXPECT_EQ(sha256Hex("", 0),
|
|
std::string("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"));
|
|
|
|
const auto linuxNames = xmrigExtractBasenames("linux-x64");
|
|
EXPECT_EQ(linuxNames.size(), static_cast<std::size_t>(1));
|
|
EXPECT_EQ(linuxNames.front(), std::string("xmrig"));
|
|
const auto winNames = xmrigExtractBasenames("win-x64");
|
|
EXPECT_EQ(winNames.size(), static_cast<std::size_t>(2));
|
|
EXPECT_EQ(winNames.front(), std::string("xmrig.exe")); // miner binary is always first
|
|
EXPECT_TRUE(std::find(winNames.begin(), winNames.end(), std::string("WinRing0x64.sys"))
|
|
!= winNames.end());
|
|
}
|
|
|
|
void testXmrigSignatureAssetSelection()
|
|
{
|
|
using namespace dragonx::util;
|
|
XmrigRelease rel; rel.ok = true; rel.tag = "v1";
|
|
rel.assets.push_back({"drg-xmrig-linux-x64.zip", "https://x/zip", 100});
|
|
EXPECT_EQ(selectXmrigSignatureAsset(rel, "drg-xmrig-linux-x64.zip"), -1); // none published
|
|
rel.assets.push_back({"drg-xmrig-linux-x64.zip.sig", "https://x/sig", 64});
|
|
EXPECT_TRUE(selectXmrigSignatureAsset(rel, "drg-xmrig-linux-x64.zip") >= 0);
|
|
EXPECT_EQ(selectXmrigSignatureAsset(rel, "other.zip"), -1);
|
|
}
|
|
|
|
void testXmrigPinnedKeyValidity()
|
|
{
|
|
using namespace dragonx::util;
|
|
const std::string pin = kXmrigSignaturePublicKeyBase64;
|
|
if (pin.empty()) return; // signing disabled -> nothing to validate
|
|
// A non-empty pinned key MUST decode to exactly a 32-byte ed25519 public key, or signature
|
|
// verification would silently never succeed. Guards against a malformed/truncated paste.
|
|
EXPECT_TRUE(sodium_init() >= 0);
|
|
unsigned char pk[crypto_sign_PUBLICKEYBYTES];
|
|
std::size_t n = 0; const char* end = nullptr;
|
|
const int rc = sodium_base642bin(pk, sizeof(pk), pin.data(), pin.size(), " \t\r\n",
|
|
&n, &end, sodium_base64_VARIANT_ORIGINAL);
|
|
EXPECT_EQ(rc, 0);
|
|
EXPECT_EQ(n, static_cast<std::size_t>(crypto_sign_PUBLICKEYBYTES));
|
|
}
|
|
|
|
// Lite Network tab pure helpers.
|
|
void testLiteServerHostParsing()
|
|
{
|
|
using dragonx::util::liteServerHost;
|
|
EXPECT_EQ(liteServerHost("https://lite.dragonx.is"), std::string("lite.dragonx.is"));
|
|
EXPECT_EQ(liteServerHost("https://lite.dragonx.is:443/path"), std::string("lite.dragonx.is:443"));
|
|
EXPECT_EQ(liteServerHost("http://1.2.3.4:9067"), std::string("1.2.3.4:9067"));
|
|
EXPECT_EQ(liteServerHost("https://user@host.example/x?y"), std::string("host.example"));
|
|
EXPECT_EQ(liteServerHost("lite.dragonx.is"), std::string("lite.dragonx.is")); // no scheme
|
|
}
|
|
|
|
void testLiteOfficialServerDetection()
|
|
{
|
|
using dragonx::wallet::isOfficialLiteServer;
|
|
EXPECT_TRUE(isOfficialLiteServer("https://lite.dragonx.is"));
|
|
EXPECT_TRUE(isOfficialLiteServer("https://lite3.dragonx.is"));
|
|
EXPECT_TRUE(isOfficialLiteServer(" https://lite.dragonx.is ")); // trimmed
|
|
EXPECT_FALSE(isOfficialLiteServer("https://my-custom-server.example"));
|
|
EXPECT_FALSE(isOfficialLiteServer(""));
|
|
}
|
|
|
|
// H7: atomic + durable file writes (settings.json / addressbook.json persistence).
|
|
void testAtomicFileWrite()
|
|
{
|
|
namespace fs = std::filesystem;
|
|
using dragonx::util::Platform;
|
|
|
|
fs::path dir = fs::temp_directory_path() / "obsidian_atomic_test";
|
|
fs::remove_all(dir);
|
|
const std::string target = (dir / "nested" / "settings.json").string();
|
|
|
|
// Creates parent dirs and writes content.
|
|
EXPECT_TRUE(Platform::writeFileAtomically(target, "{\"a\":1}"));
|
|
EXPECT_TRUE(fs::exists(target));
|
|
{
|
|
std::ifstream in(target);
|
|
std::string body((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
|
|
EXPECT_EQ(body, std::string("{\"a\":1}"));
|
|
}
|
|
|
|
// Overwrites in place (atomic replace), and leaves no stray .tmp behind.
|
|
EXPECT_TRUE(Platform::writeFileAtomically(target, "{\"a\":2,\"b\":3}"));
|
|
{
|
|
std::ifstream in(target);
|
|
std::string body((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
|
|
EXPECT_EQ(body, std::string("{\"a\":2,\"b\":3}"));
|
|
}
|
|
EXPECT_FALSE(fs::exists(target + ".tmp"));
|
|
|
|
#ifndef _WIN32
|
|
// restrictPermissions -> owner-only (0600).
|
|
const std::string secret = (dir / "vault.bin").string();
|
|
EXPECT_TRUE(Platform::writeFileAtomically(secret, "shh", /*restrictPermissions=*/true));
|
|
auto perms = fs::status(secret).permissions();
|
|
EXPECT_TRUE((perms & (fs::perms::group_all | fs::perms::others_all)) == fs::perms::none);
|
|
#endif
|
|
|
|
fs::remove_all(dir);
|
|
}
|
|
|
|
// Address checksum validation. Uses standard Base58Check / BIP173 Bech32 vectors — the
|
|
// algorithms are chain-agnostic, so passing these means real DRGX addresses validate too.
|
|
void testAddressChecksumValidation()
|
|
{
|
|
using dragonx::util::isValidBase58Check;
|
|
using dragonx::util::isValidBech32;
|
|
|
|
// Base58Check: a valid P2PKH address verifies; a one-char transcription error fails.
|
|
EXPECT_TRUE(isValidBase58Check("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"));
|
|
EXPECT_FALSE(isValidBase58Check("1A1zP1eP5QGefi2DMPTfTL5SLmv7Divfna")); // last char flipped
|
|
EXPECT_FALSE(isValidBase58Check("not-base58-0OIl")); // invalid alphabet
|
|
EXPECT_FALSE(isValidBase58Check(""));
|
|
|
|
// Bech32 (BIP173 valid vectors) verify; corrupted checksums fail.
|
|
EXPECT_TRUE(isValidBech32("A12UEL5L"));
|
|
EXPECT_TRUE(isValidBech32("abcdef1qpzry9x8gf2tvdw0s3jn54khce6mua7lmqqqxw"));
|
|
EXPECT_TRUE(isValidBech32("split1checkupstagehandshakeupstreamerranterredcaperred2y9e3w"));
|
|
EXPECT_FALSE(isValidBech32("A12UEL5M")); // checksum corrupted
|
|
EXPECT_FALSE(isValidBech32("abc1rzg")); // too short / bad checksum
|
|
EXPECT_FALSE(isValidBech32("Abcdef1qpzry9x8gf2tvdw0s3jn54khce6mua7lmqqqxw")); // mixed case
|
|
EXPECT_FALSE(isValidBech32("nosalt")); // no separator
|
|
}
|
|
|
|
// Live probe of a real lite server (env-gated). Validates CONNECT_ONLY latency + IP capture.
|
|
void testLiteServerProbeLive()
|
|
{
|
|
if (!std::getenv("DRAGONX_TEST_NETWORK")) {
|
|
std::printf("[skip] testLiteServerProbeLive (set DRAGONX_TEST_NETWORK=1 to run)\n");
|
|
return;
|
|
}
|
|
using namespace dragonx::util;
|
|
LiteServerProbe probe;
|
|
probe.start({"https://lite.dragonx.is"});
|
|
for (int i = 0; i < 150 && probe.busy(); ++i)
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
|
const auto res = probe.results();
|
|
const auto it = res.find("https://lite.dragonx.is");
|
|
EXPECT_TRUE(it != res.end());
|
|
if (it != res.end()) {
|
|
EXPECT_TRUE(it->second.probed);
|
|
if (it->second.online) { // reachable -> latency + IP captured
|
|
EXPECT_TRUE(!it->second.ip.empty());
|
|
std::printf("[live] lite.dragonx.is online %dms ip=%s\n",
|
|
it->second.latencyMs, it->second.ip.c_str());
|
|
}
|
|
}
|
|
}
|
|
|
|
void testXmrigSignatureVerify()
|
|
{
|
|
using namespace dragonx::util;
|
|
EXPECT_TRUE(sodium_init() >= 0);
|
|
unsigned char pk[crypto_sign_PUBLICKEYBYTES], sk[crypto_sign_SECRETKEYBYTES];
|
|
crypto_sign_keypair(pk, sk);
|
|
const std::string data = "drg-xmrig archive payload bytes for signing test";
|
|
unsigned char sig[crypto_sign_BYTES];
|
|
crypto_sign_detached(sig, nullptr,
|
|
reinterpret_cast<const unsigned char*>(data.data()), data.size(), sk);
|
|
auto b64 = [](const unsigned char* b, std::size_t n) {
|
|
std::vector<char> buf(sodium_base64_encoded_len(n, sodium_base64_VARIANT_ORIGINAL));
|
|
sodium_bin2base64(buf.data(), buf.size(), b, n, sodium_base64_VARIANT_ORIGINAL);
|
|
return std::string(buf.data());
|
|
};
|
|
const std::string pkB64 = b64(pk, sizeof(pk));
|
|
const std::string sigB64 = b64(sig, sizeof(sig));
|
|
|
|
// Valid base64 signature verifies; a raw 64-byte signature is also accepted.
|
|
EXPECT_TRUE(verifyXmrigSignature(data, sigB64, pkB64));
|
|
EXPECT_TRUE(verifyXmrigSignature(data, std::string(reinterpret_cast<const char*>(sig),
|
|
crypto_sign_BYTES), pkB64));
|
|
// Whitespace around the base64 signature is tolerated.
|
|
EXPECT_TRUE(verifyXmrigSignature(data, " " + sigB64 + "\n", pkB64));
|
|
|
|
// Regression: a RAW 64-byte signature whose final byte equals a whitespace value (~1.6% of
|
|
// signatures) must still verify — it must not be whitespace-trimmed. Force that case.
|
|
{
|
|
unsigned char rpk[crypto_sign_PUBLICKEYBYTES], rsk[crypto_sign_SECRETKEYBYTES],
|
|
rsig[crypto_sign_BYTES];
|
|
bool found = false;
|
|
for (int i = 0; i < 20000 && !found; ++i) {
|
|
crypto_sign_keypair(rpk, rsk);
|
|
const std::string m = "raw-ws-regression-" + std::to_string(i);
|
|
crypto_sign_detached(rsig, nullptr,
|
|
reinterpret_cast<const unsigned char*>(m.data()), m.size(), rsk);
|
|
const unsigned char last = rsig[crypto_sign_BYTES - 1];
|
|
if (last == '\n' || last == '\r' || last == ' ' || last == '\t') {
|
|
found = true;
|
|
const std::string rawSig(reinterpret_cast<const char*>(rsig), crypto_sign_BYTES);
|
|
EXPECT_TRUE(verifyXmrigSignature(m, rawSig, b64(rpk, sizeof(rpk))));
|
|
}
|
|
}
|
|
EXPECT_TRUE(found);
|
|
}
|
|
|
|
// Fails closed: tampered data, wrong key, malformed/empty inputs.
|
|
EXPECT_FALSE(verifyXmrigSignature(data + "x", sigB64, pkB64));
|
|
unsigned char pk2[crypto_sign_PUBLICKEYBYTES], sk2[crypto_sign_SECRETKEYBYTES];
|
|
crypto_sign_keypair(pk2, sk2);
|
|
EXPECT_FALSE(verifyXmrigSignature(data, sigB64, b64(pk2, sizeof(pk2))));
|
|
EXPECT_FALSE(verifyXmrigSignature(data, "not valid base64 !!!", pkB64));
|
|
EXPECT_FALSE(verifyXmrigSignature(data, sigB64, ""));
|
|
EXPECT_FALSE(verifyXmrigSignature(data, "", pkB64));
|
|
}
|
|
|
|
// Live end-to-end exercise of the XmrigUpdater WORKER (real network + curl + miniz). Env-gated so
|
|
// CI / offline runs skip it; run with DRAGONX_TEST_NETWORK=1 to hit git.dragonx.is. Verifies the
|
|
// full download -> archive-checksum -> extract/flatten -> inner-binary-checksum -> install path.
|
|
void testXmrigLiveInstall()
|
|
{
|
|
if (!std::getenv("DRAGONX_TEST_NETWORK")) {
|
|
std::printf("[skip] testXmrigLiveInstall (set DRAGONX_TEST_NETWORK=1 to run)\n");
|
|
return;
|
|
}
|
|
using namespace dragonx::util;
|
|
const std::string dir = "/tmp/obsidian-xmrig-live-test";
|
|
std::error_code ec; std::filesystem::remove_all(dir, ec);
|
|
|
|
XmrigUpdater up;
|
|
up.startInstall(dir);
|
|
for (int i = 0; i < 1200 && !up.isDone(); ++i) // up to ~120s for a ~4 MiB download
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
|
|
|
const auto p = up.getProgress();
|
|
EXPECT_TRUE(up.isDone());
|
|
EXPECT_TRUE(p.state == XmrigUpdater::State::Done);
|
|
if (p.state != XmrigUpdater::State::Done) {
|
|
std::printf("[testXmrigLiveInstall] failed: %s\n", p.error.c_str());
|
|
return;
|
|
}
|
|
// The miner binary was flattened out of the versioned subdir into the target dir. We don't pin
|
|
// a release-specific hash here (releases change) — reaching State::Done already means the worker
|
|
// verified the binary against the release's own published SHA-256. Just assert a real binary landed.
|
|
const std::string bin = dir + "/" + xmrigExtractBasenames(currentXmrigPlatformToken()).front();
|
|
EXPECT_TRUE(std::filesystem::exists(bin));
|
|
if (std::filesystem::exists(bin)) {
|
|
std::error_code szEc;
|
|
EXPECT_TRUE(std::filesystem::file_size(bin, szEc) > 100000); // a real miner binary is MBs
|
|
}
|
|
std::filesystem::remove_all(dir, ec);
|
|
}
|
|
|
|
} // namespace
|
|
|
|
int main()
|
|
{
|
|
testConnectionConfig();
|
|
testPaymentUri();
|
|
testAmountFormatting();
|
|
testSpendableFiltering();
|
|
testRefreshScheduler();
|
|
testNetworkRefreshService();
|
|
testNetworkRefreshSnapshotHelpers();
|
|
testNetworkRefreshRpcCollectors();
|
|
testNetworkRefreshResultModels();
|
|
testOperationStatusPollParsing();
|
|
testWalletSecurityController();
|
|
testWalletSecurityWorkflow();
|
|
testWalletSecurityWorkflowExecutor();
|
|
testDaemonShutdownPolicy();
|
|
testDaemonLifecycleExecution();
|
|
testDaemonLifecycleAdapters();
|
|
testRendererHelpers();
|
|
testConsoleInputModel();
|
|
testMiningBenchmarkModel();
|
|
testBalanceAddressListModel();
|
|
testExplorerBlockCache();
|
|
testTransactionHistoryCache();
|
|
testTransactionHistoryCachePrunesOldWallets();
|
|
testTransactionHistoryCacheRefreshApply();
|
|
testLiteBridgeOwnedStringCopiesBeforeFreeOnSuccess();
|
|
testLiteBridgeOwnedStringClassifiesNullWithoutFree();
|
|
testLiteBridgeOwnedStringClassifiesErrorAndFreesOnce();
|
|
testLiteBridgeOwnedStringMovePreventsDoubleFree();
|
|
testLiteClientBridgeUsesRuntimeOwnedStringCleanup();
|
|
testLiteBackendInjectableFakeBridge();
|
|
testLiteWalletControllerLifecycle();
|
|
testLiteWalletControllerOpenFailover();
|
|
testLiteWalletControllerM4();
|
|
testLiteWalletControllerM5Persistence();
|
|
testLiteWalletControllerEncryption();
|
|
testLiteChainNameMigration();
|
|
testLiteRefreshModelAppliesToWalletState();
|
|
testLiteSendShowsRecipientFromOutgoing();
|
|
testLitePartialRefreshKeepsPriorAddressBalances();
|
|
testLitePerAddressBalances();
|
|
testLiteWalletControllerNewAddress();
|
|
testLiteSyncStatusParserRealShapes();
|
|
testLiteWalletControllerRefreshPopulatesState();
|
|
testLiteWalletGatewayRefreshSkipsFailedCommand();
|
|
testLiteWalletControllerWorkerProducesModel();
|
|
testLiteWalletControllerShutdownDoesNotHangDuringSync();
|
|
testLiteRolloutVersionCompare();
|
|
testLiteRolloutBucketAndOverrideHelpers();
|
|
testLiteRolloutPolicyOverridesAndKillSwitch();
|
|
testLiteRolloutPolicyManifestGates();
|
|
testLiteRolloutPolicyStagedRollout();
|
|
testLiteRolloutManifestLoader();
|
|
testLiteWalletControllerRolloutGate();
|
|
testXmrigReleaseParsing();
|
|
testXmrigAssetSelection();
|
|
testXmrigChecksumParsing();
|
|
testXmrigSha256AndBasenames();
|
|
testXmrigSignatureAssetSelection();
|
|
testXmrigPinnedKeyValidity();
|
|
testXmrigSignatureVerify();
|
|
testLiteServerHostParsing();
|
|
testLiteOfficialServerDetection();
|
|
testAtomicFileWrite();
|
|
testAddressChecksumValidation();
|
|
testLiteServerProbeLive();
|
|
testXmrigLiveInstall();
|
|
testGeneratedResourceBehavior();
|
|
|
|
if (g_failures != 0) {
|
|
std::cerr << g_failures << " test assertion(s) failed\n";
|
|
return 1;
|
|
}
|
|
std::cout << "Focused service tests passed\n";
|
|
return 0;
|
|
}
|