feat(mining): pool registry, schema-aware stats fetcher, pool-select setting

Data + service layer for supporting multiple mining pools:
- pool_registry: KnownPool table (pool.dragonx.is + pool.dragonx.cc) carrying
  each pool's stratum host, xmrig algo, and stats-API schema; pure helpers
  findKnownPoolByUrl / resolvePoolAlgo / parsePoolHashrate / chooseWeightedPool
  (weighted-random by hashrate with incumbent stickiness).
- pool_stats_service: background, schema-aware hashrate fetcher (libcurl, TLS,
  browser UA for Cloudflare) modelled on XmrigUpdater.
- settings: pool_select_mode (manual | auto_balance), string-serialized.
- i18n strings + unit tests (both stats schemas, algo resolution, weighted pick).

Note: pool.dragonx.cc's stratum host is us.dragonx.cc:3333 — the .cc domain is
only the Cloudflare-proxied web/API host and does not accept stratum on :3333.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-01 14:35:13 -05:00
parent 24e8fb4942
commit d0586e5e75
9 changed files with 601 additions and 0 deletions

View File

@@ -500,6 +500,8 @@ set(APP_SOURCES
src/daemon/xmrig_manager.cpp
src/util/bootstrap.cpp
src/util/lite_server_probe.cpp
src/util/pool_registry_core.cpp
src/util/pool_stats_service.cpp
src/util/xmrig_updater.cpp
src/util/xmrig_updater_core.cpp
src/util/daemon_updater.cpp
@@ -1035,6 +1037,7 @@ if(BUILD_TESTING)
src/util/platform.cpp
src/util/logger.cpp
src/util/lite_server_probe.cpp
src/util/pool_registry_core.cpp
src/util/xmrig_updater.cpp
src/util/xmrig_updater_core.cpp
src/util/daemon_updater.cpp

View File

@@ -57,6 +57,25 @@ const char* liteServerSelectionPreferenceModeName(
return "sticky";
}
Settings::PoolSelectMode parsePoolSelectMode(const json& value)
{
if (!value.is_string()) return Settings::PoolSelectMode::Manual;
const std::string mode = value.get<std::string>();
if (mode == "auto_balance" || mode == "auto") {
return Settings::PoolSelectMode::AutoBalance;
}
return Settings::PoolSelectMode::Manual;
}
const char* poolSelectModeName(Settings::PoolSelectMode mode)
{
switch (mode) {
case Settings::PoolSelectMode::Manual: return "manual";
case Settings::PoolSelectMode::AutoBalance: return "auto_balance";
}
return "manual";
}
} // namespace
std::string Settings::getDefaultPath()
@@ -257,6 +276,7 @@ bool Settings::load(const std::string& path)
if (j.contains("pool_tls")) pool_tls_ = j["pool_tls"].get<bool>();
if (j.contains("pool_hugepages")) pool_hugepages_ = j["pool_hugepages"].get<bool>();
if (j.contains("pool_mode")) pool_mode_ = j["pool_mode"].get<bool>();
if (j.contains("pool_select_mode")) pool_select_mode_ = parsePoolSelectMode(j["pool_select_mode"]);
if (j.contains("mine_when_idle")) mine_when_idle_ = j["mine_when_idle"].get<bool>();
if (j.contains("xmrig_version")) xmrig_version_ = j["xmrig_version"].get<std::string>();
if (j.contains("mine_idle_delay")) mine_idle_delay_= std::max(30, j["mine_idle_delay"].get<int>());
@@ -403,6 +423,7 @@ bool Settings::save(const std::string& path)
j["pool_tls"] = pool_tls_;
j["pool_hugepages"] = pool_hugepages_;
j["pool_mode"] = pool_mode_;
j["pool_select_mode"] = poolSelectModeName(pool_select_mode_);
j["mine_when_idle"] = mine_when_idle_;
j["xmrig_version"] = xmrig_version_;
j["mine_idle_delay"]= mine_idle_delay_;

View File

