diff --git a/src/app_network.cpp b/src/app_network.cpp index d331f35..72824e6 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -35,6 +35,7 @@ #include "rpc/connection.h" #include "chat/chat_identity.h" // deriveChatIdentityFromSecret for HushChat identity provisioning #include "ui/windows/chat_tab.h" // ui::ResetChatTab — wipe chat UI plaintext on a wallet switch +#include "ui/windows/mining_pool_panel.h" // ui::resolveMiningUserAddress #include // sodium_memzero for wiping the fetched mnemonic #include #include "config/settings.h" @@ -2288,27 +2289,23 @@ void App::startPoolMining(int threads) cfg.tls = settings_->getPoolTls(); cfg.hugepages = settings_->getPoolHugepages(); - // Use first shielded address as the mining wallet address, fall back to transparent + // xmrig "user" is the pool login the block rewards are credited to. The user's + // "Payout Address" field (cfg.worker_name = getPoolWorker) is exactly that, so it + // takes priority — otherwise a payout address that differs from the wallet's own + // first z-address is silently ignored and rewards go to the wrong address. Only when + // no payout address is set do we fall back to the wallet's own first shielded, then + // transparent, address (available even before the daemon is connected/synced). + std::string firstShielded, firstTransparent; for (const auto& addr : state_.z_addresses) { - if (!addr.address.empty()) { - cfg.wallet_address = addr.address; + if (!addr.address.empty()) { firstShielded = addr.address; break; } + } + for (const auto& addr : state_.addresses) { + if (addr.type == "transparent" && !addr.address.empty()) { + firstTransparent = addr.address; break; } } - if (cfg.wallet_address.empty()) { - for (const auto& addr : state_.addresses) { - if (addr.type == "transparent" && !addr.address.empty()) { - cfg.wallet_address = addr.address; - break; - } - } - } - - // Fallback: use pool worker address from settings (available even before - // the daemon is connected or the blockchain is synced). - if (cfg.wallet_address.empty() && !cfg.worker_name.empty()) { - cfg.wallet_address = cfg.worker_name; - } + cfg.wallet_address = ui::resolveMiningUserAddress(cfg.worker_name, firstShielded, firstTransparent); if (cfg.wallet_address.empty()) { DEBUG_LOGF("[ERROR] Pool mining: No wallet address available\n"); diff --git a/src/ui/windows/mining_pool_panel.cpp b/src/ui/windows/mining_pool_panel.cpp index 2082e9e..9d3b9c8 100644 --- a/src/ui/windows/mining_pool_panel.cpp +++ b/src/ui/windows/mining_pool_panel.cpp @@ -20,6 +20,17 @@ std::string defaultPoolWorkerAddress(const std::vector& addresses) return {}; } +std::string resolveMiningUserAddress(const std::string& payoutAddress, + const std::string& firstShieldedAddress, + const std::string& firstTransparentAddress) +{ + // The configured payout address is the pool login rewards go to, so it wins over + // the wallet's own addresses. "x" is the placeholder for an unset field. + if (!payoutAddress.empty() && payoutAddress != "x") return payoutAddress; + if (!firstShieldedAddress.empty()) return firstShieldedAddress; + return firstTransparentAddress; // may be empty -> caller reports "no address" +} + bool miningValueAlreadySaved(const std::vector& savedValues, const std::string& value) { diff --git a/src/ui/windows/mining_pool_panel.h b/src/ui/windows/mining_pool_panel.h index f4e7b73..705606f 100644 --- a/src/ui/windows/mining_pool_panel.h +++ b/src/ui/windows/mining_pool_panel.h @@ -10,6 +10,14 @@ namespace ui { bool shouldDefaultPoolWorker(const std::string& currentWorker, bool alreadyDefaulted); std::string defaultPoolWorkerAddress(const std::vector& addresses); + +// The xmrig "user" — the pool login block rewards are credited to. The user-entered +// payout address wins; otherwise fall back to the wallet's own first shielded, then +// transparent, address. "x" is the empty-field placeholder and counts as unset. The +// result may be empty (no address anywhere), which the caller treats as an error. +std::string resolveMiningUserAddress(const std::string& payoutAddress, + const std::string& firstShieldedAddress, + const std::string& firstTransparentAddress); bool miningValueAlreadySaved(const std::vector& savedValues, const std::string& value); const char* defaultPoolUrl(); diff --git a/src/ui/windows/mining_stats.cpp b/src/ui/windows/mining_stats.cpp index ae734e8..31fba5a 100644 --- a/src/ui/windows/mining_stats.cpp +++ b/src/ui/windows/mining_stats.cpp @@ -251,11 +251,15 @@ static void RenderLeftPoolCard(App* app, const WalletState& state, ImDrawList* d } y += gap * 0.5f; + // The pool list = official pools ∪ user-saved favorites ∪ the current custom pool. + const auto effective = util::effectivePools(app->settings()->getPoolUrl(), + app->settings()->getSavedPoolUrls()); + // --- POOLS (N) header + Refresh --- { char hdr[48]; snprintf(hdr, sizeof(hdr), "%s (%d)", TR("mining_pools_header"), - (int)util::knownPools().size()); + (int)effective.size()); dl->AddText(ovFont, ovFont->LegacySize, ImVec2(x, y), OnSurfaceMedium(), hdr); float btnS = ovFont->LegacySize + 6 * dp; @@ -278,11 +282,11 @@ static void RenderLeftPoolCard(App* app, const WalletState& state, ImDrawList* d { ImDrawList* cdl = ImGui::GetWindowDrawList(); const auto snap = app->poolStatsSnapshot(); - const util::KnownPool* current = util::findKnownPoolByUrl(app->settings()->getPoolUrl()); + const util::KnownPool* current = util::findPoolByUrl(effective, app->settings()->getPoolUrl()); const float childW = ImGui::GetContentRegionAvail().x; const float listRowH = capFont->LegacySize + 10 * dp; - for (const auto& kp : util::knownPools()) { + for (const auto& kp : effective) { ImGui::PushID(kp.id.c_str()); const bool isCurrent = current && current->id == kp.id; const auto it = snap.byId.find(kp.id); @@ -315,7 +319,17 @@ static void RenderLeftPoolCard(App* app, const WalletState& state, ImDrawList* d char right[64]; std::string hrStr = haveHr ? FormatHashrate(it->second.hashrateHs) : std::string("—"); - snprintf(right, sizeof(right), "%s %.0f%% fee", hrStr.c_str(), kp.feePercent); + // Prefer the live fee the pool reports; fall back to the compile-time + // KnownPool.feePercent. A synthetic user pool has an unknown (<0) fee, so + // we show just its hashrate placeholder for it. + double feePct = (it != snap.byId.end() && it->second.feePercent >= 0.0) + ? it->second.feePercent + : kp.feePercent; + if (feePct >= 0.0) + snprintf(right, sizeof(right), "%s %s%% fee", hrStr.c_str(), + FormatFeePercent(feePct).c_str()); + else + snprintf(right, sizeof(right), "%s", hrStr.c_str()); ImVec2 rSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, right); cdl->AddText(capFont, capFont->LegacySize, ImVec2(rMax.x - rSz.x - 6 * dp, textY), OnSurfaceMedium(), right); diff --git a/src/ui/windows/mining_tab_helpers.cpp b/src/ui/windows/mining_tab_helpers.cpp index 40efe61..d1d8d7b 100644 --- a/src/ui/windows/mining_tab_helpers.cpp +++ b/src/ui/windows/mining_tab_helpers.cpp @@ -41,6 +41,21 @@ std::string FormatHashrate(double hashrate) return std::string(buffer); } +std::string FormatFeePercent(double feePercent) +{ + // Whole fees read "1"; fractional ones keep only their significant decimals + // ("1.5", "0.9", "1.25") with no trailing zeros. Capped at 2 dp — finer than + // any pool advertises, and the caller appends the "%". + char buffer[32]; + snprintf(buffer, sizeof(buffer), "%.2f", feePercent); + std::string s(buffer); + if (s.find('.') != std::string::npos) { + s.erase(s.find_last_not_of('0') + 1); + if (!s.empty() && s.back() == '.') s.pop_back(); + } + return s; +} + double EstimateHoursToBlock(double localHashrate, double networkHashrate, double difficulty) { (void)difficulty; diff --git a/src/ui/windows/mining_tab_helpers.h b/src/ui/windows/mining_tab_helpers.h index 4a7d1cf..60add69 100644 --- a/src/ui/windows/mining_tab_helpers.h +++ b/src/ui/windows/mining_tab_helpers.h @@ -9,6 +9,7 @@ int GetMaxMiningThreads(); int ClampMiningThreads(int requestedThreads, int maxThreads); bool IsPoolMiningActive(bool poolMode, bool xmrigRunning, bool soloMiningRunning); std::string FormatHashrate(double hashrate); +std::string FormatFeePercent(double feePercent); double EstimateHoursToBlock(double localHashrate, double networkHashrate, double difficulty); std::string FormatEstTime(double estimatedHours); diff --git a/src/util/pool_registry.h b/src/util/pool_registry.h index e86aa63..f5a63f1 100644 --- a/src/util/pool_registry.h +++ b/src/util/pool_registry.h @@ -45,16 +45,30 @@ struct PoolHashrate { std::string id; double hashrateHs = 0.0; bool ok = false; + // Live pool fee (%) read from the same stats JSON. <0 means "not available" — + // callers fall back to the compile-time KnownPool.feePercent. + double feePercent = -1.0; }; // 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. +// The pool in `pools` 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/path. +const KnownPool* findPoolByUrl(const std::vector& pools, const std::string& url); + +// Same, over the built-in official pools only. const KnownPool* findKnownPoolByUrl(const std::string& url); +// The full list the UI should show: the official knownPools(), plus a row for every +// user-saved pool URL and for `currentPoolUrl` when it isn't one of those — so a +// custom/bookmarked pool is a first-class, selectable row. Synthetic (user) rows are +// official=false and carry no statsUrl (feePercent<0, no live hashrate), and endpoints +// are de-duplicated so a saved URL that equals an official pool isn't listed twice. +std::vector effectivePools(const std::string& currentPoolUrl, + const std::vector& savedPoolUrls); + // 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); @@ -64,6 +78,13 @@ std::string resolvePoolAlgo(const std::string& url, const std::string& fallback) double parsePoolHashrate(PoolStatsSchema schema, const std::string& json, const std::string& miningcorePoolId, bool& ok); +// Parse a pool's advertised fee (%) out of the same stats JSON (DragonXIs: +// pools..poolFee; Miningcore: pools[id].poolFeePercent). Selects the same +// pool entry as parsePoolHashrate. Sets ok=false and returns 0 when the field is +// absent / malformed, so the caller keeps the compile-time fallback. +double parsePoolFee(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 diff --git a/src/util/pool_registry_core.cpp b/src/util/pool_registry_core.cpp index 619cd76..2a8271d 100644 --- a/src/util/pool_registry_core.cpp +++ b/src/util/pool_registry_core.cpp @@ -28,7 +28,7 @@ const std::vector& knownPools() 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, + /*miningcorePoolId=*/"", /*feePercent=*/1.0, /*official=*/true, }, }; return pools; @@ -83,13 +83,62 @@ bool sameEndpoint(const std::string& a, const std::string& b) 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& 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) { - for (const auto& p : knownPools()) - if (sameEndpoint(p.stratum, url)) return &p; - return nullptr; + return findPoolByUrl(knownPools(), url); +} + +std::vector effectivePools(const std::string& currentPoolUrl, + const std::vector& savedPoolUrls) +{ + std::vector 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) @@ -159,6 +208,64 @@ double parsePoolHashrate(PoolStatsSchema schema, const std::string& jsonStr, 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": , ... }, ... } } + 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(); + 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(); + } + } + } + } catch (...) { + // fall through — ok stays false + } + return 0.0; +} + std::string chooseWeightedPool(const std::vector& pools, const std::string& currentId, std::mt19937& rng) diff --git a/src/util/pool_stats_service.cpp b/src/util/pool_stats_service.cpp index 117751a..7bc479d 100644 --- a/src/util/pool_stats_service.cpp +++ b/src/util/pool_stats_service.cpp @@ -94,6 +94,12 @@ void PoolStatsService::run(std::vector pools) const double v = parsePoolHashrate(p.schema, body, p.miningcorePoolId, ok); hr.ok = ok; hr.hashrateHs = ok ? v : 0.0; + + bool feeOk = false; + const double fee = parsePoolFee(p.schema, body, p.miningcorePoolId, feeOk); + // Only trust a sane fee; anything else leaves feePercent < 0 so the UI + // falls back to the compile-time KnownPool.feePercent. + if (feeOk && fee >= 0.0 && fee <= 100.0) hr.feePercent = fee; } results[p.id] = hr; } diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 6925049..14cf7ed 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -3231,6 +3231,19 @@ void testRendererHelpers() EXPECT_EQ(dragonx::ui::defaultPoolWorkerAddress(poolAddresses), std::string("zs-default-worker")); EXPECT_TRUE(dragonx::ui::miningValueAlreadySaved({"pool-a", "pool-b"}, "pool-b")); EXPECT_FALSE(dragonx::ui::miningValueAlreadySaved({"pool-a"}, "")); + + // resolveMiningUserAddress: the configured payout address is the xmrig "user" + // (where rewards go) and must win over the wallet's own addresses. + EXPECT_EQ(dragonx::ui::resolveMiningUserAddress("zs-payout", "zs-own", "R-own"), + std::string("zs-payout")); // explicit payout wins + EXPECT_EQ(dragonx::ui::resolveMiningUserAddress("", "zs-own", "R-own"), + std::string("zs-own")); // unset -> own shielded + EXPECT_EQ(dragonx::ui::resolveMiningUserAddress("x", "zs-own", "R-own"), + std::string("zs-own")); // "x" placeholder counts as unset + EXPECT_EQ(dragonx::ui::resolveMiningUserAddress("x", "", "R-own"), + std::string("R-own")); // no shielded -> transparent + EXPECT_EQ(dragonx::ui::resolveMiningUserAddress("", "", ""), + std::string("")); // nothing anywhere -> caller errors EXPECT_EQ(std::string(dragonx::ui::defaultPoolUrl()), std::string("pool.dragonx.is:3433")); dragonx::TransactionInfo tx; @@ -5828,6 +5841,94 @@ void testPoolHashrateParsing() EXPECT_FALSE(ok); } +// Schema-aware pool fee parsing (fed to the mining-tab "N% fee" display). +void testPoolFeeParsing() +{ + using namespace dragonx::util; + bool ok = false; + + // pool.dragonx.is custom schema: pools.dragonx.poolFee (a whole-percent number). + const std::string isJson = + R"({"pools":{"dragonx":{"hashrate":27670.14,"poolFee":1,"soloFee":3}}})"; + double fee = parsePoolFee(PoolStatsSchema::DragonXIs, isJson, "", ok); + EXPECT_TRUE(ok); + EXPECT_NEAR(fee, 1.0, 0.001); + + // Fractional fees survive (display rounds, but the parse must not). + const std::string isFrac = R"({"pools":{"dragonx":{"poolFee":1.5}}})"; + fee = parsePoolFee(PoolStatsSchema::DragonXIs, isFrac, "", ok); + EXPECT_TRUE(ok); + EXPECT_NEAR(fee, 1.5, 0.001); + + // Miningcore schema: the requested pool id's poolFeePercent. + const std::string ccJson = + R"({"pools":[)" + R"({"id":"dragonx-solo","poolFeePercent":2.0,"poolStats":{"poolHashrate":88780.0}},)" + R"({"id":"dragonx-pplns","poolFeePercent":0.9,"poolStats":{"poolHashrate":1585.9}}]})"; + fee = parsePoolFee(PoolStatsSchema::Miningcore, ccJson, "dragonx-pplns", ok); + EXPECT_TRUE(ok); + EXPECT_NEAR(fee, 0.9, 0.001); + + // Missing field / malformed / wrong-schema input all fail closed (caller keeps + // the compile-time fallback rather than showing a bogus 0%). + parsePoolFee(PoolStatsSchema::DragonXIs, R"({"pools":{"dragonx":{"hashrate":1.0}}})", "", ok); + EXPECT_FALSE(ok); // no poolFee key + parsePoolFee(PoolStatsSchema::DragonXIs, "not json", "", ok); + EXPECT_FALSE(ok); + parsePoolFee(PoolStatsSchema::Miningcore, ccJson, "does-not-exist", ok); + EXPECT_FALSE(ok); + parsePoolFee(PoolStatsSchema::DragonXIs, R"({"pools":{"dragonx":{"poolFee":"1"}}})", "", ok); + EXPECT_FALSE(ok); // string, not number +} + +// The effective pool list = official pools ∪ saved favorites ∪ current custom pool, +// endpoint-deduped, with synthetic user rows flagged official=false. +void testEffectivePools() +{ + using namespace dragonx::util; + const int base = (int)knownPools().size(); + + // Current pool is the official one, nothing saved -> just the official pools. + auto a = effectivePools("pool.dragonx.is:3433", {}); + EXPECT_EQ((int)a.size(), base); + + // A custom current pool (neither official nor saved) appears as an extra row. + auto b = effectivePools("my.pool.example:3333", {}); + EXPECT_EQ((int)b.size(), base + 1); + const KnownPool* custom = findPoolByUrl(b, "my.pool.example:3333"); + EXPECT_TRUE(custom != nullptr); + EXPECT_FALSE(custom->official); + EXPECT_TRUE(custom->feePercent < 0.0); // unknown fee + + // Saved pools are appended; an official one among them and a duplicate collapse. + auto c = effectivePools("pool.dragonx.is:3433", + {"pool.dragonx.is:3433", "alt.pool:1", "alt.pool:1"}); + EXPECT_EQ((int)c.size(), base + 1); + EXPECT_TRUE(findPoolByUrl(c, "alt.pool:1") != nullptr); + + // Current pool equal to a saved one is not listed twice. + auto d = effectivePools("alt.pool:1", {"alt.pool:1"}); + EXPECT_EQ((int)d.size(), base + 1); + + // Blank/whitespace URLs are ignored (no phantom rows). + auto e = effectivePools(" ", {"", " "}); + EXPECT_EQ((int)e.size(), base); +} + +// Fee formatting: whole numbers stay clean, fractional fees keep their decimals. +void testFormatFeePercent() +{ + using dragonx::ui::FormatFeePercent; + EXPECT_TRUE(FormatFeePercent(1.0) == "1"); + EXPECT_TRUE(FormatFeePercent(0.0) == "0"); + EXPECT_TRUE(FormatFeePercent(3.0) == "3"); + EXPECT_TRUE(FormatFeePercent(1.5) == "1.5"); + EXPECT_TRUE(FormatFeePercent(0.9) == "0.9"); + EXPECT_TRUE(FormatFeePercent(1.25) == "1.25"); + EXPECT_TRUE(FormatFeePercent(2.50) == "2.5"); // trailing zero trimmed + EXPECT_TRUE(FormatFeePercent(100.0) == "100"); +} + // Weighted-random pool selection: smaller pools favored, incumbent sticky, fails safe. void testPoolWeightedSelection() { @@ -6590,6 +6691,9 @@ int main() testLiteOfficialServerDetection(); testPoolRegistryLookup(); testPoolHashrateParsing(); + testPoolFeeParsing(); + testEffectivePools(); + testFormatFeePercent(); testPoolWeightedSelection(); testAtomicFileWrite(); testHushChatCrypto();