Several related mining-tab pool improvements:
- Report the default pool fee correctly: pool.dragonx.is is 1%, not 0%.
The registry constant was hardcoded to 0. It now also fetches the live
poolFee from the pool's /api/stats alongside hashrate (no extra
request), so the displayed fee self-corrects and falls back to the
compile-time value only when the fetch hasn't landed.
- Show fractional fees: new FormatFeePercent trims trailing zeros so
whole fees read "1%" and fractional ones keep their decimals ("1.5%").
- Surface saved + custom pools in the pool list card: the list is now
the union of the official pools, the user's saved favorites, and the
currently-mined pool (effectivePools), each a selectable, endpoint-
deduped row. Previously the card only showed the hardcoded knownPools().
- Fix the xmrig "user" field: the "Payout Address" field now drives the
pool login rewards are credited to (resolveMiningUserAddress), instead
of being written only to "pass" while "user" was auto-derived from the
wallet's own first z-address -- which silently ignored a configured
payout address and could route rewards to the wrong address.
Unit tests cover parsePoolFee, FormatFeePercent, effectivePools, and
resolveMiningUserAddress; full app + ObsidianDragonTests build clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
308 lines
11 KiB
C++
308 lines
11 KiB
C++
// 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=*/1.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;
|
|
}
|
|
|
|
// Build a synthetic, selectable pool row for a user-supplied URL (a saved favorite
|
|
// or the current custom pool). We don't know its stats API, so it carries no
|
|
// statsUrl / live hashrate and an unknown (<0) fee — the UI falls back to "—".
|
|
KnownPool makeUserPool(const std::string& url)
|
|
{
|
|
KnownPool p;
|
|
const std::string hp = hostPortOf(url);
|
|
std::string host, port;
|
|
splitHostPort(hp, host, port);
|
|
p.id = "user:" + trimmed(url); // stable + unique (used as the ImGui id)
|
|
p.label = host.empty() ? hp : host;
|
|
p.stratum = trimmed(url); // what the miner connects to / a row-click restores
|
|
p.algo = ""; // unknown; xmrig resolves via resolvePoolAlgo's fallback
|
|
p.statsUrl = ""; // no known stats endpoint -> no live hashrate/fee
|
|
p.schema = PoolStatsSchema::DragonXIs;
|
|
p.miningcorePoolId = "";
|
|
p.feePercent = -1.0; // unknown fee
|
|
p.official = false;
|
|
return p;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
const KnownPool* findPoolByUrl(const std::vector<KnownPool>& pools, const std::string& url)
|
|
{
|
|
for (const auto& p : pools)
|
|
if (sameEndpoint(p.stratum, url)) return &p;
|
|
return nullptr;
|
|
}
|
|
|
|
const KnownPool* findKnownPoolByUrl(const std::string& url)
|
|
{
|
|
return findPoolByUrl(knownPools(), url);
|
|
}
|
|
|
|
std::vector<KnownPool> effectivePools(const std::string& currentPoolUrl,
|
|
const std::vector<std::string>& savedPoolUrls)
|
|
{
|
|
std::vector<KnownPool> pools = knownPools();
|
|
|
|
// Skip anything whose endpoint already appears (official or an earlier user row).
|
|
auto listed = [&](const std::string& url) {
|
|
return findPoolByUrl(pools, url) != nullptr;
|
|
};
|
|
|
|
for (const auto& url : savedPoolUrls) {
|
|
if (trimmed(url).empty() || listed(url)) continue;
|
|
pools.push_back(makeUserPool(url));
|
|
}
|
|
|
|
// The pool currently being mined, if not already shown, so the active pool is
|
|
// always visible even before it's bookmarked.
|
|
if (!trimmed(currentPoolUrl).empty() && !listed(currentPoolUrl))
|
|
pools.push_back(makeUserPool(currentPoolUrl));
|
|
|
|
return pools;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
double parsePoolFee(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": { "poolFee": <num>, ... }, ... } }
|
|
if (j.contains("pools") && j["pools"].is_object()) {
|
|
const auto& pools = j["pools"];
|
|
auto readFee = [&](const json& pool, double& out) -> bool {
|
|
if (pool.is_object() && pool.contains("poolFee") &&
|
|
pool["poolFee"].is_number()) {
|
|
out = pool["poolFee"].get<double>();
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
double fee = 0.0;
|
|
if (pools.contains("dragonx") && readFee(pools["dragonx"], fee)) {
|
|
ok = true;
|
|
return fee;
|
|
}
|
|
for (auto it = pools.begin(); it != pools.end(); ++it) {
|
|
if (readFee(it.value(), fee)) {
|
|
ok = true;
|
|
return fee;
|
|
}
|
|
}
|
|
}
|
|
} else { // Miningcore: pools[id].poolFeePercent
|
|
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("poolFeePercent") &&
|
|
(*chosen)["poolFeePercent"].is_number()) {
|
|
ok = true;
|
|
return (*chosen)["poolFeePercent"].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
|