@@ -60,6 +60,13 @@ public:
Random
};
// Pool selection mode for the mining tab: Manual (user picks the pool) or
// AutoBalance (the wallet spreads miners across the official pools by hashrate).
enum class PoolSelectMode {
Manual,
AutoBalance
};
struct LiteServerPreference {
std::string url;
std::string label;
@@ -305,6 +312,8 @@ public:
void setPoolHugepages(bool v) { pool_hugepages_ = v; }
bool getPoolMode() const { return pool_mode_; }
void setPoolMode(bool v) { pool_mode_ = v; }
PoolSelectMode getPoolSelectMode() const { return pool_select_mode_; }
void setPoolSelectMode(PoolSelectMode v) { pool_select_mode_ = v; }
// Installed DRG-XMRig release tag (for in-app miner update detection); empty if unknown/bundled.
std::string getXmrigVersion() const { return xmrig_version_; }
@@ -439,6 +448,7 @@ private:
bool pool_tls_ = false;
bool pool_hugepages_ = true;
bool pool_mode_ = false; // false=solo, true=pool
PoolSelectMode pool_select_mode_ = PoolSelectMode::Manual; // manual vs auto-balance pool choice
std::string xmrig_version_; // installed DRG-XMRig release tag (update detection)
bool mine_when_idle_ = false; // auto-start mining when system idle
int mine_idle_delay_= 120; // seconds of idle before mining starts

View File

@@ -1110,6 +1110,17 @@ void I18n::loadBuiltinEnglish()
strings_["mining_pool"] = "Pool";
strings_["mining_pool_hashrate"] = "Pool Hashrate";
strings_["mining_pool_url"] = "Pool URL";
// Pool selection mode + auto-balance (mining tab).
strings_["mining_select_manual"] = "Manual";
strings_["mining_select_auto"] = "Auto-balance";
strings_["mining_manual_desc"] = "Manual: mine to the pool you pick from the list below.";
strings_["mining_auto_balance_desc"] = "Spreads miners across the official pools by hashrate to help decentralize the network. Checks each pool periodically.";
strings_["mining_suggested_pools"] = "Suggested pools";
strings_["mining_pools_header"] = "POOLS";
strings_["mining_pool_fee"] = "Fee";
strings_["mining_mining_here"] = "mining here";
strings_["mining_refresh"] = "Refresh";
strings_["mining_auto_balanced_to"] = "Auto-balanced mining to";
strings_["mining_recent_blocks"] = "RECENT BLOCKS";
strings_["mining_recent_payouts"] = "RECENT POOL PAYOUTS";
strings_["mining_remove"] = "Remove";

81
src/util/pool_registry.h Normal file
View File

@@ -0,0 +1,81 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// PoolRegistry — the small table of official DragonX mining pools plus the pure
// (no-I/O) helpers used to compare their hashrate and pick one for auto-balance.
//
// A pool is more than a URL: the two official pools speak DIFFERENT stats APIs
// (pool.dragonx.is = a custom /api/stats; pool.dragonx.cc = Miningcore /api/pools)
// and mine with DIFFERENT algo strings (rx/hush vs rx/dragonx), so each entry
// carries how to read its hashrate and which algo xmrig must be told to use.
//
// Everything here is pure and unit-tested (see tests/test_phase4.cpp); the live
// HTTP fetch lives in pool_stats_service.{h,cpp}.
#pragma once
#include <random>
#include <string>
#include <vector>
namespace dragonx {
namespace util {
// How to read a pool's total hashrate from its public stats JSON.
enum class PoolStatsSchema {
DragonXIs, // GET /api/stats -> pools.<name>.hashrate (custom)
Miningcore, // GET /api/pools -> pools[id==poolId].poolStats.poolHashrate (Miningcore)
};
struct KnownPool {
std::string id; // stable internal id, e.g. "dragonx-cc-pplns"
std::string label; // human label for the UI (host, sans port)
std::string stratum; // host:port the miner connects to
std::string algo; // xmrig algo string for THIS pool
std::string statsUrl; // absolute https URL of the stats endpoint
PoolStatsSchema schema = PoolStatsSchema::DragonXIs;
std::string miningcorePoolId; // Miningcore pool id (empty for DragonXIs)
double feePercent = 0.0; // pool fee, for display only
bool official = true;
};
// A pool's live total hashrate sample (H/s). ok=false when the fetch/parse failed.
struct PoolHashrate {
std::string id;
double hashrateHs = 0.0;
bool ok = false;
};
// The built-in official pools (PPLNS only — never a SOLO pool, whose hashrate is
// meaningless to balance against). Stable order.
const std::vector<KnownPool>& knownPools();
// The known pool whose stratum matches `url` (host, and port when both specify one),
// or nullptr. `url` may be a bare host, host:port, or carry a scheme/userinfo/path.
const KnownPool* findKnownPoolByUrl(const std::string& url);
// The algo xmrig must use for `url`: the matching known pool's algo, else `fallback`.
std::string resolvePoolAlgo(const std::string& url, const std::string& fallback);
// Parse a pool's total hashrate (H/s) out of its stats JSON per `schema`.
// `miningcorePoolId` selects the pool entry for the Miningcore schema (ignored
// otherwise). Sets ok=false and returns 0 on any parse failure / missing field.
double parsePoolHashrate(PoolStatsSchema schema, const std::string& json,
const std::string& miningcorePoolId, bool& ok);
// Weighted-random pick among the usable (ok==true) pools: probability is inversely
// proportional to hashrate (smaller pools favored), so miners spread out instead of
// all stampeding to the single lowest pool. The current pool (`currentId`, may be
// empty) gets a stickiness multiplier so re-balancing rarely churns the miner.
// Returns the chosen pool id; returns `currentId` (or the sole usable id) when fewer
// than two pools are usable.
std::string chooseWeightedPool(const std::vector<PoolHashrate>& pools,
const std::string& currentId,
std::mt19937& rng);
// Stickiness applied to the incumbent pool's weight in chooseWeightedPool.
inline constexpr double kIncumbentStayBias = 2.0;
} // namespace util
} // namespace dragonx

View File

@@ -0,0 +1,208 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// Pure (no-I/O) implementation of the pool registry + hashrate helpers. Links
// nlohmann_json but NOT libcurl, so it can be exercised directly from the test
// binary (mirrors util/xmrig_updater_core.cpp).
#include "pool_registry.h"
#include <nlohmann/json.hpp>
#include <algorithm>
#include <cctype>
#include <cstddef>
namespace dragonx {
namespace util {
using json = nlohmann::json;
const std::vector<KnownPool>& knownPools()
{
// NOTE: only PPLNS (shared) pools belong here. The .cc SOLO pool
// (dragonx-solo, ports 5555/6666) is intentionally omitted — its hashrate
// is not comparable for network balancing.
static const std::vector<KnownPool> pools = {
KnownPool{
"dragonx-is", "pool.dragonx.is", "pool.dragonx.is:3433", "rx/hush",
"https://pool.dragonx.is/api/stats", PoolStatsSchema::DragonXIs,
/*miningcorePoolId=*/"", /*feePercent=*/0.0, /*official=*/true,
},
KnownPool{
// The mining (stratum) host is us.dragonx.cc — pool.dragonx.cc is the
// Cloudflare-proxied web/API host and does NOT accept stratum on :3333.
// Stats still come from pool.dragonx.cc/api/pools (proxied HTTP is fine).
"dragonx-cc-pplns", "pool.dragonx.cc", "us.dragonx.cc:3333", "rx/dragonx",
"https://pool.dragonx.cc/api/pools", PoolStatsSchema::Miningcore,
/*miningcorePoolId=*/"dragonx-pplns", /*feePercent=*/3.0, /*official=*/true,
},
};
return pools;
}
namespace {
std::string trimmed(const std::string& s)
{
const auto b = s.find_first_not_of(" \t\r\n");
if (b == std::string::npos) return {};
const auto e = s.find_last_not_of(" \t\r\n");
return s.substr(b, e - b + 1);
}
// Extract "host[:port]" from a URL that may carry a scheme, userinfo, path or query.
// Our pools are plain host:port endpoints (no IPv6 literals), so a last-colon split
// for the port is sufficient.
std::string hostPortOf(const std::string& url)
{
std::string s = trimmed(url);
if (const auto scheme = s.find("://"); scheme != std::string::npos)
s = s.substr(scheme + 3);
if (const auto at = s.find('@'); at != std::string::npos)
s = s.substr(at + 1);
if (const auto cut = s.find_first_of("/?#"); cut != std::string::npos)
s = s.substr(0, cut);
return s;
}
void splitHostPort(const std::string& hostport, std::string& host, std::string& port)
{
if (const auto colon = hostport.rfind(':'); colon == std::string::npos) {
host = hostport;
port.clear();
} else {
host = hostport.substr(0, colon);
port = hostport.substr(colon + 1);
}
std::transform(host.begin(), host.end(), host.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
}
// Same endpoint when hosts match and (either side omits a port, or the ports match).
bool sameEndpoint(const std::string& a, const std::string& b)
{
std::string ha, pa, hb, pb;
splitHostPort(hostPortOf(a), ha, pa);
splitHostPort(hostPortOf(b), hb, pb);
if (ha.empty() || ha != hb) return false;
if (pa.empty() || pb.empty()) return true;
return pa == pb;
}
} // namespace
const KnownPool* findKnownPoolByUrl(const std::string& url)
{
for (const auto& p : knownPools())
if (sameEndpoint(p.stratum, url)) return &p;
return nullptr;
}
std::string resolvePoolAlgo(const std::string& url, const std::string& fallback)
{
if (const KnownPool* p = findKnownPoolByUrl(url)) return p->algo;
return fallback;
}
double parsePoolHashrate(PoolStatsSchema schema, const std::string& jsonStr,
const std::string& miningcorePoolId, bool& ok)
{
ok = false;
try {
const json j = json::parse(jsonStr);
if (schema == PoolStatsSchema::DragonXIs) {
// { "pools": { "dragonx": { "hashrate": <num>, ... }, ... } }
if (j.contains("pools") && j["pools"].is_object()) {
const auto& pools = j["pools"];
auto readHr = [&](const json& pool, double& out) -> bool {
if (pool.is_object() && pool.contains("hashrate") &&
pool["hashrate"].is_number()) {
out = pool["hashrate"].get<double>();
return true;
}
return false;
};
double hr = 0.0;
if (pools.contains("dragonx") && readHr(pools["dragonx"], hr)) {
ok = true;
return hr;
}
for (auto it = pools.begin(); it != pools.end(); ++it) {
if (readHr(it.value(), hr)) {
ok = true;
return hr;
}
}
}
} else { // Miningcore
// { "pools": [ { "id": "...", "poolStats": { "poolHashrate": <num> } }, ... ] }
if (j.contains("pools") && j["pools"].is_array()) {
const json* chosen = nullptr;
for (const auto& pool : j["pools"]) {
if (!pool.is_object()) continue;
if (!miningcorePoolId.empty()) {
if (pool.value("id", std::string{}) == miningcorePoolId) {
chosen = &pool;
break;
}
} else if (!chosen) {
chosen = &pool; // first pool when no id requested
}
}
if (chosen && chosen->contains("poolStats") &&
(*chosen)["poolStats"].is_object() &&
(*chosen)["poolStats"].contains("poolHashrate") &&
(*chosen)["poolStats"]["poolHashrate"].is_number()) {
ok = true;
return (*chosen)["poolStats"]["poolHashrate"].get<double>();
}
}
}
} catch (...) {
// fall through — ok stays false
}
return 0.0;
}
std::string chooseWeightedPool(const std::vector<PoolHashrate>& pools,
const std::string& currentId,
std::mt19937& rng)
{
std::vector<const PoolHashrate*> usable;
usable.reserve(pools.size());
for (const auto& p : pools)
if (p.ok) usable.push_back(&p);
if (usable.empty()) return currentId;
if (usable.size() == 1) return usable.front()->id;
// weight = 1/(hr + eps): smaller pools get more weight; eps keeps a zero-hashrate
// pool finite (and most-favored). The incumbent gets a stickiness multiplier so a
// periodic re-balance only rarely restarts the miner.
constexpr double kEps = 1.0; // H/s
std::vector<double> weights;
weights.reserve(usable.size());
double total = 0.0;
for (const auto* p : usable) {
double w = 1.0 / (std::max(0.0, p->hashrateHs) + kEps);
if (!currentId.empty() && p->id == currentId) w *= kIncumbentStayBias;
weights.push_back(w);
total += w;
}
if (total <= 0.0) return usable.front()->id;
std::uniform_real_distribution<double> dist(0.0, total);
const double roll = dist(rng);
double cum = 0.0;
for (std::size_t i = 0; i < usable.size(); ++i) {
cum += weights[i];
if (roll <= cum) return usable[i]->id;
}
return usable.back()->id;
}
} // namespace util
} // namespace dragonx

View File

@@ -0,0 +1,113 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#include "pool_stats_service.h"
#include <curl/curl.h>
namespace dragonx {
namespace util {
namespace {
size_t writeStringCb(void* contents, size_t size, size_t nmemb, void* userp)
{
static_cast<std::string*>(userp)->append(static_cast<char*>(contents), size * nmemb);
return size * nmemb;
}
// Returning non-zero asks libcurl to abort the transfer — used so shutdown doesn't
// block on an in-flight fetch.
int xferInfoCb(void* clientp, curl_off_t, curl_off_t, curl_off_t, curl_off_t)
{
const auto* self = static_cast<const PoolStatsService*>(clientp);
return (self && self->cancelRequested()) ? 1 : 0;
}
} // namespace
PoolStatsService::~PoolStatsService()
{
cancel_requested_ = true;
if (worker_.joinable()) worker_.join();
}
void PoolStatsService::refresh(const std::vector<KnownPool>& pools)
{
if (worker_running_.exchange(true)) return; // already refreshing
if (worker_.joinable()) worker_.join(); // reap the previous finished worker
worker_ = std::thread([this, pools]() {
run(pools);
worker_running_ = false;
});
}
std::string PoolStatsService::httpGet(const std::string& url)
{
CURL* curl = curl_easy_init();
if (!curl) return {};
std::string result;
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writeStringCb);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &result);
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
// pool.dragonx.cc sits behind Cloudflare and 403s odd User-Agents — present a
// browser-like UA and accept compressed responses.
curl_easy_setopt(curl, CURLOPT_USERAGENT,
"Mozilla/5.0 (compatible; ObsidianDragon)");
curl_easy_setopt(curl, CURLOPT_ACCEPT_ENCODING, "");
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 8L);
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 5L);
curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L);
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L);
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L);
curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L);
curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, xferInfoCb);
curl_easy_setopt(curl, CURLOPT_XFERINFODATA, this);
struct curl_slist* hdrs = nullptr;
hdrs = curl_slist_append(hdrs, "Accept: application/json");
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, hdrs);
const CURLcode res = curl_easy_perform(curl);
long httpCode = 0;
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &httpCode);
curl_slist_free_all(hdrs);
curl_easy_cleanup(curl);
if (res != CURLE_OK || httpCode < 200 || httpCode >= 300) return {};
return result;
}
void PoolStatsService::run(std::vector<KnownPool> pools)
{
std::map<std::string, PoolHashrate> results;
for (const auto& p : pools) {
if (cancel_requested_.load()) break;
PoolHashrate hr;
hr.id = p.id;
const std::string body = httpGet(p.statsUrl);
if (!body.empty()) {
bool ok = false;
const double v = parsePoolHashrate(p.schema, body, p.miningcorePoolId, ok);
hr.ok = ok;
hr.hashrateHs = ok ? v : 0.0;
}
results[p.id] = hr;
}
std::lock_guard<std::mutex> lk(mutex_);
snapshot_.byId = std::move(results);
snapshot_.ready = true;
}
PoolStatsService::Snapshot PoolStatsService::snapshot() const
{
std::lock_guard<std::mutex> lk(mutex_);
return snapshot_;
}
} // namespace util
} // namespace dragonx

