Files
ObsidianDragon/src/ui/windows/mining_tab.cpp
T
DanSandClaude Opus 4.8 06afbee4f8 fix(mining): remediate mining-tab audit (22 findings) — crash-safety, async control, validation, math
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>
2026-08-18 14:53:46 -05:00

358 lines
16 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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