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:
@@ -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
|
||||
|
||||
@@ -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_;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
81
src/util/pool_registry.h
Normal 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
|
||||
208
src/util/pool_registry_core.cpp
Normal file
208
src/util/pool_registry_core.cpp
Normal 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
|
||||
113
src/util/pool_stats_service.cpp
Normal file
113
src/util/pool_stats_service.cpp
Normal 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
|
||||
59
src/util/pool_stats_service.h
Normal file
59
src/util/pool_stats_service.h
Normal 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
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user