View File

@@ -0,0 +1,59 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// PoolStatsService — fetch every known pool's live total hashrate on a background
// thread and expose a thread-safe snapshot. Modelled on util/XmrigUpdater: a
// std::thread + atomics + a mutex-guarded result, polled once per frame by the UI /
// the auto-balance driver. Schema-aware parsing is delegated to pool_registry.
#pragma once
#include "pool_registry.h"
#include <atomic>
#include <map>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
namespace dragonx {
namespace util {
class PoolStatsService {
public:
struct Snapshot {
std::map<std::string, PoolHashrate> byId; // keyed by KnownPool.id
bool ready = false; // at least one refresh completed
};
PoolStatsService() = default;
~PoolStatsService();
PoolStatsService(const PoolStatsService&) = delete;
PoolStatsService& operator=(const PoolStatsService&) = delete;
// Fetch every pool's hashrate on a background thread. No-op if a refresh is
// already in flight.
void refresh(const std::vector<KnownPool>& pools);
bool busy() const { return worker_running_.load(); }
Snapshot snapshot() const;
// Internal: consulted by the libcurl xferinfo callback so an in-flight fetch can
// abort promptly on shutdown. Public only so the C callback can reach it.
bool cancelRequested() const { return cancel_requested_.load(); }
private:
void run(std::vector<KnownPool> pools);
std::string httpGet(const std::string& url);
mutable std::mutex mutex_;
Snapshot snapshot_;
std::atomic<bool> worker_running_{false};
std::atomic<bool> cancel_requested_{false};
std::thread worker_;
};
} // namespace util
} // namespace dragonx

