diff --git a/res/fonts/NotoSansCJK-Subset.ttf b/res/fonts/NotoSansCJK-Subset.ttf index 9d09bd0..5d58ab0 100644 Binary files a/res/fonts/NotoSansCJK-Subset.ttf and b/res/fonts/NotoSansCJK-Subset.ttf differ diff --git a/res/lang/de.json b/res/lang/de.json index dee943f..450dbc1 100644 --- a/res/lang/de.json +++ b/res/lang/de.json @@ -886,6 +886,7 @@ "mining_difficulty_copied": "Schwierigkeit kopiert", "mining_est_block": "Gesch. Block", "mining_est_daily": "Gesch. täglich", + "mining_est_daily_pool_sub": "grobe Solo-Äquivalenz, vor Pool-Gebühr", "mining_filter_all": "Alle", "mining_filter_tip_all": "Alle Einnahmen anzeigen", "mining_filter_tip_pool": "Nur Pool-Einnahmen anzeigen", @@ -914,6 +915,7 @@ "mining_open_in_explorer": "Im Explorer öffnen", "mining_payout_address": "Auszahlungsadresse", "mining_payout_foreign": "⚠ Diese Auszahlungsadresse befindet sich nicht in Ihrer aktuellen Wallet — geschürfte Belohnungen würden an eine andere Wallet gehen. Aktualisieren Sie sie, wenn Sie die Wallet gewechselt haben.", + "mining_payout_invalid": "Keine gültige DragonX-Adresse — vor dem Start korrigieren, sonst gehen die Mining-Belohnungen verloren.", "mining_payout_tooltip": "Adresse für Mining-Belohnungen", "mining_pool": "Pool", "mining_pool_fee": "Gebühr", diff --git a/res/lang/es.json b/res/lang/es.json index 78009cd..4bf7d17 100644 --- a/res/lang/es.json +++ b/res/lang/es.json @@ -886,6 +886,7 @@ "mining_difficulty_copied": "Dificultad copiada", "mining_est_block": "Bloque Est.", "mining_est_daily": "Diario Est.", + "mining_est_daily_pool_sub": "equivalente solo aproximado, antes de la comisión del pool", "mining_filter_all": "Todos", "mining_filter_tip_all": "Mostrar todas las ganancias", "mining_filter_tip_pool": "Mostrar solo ganancias del pool", @@ -914,6 +915,7 @@ "mining_open_in_explorer": "Abrir en explorador", "mining_payout_address": "Dirección de Pago", "mining_payout_foreign": "⚠ Esta dirección de pago no está en tu cartera actual — las recompensas minadas irían a otra cartera. Actualízala si cambiaste de cartera.", + "mining_payout_invalid": "No es una dirección DragonX válida — corrígela antes de empezar, o se pierden las recompensas de minería.", "mining_payout_tooltip": "Dirección para recibir recompensas de minería", "mining_pool": "Pool", "mining_pool_fee": "Comisión", diff --git a/res/lang/fr.json b/res/lang/fr.json index b5ae70a..2030811 100644 --- a/res/lang/fr.json +++ b/res/lang/fr.json @@ -886,6 +886,7 @@ "mining_difficulty_copied": "Difficulté copiée", "mining_est_block": "Bloc est.", "mining_est_daily": "Est. quotidien", + "mining_est_daily_pool_sub": "équivalent solo approximatif, avant les frais du pool", "mining_filter_all": "Tout", "mining_filter_tip_all": "Afficher tous les gains", "mining_filter_tip_pool": "Afficher uniquement les gains du pool", @@ -914,6 +915,7 @@ "mining_open_in_explorer": "Ouvrir dans l'explorateur", "mining_payout_address": "Adresse de paiement", "mining_payout_foreign": "⚠ Cette adresse de paiement ne fait pas partie de votre portefeuille actuel — les récompenses minées iraient vers un autre portefeuille. Mettez-la à jour si vous avez changé de portefeuille.", + "mining_payout_invalid": "Adresse DragonX invalide — corrigez-la avant de démarrer, sinon les récompenses de minage sont perdues.", "mining_payout_tooltip": "Adresse pour recevoir les récompenses de minage", "mining_pool": "Pool", "mining_pool_fee": "Frais", diff --git a/res/lang/ja.json b/res/lang/ja.json index 931fb5d..72e5117 100644 --- a/res/lang/ja.json +++ b/res/lang/ja.json @@ -886,6 +886,7 @@ "mining_difficulty_copied": "難易度をコピーしました", "mining_est_block": "予測ブロック", "mining_est_daily": "予測日収", + "mining_est_daily_pool_sub": "おおよそのソロ換算(プール手数料前)", "mining_filter_all": "すべて", "mining_filter_tip_all": "すべての収益を表示", "mining_filter_tip_pool": "プール収益のみ表示", @@ -914,6 +915,7 @@ "mining_open_in_explorer": "エクスプローラーで開く", "mining_payout_address": "支払いアドレス", "mining_payout_foreign": "⚠ この支払いアドレスは現在のウォレットに含まれていません — マイニング報酬が別のウォレットに送られます。ウォレットを切り替えた場合は更新してください。", + "mining_payout_invalid": "有効な DragonX アドレスではありません — 開始前に修正してください。さもないとマイニング報酬が失われます。", "mining_payout_tooltip": "マイニング報酬の受取アドレス", "mining_pool": "プール", "mining_pool_fee": "手数料", diff --git a/res/lang/ko.json b/res/lang/ko.json index 0a3d210..05aa546 100644 --- a/res/lang/ko.json +++ b/res/lang/ko.json @@ -885,6 +885,7 @@ "mining_difficulty_copied": "난이도가 복사되었습니다", "mining_est_block": "예상 블록", "mining_est_daily": "예상 일일 수익", + "mining_est_daily_pool_sub": "대략적인 솔로 환산, 풀 수수료 전", "mining_filter_all": "전체", "mining_filter_tip_all": "모든 수익 표시", "mining_filter_tip_pool": "풀 수익만 표시", @@ -913,6 +914,7 @@ "mining_open_in_explorer": "탐색기에서 열기", "mining_payout_address": "지급 주소", "mining_payout_foreign": "⚠ 이 지급 주소는 현재 지갑에 없습니다 — 채굴한 보상이 다른 지갑으로 전송됩니다. 지갑을 전환했다면 주소를 업데이트하세요.", + "mining_payout_invalid": "유효한 DragonX 주소가 아닙니다 — 시작하기 전에 수정하세요. 그렇지 않으면 채굴 보상이 사라집니다.", "mining_payout_tooltip": "채굴 보상 수신 주소", "mining_pool": "풀", "mining_pool_fee": "수수료", diff --git a/res/lang/pt.json b/res/lang/pt.json index 6d0c0bf..07bceae 100644 --- a/res/lang/pt.json +++ b/res/lang/pt.json @@ -886,6 +886,7 @@ "mining_difficulty_copied": "Dificuldade copiada", "mining_est_block": "Bloco Est.", "mining_est_daily": "Est. Diário", + "mining_est_daily_pool_sub": "equivalente solo aproximado, antes da taxa do pool", "mining_filter_all": "Todos", "mining_filter_tip_all": "Mostrar todos os ganhos", "mining_filter_tip_pool": "Mostrar apenas ganhos do pool", @@ -914,6 +915,7 @@ "mining_open_in_explorer": "Abrir no explorador", "mining_payout_address": "Endereço de Pagamento", "mining_payout_foreign": "⚠ Este endereço de pagamento não está na sua carteira atual — as recompensas mineradas iriam para uma carteira diferente. Atualize-o se você trocou de carteira.", + "mining_payout_invalid": "Endereço DragonX inválido — corrija antes de iniciar, ou as recompensas de mineração serão perdidas.", "mining_payout_tooltip": "Endereço para receber recompensas de mineração", "mining_pool": "Pool", "mining_pool_fee": "Taxa", diff --git a/res/lang/ru.json b/res/lang/ru.json index e476059..b6842d2 100644 --- a/res/lang/ru.json +++ b/res/lang/ru.json @@ -886,6 +886,7 @@ "mining_difficulty_copied": "Сложность скопирована", "mining_est_block": "Расч. блок", "mining_est_daily": "Расч. за день", + "mining_est_daily_pool_sub": "примерный соло-эквивалент, до комиссии пула", "mining_filter_all": "Все", "mining_filter_tip_all": "Показать все доходы", "mining_filter_tip_pool": "Показать только доходы пула", @@ -914,6 +915,7 @@ "mining_open_in_explorer": "Открыть в обозревателе", "mining_payout_address": "Адрес выплат", "mining_payout_foreign": "⚠ Этот адрес выплат отсутствует в вашем текущем кошельке — намайненные вознаграждения будут отправлены в другой кошелёк. Обновите его, если вы сменили кошелёк.", + "mining_payout_invalid": "Недействительный адрес DragonX — исправьте перед запуском, иначе награды за майнинг будут потеряны.", "mining_payout_tooltip": "Адрес для получения вознаграждений за майнинг", "mining_pool": "Пул", "mining_pool_fee": "Комиссия", diff --git a/res/lang/zh.json b/res/lang/zh.json index fa7c265..7c51509 100644 --- a/res/lang/zh.json +++ b/res/lang/zh.json @@ -885,6 +885,7 @@ "mining_difficulty_copied": "难度已复制", "mining_est_block": "预计区块", "mining_est_daily": "预计日收益", + "mining_est_daily_pool_sub": "粗略的单人挖矿等值,扣除矿池费用前", "mining_filter_all": "全部", "mining_filter_tip_all": "显示所有收益", "mining_filter_tip_pool": "仅显示矿池收益", @@ -913,6 +914,7 @@ "mining_open_in_explorer": "在浏览器中打开", "mining_payout_address": "支付地址", "mining_payout_foreign": "⚠ 此支付地址不在您当前的钱包中——挖矿奖励将进入另一个钱包。如果您切换过钱包,请更新它。", + "mining_payout_invalid": "不是有效的 DragonX 地址——启动前请更正,否则挖矿奖励将丢失。", "mining_payout_tooltip": "接收挖矿奖励的地址", "mining_pool": "矿池", "mining_pool_fee": "费用", diff --git a/src/app.cpp b/src/app.cpp index 2991453..7a94b2a 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -147,6 +147,45 @@ void App::wipeSecrets() sodium_memzero(import_key_input_, sizeof(import_key_input_)); // pasted private key (SECRET) } +// Enqueue a blocking xmrig start/stop op onto the dedicated serialized control thread so the render thread +// never blocks on stop()'s SIGTERM->SIGKILL->join, while start/stop still execute in FIFO order. (M-03/…) +void App::postMiningControl(std::function job) +{ + { + std::lock_guard lk(mining_ctl_mutex_); + if (mining_ctl_stop_) return; // shutting down — don't enqueue new mining ops + if (!mining_ctl_thread_.joinable()) { + mining_ctl_thread_ = std::thread([this]() { + for (;;) { + std::function j; + { + std::unique_lock lk(mining_ctl_mutex_); + mining_ctl_cv_.wait(lk, [this]{ return mining_ctl_stop_ || !mining_ctl_queue_.empty(); }); + if (mining_ctl_stop_) return; // abandon any pending jobs on shutdown + j = std::move(mining_ctl_queue_.front()); + mining_ctl_queue_.pop_front(); + } + j(); + } + }); + } + mining_ctl_queue_.push_back(std::move(job)); + } + mining_ctl_cv_.notify_one(); +} + +// Signal the mining-control thread to stop and join it. Called at shutdown BEFORE xmrig_manager_ is stopped +// or destroyed, so no control job runs concurrently with teardown. Idempotent. +void App::stopMiningControlThread() +{ + { + std::lock_guard lk(mining_ctl_mutex_); + mining_ctl_stop_ = true; + } + mining_ctl_cv_.notify_all(); + if (mining_ctl_thread_.joinable()) mining_ctl_thread_.join(); +} + namespace { // How often auto-balance re-evaluates the pool while active. Long, because switching // restarts the miner (drops in-flight shares + reconnect); the incumbent stickiness @@ -166,6 +205,7 @@ void App::updatePoolAutoBalance() if (!supportsPoolMining()) return; // pool mining is available in both builds (solo is full-node only) if (settings_->getPoolSelectMode() != config::Settings::PoolSelectMode::AutoBalance) return; if (!settings_->getPoolMode()) return; // only while POOL mode is selected + if (ui::IsMiningBenchmarkActive()) return; // don't auto-switch pools mid-benchmark — it restarts xmrig at the wrong thread count (L-12) const long long now = steadyNowMs(); const bool intervalDue = (last_balance_eval_ms_ == 0) || @@ -965,6 +1005,20 @@ void App::update() } } + // Surface an unexpected miner exit (crash / OOM-kill / external SIGKILL) once, and clear the stale + // running flag so the UI and auto-balance don't keep believing it's still hashing. (L-03, L-10) + if (xmrig_manager_ && state_.pool_mining.xmrig_running + && xmrig_manager_->getState() == daemon::XmrigManager::State::Error) { + state_.pool_mining.xmrig_running = false; + state_.pool_mining.hashrate_10s = 0.0; + state_.pool_mining.hashrate_60s = 0.0; + state_.pool_mining.hashrate_15m = 0.0; + pool_starting_.store(false, std::memory_order_relaxed); + const std::string err = xmrig_manager_->getLastError(); + ui::Notifications::instance().error(err.empty() ? "Miner stopped unexpectedly." + : ("Miner stopped: " + err)); + } + // Poll xmrig stats every ~2 seconds (use a simple toggle) static bool xmrig_poll_tick = false; xmrig_poll_tick = !xmrig_poll_tick; @@ -1797,6 +1851,10 @@ void App::render() if ((current_page_ == ui::NavPage::Console || current_page_ == ui::NavPage::LiteConsole) && settings_ && settings_->getConsoleAutoFocus()) console_tab_.requestInputFocus(); + // Leaving the Mining tab → cancel a running thread benchmark so the miner isn't abandoned at a + // benchmark step. (L-04) + if (prev_page_ == ui::NavPage::Mining && current_page_ != ui::NavPage::Mining) + ui::CancelMiningBenchmark(this); prev_page_ = current_page_; } if (page_alpha_ < 1.0f) { @@ -5595,6 +5653,10 @@ void App::beginShutdown() fast_worker_->requestStop(); } + // Drain + join the mining-control thread FIRST so no async start/stop job runs while we tear the miner + // down here (avoids two threads driving xmrig_manager_ during shutdown). (M-03 cluster) + stopMiningControlThread(); + // Stop xmrig pool miner before stopping the daemon if (xmrig_manager_ && xmrig_manager_->isRunning()) { shutdown_status_ = "Stopping pool miner..."; @@ -6380,6 +6442,10 @@ void App::renderLoadingOverlay(float contentH) void App::shutdown() { + // Ensure the mining-control thread is stopped + joined (idempotent; beginShutdown already did it on the + // normal quit path, but shutdown() can also run without it). Must precede xmrig_manager_ teardown. + stopMiningControlThread(); + // Wipe any copied secret from the OS clipboard before we exit — the 45s auto-clear timer // never fires if the user quits sooner, which would otherwise leave a key/seed resident. // (ImGui context is still alive here; App::shutdown() runs before ImGui::DestroyContext().) diff --git a/src/app.h b/src/app.h index 48c9cac..323f1d1 100644 --- a/src/app.h +++ b/src/app.h @@ -14,6 +14,7 @@ #include #include #include +#include #include #include "data/transaction_history_cache.h" #include "data/address_book.h" @@ -855,6 +856,16 @@ private: bool lite_startup_lock_checked_ = false; std::unique_ptr daemon_controller_; std::unique_ptr xmrig_manager_; + // Serialized async mining-control queue: xmrig start/stop (SIGTERM->SIGKILL->join, up to ~3s) run on + // this dedicated FIFO thread instead of the render thread, so the UI never blocks and stop/start + // ordering is preserved across the ~13 call sites. (M-03/L-06/L-08/L-09/L-13) + std::thread mining_ctl_thread_; + std::mutex mining_ctl_mutex_; + std::condition_variable mining_ctl_cv_; + std::deque> mining_ctl_queue_; + bool mining_ctl_stop_ = false; + void postMiningControl(std::function job); // enqueue a blocking xmrig op onto the FIFO thread + void stopMiningControlThread(); // signal + join the control thread (shutdown) // Auto-balance runtime state (pool mining, full-node only). The service fetches // pool hashrates off-thread; the RNG drives the weighted-random pick. util::PoolStatsService pool_stats_service_; diff --git a/src/app_network.cpp b/src/app_network.cpp index e586e9e..64b0eca 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -63,6 +63,7 @@ #include // popen the rebuild helper #include "util/perf_log.h" #include "util/i18n.h" +#include "util/address_validation.h" // isValidRecipientAddress — payout validation at every start path (M-01) #include "util/secure_vault.h" #include @@ -2338,6 +2339,10 @@ void App::startMining(int threads) return; } if (!state_.connected || !rpc_ || !worker_) return; + // Clamp the requested thread count to [1, logical cores] before setgenerate — an unclamped value + // (from a settings field or idle-scaling) would ask the daemon to spawn arbitrarily many threads. (M-08) + const int maxThreads = std::max(1, (int)std::thread::hardware_concurrency()); + threads = std::clamp(threads, 1, maxThreads); if (mining_toggle_in_progress_.exchange(true)) return; // already in progress worker_->post([this, threads]() -> rpc::RPCWorker::MainCb { @@ -2372,19 +2377,25 @@ void App::stopMining() worker_->post([this]() -> rpc::RPCWorker::MainCb { bool ok = false; + std::string errMsg; try { rpc::RPCClient::TraceScope trace("Mining tab / Stop mining"); rpc_->call("setgenerate", {false, 0}); ok = true; } catch (const std::exception& e) { - DEBUG_LOGF("Failed to stop mining: %s\n", e.what()); + errMsg = e.what(); + DEBUG_LOGF("Failed to stop mining: %s\n", errMsg.c_str()); } - return [this, ok]() { + return [this, ok, errMsg]() { mining_toggle_in_progress_.store(false); if (ok) { state_.mining.generate = false; state_.mining.localHashrate = 0.0; DEBUG_LOGF("Mining stopped\n"); + } else { + // Don't silently leave generate=true as if it worked: tell the user and let the next + // getmininginfo refresh reconcile the true daemon state. (M-07) + ui::Notifications::instance().error("Failed to stop mining: " + errMsg); } }; }); @@ -2397,21 +2408,28 @@ void App::startPoolMining(int threads) ui::Notifications::instance().warning("Pool mining is unavailable in this build"); return; } + // Clamp to [1, logical cores] before the count reaches xmrig (M-06/M-08 pool path). + threads = std::clamp(threads, 1, std::max(1, (int)std::thread::hardware_concurrency())); if (!xmrig_manager_) xmrig_manager_ = std::make_unique(); - // If already running, stop first (e.g. thread count change) - if (xmrig_manager_->isRunning()) { - xmrig_manager_->stop(); - } - - // Stop solo mining first if active + // Stop solo mining first if active (async via the RPC worker). if (state_.mining.generate) stopMining(); + // (the "stop the already-running miner first" step is done inside the control job below, in FIFO order) daemon::XmrigManager::Config cfg; cfg.pool_url = settings_->getPoolUrl(); cfg.worker_name = settings_->getPoolWorker(); + // Validate the payout address at EVERY start entry point (manual Start button, idle auto-start, thread + // scaling) — not just the UI gate — since a stale/hand-edited/wrong-chain address here silently loses + // mining rewards. (M-01) worker_name IS the pool login the rewards are credited to (see below). + if (!cfg.worker_name.empty() && cfg.worker_name != "x" && + !util::isValidRecipientAddress(cfg.worker_name)) { + ui::Notifications::instance().error( + "Pool payout address is not a valid DragonX address — mining not started."); + return; + } // The algo follows the pool: official pools use their own algo (pool.dragonx.cc // needs rx/dragonx, pool.dragonx.is rx/hush); custom hosts keep the setting. cfg.algo = util::resolvePoolAlgo(cfg.pool_url, settings_->getPoolAlgo()); @@ -2443,34 +2461,46 @@ void App::startPoolMining(int threads) return; } - if (!xmrig_manager_->start(cfg)) { - std::string err = xmrig_manager_->getLastError(); - DEBUG_LOGF("[ERROR] Pool mining: %s\n", err.c_str()); - - // Check for Windows Defender blocking (error 225 = ERROR_VIRUS_INFECTED) - if (err.find("error 225") != std::string::npos || - err.find("virus") != std::string::npos) { - ui::Notifications::instance().error( - "Windows Defender blocked xmrig. Add exclusion for %APPDATA%\\ObsidianDragon"); + // Run the blocking stop(if running)+start on the serialized mining-control thread so the render thread + // never blocks on stop()'s SIGTERM->SIGKILL->join; marshal the spawn result back to the UI. (M-03/L-06/ + // L-08/L-09/L-13). cfg was fully built above on this (main) thread. + daemon::XmrigManager::Config cfgCopy = cfg; + postMiningControl([this, cfgCopy]() { + if (xmrig_manager_->isRunning()) xmrig_manager_->stop(3000); + const bool ok = xmrig_manager_->start(cfgCopy); + const std::string err = ok ? std::string() : xmrig_manager_->getLastError(); + if (!worker_) return; + worker_->post([this, ok, err]() -> rpc::RPCWorker::MainCb { + return [this, ok, err]() { + if (ok) { + // Miner spawned — it still needs a few seconds to connect to the pool and start hashing. + pool_starting_.store(true, std::memory_order_relaxed); + ui::Notifications::instance().info("Starting pool miner — connecting to the pool…"); + } else { + DEBUG_LOGF("[ERROR] Pool mining: %s\n", err.c_str()); + // Windows Defender blocking (error 225 = ERROR_VIRUS_INFECTED) + if (err.find("error 225") != std::string::npos || err.find("virus") != std::string::npos) { + ui::Notifications::instance().error( + "Windows Defender blocked xmrig. Add exclusion for %APPDATA%\\ObsidianDragon"); #ifdef _WIN32 - // Offer to open Windows Security settings - pending_antivirus_dialog_ = true; + pending_antivirus_dialog_ = true; #endif - } else { - ui::Notifications::instance().error("Failed to start pool miner: " + err); - } - } else { - // Miner spawned — it still needs a few seconds to connect to the pool and start hashing. - pool_starting_.store(true, std::memory_order_relaxed); - ui::Notifications::instance().info("Starting pool miner — connecting to the pool…"); - } + } else { + ui::Notifications::instance().error("Failed to start pool miner: " + err); + } + } + }; + }); + }); } void App::stopPoolMining() { - if (xmrig_manager_ && xmrig_manager_->isRunning()) { - xmrig_manager_->stop(3000); - } + if (!xmrig_manager_) return; + // Off the render thread — stop()'s SIGTERM->SIGKILL->join can block up to ~3s. (M-03/L-06/L-08/L-09) + postMiningControl([this]() { + if (xmrig_manager_->isRunning()) xmrig_manager_->stop(3000); + }); } // ============================================================================ diff --git a/src/app_security.cpp b/src/app_security.cpp index 192f986..845894b 100644 --- a/src/app_security.cpp +++ b/src/app_security.cpp @@ -759,6 +759,10 @@ void App::checkIdleMining() { // Resolve auto values: active defaults to half, idle defaults to all if (activeThreads <= 0) activeThreads = std::max(1, maxThreads / 2); if (idleThreads <= 0) idleThreads = maxThreads; + // Clamp to [1, logical cores] before these reach setgenerate / startPoolMining — a settings field + // could otherwise carry an arbitrary count straight past every bound. (M-06) + activeThreads = std::clamp(activeThreads, 1, maxThreads); + idleThreads = std::clamp(idleThreads, 1, maxThreads); if (systemIdle) { // System is idle — scale up to idle thread count diff --git a/src/daemon/xmrig_manager.cpp b/src/daemon/xmrig_manager.cpp index 9ca5a76..4d76c21 100644 --- a/src/daemon/xmrig_manager.cpp +++ b/src/daemon/xmrig_manager.cpp @@ -89,8 +89,32 @@ static std::string getConfigDir() { // libcurl write callback static size_t curlWriteCb(void* ptr, size_t sz, size_t n, void* userdata) { auto* s = static_cast(userdata); - s->append(static_cast(ptr), sz * n); - return sz * n; + const size_t add = sz * n; + // Stats JSON (local xmrig HTTP API + pool API) is tiny; refuse an unbounded body from a hostile or + // MITM'd endpoint so it can't grow this string until OOM. Returning < add aborts the transfer. (L-02) + constexpr size_t kMaxStatsBytes = 1u << 20; // 1 MiB + if (s->size() + add > kMaxStatsBytes) return 0; + s->append(static_cast(ptr), add); + return add; +} + +// True if `host` (already stripped of scheme+port) is a loopback/private/link-local/single-label target +// that a public mining pool would never be — used to refuse a background stats GET to it (M-09). +static bool hostLooksInternal(const std::string& host) { + if (host.empty() || host == "localhost") return true; + if (host.rfind("127.", 0) == 0 || host.rfind("10.", 0) == 0 || + host.rfind("192.168.", 0) == 0 || host.rfind("169.254.", 0) == 0) return true; + if (host.rfind("172.", 0) == 0) { // 172.16.0.0 - 172.31.255.255 + const int second = std::atoi(host.c_str() + 4); + if (second >= 16 && second <= 31) return true; + } + if (host.find(':') != std::string::npos) { // IPv6 literal: loopback / ULA / link-local + if (host == "::1" || host.rfind("fc", 0) == 0 || host.rfind("fd", 0) == 0 || + host.rfind("fe80", 0) == 0) return true; + } + if (host.size() >= 6 && host.compare(host.size() - 6, 6, ".local") == 0) return true; + if (host.find('.') == std::string::npos) return true; // bare single-label name = LAN/hosts, not a pool + return false; } // ============================================================================ @@ -100,9 +124,14 @@ static size_t curlWriteCb(void* ptr, size_t sz, size_t n, void* userdata) { XmrigManager::XmrigManager() = default; XmrigManager::~XmrigManager() { + should_stop_ = true; if (isRunning()) { stop(3000); } + // Join a monitor thread left joinable by an unexpected xmrig exit (State::Error, so isRunning() is + // false and stop() above was skipped) — std::thread's destructor would otherwise std::terminate(). (M-04) + if (monitor_thread_.joinable()) + monitor_thread_.join(); } // ============================================================================ @@ -208,21 +237,41 @@ bool XmrigManager::generateConfig(const Config& cfg, const std::string& outPath) try { fs::create_directories(fs::path(outPath).parent_path()); + const std::string dumped = j.dump(4); +#ifndef _WIN32 + // Create the config 0600 AT CREATION (open with mode) so the API token + wallet address are never + // in a world/group-readable file — even for a local attacker who opened it in the old + // create-then-chmod window and held the fd open across the chmod. (L-01) + int fd = ::open(outPath.c_str(), O_WRONLY | O_CREAT | O_TRUNC, 0600); + if (fd < 0) { + setLastError("Cannot write xmrig config: " + outPath); + DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str()); + return false; + } + size_t off = 0; + bool wrote = true; + while (off < dumped.size()) { + ssize_t nw = ::write(fd, dumped.data() + off, dumped.size() - off); + if (nw <= 0) { wrote = false; break; } + off += static_cast(nw); + } + ::close(fd); + if (!wrote) { + setLastError("Cannot write xmrig config: " + outPath); + return false; + } + return true; +#else std::ofstream ofs(outPath, std::ios::trunc); if (!ofs.is_open()) { setLastError("Cannot write xmrig config: " + outPath); DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str()); return false; } -#ifndef _WIN32 - // Restrict to owner (0600) BEFORE writing any secret material (API token, wallet - // address, worker name). The file is still empty here, so the config is never - // world-readable — closing the window between creation and the previous post-write chmod. - chmod(outPath.c_str(), 0600); -#endif - ofs << j.dump(4); + ofs << dumped; ofs.close(); return true; +#endif } catch (const std::exception& e) { setLastError(std::string("Config write error: ") + e.what()); DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str()); @@ -296,7 +345,11 @@ bool XmrigManager::start(const Config& cfg) { return false; } - // Start monitor thread + // Join a prior monitor thread before move-assigning: if xmrig exited unexpectedly, monitorProcess set + // State::Error and returned, leaving monitor_thread_ joinable — move-assigning over a joinable + // std::thread calls std::terminate() and aborts the whole wallet. (M-04) + if (monitor_thread_.joinable()) + monitor_thread_.join(); monitor_thread_ = std::thread(&XmrigManager::monitorProcess, this); state_ = State::Running; DEBUG_LOGF("[INFO] XmrigManager: started\n"); @@ -783,6 +836,11 @@ void XmrigManager::fetchPoolApiStats() { // own API shape (pool.dragonx.is = custom /api/stats; pool.dragonx.cc = Miningcore // /api/pools); unknown/custom hosts fall back to the .is convention. const util::KnownPool* known = util::findKnownPoolByUrl(pool_host_); + // SSRF guard: for an UNKNOWN (user-typed) pool host, don't let the wallet issue a background GET to a + // loopback/private/link-local/single-label target — those aren't public mining pools, and a + // paste-a-pool-config lure could otherwise point us at an internal host. Known pools use their trusted + // registry statsUrl and are exempt. (M-09) + if (!known && hostLooksInternal(pool_host_)) return; const std::string url = known ? known->statsUrl : ("https://" + pool_host_ + "/api/stats"); @@ -861,7 +919,14 @@ void XmrigManager::startVersionDetection() std::thread([]() { const std::string bin = findXmrigBinary(); std::string ver; - if (!bin.empty()) { + // Don't hand a path containing shell/cmd metacharacters to popen()'s shell — bin is normally an + // app-controlled path, but this closes command injection if it ever isn't. (M-10) + // Reject only chars that stay shell-special INSIDE the double-quotes we wrap bin in ("\"" + bin + "\"") + // on cmd.exe or /bin/sh. Parens are inert when quoted, so they're excluded — otherwise common Windows + // paths like "C:\Program Files (x86)\..." would be rejected and version detection would silently fail. (M-10) + const bool binShellSafe = + !bin.empty() && bin.find_first_of("\"'`$;&|<>^%\n\r") == std::string::npos; + if (binShellSafe) { const std::string cmd = "\"" + bin + "\" --version 2>&1"; #ifdef _WIN32 FILE* fp = _popen(cmd.c_str(), "r"); diff --git a/src/ui/windows/mining_controls.cpp b/src/ui/windows/mining_controls.cpp index 133db30..8e25f47 100644 --- a/src/ui/windows/mining_controls.cpp +++ b/src/ui/windows/mining_controls.cpp @@ -12,6 +12,7 @@ #include "../../config/settings.h" #include "../../util/i18n.h" #include "../../util/platform.h" +#include "../../util/address_validation.h" // isValidRecipientAddress — payout validation (M-01) #include "../schema/ui_schema.h" #include "../material/type.h" #include "../material/draw_helpers.h" @@ -685,8 +686,12 @@ void RenderMiningControls(App* app, const WalletState& state, const MiningInfo& bool poolStillRunning = !s_pool_mode && state.pool_mining.xmrig_running; // Can't start pool mining without a payout address (blank for a new wallet with no z-address); // only blocks starting — stopping a running miner stays enabled. + // Block start when the payout address is empty OR not a valid DragonX address — mining to a + // malformed / wrong-chain address silently loses the rewards. (M-01) + const std::string poolPayoutStr(s_pool_worker); bool poolNeedsPayout = s_pool_mode && !state.pool_mining.xmrig_running && - std::string(s_pool_worker).empty(); + (poolPayoutStr.empty() || + (poolPayoutStr != "x" && !util::isValidRecipientAddress(poolPayoutStr))); bool disabled = s_pool_mode ? (isToggling || poolBlockedBySolo || poolNeedsPayout) : (poolStillRunning ? false : (!app->isConnected() || isToggling || isSyncing)); diff --git a/src/ui/windows/mining_earnings.cpp b/src/ui/windows/mining_earnings.cpp index 8c10471..2bccb17 100644 --- a/src/ui/windows/mining_earnings.cpp +++ b/src/ui/windows/mining_earnings.cpp @@ -104,7 +104,10 @@ void RenderMiningEarnings(App* app, const WalletState& state, const MiningInfo& } } - // Use pool hashrate for EST. DAILY when in pool mode + // Est. Daily = expected reward for YOUR own hashrate share of the network. In pool mode this uses + // your local miner rate (pool_mining.hashrate_10s), NOT the pool's aggregate rate: pool payouts are + // share-proportional, so your expected daily is the same solo-equivalent value (a rough estimate, + // before the pool fee). Using the pool's total rate here would show the POOL's earnings, not yours. (M-05) double estHashrate = s_pool_mode ? state.pool_mining.hashrate_10s : mining.localHashrate; double est_hours_2 = EstimateHoursToBlock(estHashrate, mining.networkHashrate, mining.difficulty); double estDailyBlocks = (est_hours_2 > 0) ? (24.0 / est_hours_2) : 0.0; @@ -218,12 +221,15 @@ void RenderMiningEarnings(App* app, const WalletState& state, const MiningInfo& snprintf(estVal, sizeof(estVal), "~%.4f", estDaily); else snprintf(estVal, sizeof(estVal), "N/A"); + // Disclose in pool mode that Est. Daily is a rough solo-equivalent (before the pool fee), so the + // number isn't silently mismatched to its plain "Est. Daily" label. (M-05) + const char* estSub = (s_pool_mode && estActive) ? TR("mining_est_daily_pool_sub") : nullptr; EarningsEntry entries[] = { { TR("mining_today"), todayVal, todaySub, greenCol2 }, { TR("mining_yesterday"), yesterdayVal, yesterdaySub, OnSurface() }, { TR("mining_all_time"), allVal, allSub, OnSurface() }, - { TR("mining_est_daily"), estVal, nullptr, estActive ? greenCol2 : OnSurfaceDisabled() }, + { TR("mining_est_daily"), estVal, estSub, estActive ? greenCol2 : OnSurfaceDisabled() }, }; for (int ei = 0; ei < numCols; ei++) { diff --git a/src/ui/windows/mining_mode_toggle.cpp b/src/ui/windows/mining_mode_toggle.cpp index 862fd36..65f7e2c 100644 --- a/src/ui/windows/mining_mode_toggle.cpp +++ b/src/ui/windows/mining_mode_toggle.cpp @@ -4,6 +4,7 @@ #include "mining_mode_toggle.h" #include "mining_tab_helpers.h" +#include "mining_tab.h" // CancelMiningBenchmark (L-11) #include "mining_pool_panel.h" #include "../../app.h" @@ -16,6 +17,7 @@ #include "../material/type.h" #include "../material/draw_helpers.h" #include "../material/colors.h" +#include "../../util/address_validation.h" // isValidRecipientAddress — payout validation (M-01) #include "../layout.h" #include "../notifications.h" #include "../../embedded/IconsMaterialDesign.h" @@ -101,6 +103,7 @@ void RenderMiningModeToggle(App* app, const WalletState& state, const MiningInfo s_pool_mode = false; app->settings()->setPoolMode(false); app->settings()->save(); + CancelMiningBenchmark(app); // don't leave a pool benchmark running after switching to solo (L-11) app->stopPoolMining(); } if (soloHov) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); @@ -382,6 +385,8 @@ void RenderMiningModeToggle(App* app, const WalletState& state, const MiningInfo std::string currentWorkerStr(s_pool_worker); if (currentWorkerStr.empty()) { material::Tooltip("%s", TR("mining_generate_z_address_hint")); + } else if (currentWorkerStr != "x" && !util::isValidRecipientAddress(currentWorkerStr)) { + material::Tooltip("%s", TR("mining_payout_invalid")); // block start below too (M-01) } else { material::Tooltip("%s", TR("mining_payout_tooltip")); } diff --git a/src/ui/windows/mining_tab.cpp b/src/ui/windows/mining_tab.cpp index 49b8774..3749c87 100644 --- a/src/ui/windows/mining_tab.cpp +++ b/src/ui/windows/mining_tab.cpp @@ -55,6 +55,19 @@ 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. diff --git a/src/ui/windows/mining_tab.h b/src/ui/windows/mining_tab.h index 2f55762..0515925 100644 --- a/src/ui/windows/mining_tab.h +++ b/src/ui/windows/mining_tab.h @@ -21,5 +21,12 @@ void RenderMiningTab(App* app); */ bool IsMiningBenchmarkActive(); +/** + * @brief Cancel a running thread benchmark and restore the miner to its pre-benchmark thread count. + * Safe to call when no benchmark is active (no-op). Used when leaving the Mining tab or switching to + * solo mode so the miner isn't left stuck at a benchmark step. (L-04, L-11) + */ +void CancelMiningBenchmark(App* app); + } // namespace ui } // namespace dragonx diff --git a/src/ui/windows/mining_tab_helpers.cpp b/src/ui/windows/mining_tab_helpers.cpp index d1d8d7b..801e1b7 100644 --- a/src/ui/windows/mining_tab_helpers.cpp +++ b/src/ui/windows/mining_tab_helpers.cpp @@ -60,7 +60,7 @@ double EstimateHoursToBlock(double localHashrate, double networkHashrate, double { (void)difficulty; if (localHashrate <= 0.0 || networkHashrate <= 0.0) return 0.0; - double blocksPerHour = 3600.0 / 75.0; + double blocksPerHour = 3600.0 / 150.0; // DragonX mainnet target spacing is 150s (chainparams) (L-05) double share = localHashrate / networkHashrate; if (share <= 0.0) return 0.0; return 1.0 / (blocksPerHour * share); diff --git a/src/util/daemon_updater.cpp b/src/util/daemon_updater.cpp index d818c55..4dd905e 100644 --- a/src/util/daemon_updater.cpp +++ b/src/util/daemon_updater.cpp @@ -354,6 +354,7 @@ void DaemonUpdater::installResolved(const std::string& targetDir, const DaemonRe bool failed = false; const int numFiles = static_cast(mz_zip_reader_get_num_files(&zip)); for (int i = 0; i < numFiles && !failed; ++i) { + if (cancel_requested_) { failed = true; break; } // honor cancel mid-extraction so the join returns promptly (L-07) mz_zip_archive_file_stat st; if (!mz_zip_reader_file_stat(&zip, i, &st)) continue; if (mz_zip_reader_is_file_a_directory(&zip, i)) continue; diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index f5ab0ca..d591196 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -1880,6 +1880,8 @@ void I18n::loadBuiltinEnglish() strings_["mining_open_in_explorer"] = "Open in explorer"; strings_["mining_payout_address"] = "Payout Address"; strings_["mining_payout_tooltip"] = "Address to receive mining rewards"; + strings_["mining_payout_invalid"] = "Not a valid DragonX address — fix it before starting, or mining rewards are lost."; + strings_["mining_est_daily_pool_sub"] = "rough solo-equivalent, before pool fee"; strings_["mining_generate_z_address_hint"] = "Generate a Z address in the Receive tab to use as your payout address"; strings_["mining_pool"] = "Pool"; strings_["mining_payout_foreign"] = "⚠ This payout address isn't in your current wallet — mined rewards would go to a different wallet. Update it if you switched wallets."; diff --git a/src/util/pool_stats_service.cpp b/src/util/pool_stats_service.cpp index 7bc479d..0be6a34 100644 --- a/src/util/pool_stats_service.cpp +++ b/src/util/pool_stats_service.cpp @@ -13,8 +13,14 @@ namespace { size_t writeStringCb(void* contents, size_t size, size_t nmemb, void* userp) { - static_cast(userp)->append(static_cast(contents), size * nmemb); - return size * nmemb; + auto* s = static_cast(userp); + const size_t add = size * nmemb; + // Pool stats JSON is tiny; refuse an unbounded body from a hostile/MITM'd endpoint (returning < add + // aborts the transfer) so it can't grow this string until OOM. (M-02) + constexpr size_t kMaxPoolStatsBytes = 1u << 20; // 1 MiB + if (s->size() + add > kMaxPoolStatsBytes) return 0; + s->append(static_cast(contents), add); + return add; } // Returning non-zero asks libcurl to abort the transfer — used so shutdown doesn't diff --git a/src/util/xmrig_updater.cpp b/src/util/xmrig_updater.cpp index 026a115..67453a6 100644 --- a/src/util/xmrig_updater.cpp +++ b/src/util/xmrig_updater.cpp @@ -345,6 +345,7 @@ void XmrigUpdater::installResolved(const std::string& targetDir, const XmrigRele bool failed = false; const int numFiles = static_cast(mz_zip_reader_get_num_files(&zip)); for (int i = 0; i < numFiles && !failed; ++i) { + if (cancel_requested_) { failed = true; break; } // honor cancel mid-extraction so the dialog's join returns promptly (L-07) mz_zip_archive_file_stat st; if (!mz_zip_reader_file_stat(&zip, i, &st)) continue; if (mz_zip_reader_is_file_a_directory(&zip, i)) continue;