From d0586e5e7568d8a86ff61bfb48a75302deb23d80 Mon Sep 17 00:00:00 2001 From: DanS Date: Wed, 1 Jul 2026 14:35:13 -0500 Subject: [PATCH] feat(mining): pool registry, schema-aware stats fetcher, pool-select setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CMakeLists.txt | 3 + src/config/settings.cpp | 21 ++++ src/config/settings.h | 10 ++ src/util/i18n.cpp | 11 ++ src/util/pool_registry.h | 81 +++++++++++++ src/util/pool_registry_core.cpp | 208 ++++++++++++++++++++++++++++++++ src/util/pool_stats_service.cpp | 113 +++++++++++++++++ src/util/pool_stats_service.h | 59 +++++++++ tests/test_phase4.cpp | 95 +++++++++++++++ 9 files changed, 601 insertions(+) create mode 100644 src/util/pool_registry.h create mode 100644 src/util/pool_registry_core.cpp create mode 100644 src/util/pool_stats_service.cpp create mode 100644 src/util/pool_stats_service.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 7ef092b..2347d46 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/src/config/settings.cpp b/src/config/settings.cpp index aa4a303..1003b92 100644 --- a/src/config/settings.cpp +++ b/src/config/settings.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(); + 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(); if (j.contains("pool_hugepages")) pool_hugepages_ = j["pool_hugepages"].get(); if (j.contains("pool_mode")) pool_mode_ = j["pool_mode"].get(); + 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(); if (j.contains("xmrig_version")) xmrig_version_ = j["xmrig_version"].get(); if (j.contains("mine_idle_delay")) mine_idle_delay_= std::max(30, j["mine_idle_delay"].get()); @@ -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_; diff --git a/src/config/settings.h b/src/config/settings.h index 04dc966..013a759 100644 --- a/src/config/settings.h +++ b/src/config/settings.h @@ -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 diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index 7e4be2c..bd77d56 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -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"; diff --git a/src/util/pool_registry.h b/src/util/pool_registry.h new file mode 100644 index 0000000..e86aa63 --- /dev/null +++ b/src/util/pool_registry.h @@ -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 +#include +#include + +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..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& 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& 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 diff --git a/src/util/pool_registry_core.cpp b/src/util/pool_registry_core.cpp new file mode 100644 index 0000000..e6cfcc7 --- /dev/null +++ b/src/util/pool_registry_core.cpp @@ -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 + +#include +#include +#include + +namespace dragonx { +namespace util { + +using json = nlohmann::json; + +const std::vector& 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 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(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": , ... }, ... } } + 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(); + 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": } }, ... ] } + 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(); + } + } + } + } catch (...) { + // fall through — ok stays false + } + return 0.0; +} + +std::string chooseWeightedPool(const std::vector& pools, + const std::string& currentId, + std::mt19937& rng) +{ + std::vector 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 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 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 diff --git a/src/util/pool_stats_service.cpp b/src/util/pool_stats_service.cpp new file mode 100644 index 0000000..117751a --- /dev/null +++ b/src/util/pool_stats_service.cpp @@ -0,0 +1,113 @@ +// DragonX Wallet - ImGui Edition +// Copyright 2024-2026 The Hush Developers +// Released under the GPLv3 + +#include "pool_stats_service.h" + +#include + +namespace dragonx { +namespace util { + +namespace { + +size_t writeStringCb(void* contents, size_t size, size_t nmemb, void* userp) +{ + static_cast(userp)->append(static_cast(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(clientp); + return (self && self->cancelRequested()) ? 1 : 0; +} + +} // namespace + +PoolStatsService::~PoolStatsService() +{ + cancel_requested_ = true; + if (worker_.joinable()) worker_.join(); +} + +void PoolStatsService::refresh(const std::vector& 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 pools) +{ + std::map 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 lk(mutex_); + snapshot_.byId = std::move(results); + snapshot_.ready = true; +} + +PoolStatsService::Snapshot PoolStatsService::snapshot() const +{ + std::lock_guard lk(mutex_); + return snapshot_; +} + +} // namespace util +} // namespace dragonx diff --git a/src/util/pool_stats_service.h b/src/util/pool_stats_service.h new file mode 100644 index 0000000..2a3c9ef --- /dev/null +++ b/src/util/pool_stats_service.h @@ -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 +#include +#include +#include +#include +#include + +namespace dragonx { +namespace util { + +class PoolStatsService { +public: + struct Snapshot { + std::map 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& 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 pools); + std::string httpGet(const std::string& url); + + mutable std::mutex mutex_; + Snapshot snapshot_; + std::atomic worker_running_{false}; + std::atomic cancel_requested_{false}; + std::thread worker_; +}; + +} // namespace util +} // namespace dragonx diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 2bb4afd..423b29c 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -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(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 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 one = {{"a", 5.0, true}, {"b", 1.0, false}}; + EXPECT_EQ(chooseWeightedPool(one, "", rng), std::string("a")); + std::vector 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 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();