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
co-authored by Claude Opus 4.8
parent 24e8fb4942
commit d0586e5e75
9 changed files with 601 additions and 0 deletions
+208
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