View File

@@ -23,6 +23,7 @@
#include "util/payment_uri.h"
#include "util/platform.h"
#include "util/xmrig_updater.h"
#include "util/pool_registry.h"
#include "util/daemon_updater.h"
#include "util/lite_server_probe.h"
#include "wallet/lite_connection_service.h"
@@ -4826,6 +4827,97 @@ void testXmrigLiveInstall()
std::filesystem::remove_all(dir, ec);
}
// Mining pool registry: URL matching + per-pool algo resolution.
void testPoolRegistryLookup()
{
using namespace dragonx::util;
EXPECT_EQ(knownPools().size(), static_cast<std::size_t>(2));
// The algo follows the pool. Note pool.dragonx.cc's stratum host is us.dragonx.cc
// (the .cc domain is only the Cloudflare-proxied web/API host).
EXPECT_EQ(resolvePoolAlgo("us.dragonx.cc:3333", "rx/hush"), std::string("rx/dragonx"));
EXPECT_EQ(resolvePoolAlgo("pool.dragonx.is:3433", "rx/dragonx"), std::string("rx/hush"));
// Bare host (no port) still matches; scheme + path are tolerated.
EXPECT_EQ(resolvePoolAlgo("us.dragonx.cc", "rx/hush"), std::string("rx/dragonx"));
EXPECT_EQ(resolvePoolAlgo("stratum+tcp://us.dragonx.cc:3333/x", "rx/hush"),
std::string("rx/dragonx"));
// Unknown host -> fallback algo.
EXPECT_EQ(resolvePoolAlgo("my.pool.example:1234", "rx/hush"), std::string("rx/hush"));
EXPECT_TRUE(findKnownPoolByUrl("us.dragonx.cc:3333") != nullptr);
EXPECT_TRUE(findKnownPoolByUrl("unknown.host:1") == nullptr);
// A mismatched explicit port must NOT match a known pool.
EXPECT_TRUE(findKnownPoolByUrl("us.dragonx.cc:9999") == nullptr);
}
// Schema-aware pool hashrate parsing (the two pools speak different APIs).
void testPoolHashrateParsing()
{
using namespace dragonx::util;
bool ok = false;
// pool.dragonx.is custom schema: pools.dragonx.hashrate
const std::string isJson =
R"({"pools":{"dragonx":{"hashrate":27670.14,"workerCount":12}},"global":{"hashrate":30000.0}})";
double hr = parsePoolHashrate(PoolStatsSchema::DragonXIs, isJson, "", ok);
EXPECT_TRUE(ok);
EXPECT_NEAR(hr, 27670.14, 0.01);
// Miningcore schema: the requested pool id selects the entry (not just the first).
const std::string ccJson =
R"({"pools":[)"
R"({"id":"dragonx-solo","poolStats":{"poolHashrate":88780.0}},)"
R"({"id":"dragonx-pplns","poolStats":{"poolHashrate":1585.9}}]})";
hr = parsePoolHashrate(PoolStatsSchema::Miningcore, ccJson, "dragonx-pplns", ok);
EXPECT_TRUE(ok);
EXPECT_NEAR(hr, 1585.9, 0.01);
hr = parsePoolHashrate(PoolStatsSchema::Miningcore, ccJson, "dragonx-solo", ok);
EXPECT_TRUE(ok);
EXPECT_NEAR(hr, 88780.0, 0.01);
// Missing pool id / malformed / wrong-schema input all fail closed (no silent 0).
parsePoolHashrate(PoolStatsSchema::Miningcore, ccJson, "does-not-exist", ok);
EXPECT_FALSE(ok);
parsePoolHashrate(PoolStatsSchema::DragonXIs, "not json", "", ok);
EXPECT_FALSE(ok);
parsePoolHashrate(PoolStatsSchema::Miningcore, "{}", "dragonx-pplns", ok);
EXPECT_FALSE(ok);
parsePoolHashrate(PoolStatsSchema::DragonXIs, ccJson, "", ok); // .cc JSON, .is parser
EXPECT_FALSE(ok);
}
// Weighted-random pool selection: smaller pools favored, incumbent sticky, fails safe.
void testPoolWeightedSelection()
{
using namespace dragonx::util;
std::mt19937 rng(12345u); // fixed seed -> deterministic
std::vector<PoolHashrate> pools = {
{"big", 100000.0, true}, // large -> low weight
{"small", 1000.0, true}, // small -> high weight (favored)
};
int smallCount = 0, bigCount = 0;
for (int i = 0; i < 2000; ++i) {
const std::string pick = chooseWeightedPool(pools, /*currentId=*/"", rng);
if (pick == "small") ++smallCount;
else if (pick == "big") ++bigCount;
}
EXPECT_TRUE(smallCount > bigCount * 5); // ~100:1 by weight
// Only one usable pool -> always chosen; no usable pool -> keep current.
std::vector<PoolHashrate> one = {{"a", 5.0, true}, {"b", 1.0, false}};
EXPECT_EQ(chooseWeightedPool(one, "", rng), std::string("a"));
std::vector<PoolHashrate> none = {{"a", 5.0, false}, {"b", 1.0, false}};
EXPECT_EQ(chooseWeightedPool(none, "keepme", rng), std::string("keepme"));
// Incumbent stickiness: with equal hashrates the current pool wins the majority.
std::vector<PoolHashrate> equal = {{"x", 1000.0, true}, {"y", 1000.0, true}};
int stay = 0;
for (int i = 0; i < 2000; ++i)
if (chooseWeightedPool(equal, "x", rng) == "x") ++stay;
EXPECT_TRUE(stay > 1000); // >50% thanks to kIncumbentStayBias
}
} // namespace
int main()
@@ -4903,6 +4995,9 @@ int main()
testDaemonReleaseListParsing();
testLiteServerHostParsing();
testLiteOfficialServerDetection();
testPoolRegistryLookup();
testPoolHashrateParsing();
testPoolWeightedSelection();
testAtomicFileWrite();
testAddressChecksumValidation();
testLiteServerProbeLive();