Fixes all 22 confirmed findings from the mining-tab audit (10 Medium, 12 Low; 0 Critical/High), adversarially reviewed (6 follow-ups found + fixed, incl. the review-caught idle-auto-start bypass and a wrong benchmark-restore condition). Crash-safety & lifecycle: - M-04: join a stale/finished monitor thread in XmrigManager::start() and ~XmrigManager so an xmrig crash-then-restart (or quit) no longer std::terminate()s the wallet. - L-03/L-10: surface an unexpected miner exit once and clear the stale running flag. UI never blocks (M-03/L-06/L-08/L-09/L-13): pool start/stop now run on a dedicated serialized FIFO mining-control thread (joined before teardown), so the ~13 call sites don't block the render thread on stop()'s SIGTERM->SIGKILL->join; the spawn result marshals back to the UI. Miner-process / pool trust boundary: - M-01: validate the payout address (util::isValidRecipientAddress) at EVERY start path — the UI gate AND App::startPoolMining() (idle auto-start / thread scaling) — so a stale/wrong-chain address can't silently lose rewards. - M-09: SSRF guard skips the background pool-stats GET for loopback/private/link-local/single-label hosts. - M-02/L-02: cap the pool-stats + xmrig-API HTTP response bodies. - L-01: write the xmrig config 0600 at creation (POSIX open with mode) — no world/group-readable window. - M-10: reject shell-metacharacter binary paths before the version popen (excluding '()' so Program Files (x86) still works). Solo mining: M-06/M-08 clamp thread count to [1, cores] at the setgenerate/xmrig boundary; M-07 notify + don't lie on stop failure. Correctness: L-05 block-time constant 75->150s (chainparams); M-05 discloses pool-mode "Est. Daily" as a rough solo-equivalent; L-04/L-11/L-12 benchmark lifecycle (cancel on nav-away / mode-switch with restore, skip rebalance mid-benchmark); L-07 honor cancel mid-extract in both the xmrig and daemon updaters. Two new i18n keys back-filled across all 8 languages; CJK subset font rebuilt for the new glyphs. Verified across full-node, lite, and Windows builds; tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
358 lines
16 KiB
C++
358 lines
16 KiB
C++
// DragonX Wallet - ImGui Edition
|
||
// Copyright 2024-2026 The Hush Developers
|
||
// Released under the GPLv3
|
||
|
||
#include "mining_tab.h"
|
||
#include "mining_benchmark.h"
|
||
#include "mining_tab_helpers.h"
|
||
#include "mining_pool_panel.h"
|
||
#include "mining_earnings.h"
|
||
#include "mining_stats.h"
|
||
#include "mining_controls.h"
|
||
#include "mining_mode_toggle.h"
|
||
#include "xmrig_download_dialog.h"
|
||
#include "../../util/xmrig_updater.h"
|
||
#include "../../app.h"
|
||
#include "../../util/i18n.h"
|
||
#include "../../config/version.h"
|
||
#include "../../data/wallet_state.h"
|
||
#include "../../config/settings.h"
|
||
#include "../../util/platform.h"
|
||
#include "../schema/ui_schema.h"
|
||
#include "../material/type.h"
|
||
#include "../material/draw_helpers.h"
|
||
#include "../material/colors.h"
|
||
#include "../effects/low_spec.h"
|
||
#include "../layout.h"
|
||
#include "../notifications.h"
|
||
#include "../../embedded/IconsMaterialDesign.h"
|
||
#include "imgui.h"
|
||
#include "imgui_internal.h"
|
||
|
||
#include <thread>
|
||
#include <algorithm>
|
||
#include <cmath>
|
||
#include <ctime>
|
||
#include <vector>
|
||
|
||
namespace dragonx {
|
||
namespace ui {
|
||
|
||
using namespace material;
|
||
|
||
// Local UI state for thread grid
|
||
static int s_selected_threads = 0;
|
||
static bool s_threads_initialized = false;
|
||
|
||
// Drag-to-select state
|
||
static bool s_drag_active = false;
|
||
static int s_drag_anchor_thread = 0; // thread# where drag started
|
||
|
||
|
||
static ThreadBenchmark s_benchmark;
|
||
|
||
bool IsMiningBenchmarkActive() {
|
||
return s_benchmark.active();
|
||
}
|
||
|
||
void CancelMiningBenchmark(App* app) {
|
||
if (!s_benchmark.active()) return;
|
||
const int restoreThreads = s_benchmark.prev_threads;
|
||
s_benchmark.reset();
|
||
// Restore the miner to its pre-benchmark thread count. A benchmark runs in pool mode, so restore
|
||
// regardless of the instantaneous running state — the sweep may be mid inter-candidate stop, where an
|
||
// isPoolMinerRunning() check would be transiently false and silently drop the restart. (L-04, L-11)
|
||
if (app && restoreThreads > 0) {
|
||
app->stopPoolMining();
|
||
app->startPoolMining(restoreThreads);
|
||
}
|
||
}
|
||
|
||
// Miner-update version check (one shot per session): fetches the latest DRG-XMRig release tag in
|
||
// the background so the "Update" button can show it. Network call to the project Gitea, started
|
||
// the first time the pool section is shown.
|
||
static util::XmrigUpdater s_xmrig_version_check;
|
||
static bool s_xmrig_check_started = false;
|
||
static std::string s_xmrig_latest_tag;
|
||
|
||
// Pool mode state
|
||
static bool s_pool_mode = false;
|
||
static char s_pool_url[256] = "pool.dragonx.is:3433";
|
||
static char s_pool_worker[256] = "x";
|
||
static bool s_pool_settings_dirty = false;
|
||
static bool s_pool_state_loaded = false;
|
||
|
||
static void RenderMiningTabContent(App* app);
|
||
|
||
void RenderMiningTab(App* app)
|
||
{
|
||
// Scrollable child to contain all content within available space
|
||
ImVec2 miningAvail = ImGui::GetContentRegionAvail();
|
||
ImGui::BeginChild("##MiningScroll", miningAvail, false, ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollbar);
|
||
|
||
ImGuiErrorRecoveryState erState;
|
||
ImGui::ErrorRecoveryStoreState(&erState);
|
||
try {
|
||
RenderMiningTabContent(app);
|
||
} catch (const std::exception& e) {
|
||
DEBUG_LOGF("[MiningTab] Exception: %s\n", e.what());
|
||
ImGui::ErrorRecoveryTryToRecoverState(&erState);
|
||
} catch (...) {
|
||
DEBUG_LOGF("[MiningTab] Unknown exception\n");
|
||
ImGui::ErrorRecoveryTryToRecoverState(&erState);
|
||
}
|
||
|
||
ImGui::EndChild(); // ##MiningScroll
|
||
}
|
||
|
||
static void RenderMiningTabContent(App* app)
|
||
{
|
||
auto& S = schema::UI();
|
||
ImVec2 miningAvail = ImGui::GetContentRegionAvail();
|
||
auto sliderInput = S.input("tabs.mining", "thread-slider");
|
||
auto startBtn = S.button("tabs.mining", "start-button");
|
||
auto lbl = S.label("tabs.mining", "label-column");
|
||
const auto& state = app->getWalletState();
|
||
const auto& mining = state.mining;
|
||
|
||
// Responsive: scale factors per frame
|
||
float availWidth = ImGui::GetContentRegionAvail().x;
|
||
float hs = Layout::hScale(availWidth);
|
||
float vs = Layout::vScale(miningAvail.y);
|
||
float pad = Layout::cardInnerPadding();
|
||
float gap = Layout::cardGap();
|
||
const float dp = Layout::dpiScale();
|
||
auto tier = Layout::currentTier(availWidth, miningAvail.y);
|
||
(void)tier;
|
||
|
||
int max_threads = GetMaxMiningThreads();
|
||
|
||
if (!s_threads_initialized) {
|
||
int saved = app->settings()->getPoolThreads();
|
||
if (mining.generate)
|
||
s_selected_threads = ClampMiningThreads(mining.genproclimit, max_threads);
|
||
else if (saved > 0)
|
||
s_selected_threads = ClampMiningThreads(saved, max_threads);
|
||
else
|
||
s_selected_threads = 1;
|
||
s_threads_initialized = true;
|
||
}
|
||
|
||
// Sync thread grid with actual count when idle thread scaling adjusts threads
|
||
// Skip during benchmark — the benchmark controls thread counts directly
|
||
if (app->settings()->getMineWhenIdle() && app->settings()->getIdleThreadScaling() && !s_drag_active && !IsMiningBenchmarkActive()) {
|
||
if (s_pool_mode && state.pool_mining.xmrig_running) {
|
||
// Use the requested thread count (available immediately) rather
|
||
// than threads_active from the xmrig API which lags during restarts.
|
||
int reqThreads = app->getXmrigRequestedThreads();
|
||
if (reqThreads > 0)
|
||
s_selected_threads = ClampMiningThreads(reqThreads, max_threads);
|
||
else if (state.pool_mining.threads_active > 0)
|
||
s_selected_threads = ClampMiningThreads(state.pool_mining.threads_active, max_threads);
|
||
} else if (mining.generate && mining.genproclimit > 0) {
|
||
s_selected_threads = ClampMiningThreads(mining.genproclimit, max_threads);
|
||
}
|
||
}
|
||
|
||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||
GlassPanelSpec glassSpec;
|
||
glassSpec.rounding = Layout::glassRounding();
|
||
ImFont* ovFont = Type().overline();
|
||
ImFont* capFont = Type().caption();
|
||
ImFont* sub1 = Type().subtitle1();
|
||
if (!ovFont || !capFont || !sub1) {
|
||
return;
|
||
}
|
||
char buf[128];
|
||
|
||
// Load pool state from settings on first frame
|
||
if (!s_pool_state_loaded) {
|
||
s_pool_mode = app->settings()->getPoolMode();
|
||
strncpy(s_pool_url, app->settings()->getPoolUrl().c_str(), sizeof(s_pool_url) - 1);
|
||
strncpy(s_pool_worker, app->settings()->getPoolWorker().c_str(), sizeof(s_pool_worker) - 1);
|
||
s_pool_state_loaded = true;
|
||
}
|
||
// Kick off a one-shot miner-version check the first time the pool section is shown, then cache
|
||
// the latest tag once it arrives (so the "Update" button can display it). Network call.
|
||
if (s_pool_mode && !s_xmrig_check_started) {
|
||
s_xmrig_version_check.startCheck(app->settings()->getXmrigVersion());
|
||
s_xmrig_check_started = true;
|
||
}
|
||
if (s_xmrig_check_started && s_xmrig_latest_tag.empty()) {
|
||
const auto _vp = s_xmrig_version_check.getProgress();
|
||
if (!_vp.latest_tag.empty()) s_xmrig_latest_tag = _vp.latest_tag;
|
||
}
|
||
|
||
if (!app->supportsSoloMining() && !s_pool_mode) {
|
||
s_pool_mode = true;
|
||
app->settings()->setPoolMode(true);
|
||
app->settings()->save();
|
||
}
|
||
|
||
// Default pool worker to user's first shielded (z) address once available.
|
||
// For new wallets without a z-address, leave the field blank so the user
|
||
// is prompted to generate one before mining.
|
||
{
|
||
static bool s_pool_worker_defaulted = false;
|
||
std::string workerStr(s_pool_worker);
|
||
if (shouldDefaultPoolWorker(workerStr, s_pool_worker_defaulted) && !state.addresses.empty()) {
|
||
std::string defaultAddr = defaultPoolWorkerAddress(state.addresses);
|
||
if (!defaultAddr.empty()) {
|
||
strncpy(s_pool_worker, defaultAddr.c_str(), sizeof(s_pool_worker) - 1);
|
||
s_pool_worker[sizeof(s_pool_worker) - 1] = '\0';
|
||
s_pool_settings_dirty = true;
|
||
} else {
|
||
// No z-address yet — clear the placeholder "x" so field shows empty
|
||
s_pool_worker[0] = '\0';
|
||
s_pool_settings_dirty = true;
|
||
}
|
||
s_pool_worker_defaulted = true;
|
||
}
|
||
}
|
||
|
||
// Persist pool settings when dirty and no field is active
|
||
if (s_pool_settings_dirty && !ImGui::IsAnyItemActive()) {
|
||
// Trim whitespace/newlines (a pasted URL or payout address often carries a trailing newline)
|
||
// before persisting and feeding it to xmrig; write the trimmed value back so the field agrees.
|
||
auto trimmed = [](const char* b) {
|
||
std::string s(b);
|
||
while (!s.empty() && (s.front()==' '||s.front()=='\t'||s.front()=='\n'||s.front()=='\r')) s.erase(s.begin());
|
||
while (!s.empty() && (s.back()==' '||s.back()=='\t'||s.back()=='\n'||s.back()=='\r')) s.pop_back();
|
||
return s;
|
||
};
|
||
std::string poolUrl = trimmed(s_pool_url), poolWorker = trimmed(s_pool_worker);
|
||
snprintf(s_pool_url, sizeof(s_pool_url), "%s", poolUrl.c_str());
|
||
snprintf(s_pool_worker, sizeof(s_pool_worker), "%s", poolWorker.c_str());
|
||
app->settings()->setPoolUrl(poolUrl);
|
||
app->settings()->setPoolWorker(poolWorker);
|
||
app->settings()->save();
|
||
s_pool_settings_dirty = false;
|
||
|
||
// Auto-restart pool miner if it is currently running so the new
|
||
// URL / worker address takes effect immediately.
|
||
if (state.pool_mining.xmrig_running) {
|
||
app->stopPoolMining();
|
||
app->startPoolMining(s_selected_threads);
|
||
}
|
||
}
|
||
|
||
// Determine active mining state for UI
|
||
// Include pool mining running state even when user just switched to solo,
|
||
// so the button shows STOP/STOPPING while xmrig shuts down.
|
||
bool isMiningActive = IsPoolMiningActive(s_pool_mode,
|
||
state.pool_mining.xmrig_running,
|
||
mining.generate);
|
||
|
||
// ================================================================
|
||
// Thread Benchmark state machine — runs pool mining at each candidate
|
||
// thread count to find the optimal setting for this CPU.
|
||
// ================================================================
|
||
if (s_benchmark.active()) {
|
||
auto benchmarkUpdate = AdvanceThreadBenchmark(
|
||
s_benchmark, ImGui::GetIO().DeltaTime, state.pool_mining.hashrate_10s);
|
||
if (benchmarkUpdate.stopPoolMining) {
|
||
app->stopPoolMining();
|
||
}
|
||
if (benchmarkUpdate.saveOptimalThreads) {
|
||
s_selected_threads = benchmarkUpdate.optimalThreads;
|
||
app->settings()->setPoolThreads(s_selected_threads);
|
||
app->settings()->save();
|
||
}
|
||
if (benchmarkUpdate.startPoolMining) {
|
||
app->startPoolMining(benchmarkUpdate.startThreads);
|
||
}
|
||
if (benchmarkUpdate.inconclusive) {
|
||
Notifications::instance().warning(
|
||
"Benchmark inconclusive: no hashrate samples were recorded. "
|
||
"Check the pool connection and try again.");
|
||
}
|
||
}
|
||
|
||
// ================================================================
|
||
// Proportional section budget — ensures all content fits without
|
||
// scrolling at the minimum window size (1024×775).
|
||
// ================================================================
|
||
float sHdr = ovFont->LegacySize + Layout::spacingXs()
|
||
+ ImGui::GetStyle().ItemSpacing.y * 2.0f;
|
||
float gapOver = gap + ImGui::GetStyle().ItemSpacing.y;
|
||
// 3 sections with headers (CHART+STATS, DETAILS+EARNINGS, BLOCKS)
|
||
float totalOverhead = 3.0f * (sHdr + gapOver) + 1.0f * gapOver;
|
||
float cardBudget = std::max(schema::UI().drawElement("tabs.mining", "card-budget-min").size, miningAvail.y - totalOverhead);
|
||
|
||
Layout::SectionBudget cb(cardBudget);
|
||
float controlsBudgetH = cb.allocate(0.26f, 80.0f * dp);
|
||
float chartBudgetH = cb.allocate(0.22f, 60.0f * dp);
|
||
(void)cb; // remaining budget used by combined earnings+details card
|
||
|
||
// ================================================================
|
||
// MODE TOGGLE — SOLO | POOL segmented control
|
||
// ================================================================
|
||
RenderMiningModeToggle(app, state, mining, dl, capFont, ovFont, dp, hs, gap, availWidth,
|
||
s_pool_mode, s_pool_url, s_pool_worker, s_pool_settings_dirty);
|
||
|
||
// Payout safety (multi-wallet): in pool mode, warn if the worker looks like a DragonX address
|
||
// that isn't in the CURRENT wallet — e.g. left over from a wallet you switched away from, so
|
||
// rewards would land in the wrong wallet. Non-destructive: we only warn, never overwrite (it
|
||
// could be a deliberate external payout).
|
||
if (s_pool_mode) {
|
||
std::string w(s_pool_worker);
|
||
while (!w.empty() && (w.front()==' '||w.front()=='\t'||w.front()=='\n'||w.front()=='\r')) w.erase(w.begin());
|
||
while (!w.empty() && (w.back()==' '||w.back()=='\t'||w.back()=='\n'||w.back()=='\r')) w.pop_back();
|
||
const bool looksLikeAddr = w.size() > 10 && (w.rfind("zs", 0) == 0 || w[0] == 'R');
|
||
bool inWallet = false;
|
||
for (const auto& a : state.addresses) if (a.address == w) { inWallet = true; break; }
|
||
if (looksLikeAddr && !inWallet && !state.addresses.empty()) {
|
||
ImGui::Dummy(ImVec2(0, gap));
|
||
Type().textColored(TypeStyle::Caption, Warning(), TR("mining_payout_foreign"));
|
||
}
|
||
}
|
||
|
||
// ================================================================
|
||
// CONTROLS — Glass card with CPU core grid (no heading)
|
||
// ================================================================
|
||
RenderMiningControls(app, state, mining, dl, capFont, sub1, ovFont, dp, hs, vs, gap, pad,
|
||
availWidth, glassSpec, controlsBudgetH, max_threads, isMiningActive,
|
||
s_pool_mode, s_pool_worker, s_xmrig_latest_tag, s_benchmark,
|
||
s_selected_threads, s_drag_active, s_drag_anchor_thread);
|
||
|
||
// (The miner download/update control lives in the mining-control header row, next to the
|
||
// benchmark button — see the "Miner update" block above.)
|
||
|
||
// ================================================================
|
||
// HASHRATE + STATS — Combined glass card: stat values on top, chart below
|
||
// (Or full-card log view when toggled in pool mode)
|
||
// ================================================================
|
||
RenderMiningStats(state, mining, dl, capFont, sub1, ovFont, dp, vs, gap, pad,
|
||
availWidth, glassSpec, chartBudgetH, s_pool_mode,
|
||
app, s_pool_url, s_pool_settings_dirty);
|
||
|
||
// ================================================================
|
||
// EARNINGS — Horizontal row card (Today | Yesterday | All Time | Est. Daily)
|
||
// ================================================================
|
||
RenderMiningEarnings(app, state, mining, dl, capFont, sub1, ovFont, dp, vs, gap, pad,
|
||
isMiningActive, s_pool_mode, availWidth, glassSpec, sHdr, gapOver);
|
||
|
||
// ================================================================
|
||
// POOL CONNECTION STATUS — inline indicator (pool mode, no log)
|
||
// ================================================================
|
||
if (s_pool_mode && state.pool_mining.log_lines.empty() && state.pool_mining.xmrig_running) {
|
||
ImFont* iconFont = Type().iconSmall();
|
||
const char* dotIcon = ICON_MD_CIRCLE;
|
||
ImU32 dotCol = state.pool_mining.connected ? WithAlpha(Success(), 200) : WithAlpha(Error(), 200);
|
||
const char* statusText = state.pool_mining.connected
|
||
? (state.pool_mining.pool_url.empty() ? TR("mining_connected") : state.pool_mining.pool_url.c_str())
|
||
: TR("mining_connecting");
|
||
|
||
ImVec2 pos = ImGui::GetCursorScreenPos();
|
||
ImVec2 dotSz = iconFont->CalcTextSizeA(iconFont->LegacySize, 1000.0f, 0.0f, dotIcon);
|
||
dl->AddText(iconFont, iconFont->LegacySize, pos, dotCol, dotIcon);
|
||
dl->AddText(capFont, capFont->LegacySize,
|
||
ImVec2(pos.x + dotSz.x + 4 * dp, pos.y + (dotSz.y - capFont->LegacySize) * 0.5f),
|
||
OnSurfaceMedium(), statusText);
|
||
ImGui::Dummy(ImVec2(availWidth, capFont->LegacySize + 4 * dp));
|
||
}
|
||
}
|
||
|
||
} // namespace ui
|
||
} // namespace dragonx
|