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

@@ -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();