When the preferred lightwalletd server is reachable but warming up (JSON-RPC -28 / "Activating best chain"), the failover treated it like a dead server and fell through to the others, so the wallet didn't open until the next 20s retry — even though the healthy server was ready within seconds. Detect the warmup error during failover, flag it on the open outcome (lastOpenWasWarmup()), and have the App retry on a short ~4s interval in that case instead of 20s, so the wallet opens promptly once warmup clears. A unit test covers a warming-preferred + dead-fallback open setting the flag. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4549 lines
182 KiB
C++
4549 lines
182 KiB
C++
#include "daemon/daemon_controller.h"
|
|
#include "data/transaction_history_cache.h"
|
|
#include "daemon/lifecycle_adapters.h"
|
|
#include "data/wallet_state.h"
|
|
#include "rpc/connection.h"
|
|
#include "resources/embedded_resources.h"
|
|
#include "services/network_refresh_service.h"
|
|
#include "services/refresh_scheduler.h"
|
|
#include "services/wallet_security_controller.h"
|
|
#include "services/wallet_security_workflow.h"
|
|
#include "services/wallet_security_workflow_executor.h"
|
|
#include "ui/explorer/explorer_block_cache.h"
|
|
#include "ui/windows/balance_address_list.h"
|
|
#include "ui/windows/balance_recent_tx.h"
|
|
#include "ui/windows/console_input_model.h"
|
|
#include "ui/windows/console_output_model.h"
|
|
#include "ui/windows/console_tab_helpers.h"
|
|
#include "ui/windows/mining_benchmark.h"
|
|
#include "ui/windows/mining_pool_panel.h"
|
|
#include "ui/windows/mining_tab_helpers.h"
|
|
#include "util/address_validation.h"
|
|
#include "util/amount_format.h"
|
|
#include "util/payment_uri.h"
|
|
#include "util/platform.h"
|
|
#include "util/xmrig_updater.h"
|
|
#include "util/lite_server_probe.h"
|
|
#include "wallet/lite_connection_service.h"
|
|
#include "wallet/lite_diagnostics.h"
|
|
#include "wallet/lite_owned_string.h"
|
|
#include "wallet/lite_rollout_policy.h"
|
|
#include "wallet/lite_wallet_controller.h"
|
|
#include "wallet/lite_wallet_gateway.h"
|
|
#include "wallet/lite_wallet_state_mapper.h"
|
|
#include "config/settings.h"
|
|
#include "data/wallet_state.h"
|
|
#include "fake_lite_backend.h"
|
|
|
|
#include <chrono>
|
|
#include <cmath>
|
|
#include <thread>
|
|
#include <atomic>
|
|
#include <deque>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <iostream>
|
|
#include <memory>
|
|
#include <new>
|
|
#include <set>
|
|
#include <stdexcept>
|
|
#include <string>
|
|
#include <type_traits>
|
|
#include <unordered_map>
|
|
#include <unordered_set>
|
|
#include <utility>
|
|
#include <vector>
|
|
#include <sodium.h>
|
|
|
|
namespace fs = std::filesystem;
|
|
|
|
namespace {
|
|
|
|
int g_failures = 0;
|
|
|
|
void expectTrue(bool value, const char* expr, const char* file, int line)
|
|
{
|
|
if (value) return;
|
|
std::cerr << file << ":" << line << " expected true: " << expr << "\n";
|
|
++g_failures;
|
|
}
|
|
|
|
template <typename T, typename U>
|
|
void expectEqual(const T& actual, const U& expected, const char* actualExpr,
|
|
const char* expectedExpr, const char* file, int line)
|
|
{
|
|
if (actual == expected) return;
|
|
std::cerr << file << ":" << line << " expected " << actualExpr << " == "
|
|
<< expectedExpr << "\n";
|
|
++g_failures;
|
|
}
|
|
|
|
void expectNear(double actual, double expected, double epsilon, const char* file, int line)
|
|
{
|
|
if (std::abs(actual - expected) <= epsilon) return;
|
|
std::cerr << file << ":" << line << " expected near " << expected
|
|
<< " (actual=" << actual << ")\n";
|
|
++g_failures;
|
|
}
|
|
|
|
#define EXPECT_TRUE(expr) expectTrue((expr), #expr, __FILE__, __LINE__)
|
|
#define EXPECT_FALSE(expr) expectTrue(!(expr), "!(" #expr ")", __FILE__, __LINE__)
|
|
#define EXPECT_EQ(actual, expected) expectEqual((actual), (expected), #actual, #expected, __FILE__, __LINE__)
|
|
#define EXPECT_NEAR(actual, expected, epsilon) expectNear((actual), (expected), (epsilon), __FILE__, __LINE__)
|
|
|
|
fs::path makeTempDir()
|
|
{
|
|
auto now = std::chrono::steady_clock::now().time_since_epoch().count();
|
|
fs::path dir = fs::temp_directory_path() / ("obsidian_phase4_tests_" + std::to_string(now));
|
|
fs::create_directories(dir);
|
|
return dir;
|
|
}
|
|
|
|
void writeTestFile(const fs::path& path, const std::string& content)
|
|
{
|
|
std::ofstream output(path, std::ios::binary);
|
|
output << content;
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
int g_liteBridgeRuntimeFakeCallCount = 0;
|
|
int g_liteBridgeRuntimeTrackedFreeCount = 0;
|
|
std::vector<std::string> g_liteBridgeRuntimeTrackedFreedValues;
|
|
|
|
char* makeLiteBridgeRuntimeOwnedCString(const std::string& value)
|
|
{
|
|
char* rawValue = new char[value.size() + 1];
|
|
for (std::size_t index = 0; index < value.size(); ++index) {
|
|
rawValue[index] = value[index];
|
|
}
|
|
rawValue[value.size()] = '\0';
|
|
return rawValue;
|
|
}
|
|
|
|
void resetLiteBridgeRuntimeTrackedFree()
|
|
{
|
|
g_liteBridgeRuntimeTrackedFreeCount = 0;
|
|
g_liteBridgeRuntimeTrackedFreedValues.clear();
|
|
}
|
|
|
|
void fakeLiteBridgeRuntimeTrackedFreeString(char* value)
|
|
{
|
|
++g_liteBridgeRuntimeTrackedFreeCount;
|
|
if (value) {
|
|
g_liteBridgeRuntimeTrackedFreedValues.push_back(value);
|
|
}
|
|
delete[] value;
|
|
}
|
|
|
|
bool fakeLiteBridgeRuntimeWalletExists(const char*)
|
|
{
|
|
++g_liteBridgeRuntimeFakeCallCount;
|
|
return true;
|
|
}
|
|
|
|
char* fakeLiteBridgeRuntimeInitializeNew(bool, const char*)
|
|
{
|
|
++g_liteBridgeRuntimeFakeCallCount;
|
|
return nullptr;
|
|
}
|
|
|
|
char* fakeLiteBridgeRuntimeInitializeNewFromPhrase(bool,
|
|
const char*,
|
|
const char*,
|
|
unsigned long long,
|
|
unsigned long long,
|
|
bool)
|
|
{
|
|
++g_liteBridgeRuntimeFakeCallCount;
|
|
return nullptr;
|
|
}
|
|
|
|
char* fakeLiteBridgeRuntimeInitializeExisting(bool, const char*)
|
|
{
|
|
++g_liteBridgeRuntimeFakeCallCount;
|
|
return nullptr;
|
|
}
|
|
|
|
char* fakeLiteBridgeRuntimeExecute(const char*, const char*)
|
|
{
|
|
++g_liteBridgeRuntimeFakeCallCount;
|
|
return nullptr;
|
|
}
|
|
|
|
char* fakeLiteBridgeRuntimeOwnedExecute(const char*, const char*)
|
|
{
|
|
++g_liteBridgeRuntimeFakeCallCount;
|
|
return makeLiteBridgeRuntimeOwnedCString("bridge-runtime-ok");
|
|
}
|
|
|
|
void fakeLiteBridgeRuntimeFreeString(char*)
|
|
{
|
|
++g_liteBridgeRuntimeFakeCallCount;
|
|
}
|
|
|
|
bool fakeLiteBridgeRuntimeCheckServerOnline(const char*)
|
|
{
|
|
++g_liteBridgeRuntimeFakeCallCount;
|
|
return true;
|
|
}
|
|
|
|
void fakeLiteBridgeRuntimeShutdown()
|
|
{
|
|
++g_liteBridgeRuntimeFakeCallCount;
|
|
}
|
|
|
|
dragonx::wallet::LiteClientBridgeApi makeCompleteFakeLiteBridgeRuntimeApi()
|
|
{
|
|
return dragonx::wallet::LiteClientBridgeApi{
|
|
&fakeLiteBridgeRuntimeWalletExists,
|
|
&fakeLiteBridgeRuntimeInitializeNew,
|
|
&fakeLiteBridgeRuntimeInitializeNewFromPhrase,
|
|
&fakeLiteBridgeRuntimeInitializeExisting,
|
|
&fakeLiteBridgeRuntimeExecute,
|
|
&fakeLiteBridgeRuntimeFreeString,
|
|
&fakeLiteBridgeRuntimeCheckServerOnline,
|
|
&fakeLiteBridgeRuntimeShutdown,
|
|
};
|
|
}
|
|
|
|
class MockWalletSecurityRpc : public dragonx::services::WalletSecurityController::RpcGateway {
|
|
public:
|
|
bool encryptResult = true;
|
|
std::string encryptError;
|
|
int encryptCalls = 0;
|
|
std::string lastPassphrase;
|
|
|
|
bool encryptWallet(const std::string& passphrase, std::string& error) override
|
|
{
|
|
++encryptCalls;
|
|
lastPassphrase = passphrase;
|
|
error = encryptError;
|
|
return encryptResult;
|
|
}
|
|
|
|
bool unlockWallet(const std::string&, int, std::string&) override { return true; }
|
|
bool exportWallet(const std::string&, long, std::string&) override { return true; }
|
|
bool importWallet(const std::string&, long, std::string&) override { return true; }
|
|
};
|
|
|
|
class MockDaemonLifecycleRuntime : public dragonx::daemon::DaemonController::LifecycleRuntime {
|
|
public:
|
|
std::vector<std::string> calls;
|
|
int deletedItems = 7;
|
|
bool startResult = true;
|
|
std::string rpcStopContext;
|
|
std::string disconnectReason;
|
|
|
|
void stopDaemonWithPolicy() override { calls.push_back("stop"); }
|
|
bool startDaemon() override
|
|
{
|
|
calls.push_back("start");
|
|
return startResult;
|
|
}
|
|
int deleteBlockchainData() override
|
|
{
|
|
calls.push_back("delete");
|
|
return deletedItems;
|
|
}
|
|
void resetOutputOffset() override { calls.push_back("reset-output"); }
|
|
void requestRpcStopAndDisconnect(const char* context, const char* reason) override
|
|
{
|
|
calls.push_back("rpc-stop-disconnect");
|
|
rpcStopContext = context ? context : "";
|
|
disconnectReason = reason ? reason : "";
|
|
}
|
|
};
|
|
|
|
class MockDaemonLifecycleTask : public dragonx::daemon::DaemonController::LifecycleTaskContext {
|
|
public:
|
|
bool isCancelled = false;
|
|
bool isShuttingDown = false;
|
|
int cancelAfterSleeps = 0;
|
|
int sleeps = 0;
|
|
int sleptMs = 0;
|
|
|
|
bool cancelled() const override { return isCancelled; }
|
|
bool shuttingDown() const override { return isShuttingDown; }
|
|
void sleepForMs(int milliseconds) override
|
|
{
|
|
++sleeps;
|
|
sleptMs += milliseconds;
|
|
if (cancelAfterSleeps > 0 && sleeps >= cancelAfterSleeps) {
|
|
isCancelled = true;
|
|
}
|
|
}
|
|
};
|
|
|
|
class MockWalletSecurityVault : public dragonx::services::WalletSecurityController::VaultGateway {
|
|
public:
|
|
bool storeResult = true;
|
|
int storeCalls = 0;
|
|
std::string lastPin;
|
|
std::string lastPassphrase;
|
|
|
|
bool storePin(const std::string& pin, const std::string& passphrase) override
|
|
{
|
|
++storeCalls;
|
|
lastPin = pin;
|
|
lastPassphrase = passphrase;
|
|
return storeResult;
|
|
}
|
|
};
|
|
|
|
class MockWorkflowRpc : public dragonx::services::WalletSecurityWorkflowExecutor::RpcGateway {
|
|
public:
|
|
bool unlockResult = true;
|
|
bool exportResult = true;
|
|
bool stopResult = true;
|
|
int probeSuccessOnCall = 1;
|
|
std::string unlockError;
|
|
std::string exportError;
|
|
std::string stopError;
|
|
std::string probeError;
|
|
int unlockCalls = 0;
|
|
int exportCalls = 0;
|
|
int stopCalls = 0;
|
|
int probeCalls = 0;
|
|
std::string lastPassphrase;
|
|
std::string lastExportFile;
|
|
|
|
bool unlockWallet(const std::string& passphrase, int, std::string& error) override
|
|
{
|
|
++unlockCalls;
|
|
lastPassphrase = passphrase;
|
|
error = unlockError;
|
|
return unlockResult;
|
|
}
|
|
|
|
bool exportWallet(const std::string& fileName, long, std::string& error) override
|
|
{
|
|
++exportCalls;
|
|
lastExportFile = fileName;
|
|
error = exportError;
|
|
return exportResult;
|
|
}
|
|
|
|
bool requestDaemonStop(std::string& error) override
|
|
{
|
|
++stopCalls;
|
|
error = stopError;
|
|
return stopResult;
|
|
}
|
|
|
|
bool probeDaemon(std::string& error) override
|
|
{
|
|
++probeCalls;
|
|
error = probeError;
|
|
return probeCalls >= probeSuccessOnCall;
|
|
}
|
|
};
|
|
|
|
class MockWorkflowFiles : public dragonx::services::WalletSecurityWorkflowExecutor::FileGateway {
|
|
public:
|
|
std::string dir = "/tmp/dragonx/";
|
|
bool backupResult = true;
|
|
std::string backupError;
|
|
int backupCalls = 0;
|
|
|
|
std::string dataDir() override { return dir; }
|
|
bool backupEncryptedWallet(const dragonx::services::WalletSecurityWorkflowExecutor::WalletFilePlan&,
|
|
std::string& error) override
|
|
{
|
|
++backupCalls;
|
|
error = backupError;
|
|
return backupResult;
|
|
}
|
|
};
|
|
|
|
class MockWorkflowDaemon : public dragonx::services::WalletSecurityWorkflowExecutor::DaemonGateway {
|
|
public:
|
|
bool usingEmbedded = true;
|
|
bool isCancelled = false;
|
|
bool isShuttingDown = false;
|
|
int cancelAfterSleeps = 0;
|
|
int sleepCalls = 0;
|
|
int sleptMs = 0;
|
|
int stopCalls = 0;
|
|
int startCalls = 0;
|
|
|
|
bool isUsingEmbeddedDaemon() const override { return usingEmbedded; }
|
|
void stopEmbeddedDaemon() override { ++stopCalls; }
|
|
bool startEmbeddedDaemon() override { ++startCalls; return true; }
|
|
bool cancelled() const override { return isCancelled; }
|
|
bool shuttingDown() const override { return isShuttingDown; }
|
|
void sleepForMs(int milliseconds) override
|
|
{
|
|
++sleepCalls;
|
|
sleptMs += milliseconds;
|
|
if (cancelAfterSleeps > 0 && sleepCalls >= cancelAfterSleeps) {
|
|
isCancelled = true;
|
|
}
|
|
}
|
|
};
|
|
|
|
class MockWorkflowImporter : public dragonx::services::WalletSecurityWorkflowExecutor::ImportGateway {
|
|
public:
|
|
bool result = true;
|
|
std::string importError;
|
|
int importCalls = 0;
|
|
std::string lastExportPath;
|
|
|
|
bool importWallet(const std::string& exportPath, long, std::string& error) override
|
|
{
|
|
++importCalls;
|
|
lastExportPath = exportPath;
|
|
error = importError;
|
|
return result;
|
|
}
|
|
};
|
|
|
|
class FakeRefreshWorker {
|
|
public:
|
|
std::size_t reportedPending = 0;
|
|
std::vector<dragonx::rpc::RPCWorker::WorkFn> tasks;
|
|
|
|
std::size_t pendingTaskCount() const { return reportedPending; }
|
|
void post(dragonx::rpc::RPCWorker::WorkFn work)
|
|
{
|
|
tasks.push_back(std::move(work));
|
|
}
|
|
dragonx::rpc::RPCWorker::MainCb runNext()
|
|
{
|
|
auto work = std::move(tasks.front());
|
|
tasks.erase(tasks.begin());
|
|
return work();
|
|
}
|
|
dragonx::rpc::RPCWorker::MainCb runAt(std::size_t index)
|
|
{
|
|
auto work = std::move(tasks[index]);
|
|
tasks.erase(tasks.begin() + static_cast<std::ptrdiff_t>(index));
|
|
return work();
|
|
}
|
|
};
|
|
|
|
class MockRefreshRpc : public dragonx::services::NetworkRefreshService::RefreshRpcGateway {
|
|
public:
|
|
struct RecordedCall {
|
|
std::string method;
|
|
nlohmann::json params;
|
|
};
|
|
|
|
struct Response {
|
|
nlohmann::json value;
|
|
bool throws = false;
|
|
std::string error = "mock rpc failure";
|
|
};
|
|
|
|
std::vector<RecordedCall> calls;
|
|
std::unordered_map<std::string, std::deque<Response>> responses;
|
|
|
|
void addResponse(const std::string& method, nlohmann::json value)
|
|
{
|
|
responses[method].push_back({std::move(value), false, {}});
|
|
}
|
|
|
|
void addFailure(const std::string& method, std::string error = "mock rpc failure")
|
|
{
|
|
responses[method].push_back({nlohmann::json(), true, std::move(error)});
|
|
}
|
|
|
|
nlohmann::json call(const std::string& method, const nlohmann::json& params) override
|
|
{
|
|
calls.push_back({method, params});
|
|
auto& queue = responses[method];
|
|
if (queue.empty()) {
|
|
throw std::runtime_error("unexpected rpc call: " + method);
|
|
}
|
|
auto response = std::move(queue.front());
|
|
queue.pop_front();
|
|
if (response.throws) throw std::runtime_error(response.error);
|
|
return response.value;
|
|
}
|
|
|
|
std::vector<std::string> methodNames() const
|
|
{
|
|
std::vector<std::string> names;
|
|
names.reserve(calls.size());
|
|
for (const auto& call : calls) names.push_back(call.method);
|
|
return names;
|
|
}
|
|
};
|
|
|
|
void testConnectionConfig()
|
|
{
|
|
using dragonx::rpc::AuthSource;
|
|
using dragonx::rpc::Connection;
|
|
|
|
fs::path dir = makeTempDir();
|
|
fs::path conf = dir / "DRAGONX.conf";
|
|
{
|
|
std::ofstream out(conf);
|
|
out << "rpcuser=alice\n"
|
|
<< "rpcpassword = secret \r\n"
|
|
<< "rpcconnect=rpc.example.test\n"
|
|
<< "rpcport=12345\n"
|
|
<< "rpctls=yes\n";
|
|
}
|
|
|
|
auto config = Connection::parseConfFile(conf.string());
|
|
EXPECT_EQ(config.rpcuser, std::string("alice"));
|
|
EXPECT_EQ(config.rpcpassword, std::string("secret"));
|
|
EXPECT_EQ(config.host, std::string("rpc.example.test"));
|
|
EXPECT_EQ(config.port, std::string("12345"));
|
|
EXPECT_TRUE(config.use_tls);
|
|
EXPECT_EQ(config.auth_source, AuthSource::ConfigFile);
|
|
EXPECT_FALSE(Connection::usesPlaintextRemote(config));
|
|
|
|
fs::path tlsConf = dir / "tls.conf";
|
|
{
|
|
std::ofstream out(tlsConf);
|
|
out << "rpcuser=bob\n"
|
|
<< "rpcpassword=remote-secret\n"
|
|
<< "rpcconnect=198.51.100.10\n"
|
|
<< "rpcssl=1\n";
|
|
}
|
|
auto tlsConfig = Connection::parseConfFile(tlsConf.string());
|
|
EXPECT_TRUE(tlsConfig.use_tls);
|
|
EXPECT_FALSE(Connection::usesPlaintextRemote(tlsConfig));
|
|
|
|
config.use_tls = false;
|
|
EXPECT_TRUE(Connection::usesPlaintextRemote(config));
|
|
config.host = "127.4.5.6";
|
|
EXPECT_FALSE(Connection::usesPlaintextRemote(config));
|
|
config.host = "[::1]";
|
|
EXPECT_FALSE(Connection::usesPlaintextRemote(config));
|
|
|
|
fs::path cookieDir = dir / "cookie";
|
|
fs::create_directories(cookieDir);
|
|
{
|
|
std::ofstream out(cookieDir / ".cookie");
|
|
out << "__cookie__:cookie-secret\r\n";
|
|
}
|
|
|
|
dragonx::rpc::ConnectionConfig cookieBase;
|
|
cookieBase.host = "rpc.example.test";
|
|
cookieBase.hush_dir = cookieDir.string();
|
|
cookieBase.auth_source = AuthSource::ConfigFile;
|
|
cookieBase.use_tls = true;
|
|
|
|
dragonx::rpc::ConnectionConfig cookieConfig;
|
|
EXPECT_TRUE(Connection::buildCookieAuthConfig(cookieBase, cookieConfig));
|
|
EXPECT_EQ(cookieConfig.rpcuser, std::string("__cookie__"));
|
|
EXPECT_EQ(cookieConfig.rpcpassword, std::string("cookie-secret"));
|
|
EXPECT_EQ(cookieConfig.auth_source, AuthSource::Cookie);
|
|
EXPECT_TRUE(cookieConfig.use_tls);
|
|
|
|
fs::remove_all(dir);
|
|
}
|
|
|
|
void testPaymentUri()
|
|
{
|
|
std::string taddr = "R" + std::string(33, 'a');
|
|
auto parsed = dragonx::util::parsePaymentURI(
|
|
"drgx:" + taddr + "?amount=1.25000000&label=Main+Wallet&memo=hello%20there&message=thanks");
|
|
|
|
EXPECT_TRUE(parsed.valid);
|
|
EXPECT_EQ(parsed.address, taddr);
|
|
EXPECT_NEAR(parsed.amount, 1.25, 0.00000001);
|
|
EXPECT_EQ(parsed.label, std::string("Main Wallet"));
|
|
EXPECT_EQ(parsed.memo, std::string("hello there"));
|
|
EXPECT_EQ(parsed.message, std::string("thanks"));
|
|
|
|
std::string zaddr = "zs" + std::string(76, 'b');
|
|
auto zparsed = dragonx::util::parsePaymentURI("hush://" + zaddr + "?amt=0.5");
|
|
EXPECT_TRUE(zparsed.valid);
|
|
EXPECT_NEAR(zparsed.amount, 0.5, 0.00000001);
|
|
|
|
auto invalid = dragonx::util::parsePaymentURI("drgx:" + taddr + "?amount=-1");
|
|
EXPECT_FALSE(invalid.valid);
|
|
EXPECT_EQ(invalid.error, std::string("Invalid negative amount"));
|
|
}
|
|
|
|
void testAmountFormatting()
|
|
{
|
|
EXPECT_EQ(dragonx::util::formatAmountFixed(1.0), std::string("1.00000000"));
|
|
EXPECT_EQ(dragonx::util::formatAmountFixed(0.1), std::string("0.10000000"));
|
|
EXPECT_EQ(dragonx::util::formatAmountFixed(0.000000019), std::string("0.00000002"));
|
|
EXPECT_EQ(dragonx::util::formatAmountFixed(12.3456, 2), std::string("12.35"));
|
|
}
|
|
|
|
void testSpendableFiltering()
|
|
{
|
|
std::vector<dragonx::AddressInfo> addresses;
|
|
addresses.push_back({"zs-view", 50.0, "shielded", false});
|
|
addresses.push_back({"zs-low", 2.0, "shielded", true});
|
|
addresses.push_back({"R-zero", 0.0, "transparent", true});
|
|
addresses.push_back({"R-high", 5.0, "transparent", true});
|
|
|
|
EXPECT_EQ(dragonx::bestSpendableAddressIndex(addresses), 3);
|
|
|
|
auto indices = dragonx::sortedSpendableAddressIndices(addresses);
|
|
EXPECT_EQ(indices.size(), static_cast<size_t>(2));
|
|
EXPECT_EQ(indices[0], static_cast<size_t>(3));
|
|
EXPECT_EQ(indices[1], static_cast<size_t>(1));
|
|
|
|
auto includeZero = dragonx::sortedSpendableAddressIndices(addresses, false);
|
|
EXPECT_EQ(includeZero.size(), static_cast<size_t>(3));
|
|
}
|
|
|
|
void testRefreshScheduler()
|
|
{
|
|
using dragonx::services::RefreshScheduler;
|
|
using Timer = RefreshScheduler::Timer;
|
|
|
|
RefreshScheduler scheduler;
|
|
scheduler.applyPage(dragonx::ui::NavPage::Overview);
|
|
EXPECT_NEAR(scheduler.intervals().core, 2.0, 0.0001);
|
|
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";
|
|
LiteDiagnostics::instance().clear(); // verify the open populates the console log
|
|
LiteWalletController controller(liteCaps, conn,
|
|
LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
|
EXPECT_TRUE(controller.beginOpenExisting());
|
|
for (int i = 0; i < 400 && controller.openInProgress(); ++i)
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
|
EXPECT_FALSE(controller.openInProgress());
|
|
controller.pumpAsyncOpen();
|
|
EXPECT_TRUE(controller.walletOpen());
|
|
EXPECT_TRUE(controller.lastOpenError().empty());
|
|
|
|
// The Console tab reads this log: the failover attempt + success must be recorded.
|
|
const auto log = LiteDiagnostics::instance().snapshot();
|
|
EXPECT_TRUE(!log.empty());
|
|
bool sawConnecting = false, sawOpened = false;
|
|
for (const auto& l : log) {
|
|
if (l.find("connecting to") != std::string::npos) sawConnecting = true;
|
|
if (l.find("Wallet opened via") != std::string::npos) sawOpened = true;
|
|
}
|
|
EXPECT_TRUE(sawConnecting);
|
|
EXPECT_TRUE(sawOpened);
|
|
}
|
|
|
|
// All servers dead -> open fails, wallet stays closed, reason surfaced.
|
|
{
|
|
dragonx::test::resetLiteFakeCounters();
|
|
dragonx::test::g_liteFakeWalletExists = true;
|
|
dragonx::test::g_liteFakeDeadServerSubstr = "example"; // both servers fail
|
|
LiteWalletController controller(liteCaps, conn,
|
|
LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
|
EXPECT_TRUE(controller.beginOpenExisting());
|
|
for (int i = 0; i < 400 && controller.openInProgress(); ++i)
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
|
controller.pumpAsyncOpen();
|
|
EXPECT_FALSE(controller.walletOpen());
|
|
EXPECT_TRUE(!controller.lastOpenError().empty());
|
|
}
|
|
|
|
// A warming-up (-28) server is flagged so the caller can retry sooner. Preferred server is
|
|
// warming, the fallback is dead -> open fails but lastOpenWasWarmup() is set.
|
|
{
|
|
dragonx::test::resetLiteFakeCounters();
|
|
dragonx::test::g_liteFakeWalletExists = true;
|
|
dragonx::test::g_liteFakeWarmupServerSubstr = "dead.example"; // preferred (sticky) is warming
|
|
dragonx::test::g_liteFakeDeadServerSubstr = "good.example"; // fallback unreachable
|
|
LiteWalletController controller(liteCaps, conn,
|
|
LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
|
EXPECT_TRUE(controller.beginOpenExisting());
|
|
for (int i = 0; i < 400 && controller.openInProgress(); ++i)
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
|
controller.pumpAsyncOpen();
|
|
EXPECT_FALSE(controller.walletOpen());
|
|
EXPECT_TRUE(controller.lastOpenWasWarmup());
|
|
}
|
|
|
|
dragonx::test::g_liteFakeWalletExists = false;
|
|
dragonx::test::g_liteFakeDeadServerSubstr.clear();
|
|
dragonx::test::g_liteFakeWarmupServerSubstr.clear();
|
|
}
|
|
|
|
// M2: a parsed lite refresh bundle maps through to the app's WalletState (the last hop
|
|
// the Balance/Receive/Transactions tabs read), with zatoshi->DRGX conversion, z/t address
|
|
// split, transaction typing, confirmations, and sync progress.
|
|
void testLiteRefreshModelAppliesToWalletState()
|
|
{
|
|
using namespace dragonx::wallet;
|
|
|
|
LiteWalletRefreshBundle bundle;
|
|
bundle.complete = true;
|
|
bundle.successfulCommandCount = 4;
|
|
|
|
bundle.hasBalance = true;
|
|
bundle.balance.shieldedBalance = 200000000; // 2 DRGX
|
|
bundle.balance.transparentBalance = 100000000; // 1 DRGX
|
|
bundle.balance.unconfirmedBalance = 50000000; // 0.5 DRGX
|
|
|
|
bundle.hasAddresses = true;
|
|
bundle.addresses.zAddresses = {"zs1shielded"};
|
|
bundle.addresses.tAddresses = {"t1transparent"};
|
|
|
|
bundle.hasTransactions = true;
|
|
LiteTransactionRecord rec;
|
|
rec.txid = "deadbeef";
|
|
rec.datetime = 1700000000;
|
|
rec.blockHeight = 990;
|
|
rec.unconfirmed = false;
|
|
rec.direction = LiteTransactionDirection::Receive;
|
|
rec.address = "zs1shielded";
|
|
rec.amount = 150000000; // 1.5 DRGX
|
|
bundle.transactions.transactions.push_back(rec);
|
|
|
|
bundle.hasSyncStatus = true;
|
|
bundle.syncStatus.syncedBlocks = 1000;
|
|
bundle.syncStatus.totalBlocks = 1000;
|
|
bundle.syncStatus.progress = 1.0;
|
|
bundle.syncStatus.complete = true;
|
|
|
|
const auto mapped = mapLiteWalletRefreshBundle(bundle);
|
|
EXPECT_TRUE(mapped.ok);
|
|
|
|
dragonx::WalletState state;
|
|
applyLiteRefreshModelToWalletState(mapped.model, state);
|
|
|
|
EXPECT_NEAR(state.privateBalance, 2.0, 1e-9);
|
|
EXPECT_NEAR(state.transparentBalance, 1.0, 1e-9);
|
|
EXPECT_NEAR(state.totalBalance, 3.0, 1e-9);
|
|
EXPECT_NEAR(state.unconfirmedBalance, 0.5, 1e-9);
|
|
|
|
EXPECT_EQ(static_cast<int>(state.addresses.size()), 2);
|
|
EXPECT_EQ(static_cast<int>(state.z_addresses.size()), 1);
|
|
EXPECT_EQ(static_cast<int>(state.t_addresses.size()), 1);
|
|
EXPECT_EQ(state.z_addresses[0].type, std::string("shielded"));
|
|
EXPECT_EQ(state.t_addresses[0].type, std::string("transparent"));
|
|
|
|
EXPECT_EQ(static_cast<int>(state.transactions.size()), 1);
|
|
EXPECT_EQ(state.transactions[0].type, std::string("receive"));
|
|
EXPECT_NEAR(state.transactions[0].amount, 1.5, 1e-9);
|
|
EXPECT_EQ(state.transactions[0].confirmations, 11); // chain 1000 - block 990 + 1
|
|
|
|
EXPECT_EQ(state.sync.blocks, 1000);
|
|
EXPECT_EQ(state.sync.headers, 1000);
|
|
EXPECT_FALSE(state.sync.syncing);
|
|
}
|
|
|
|
// A Send record carries its recipient in outgoing_metadata, not the top-level address/memo —
|
|
// the transactions list must surface the destination + memo instead of blanks.
|
|
void testLiteSendShowsRecipientFromOutgoing()
|
|
{
|
|
using namespace dragonx::wallet;
|
|
LiteWalletRefreshBundle bundle;
|
|
bundle.complete = true;
|
|
bundle.hasTransactions = true;
|
|
LiteTransactionRecord rec;
|
|
rec.txid = "sendtx";
|
|
rec.datetime = 1700000000;
|
|
rec.unconfirmed = true;
|
|
rec.direction = LiteTransactionDirection::Send;
|
|
rec.address = ""; // sends have no top-level address
|
|
rec.memo = "";
|
|
LiteTransactionOutput out;
|
|
out.address = "zs1recipient";
|
|
out.value = 75000000; // 0.75 DRGX
|
|
out.memo = "thanks";
|
|
rec.outgoingMetadata.push_back(out);
|
|
bundle.transactions.transactions.push_back(rec);
|
|
|
|
const auto mapped = mapLiteWalletRefreshBundle(bundle);
|
|
dragonx::WalletState state;
|
|
applyLiteRefreshModelToWalletState(mapped.model, state);
|
|
|
|
EXPECT_EQ(static_cast<int>(state.transactions.size()), 1);
|
|
EXPECT_EQ(state.transactions[0].type, std::string("send"));
|
|
EXPECT_EQ(state.transactions[0].address, std::string("zs1recipient"));
|
|
EXPECT_EQ(state.transactions[0].memo, std::string("thanks"));
|
|
}
|
|
|
|
// A tolerated partial refresh where the notes/utxo command failed (hasAddresses but
|
|
// !hasSpendableOutputs) must NOT zero every per-address balance — it keeps the last-known
|
|
// values so a correct total isn't accompanied by a misleading all-zero breakdown.
|
|
void testLitePartialRefreshKeepsPriorAddressBalances()
|
|
{
|
|
using namespace dragonx::wallet;
|
|
|
|
// First, a full refresh establishes a per-address balance.
|
|
LiteWalletRefreshBundle full;
|
|
full.complete = true;
|
|
full.hasAddresses = true;
|
|
full.addresses.zAddresses = {"zs1keep"};
|
|
full.hasNotes = true;
|
|
LiteSpendableOutput note;
|
|
note.address = "zs1keep";
|
|
note.value = 120000000; // 1.2 DRGX
|
|
note.spent = false;
|
|
full.notes.unspentNotes.push_back(note);
|
|
|
|
dragonx::WalletState state;
|
|
applyLiteRefreshModelToWalletState(mapLiteWalletRefreshBundle(full).model, state);
|
|
EXPECT_EQ(static_cast<int>(state.z_addresses.size()), 1);
|
|
EXPECT_NEAR(state.z_addresses[0].balance, 1.2, 1e-9);
|
|
|
|
// Now a partial refresh: addresses present, notes command failed (hasNotes=false).
|
|
LiteWalletRefreshBundle partial;
|
|
partial.complete = true;
|
|
partial.hasAddresses = true;
|
|
partial.addresses.zAddresses = {"zs1keep"};
|
|
partial.hasNotes = false; // notes/utxo failed this cycle
|
|
|
|
applyLiteRefreshModelToWalletState(mapLiteWalletRefreshBundle(partial).model, state);
|
|
EXPECT_EQ(static_cast<int>(state.z_addresses.size()), 1);
|
|
EXPECT_NEAR(state.z_addresses[0].balance, 1.2, 1e-9); // preserved, not zeroed
|
|
}
|
|
|
|
// M2b: the controller, after a wallet is ready, auto-starts sync and refreshWalletState()
|
|
// pulls balance/addresses/transactions/syncstatus through the shared bridge into WalletState.
|
|
void testLiteWalletControllerRefreshPopulatesState()
|
|
{
|
|
using namespace dragonx::wallet;
|
|
const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, /*embeddedDaemon*/ false, /*liteBackendLinked*/ true);
|
|
const auto conn = defaultLiteConnectionSettings();
|
|
|
|
LiteWalletController controller(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
|
EXPECT_TRUE(controller.createWallet(LiteWalletCreateRequest{}).walletReady);
|
|
EXPECT_TRUE(controller.walletOpen());
|
|
EXPECT_TRUE(controller.syncStarted()); // auto-started when the wallet became ready
|
|
|
|
// Sync runs on a detached thread; the full refresh (balance/addresses) only runs once it
|
|
// completes. Wait for it (instant with the fake) so the refresh is deterministic.
|
|
for (int i = 0; i < 500 && !controller.syncComplete(); ++i)
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
|
EXPECT_TRUE(controller.syncComplete());
|
|
|
|
dragonx::WalletState state;
|
|
EXPECT_TRUE(controller.refreshWalletState(state));
|
|
EXPECT_NEAR(state.privateBalance, 2.0, 1e-9);
|
|
EXPECT_NEAR(state.transparentBalance, 1.0, 1e-9);
|
|
EXPECT_EQ(static_cast<int>(state.z_addresses.size()), 1);
|
|
EXPECT_EQ(static_cast<int>(state.t_addresses.size()), 1);
|
|
EXPECT_EQ(static_cast<int>(state.transactions.size()), 1);
|
|
EXPECT_EQ(state.transactions[0].type, std::string("receive"));
|
|
EXPECT_NEAR(state.transactions[0].amount, 1.5, 1e-9);
|
|
EXPECT_EQ(state.sync.headers, 1000);
|
|
|
|
// No wallet open -> refresh is a safe no-op
|
|
LiteWalletController fresh(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
|
dragonx::WalletState empty;
|
|
EXPECT_FALSE(fresh.refreshWalletState(empty));
|
|
}
|
|
|
|
// syncstatus parser must accept both real backend shapes: idle {"syncing":"false"} and
|
|
// in-progress {"syncing":"true","synced_blocks":N,"total_blocks":M} ("syncing" is a string).
|
|
void testLiteSyncStatusParserRealShapes()
|
|
{
|
|
using namespace dragonx::wallet;
|
|
|
|
// Idle: no block counts, syncing is the string "false".
|
|
{
|
|
const auto p = parseLiteSyncStatusResponse(std::string("{\"syncing\":\"false\"}"));
|
|
EXPECT_TRUE(p.ok);
|
|
EXPECT_EQ(static_cast<long long>(p.syncStatus.syncedBlocks), 0LL);
|
|
EXPECT_TRUE(p.syncStatus.complete);
|
|
}
|
|
// In-progress: block counts present.
|
|
{
|
|
const auto p = parseLiteSyncStatusResponse(
|
|
std::string("{\"syncing\":\"true\",\"synced_blocks\":900,\"total_blocks\":1000}"));
|
|
EXPECT_TRUE(p.ok);
|
|
EXPECT_EQ(static_cast<long long>(p.syncStatus.syncedBlocks), 900LL);
|
|
EXPECT_EQ(static_cast<long long>(p.syncStatus.totalBlocks), 1000LL);
|
|
EXPECT_FALSE(p.syncStatus.complete);
|
|
EXPECT_NEAR(p.syncStatus.progress, 0.9, 1e-9);
|
|
}
|
|
// While syncing, missing block counts is still an error.
|
|
{
|
|
const auto p = parseLiteSyncStatusResponse(std::string("{\"syncing\":\"true\"}"));
|
|
EXPECT_FALSE(p.ok);
|
|
}
|
|
}
|
|
|
|
// Per-address balances: unspent notes/utxos are summed per address (spent ones excluded)
|
|
// into the WalletState address list, instead of aggregate-only zeros.
|
|
void testLitePerAddressBalances()
|
|
{
|
|
using namespace dragonx::wallet;
|
|
|
|
LiteWalletRefreshBundle bundle;
|
|
bundle.complete = true;
|
|
bundle.hasAddresses = true;
|
|
bundle.addresses.zAddresses = {"zs1aaa"};
|
|
bundle.addresses.tAddresses = {"t1bbb"};
|
|
bundle.hasNotes = true;
|
|
|
|
LiteSpendableOutput note; note.address = "zs1aaa"; note.value = 150000000; note.createdInTxid = "n1";
|
|
LiteSpendableOutput utxo; utxo.address = "t1bbb"; utxo.value = 50000000; utxo.createdInTxid = "u1";
|
|
LiteSpendableOutput spentNote; spentNote.address = "zs1aaa"; spentNote.value = 999; spentNote.spent = true; spentNote.createdInTxid = "n2";
|
|
bundle.notes.unspentNotes.push_back(note);
|
|
bundle.notes.unspentNotes.push_back(spentNote); // excluded: spent
|
|
bundle.notes.utxos.push_back(utxo);
|
|
|
|
const auto mapped = mapLiteWalletRefreshBundle(bundle);
|
|
EXPECT_TRUE(mapped.ok);
|
|
|
|
dragonx::WalletState state;
|
|
applyLiteRefreshModelToWalletState(mapped.model, state);
|
|
EXPECT_EQ(static_cast<int>(state.z_addresses.size()), 1);
|
|
EXPECT_EQ(static_cast<int>(state.t_addresses.size()), 1);
|
|
EXPECT_NEAR(state.z_addresses[0].balance, 1.5, 1e-9); // 150000000; the spent note is excluded
|
|
EXPECT_NEAR(state.t_addresses[0].balance, 0.5, 1e-9); // 50000000
|
|
}
|
|
|
|
// M3: new-address generation via the controller (backend "new" zs/R) returns the address.
|
|
void testLiteWalletControllerNewAddress()
|
|
{
|
|
using namespace dragonx::wallet;
|
|
const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, /*embeddedDaemon*/ false, /*liteBackendLinked*/ true);
|
|
const auto conn = defaultLiteConnectionSettings();
|
|
|
|
LiteWalletController controller(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
|
EXPECT_TRUE(controller.createWallet(LiteWalletCreateRequest{}).walletReady);
|
|
|
|
const auto z = controller.newAddress(/*shielded*/ true);
|
|
EXPECT_TRUE(z.ok);
|
|
EXPECT_TRUE(z.address.rfind("zs1", 0) == 0);
|
|
|
|
const auto t = controller.newAddress(/*shielded*/ false);
|
|
EXPECT_TRUE(t.ok);
|
|
EXPECT_TRUE(t.address.rfind("R1", 0) == 0);
|
|
|
|
// No wallet open -> error, no address.
|
|
LiteWalletController idle(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
|
const auto none = idle.newAddress(true);
|
|
EXPECT_FALSE(none.ok);
|
|
}
|
|
|
|
// Gateway hardening: one command's parse failure must not abort the whole refresh — the
|
|
// other commands still populate the bundle (graceful degradation against real-shape drift).
|
|
void testLiteWalletGatewayRefreshSkipsFailedCommand()
|
|
{
|
|
using namespace dragonx::wallet;
|
|
const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, /*embeddedDaemon*/ false, /*liteBackendLinked*/ true);
|
|
const auto conn = defaultLiteConnectionSettings();
|
|
|
|
auto bridge = LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi());
|
|
LiteWalletGateway gateway(caps, conn, &bridge, LiteWalletGatewayOptions{/*allowBridgeCalls*/ true});
|
|
|
|
dragonx::test::g_liteFakeBadBalance.store(true); // make only the balance command fail to parse
|
|
const auto result = gateway.refresh(LiteWalletRefreshRequest{});
|
|
dragonx::test::g_liteFakeBadBalance.store(false);
|
|
|
|
EXPECT_TRUE(result.ok); // partial success, not a total failure
|
|
EXPECT_FALSE(result.bundle.complete); // not all commands succeeded
|
|
EXPECT_FALSE(result.bundle.hasBalance); // the failed command is skipped
|
|
EXPECT_TRUE(result.bundle.hasAddresses); // the others still populate
|
|
EXPECT_TRUE(result.bundle.hasTransactions);
|
|
EXPECT_TRUE(result.bundle.hasInfo);
|
|
}
|
|
|
|
// M2b-3: opening a wallet auto-starts the background worker, which produces a refresh model
|
|
// the main thread can pick up via takeRefreshedModel() and apply to WalletState.
|
|
void testLiteWalletControllerWorkerProducesModel()
|
|
{
|
|
using namespace dragonx::wallet;
|
|
const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, /*embeddedDaemon*/ false, /*liteBackendLinked*/ true);
|
|
const auto conn = defaultLiteConnectionSettings();
|
|
|
|
LiteWalletController controller(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
|
EXPECT_TRUE(controller.createWallet(LiteWalletCreateRequest{}).walletReady); // auto-starts the worker
|
|
|
|
// The worker publishes progress-only models while syncing, then full models once synced.
|
|
// Poll until a full (balance-bearing) model arrives (sync is instant with the fake).
|
|
LiteWalletAppRefreshModel model;
|
|
bool gotFull = false;
|
|
for (int i = 0; i < 500 && !gotFull; ++i) {
|
|
LiteWalletAppRefreshModel m;
|
|
if (controller.takeRefreshedModel(m) && m.hasBalance) {
|
|
model = m;
|
|
gotFull = true;
|
|
break;
|
|
}
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
|
}
|
|
EXPECT_TRUE(gotFull);
|
|
EXPECT_TRUE(model.hasBalance);
|
|
EXPECT_TRUE(model.hasAddresses);
|
|
|
|
dragonx::WalletState state;
|
|
applyLiteRefreshModelToWalletState(model, state);
|
|
EXPECT_NEAR(state.privateBalance, 2.0, 1e-9);
|
|
|
|
// Idle controller (no wallet -> no worker) has nothing pending.
|
|
LiteWalletController idle(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
|
LiteWalletAppRefreshModel none;
|
|
EXPECT_FALSE(idle.takeRefreshedModel(none));
|
|
}
|
|
|
|
// M2b-3 hardening: the backend `sync` is a blocking, uninterruptible full scan. Destroying the
|
|
// controller while a sync is in flight must NOT hang (the sync thread is detached, not joined).
|
|
void testLiteWalletControllerShutdownDoesNotHangDuringSync()
|
|
{
|
|
using namespace dragonx::wallet;
|
|
const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, /*embeddedDaemon*/ false, /*liteBackendLinked*/ true);
|
|
const auto conn = defaultLiteConnectionSettings();
|
|
|
|
dragonx::test::g_liteFakeSyncBlock.store(true); // make the backend "sync" block indefinitely
|
|
const auto start = std::chrono::steady_clock::now();
|
|
{
|
|
LiteWalletController controller(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
|
controller.createWallet(LiteWalletCreateRequest{}); // launches the (now-blocked) sync thread
|
|
EXPECT_TRUE(controller.syncStarted());
|
|
EXPECT_FALSE(controller.syncComplete());
|
|
// controller destructs here with the sync thread still blocked -> must return promptly.
|
|
}
|
|
const auto elapsedMs = std::chrono::duration_cast<std::chrono::milliseconds>(
|
|
std::chrono::steady_clock::now() - start).count();
|
|
EXPECT_TRUE(elapsedMs < 1500); // did not wait for the (blocked) sync to finish
|
|
|
|
dragonx::test::g_liteFakeSyncBlock.store(false); // release the detached sync thread
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(50)); // let it unwind cleanly
|
|
}
|
|
|
|
// M5b: lite-wallet rollout / kill-switch policy (wallet/lite_rollout_policy.h).
|
|
void testLiteRolloutVersionCompare()
|
|
{
|
|
using namespace dragonx::wallet;
|
|
EXPECT_EQ(compareLiteVersions("1.2.0", "1.2.0"), 0);
|
|
EXPECT_EQ(compareLiteVersions("1.2.0", "1.2"), 0); // missing components treated as 0
|
|
EXPECT_EQ(compareLiteVersions("1.2.0-rc1", "1.2.0"), 0); // pre-release suffix ignored
|
|
EXPECT_EQ(compareLiteVersions("1.2.0", "1.3.0"), -1);
|
|
EXPECT_EQ(compareLiteVersions("2.0.0", "1.9.9"), 1);
|
|
EXPECT_EQ(compareLiteVersions("1.2.10", "1.2.9"), 1); // numeric, not lexical
|
|
}
|
|
|
|
void testLiteRolloutBucketAndOverrideHelpers()
|
|
{
|
|
using namespace dragonx::wallet;
|
|
const int a = liteRolloutBucketFromInstallId("install-abc");
|
|
EXPECT_EQ(a, liteRolloutBucketFromInstallId("install-abc")); // deterministic
|
|
EXPECT_TRUE(a >= 0 && a < 1000); // in range
|
|
EXPECT_EQ(liteRolloutBucketFromInstallId(""), 0); // empty -> 0
|
|
EXPECT_TRUE(liteRolloutBucketFromInstallId("install-abc")
|
|
!= liteRolloutBucketFromInstallId("install-xyz"));
|
|
|
|
EXPECT_EQ(std::string(liteRolloutOverrideToString(liteRolloutOverrideFromString("force_on"))),
|
|
std::string("force_on"));
|
|
EXPECT_EQ(std::string(liteRolloutOverrideToString(liteRolloutOverrideFromString("force_off"))),
|
|
std::string("force_off"));
|
|
EXPECT_EQ(std::string(liteRolloutOverrideToString(liteRolloutOverrideFromString("auto"))),
|
|
std::string("auto"));
|
|
EXPECT_EQ(std::string(liteRolloutOverrideToString(liteRolloutOverrideFromString("garbage"))),
|
|
std::string("auto")); // unknown -> auto
|
|
}
|
|
|
|
void testLiteRolloutPolicyOverridesAndKillSwitch()
|
|
{
|
|
using namespace dragonx::wallet;
|
|
LiteRolloutInputs in;
|
|
in.appVersion = "1.2.0";
|
|
|
|
// No manifest, auto -> allowed (fail-open).
|
|
EXPECT_TRUE(evaluateLiteRollout(in).allowed);
|
|
|
|
// force_off -> disabled by local override.
|
|
in.override = LiteRolloutOverride::ForceOff;
|
|
auto off = evaluateLiteRollout(in);
|
|
EXPECT_FALSE(off.allowed);
|
|
EXPECT_TRUE(off.status == LiteRolloutStatus::DisabledByLocalOverride);
|
|
|
|
// force_on -> allowed, even with a disabling manifest.
|
|
in.override = LiteRolloutOverride::ForceOn;
|
|
in.manifest.present = true; in.manifest.valid = true; in.manifest.globalEnabled = false;
|
|
EXPECT_TRUE(evaluateLiteRollout(in).allowed);
|
|
|
|
// env kill-switch is absolute: disables even with force_on.
|
|
in.killSwitchEnv = true;
|
|
auto killed = evaluateLiteRollout(in);
|
|
EXPECT_FALSE(killed.allowed);
|
|
EXPECT_TRUE(killed.status == LiteRolloutStatus::DisabledByKillSwitchEnv);
|
|
}
|
|
|
|
void testLiteRolloutPolicyManifestGates()
|
|
{
|
|
using namespace dragonx::wallet;
|
|
LiteRolloutInputs in;
|
|
in.appVersion = "1.2.0";
|
|
in.manifest.present = true;
|
|
in.manifest.valid = true;
|
|
|
|
// Global disable carries the manifest message through to the decision.
|
|
in.manifest.globalEnabled = false;
|
|
in.manifest.message = "paused for maintenance";
|
|
{
|
|
auto d = evaluateLiteRollout(in);
|
|
EXPECT_FALSE(d.allowed);
|
|
EXPECT_TRUE(d.status == LiteRolloutStatus::DisabledByManifest);
|
|
EXPECT_EQ(d.message, std::string("paused for maintenance"));
|
|
}
|
|
in.manifest.globalEnabled = true;
|
|
in.manifest.message.clear();
|
|
|
|
// Version floor / ceiling / blocklist.
|
|
in.manifest.minVersion = "1.3.0";
|
|
EXPECT_TRUE(evaluateLiteRollout(in).status == LiteRolloutStatus::DisabledUnsupportedVersion);
|
|
in.manifest.minVersion.clear();
|
|
in.manifest.maxVersion = "1.1.0";
|
|
EXPECT_TRUE(evaluateLiteRollout(in).status == LiteRolloutStatus::DisabledUnsupportedVersion);
|
|
in.manifest.maxVersion.clear();
|
|
in.manifest.blockedVersions = {"1.2.0"};
|
|
EXPECT_TRUE(evaluateLiteRollout(in).status == LiteRolloutStatus::DisabledBlockedVersion);
|
|
in.manifest.blockedVersions.clear();
|
|
|
|
// Within range and not blocked -> allowed.
|
|
in.manifest.minVersion = "1.0.0";
|
|
in.manifest.maxVersion = "2.0.0";
|
|
EXPECT_TRUE(evaluateLiteRollout(in).allowed);
|
|
}
|
|
|
|
void testLiteRolloutPolicyStagedRollout()
|
|
{
|
|
using namespace dragonx::wallet;
|
|
LiteRolloutInputs in;
|
|
in.appVersion = "1.2.0";
|
|
in.manifest.present = true;
|
|
in.manifest.valid = true;
|
|
in.manifest.globalEnabled = true;
|
|
|
|
in.manifest.rolloutPermille = 0; // nobody (bucket 0 >= 0)
|
|
in.installBucket = 0;
|
|
EXPECT_FALSE(evaluateLiteRollout(in).allowed);
|
|
|
|
in.manifest.rolloutPermille = 500; // bucket < permille is in
|
|
in.installBucket = 499;
|
|
EXPECT_TRUE(evaluateLiteRollout(in).allowed);
|
|
in.installBucket = 500;
|
|
auto out = evaluateLiteRollout(in);
|
|
EXPECT_FALSE(out.allowed);
|
|
EXPECT_TRUE(out.status == LiteRolloutStatus::DisabledByStagedRollout);
|
|
|
|
in.manifest.rolloutPermille = 1000; // everyone (bucket 999 < 1000)
|
|
in.installBucket = 999;
|
|
EXPECT_TRUE(evaluateLiteRollout(in).allowed);
|
|
}
|
|
|
|
void testLiteRolloutManifestLoader()
|
|
{
|
|
using namespace dragonx::wallet;
|
|
// Missing file -> not present -> fail-open allowed.
|
|
auto missing = loadLiteRolloutManifestFromFile("/tmp/obsidian-rollout-missing-xyz.json");
|
|
EXPECT_FALSE(missing.present);
|
|
{
|
|
LiteRolloutInputs in; in.appVersion = "1.2.0"; in.manifest = missing;
|
|
EXPECT_TRUE(evaluateLiteRollout(in).allowed);
|
|
}
|
|
|
|
// A valid manifest file parses into the expected fields.
|
|
const std::string path = "/tmp/obsidian-rollout-test.json";
|
|
{
|
|
std::ofstream f(path);
|
|
f << R"({"global_enabled":false,"min_version":"1.0.0","rollout_permille":250,"message":"hold"})";
|
|
}
|
|
auto m = loadLiteRolloutManifestFromFile(path);
|
|
EXPECT_TRUE(m.present);
|
|
EXPECT_TRUE(m.valid);
|
|
EXPECT_FALSE(m.globalEnabled);
|
|
EXPECT_EQ(m.minVersion, std::string("1.0.0"));
|
|
EXPECT_EQ(m.rolloutPermille, 250);
|
|
EXPECT_EQ(m.message, std::string("hold"));
|
|
std::remove(path.c_str());
|
|
|
|
// Malformed JSON -> present but invalid -> fail-open allowed.
|
|
const std::string badPath = "/tmp/obsidian-rollout-bad.json";
|
|
{ std::ofstream f(badPath); f << "{ this is not json"; }
|
|
auto bad = loadLiteRolloutManifestFromFile(badPath);
|
|
EXPECT_TRUE(bad.present);
|
|
EXPECT_FALSE(bad.valid);
|
|
{
|
|
LiteRolloutInputs in; in.appVersion = "1.2.0"; in.manifest = bad;
|
|
EXPECT_TRUE(evaluateLiteRollout(in).allowed);
|
|
}
|
|
std::remove(badPath.c_str());
|
|
}
|
|
|
|
// M5b: the rollout gate threads through the controller -> lifecycle availability() and blocks
|
|
// lifecycle execution with the gate's user-facing message.
|
|
void testLiteWalletControllerRolloutGate()
|
|
{
|
|
using namespace dragonx::wallet;
|
|
const auto liteCaps = makeWalletCapabilities(WalletBuildKind::Lite, /*embeddedDaemon*/ false, /*liteBackendLinked*/ true);
|
|
const auto conn = defaultLiteConnectionSettings();
|
|
|
|
// Gated OFF: availability() reports RolloutDisabled, create is blocked, wallet stays closed.
|
|
{
|
|
LiteWalletController controller(
|
|
liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()),
|
|
LiteWalletControllerOptions{/*allowBridgeCalls*/ true, /*rolloutBlocked*/ true,
|
|
"lite paused for maintenance"});
|
|
EXPECT_TRUE(controller.availability() == LiteWalletLifecycleAvailability::RolloutDisabled);
|
|
const auto result = controller.createWallet(LiteWalletCreateRequest{});
|
|
EXPECT_FALSE(result.walletReady);
|
|
EXPECT_TRUE(result.status.message.find("maintenance") != std::string::npos);
|
|
EXPECT_FALSE(controller.walletOpen());
|
|
}
|
|
|
|
// Not gated: availability() is Ready (matches the other lifecycle tests).
|
|
{
|
|
LiteWalletController controller(
|
|
liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()),
|
|
LiteWalletControllerOptions{/*allowBridgeCalls*/ true, /*rolloutBlocked*/ false, ""});
|
|
EXPECT_TRUE(controller.availability() == LiteWalletLifecycleAvailability::Ready);
|
|
}
|
|
}
|
|
|
|
// ── xmrig updater pure core (util/xmrig_updater.h) — driven by the real release fixture ──
|
|
static std::string readXmrigFixture()
|
|
{
|
|
const std::string path = std::string(DRAGONX_TEST_FIXTURE_DIR) + "/xmrig/release_latest.json";
|
|
std::ifstream f(path, std::ios::binary);
|
|
return std::string((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>());
|
|
}
|
|
|
|
void testXmrigReleaseParsing()
|
|
{
|
|
using namespace dragonx::util;
|
|
const auto rel = parseXmrigRelease(readXmrigFixture());
|
|
EXPECT_TRUE(rel.ok);
|
|
EXPECT_EQ(rel.tag, std::string("v1.0.0"));
|
|
EXPECT_EQ(rel.assets.size(), static_cast<std::size_t>(2));
|
|
// Both assets carry a name + a usable download URL.
|
|
for (const auto& a : rel.assets) {
|
|
EXPECT_TRUE(!a.name.empty());
|
|
EXPECT_TRUE(a.downloadUrl.rfind("https://", 0) == 0);
|
|
EXPECT_TRUE(a.size > 0);
|
|
}
|
|
// Garbage input fails closed.
|
|
EXPECT_FALSE(parseXmrigRelease("not json at all").ok);
|
|
EXPECT_FALSE(parseXmrigRelease("{}").ok); // no tag_name
|
|
}
|
|
|
|
void testXmrigAssetSelection()
|
|
{
|
|
using namespace dragonx::util;
|
|
const auto rel = parseXmrigRelease(readXmrigFixture());
|
|
const int linux = selectXmrigAsset(rel, "linux-x64");
|
|
const int win = selectXmrigAsset(rel, "win-x64");
|
|
EXPECT_TRUE(linux >= 0);
|
|
EXPECT_TRUE(win >= 0);
|
|
EXPECT_TRUE(linux != win);
|
|
EXPECT_TRUE(rel.assets[linux].name.find("linux-x64.zip") != std::string::npos);
|
|
EXPECT_TRUE(rel.assets[win].name.find("win-x64.zip") != std::string::npos);
|
|
// No macOS build in this fixture -> graceful "not found".
|
|
EXPECT_EQ(selectXmrigAsset(rel, "macos-x86_64"), -1);
|
|
EXPECT_EQ(selectXmrigAsset(rel, "macos-arm64"), -1);
|
|
EXPECT_EQ(selectXmrigAsset(rel, ""), -1);
|
|
|
|
// The matcher handles the macOS asset naming ("macos-x86_64", not "macos-x64").
|
|
XmrigRelease mac; mac.ok = true; mac.tag = "v6.25.3";
|
|
mac.assets.push_back({"drg-xmrig-6.25.3-macos-x86_64.zip", "https://x/m", 100});
|
|
EXPECT_TRUE(selectXmrigAsset(mac, "macos-x86_64") >= 0);
|
|
EXPECT_EQ(selectXmrigAsset(mac, "macos-x64"), -1); // the wrong token must NOT match
|
|
}
|
|
|
|
void testXmrigChecksumParsing()
|
|
{
|
|
using namespace dragonx::util;
|
|
const auto rel = parseXmrigRelease(readXmrigFixture());
|
|
const auto sums = parseXmrigChecksums(rel.body);
|
|
// Inner-binary checksums (what we verify before making the binary executable).
|
|
EXPECT_EQ(sums.at("xmrig"),
|
|
std::string("37c178f743c269c1d9e18302cead0ed117ded2b5fe30910e836896b4abc20e57"));
|
|
EXPECT_EQ(sums.at("xmrig.exe"),
|
|
std::string("01223711eddea347eee394c4b6d265b9a3e5c13fe93204bc041c4399c9c758f8"));
|
|
// Archive checksum, keyed by the asset filename.
|
|
EXPECT_EQ(sums.at("drg-xmrig-6.25.1-drg1-linux-x64.zip"),
|
|
std::string("c121a078ee46943584aa6148cac26583409c832d28668cf38ba908e10214c9a6"));
|
|
// Non-checksum prose lines are ignored.
|
|
EXPECT_TRUE(parseXmrigChecksums("just some text\nno hashes here").empty());
|
|
}
|
|
|
|
void testXmrigSha256AndBasenames()
|
|
{
|
|
using namespace dragonx::util;
|
|
// Known-answer: SHA-256("abc").
|
|
const std::string abc = "abc";
|
|
EXPECT_EQ(sha256Hex(abc.data(), abc.size()),
|
|
std::string("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"));
|
|
// The selected asset's published archive checksum matches what the verifier will compute over
|
|
// the bytes (closes the loop between parse + hash). Empty input is the SHA-256 of "".
|
|
EXPECT_EQ(sha256Hex("", 0),
|
|
std::string("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"));
|
|
|
|
const auto linuxNames = xmrigExtractBasenames("linux-x64");
|
|
EXPECT_EQ(linuxNames.size(), static_cast<std::size_t>(1));
|
|
EXPECT_EQ(linuxNames.front(), std::string("xmrig"));
|
|
const auto winNames = xmrigExtractBasenames("win-x64");
|
|
EXPECT_EQ(winNames.size(), static_cast<std::size_t>(2));
|
|
EXPECT_EQ(winNames.front(), std::string("xmrig.exe")); // miner binary is always first
|
|
EXPECT_TRUE(std::find(winNames.begin(), winNames.end(), std::string("WinRing0x64.sys"))
|
|
!= winNames.end());
|
|
}
|
|
|
|
void testXmrigSignatureAssetSelection()
|
|
{
|
|
using namespace dragonx::util;
|
|
XmrigRelease rel; rel.ok = true; rel.tag = "v1";
|
|
rel.assets.push_back({"drg-xmrig-linux-x64.zip", "https://x/zip", 100});
|
|
EXPECT_EQ(selectXmrigSignatureAsset(rel, "drg-xmrig-linux-x64.zip"), -1); // none published
|
|
rel.assets.push_back({"drg-xmrig-linux-x64.zip.sig", "https://x/sig", 64});
|
|
EXPECT_TRUE(selectXmrigSignatureAsset(rel, "drg-xmrig-linux-x64.zip") >= 0);
|
|
EXPECT_EQ(selectXmrigSignatureAsset(rel, "other.zip"), -1);
|
|
}
|
|
|
|
void testXmrigPinnedKeyValidity()
|
|
{
|
|
using namespace dragonx::util;
|
|
const std::string pin = kXmrigSignaturePublicKeyBase64;
|
|
if (pin.empty()) return; // signing disabled -> nothing to validate
|
|
// A non-empty pinned key MUST decode to exactly a 32-byte ed25519 public key, or signature
|
|
// verification would silently never succeed. Guards against a malformed/truncated paste.
|
|
EXPECT_TRUE(sodium_init() >= 0);
|
|
unsigned char pk[crypto_sign_PUBLICKEYBYTES];
|
|
std::size_t n = 0; const char* end = nullptr;
|
|
const int rc = sodium_base642bin(pk, sizeof(pk), pin.data(), pin.size(), " \t\r\n",
|
|
&n, &end, sodium_base64_VARIANT_ORIGINAL);
|
|
EXPECT_EQ(rc, 0);
|
|
EXPECT_EQ(n, static_cast<std::size_t>(crypto_sign_PUBLICKEYBYTES));
|
|
}
|
|
|
|
// Lite Network tab pure helpers.
|
|
void testLiteServerHostParsing()
|
|
{
|
|
using dragonx::util::liteServerHost;
|
|
EXPECT_EQ(liteServerHost("https://lite.dragonx.is"), std::string("lite.dragonx.is"));
|
|
EXPECT_EQ(liteServerHost("https://lite.dragonx.is:443/path"), std::string("lite.dragonx.is:443"));
|
|
EXPECT_EQ(liteServerHost("http://1.2.3.4:9067"), std::string("1.2.3.4:9067"));
|
|
EXPECT_EQ(liteServerHost("https://user@host.example/x?y"), std::string("host.example"));
|
|
EXPECT_EQ(liteServerHost("lite.dragonx.is"), std::string("lite.dragonx.is")); // no scheme
|
|
}
|
|
|
|
void testLiteOfficialServerDetection()
|
|
{
|
|
using dragonx::wallet::isOfficialLiteServer;
|
|
EXPECT_TRUE(isOfficialLiteServer("https://lite.dragonx.is"));
|
|
EXPECT_TRUE(isOfficialLiteServer("https://lite3.dragonx.is"));
|
|
EXPECT_TRUE(isOfficialLiteServer(" https://lite.dragonx.is ")); // trimmed
|
|
EXPECT_FALSE(isOfficialLiteServer("https://my-custom-server.example"));
|
|
EXPECT_FALSE(isOfficialLiteServer(""));
|
|
}
|
|
|
|
// H7: atomic + durable file writes (settings.json / addressbook.json persistence).
|
|
void testAtomicFileWrite()
|
|
{
|
|
namespace fs = std::filesystem;
|
|
using dragonx::util::Platform;
|
|
|
|
fs::path dir = fs::temp_directory_path() / "obsidian_atomic_test";
|
|
fs::remove_all(dir);
|
|
const std::string target = (dir / "nested" / "settings.json").string();
|
|
|
|
// Creates parent dirs and writes content.
|
|
EXPECT_TRUE(Platform::writeFileAtomically(target, "{\"a\":1}"));
|
|
EXPECT_TRUE(fs::exists(target));
|
|
{
|
|
std::ifstream in(target);
|
|
std::string body((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
|
|
EXPECT_EQ(body, std::string("{\"a\":1}"));
|
|
}
|
|
|
|
// Overwrites in place (atomic replace), and leaves no stray .tmp behind.
|
|
EXPECT_TRUE(Platform::writeFileAtomically(target, "{\"a\":2,\"b\":3}"));
|
|
{
|
|
std::ifstream in(target);
|
|
std::string body((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
|
|
EXPECT_EQ(body, std::string("{\"a\":2,\"b\":3}"));
|
|
}
|
|
EXPECT_FALSE(fs::exists(target + ".tmp"));
|
|
|
|
#ifndef _WIN32
|
|
// restrictPermissions -> owner-only (0600).
|
|
const std::string secret = (dir / "vault.bin").string();
|
|
EXPECT_TRUE(Platform::writeFileAtomically(secret, "shh", /*restrictPermissions=*/true));
|
|
auto perms = fs::status(secret).permissions();
|
|
EXPECT_TRUE((perms & (fs::perms::group_all | fs::perms::others_all)) == fs::perms::none);
|
|
#endif
|
|
|
|
fs::remove_all(dir);
|
|
}
|
|
|
|
// Address checksum validation. Uses standard Base58Check / BIP173 Bech32 vectors — the
|
|
// algorithms are chain-agnostic, so passing these means real DRGX addresses validate too.
|
|
void testAddressChecksumValidation()
|
|
{
|
|
using dragonx::util::isValidBase58Check;
|
|
using dragonx::util::isValidBech32;
|
|
|
|
// Base58Check: a valid P2PKH address verifies; a one-char transcription error fails.
|
|
EXPECT_TRUE(isValidBase58Check("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"));
|
|
EXPECT_FALSE(isValidBase58Check("1A1zP1eP5QGefi2DMPTfTL5SLmv7Divfna")); // last char flipped
|
|
EXPECT_FALSE(isValidBase58Check("not-base58-0OIl")); // invalid alphabet
|
|
EXPECT_FALSE(isValidBase58Check(""));
|
|
|
|
// Bech32 (BIP173 valid vectors) verify; corrupted checksums fail.
|
|
EXPECT_TRUE(isValidBech32("A12UEL5L"));
|
|
EXPECT_TRUE(isValidBech32("abcdef1qpzry9x8gf2tvdw0s3jn54khce6mua7lmqqqxw"));
|
|
EXPECT_TRUE(isValidBech32("split1checkupstagehandshakeupstreamerranterredcaperred2y9e3w"));
|
|
EXPECT_FALSE(isValidBech32("A12UEL5M")); // checksum corrupted
|
|
EXPECT_FALSE(isValidBech32("abc1rzg")); // too short / bad checksum
|
|
EXPECT_FALSE(isValidBech32("Abcdef1qpzry9x8gf2tvdw0s3jn54khce6mua7lmqqqxw")); // mixed case
|
|
EXPECT_FALSE(isValidBech32("nosalt")); // no separator
|
|
}
|
|
|
|
// Live probe of a real lite server (env-gated). Validates CONNECT_ONLY latency + IP capture.
|
|
void testLiteServerProbeLive()
|
|
{
|
|
if (!std::getenv("DRAGONX_TEST_NETWORK")) {
|
|
std::printf("[skip] testLiteServerProbeLive (set DRAGONX_TEST_NETWORK=1 to run)\n");
|
|
return;
|
|
}
|
|
using namespace dragonx::util;
|
|
LiteServerProbe probe;
|
|
probe.start({"https://lite.dragonx.is"});
|
|
for (int i = 0; i < 150 && probe.busy(); ++i)
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
|
const auto res = probe.results();
|
|
const auto it = res.find("https://lite.dragonx.is");
|
|
EXPECT_TRUE(it != res.end());
|
|
if (it != res.end()) {
|
|
EXPECT_TRUE(it->second.probed);
|
|
if (it->second.online) { // reachable -> latency + IP captured
|
|
EXPECT_TRUE(!it->second.ip.empty());
|
|
std::printf("[live] lite.dragonx.is online %dms ip=%s\n",
|
|
it->second.latencyMs, it->second.ip.c_str());
|
|
}
|
|
}
|
|
}
|
|
|
|
void testXmrigSignatureVerify()
|
|
{
|
|
using namespace dragonx::util;
|
|
EXPECT_TRUE(sodium_init() >= 0);
|
|
unsigned char pk[crypto_sign_PUBLICKEYBYTES], sk[crypto_sign_SECRETKEYBYTES];
|
|
crypto_sign_keypair(pk, sk);
|
|
const std::string data = "drg-xmrig archive payload bytes for signing test";
|
|
unsigned char sig[crypto_sign_BYTES];
|
|
crypto_sign_detached(sig, nullptr,
|
|
reinterpret_cast<const unsigned char*>(data.data()), data.size(), sk);
|
|
auto b64 = [](const unsigned char* b, std::size_t n) {
|
|
std::vector<char> buf(sodium_base64_encoded_len(n, sodium_base64_VARIANT_ORIGINAL));
|
|
sodium_bin2base64(buf.data(), buf.size(), b, n, sodium_base64_VARIANT_ORIGINAL);
|
|
return std::string(buf.data());
|
|
};
|
|
const std::string pkB64 = b64(pk, sizeof(pk));
|
|
const std::string sigB64 = b64(sig, sizeof(sig));
|
|
|
|
// Valid base64 signature verifies; a raw 64-byte signature is also accepted.
|
|
EXPECT_TRUE(verifyXmrigSignature(data, sigB64, pkB64));
|
|
EXPECT_TRUE(verifyXmrigSignature(data, std::string(reinterpret_cast<const char*>(sig),
|
|
crypto_sign_BYTES), pkB64));
|
|
// Whitespace around the base64 signature is tolerated.
|
|
EXPECT_TRUE(verifyXmrigSignature(data, " " + sigB64 + "\n", pkB64));
|
|
|
|
// Regression: a RAW 64-byte signature whose final byte equals a whitespace value (~1.6% of
|
|
// signatures) must still verify — it must not be whitespace-trimmed. Force that case.
|
|
{
|
|
unsigned char rpk[crypto_sign_PUBLICKEYBYTES], rsk[crypto_sign_SECRETKEYBYTES],
|
|
rsig[crypto_sign_BYTES];
|
|
bool found = false;
|
|
for (int i = 0; i < 20000 && !found; ++i) {
|
|
crypto_sign_keypair(rpk, rsk);
|
|
const std::string m = "raw-ws-regression-" + std::to_string(i);
|
|
crypto_sign_detached(rsig, nullptr,
|
|
reinterpret_cast<const unsigned char*>(m.data()), m.size(), rsk);
|
|
const unsigned char last = rsig[crypto_sign_BYTES - 1];
|
|
if (last == '\n' || last == '\r' || last == ' ' || last == '\t') {
|
|
found = true;
|
|
const std::string rawSig(reinterpret_cast<const char*>(rsig), crypto_sign_BYTES);
|
|
EXPECT_TRUE(verifyXmrigSignature(m, rawSig, b64(rpk, sizeof(rpk))));
|
|
}
|
|
}
|
|
EXPECT_TRUE(found);
|
|
}
|
|
|
|
// Fails closed: tampered data, wrong key, malformed/empty inputs.
|
|
EXPECT_FALSE(verifyXmrigSignature(data + "x", sigB64, pkB64));
|
|
unsigned char pk2[crypto_sign_PUBLICKEYBYTES], sk2[crypto_sign_SECRETKEYBYTES];
|
|
crypto_sign_keypair(pk2, sk2);
|
|
EXPECT_FALSE(verifyXmrigSignature(data, sigB64, b64(pk2, sizeof(pk2))));
|
|
EXPECT_FALSE(verifyXmrigSignature(data, "not valid base64 !!!", pkB64));
|
|
EXPECT_FALSE(verifyXmrigSignature(data, sigB64, ""));
|
|
EXPECT_FALSE(verifyXmrigSignature(data, "", pkB64));
|
|
}
|
|
|
|
// Live end-to-end exercise of the XmrigUpdater WORKER (real network + curl + miniz). Env-gated so
|
|
// CI / offline runs skip it; run with DRAGONX_TEST_NETWORK=1 to hit git.dragonx.is. Verifies the
|
|
// full download -> archive-checksum -> extract/flatten -> inner-binary-checksum -> install path.
|
|
void testXmrigLiveInstall()
|
|
{
|
|
if (!std::getenv("DRAGONX_TEST_NETWORK")) {
|
|
std::printf("[skip] testXmrigLiveInstall (set DRAGONX_TEST_NETWORK=1 to run)\n");
|
|
return;
|
|
}
|
|
using namespace dragonx::util;
|
|
const std::string dir = "/tmp/obsidian-xmrig-live-test";
|
|
std::error_code ec; std::filesystem::remove_all(dir, ec);
|
|
|
|
XmrigUpdater up;
|
|
up.startInstall(dir);
|
|
for (int i = 0; i < 1200 && !up.isDone(); ++i) // up to ~120s for a ~4 MiB download
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
|
|
|
const auto p = up.getProgress();
|
|
EXPECT_TRUE(up.isDone());
|
|
EXPECT_TRUE(p.state == XmrigUpdater::State::Done);
|
|
if (p.state != XmrigUpdater::State::Done) {
|
|
std::printf("[testXmrigLiveInstall] failed: %s\n", p.error.c_str());
|
|
return;
|
|
}
|
|
// The miner binary was flattened out of the versioned subdir into the target dir. We don't pin
|
|
// a release-specific hash here (releases change) — reaching State::Done already means the worker
|
|
// verified the binary against the release's own published SHA-256. Just assert a real binary landed.
|
|
const std::string bin = dir + "/" + xmrigExtractBasenames(currentXmrigPlatformToken()).front();
|
|
EXPECT_TRUE(std::filesystem::exists(bin));
|
|
if (std::filesystem::exists(bin)) {
|
|
std::error_code szEc;
|
|
EXPECT_TRUE(std::filesystem::file_size(bin, szEc) > 100000); // a real miner binary is MBs
|
|
}
|
|
std::filesystem::remove_all(dir, ec);
|
|
}
|
|
|
|
} // namespace
|
|
|
|
int main()
|
|
{
|
|
testConnectionConfig();
|
|
testPaymentUri();
|
|
testAmountFormatting();
|
|
testSpendableFiltering();
|
|
testRefreshScheduler();
|
|
testNetworkRefreshService();
|
|
testNetworkRefreshSnapshotHelpers();
|
|
testNetworkRefreshRpcCollectors();
|
|
testNetworkRefreshResultModels();
|
|
testOperationStatusPollParsing();
|
|
testWalletSecurityController();
|
|
testWalletSecurityWorkflow();
|
|
testWalletSecurityWorkflowExecutor();
|
|
testDaemonShutdownPolicy();
|
|
testDaemonLifecycleExecution();
|
|
testDaemonLifecycleAdapters();
|
|
testRendererHelpers();
|
|
testConsoleInputModel();
|
|
testMiningBenchmarkModel();
|
|
testBalanceAddressListModel();
|
|
testExplorerBlockCache();
|
|
testTransactionHistoryCache();
|
|
testTransactionHistoryCachePrunesOldWallets();
|
|
testTransactionHistoryCacheRefreshApply();
|
|
testLiteBridgeOwnedStringCopiesBeforeFreeOnSuccess();
|
|
testLiteBridgeOwnedStringClassifiesNullWithoutFree();
|
|
testLiteBridgeOwnedStringClassifiesErrorAndFreesOnce();
|
|
testLiteBridgeOwnedStringMovePreventsDoubleFree();
|
|
testLiteClientBridgeUsesRuntimeOwnedStringCleanup();
|
|
testLiteBackendInjectableFakeBridge();
|
|
testLiteWalletControllerLifecycle();
|
|
testLiteWalletControllerOpenFailover();
|
|
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;
|
|
}
|