From 0ca1caf148290c86ff41cadb8b1d620d5f18c48b Mon Sep 17 00:00:00 2001 From: dan_s Date: Wed, 4 Mar 2026 15:12:24 -0600 Subject: [PATCH] feat: RPC caching, background decrypt import, fast-lane peers, mining fix RPC client: - Add call() overload with per-call timeout parameter - z_exportwallet uses 300s, z_importwallet uses 1200s timeout Decrypt wallet (app_security.cpp, app.cpp): - Show per-step and overall elapsed timers during decrypt flow - Reduce dialog to 5 steps; close before key import begins - Run z_importwallet on detached background thread - Add pulsing "Importing keys..." status bar indicator - Report success/failure via notifications instead of dialog RPC caching (app_network.cpp, app.h): - Cache z_viewtransaction results in viewtx_cache_ across refresh cycles - Skip RPC calls for already-cached txids (biggest perf win) - Build confirmed_tx_cache_ for deeply-confirmed transactions - Clear all caches on disconnect - Remove unused refreshTransactions() dead code Peers (app_network.cpp, peers_tab.cpp): - Route refreshPeerInfo() through fast_worker_ to avoid head-of-line blocking - Replace footer "Refresh Peers" button with ICON_MD_REFRESH in toggle header - Refresh button triggers both peer list and full blockchain data refresh Mining (mining_tab.cpp): - Allow pool mining toggle when blockchain is not synced - Pool mining only needs xmrig, not local daemon sync --- src/app.cpp | 79 +++++++ src/app.h | 35 ++- src/app_network.cpp | 392 ++++++++++++++-------------------- src/app_security.cpp | 104 ++++++--- src/rpc/rpc_client.cpp | 59 +++++ src/rpc/rpc_client.h | 9 + src/ui/windows/mining_tab.cpp | 8 +- src/ui/windows/peers_tab.cpp | 43 ++-- 8 files changed, 459 insertions(+), 270 deletions(-) diff --git a/src/app.cpp b/src/app.cpp index 6ef5987..07f376f 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -68,6 +68,7 @@ #include #include +#include #include #include #include @@ -273,6 +274,8 @@ void App::update() refresh_timer_ += io.DeltaTime; price_timer_ += io.DeltaTime; fast_refresh_timer_ += io.DeltaTime; + tx_age_timer_ += io.DeltaTime; + opid_poll_timer_ += io.DeltaTime; // Fast refresh (mining stats + daemon memory) every second // Skip when wallet is locked — no need to poll, and queued tasks @@ -443,6 +446,64 @@ void App::update() } } + // Poll pending z_sendmany operations for completion + if (opid_poll_timer_ >= OPID_POLL_INTERVAL && !pending_opids_.empty() + && state_.connected && fast_worker_) { + opid_poll_timer_ = 0.0f; + auto opids = pending_opids_; // copy for worker thread + fast_worker_->post([this, opids]() -> rpc::RPCWorker::MainCb { + auto* rpc = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get(); + if (!rpc) return [](){}; + json ids = json::array(); + for (const auto& id : opids) ids.push_back(id); + json result; + try { + result = rpc->call("z_getoperationstatus", {ids}); + } catch (...) { + return [](){}; + } + // Collect completed/failed opids + std::vector done; + bool anySuccess = false; + for (const auto& op : result) { + std::string status = op.value("status", ""); + std::string opid = op.value("id", ""); + if (status == "success") { + done.push_back(opid); + anySuccess = true; + } else if (status == "failed") { + done.push_back(opid); + std::string msg = "Transaction failed"; + if (op.contains("error") && op["error"].contains("message")) + msg = op["error"]["message"].get(); + // Capture for main thread + return [this, done, msg]() { + ui::Notifications::instance().error(msg); + for (const auto& id : done) { + pending_opids_.erase( + std::remove(pending_opids_.begin(), pending_opids_.end(), id), + pending_opids_.end()); + } + }; + } + } + return [this, done, anySuccess]() { + for (const auto& id : done) { + pending_opids_.erase( + std::remove(pending_opids_.begin(), pending_opids_.end(), id), + pending_opids_.end()); + } + if (anySuccess) { + // Transaction confirmed by daemon — force immediate data refresh + transactions_dirty_ = true; + addresses_dirty_ = true; + last_tx_block_height_ = -1; + refresh_timer_ = REFRESH_INTERVAL; + } + }; + }); + } + // Regular refresh every 5 seconds // Skip when wallet is locked — same reason as above. if (refresh_timer_ >= REFRESH_INTERVAL) { @@ -1288,6 +1349,21 @@ void App::renderStatusBar() state_.mining.localHashrate); } + // Decrypt-import background task indicator + if (decrypt_import_active_) { + ImGui::SameLine(0, sbSectionGap); + ImGui::TextDisabled("|"); + ImGui::SameLine(0, sbSeparatorGap); + ImGui::PushFont(ui::material::Type().iconSmall()); + float pulse = 0.6f + 0.4f * sinf((float)ImGui::GetTime() * 3.0f); + ImGui::TextColored(ImVec4(0.6f, 0.8f, 1.0f, pulse), ICON_MD_LOCK_OPEN); + ImGui::PopFont(); + ImGui::SameLine(0, sbIconTextGap); + int dots = (int)(ImGui::GetTime() * 2.0f) % 4; + const char* dotStr = (dots == 0) ? "." : (dots == 1) ? ".." : (dots == 2) ? "..." : ""; + ImGui::TextColored(ImVec4(0.6f, 0.8f, 1.0f, 1.0f), "Importing keys%s", dotStr); + } + // Right side: connection status message (if any) + version always at far right float rightStart = ImGui::GetWindowWidth() - sbRightContentOff; if (!connection_status_.empty() && connection_status_ != "Connected") { @@ -1632,6 +1708,9 @@ void App::renderAntivirusHelpDialog() void App::refreshNow() { refresh_timer_ = REFRESH_INTERVAL; // Trigger immediate refresh + transactions_dirty_ = true; // Force transaction list update + addresses_dirty_ = true; // Force address/balance update + last_tx_block_height_ = -1; // Reset tx cache } void App::handlePaymentURI(const std::string& uri) diff --git a/src/app.h b/src/app.h index 4538ccc..365f368 100644 --- a/src/app.h +++ b/src/app.h @@ -10,6 +10,8 @@ #include #include #include +#include +#include #include "data/wallet_state.h" #include "rpc/connection.h" #include "ui/sidebar.h" @@ -443,11 +445,40 @@ private: // P4: Incremental transaction cache int last_tx_block_height_ = -1; // block height at last full tx fetch + float tx_age_timer_ = 0.0f; // seconds since last tx fetch + static constexpr float TX_MAX_AGE = 15.0f; // force tx refresh every N seconds even without new blocks + static constexpr int MAX_VIEWTX_PER_CYCLE = 25; // cap z_viewtransaction calls per refresh + + // P4b: z_viewtransaction result cache — avoids re-calling the RPC for + // txids we've already enriched. Keyed by txid. + struct ViewTxCacheEntry { + std::string from_address; // first spend address + struct Output { + std::string address; + double value = 0.0; + std::string memo; + }; + std::vector outgoing_outputs; + }; + std::unordered_map viewtx_cache_; + + // P4c: Confirmed transaction cache — deeply-confirmed txns (>= 10 confs) + // are accumulated here and reused across refresh cycles. Only + // recent/unconfirmed txns are re-fetched from the daemon each time. + std::vector confirmed_tx_cache_; + std::unordered_set confirmed_tx_ids_; // fast lookup + int confirmed_cache_block_ = -1; // block height when cache was last built // Dirty flags for demand-driven refresh bool addresses_dirty_ = true; // true → refreshAddresses() will run + bool transactions_dirty_ = false; // true → force tx refresh regardless of block height bool encryption_state_prefetched_ = false; // suppress duplicate getwalletinfo on connect + // Pending z_sendmany operation tracking + std::vector pending_opids_; // opids to poll for completion + float opid_poll_timer_ = 0.0f; + static constexpr float OPID_POLL_INTERVAL = 2.0f; + // First-run wizard state WizardPhase wizard_phase_ = WizardPhase::None; std::unique_ptr bootstrap_; @@ -512,6 +543,9 @@ private: char decrypt_pass_buf_[256] = {}; std::string decrypt_status_; bool decrypt_in_progress_ = false; + std::chrono::steady_clock::time_point decrypt_step_start_time_{}; + std::chrono::steady_clock::time_point decrypt_overall_start_time_{}; + std::atomic decrypt_import_active_{false}; // background z_importwallet running // Wizard PIN setup state char wizard_pin_buf_[16] = {}; @@ -544,7 +578,6 @@ private: void refreshData(); void refreshBalance(); void refreshAddresses(); - void refreshTransactions(); void refreshPrice(); void refreshWalletEncryptionState(); void checkAutoLock(); diff --git a/src/app_network.cpp b/src/app_network.cpp index d390349..7665ee9 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -229,6 +229,13 @@ void App::onDisconnected(const std::string& reason) state_.clear(); connection_status_ = reason; + // Clear RPC result caches + viewtx_cache_.clear(); + confirmed_tx_cache_.clear(); + confirmed_tx_ids_.clear(); + confirmed_cache_block_ = -1; + last_tx_block_height_ = -1; + // Tear down the fast-lane connection if (fast_worker_) { fast_worker_->stop(); @@ -255,11 +262,19 @@ void App::refreshData() const bool doEncrypt = !encryption_state_prefetched_; if (encryption_state_prefetched_) encryption_state_prefetched_ = false; - // P4a: Skip transactions if no new blocks since last full fetch + // P4a: Refresh transactions when new blocks arrive, when explicitly + // dirtied (e.g. after a send), or when the max-age timer expires + // (catches mempool changes for incoming unconfirmed transactions). const int currentBlocks = state_.sync.blocks; const bool doTransactions = (last_tx_block_height_ < 0 || currentBlocks != last_tx_block_height_ - || state_.transactions.empty()); + || state_.transactions.empty() + || transactions_dirty_ + || tx_age_timer_ >= TX_MAX_AGE); + if (doTransactions) { + transactions_dirty_ = false; + tx_age_timer_ = 0.0f; + } // Snapshot z-addresses for transaction fetch (needed on worker thread) std::vector txZAddrs; @@ -270,8 +285,15 @@ void App::refreshData() } // P4b: Collect txids that are fully enriched (skip re-enrichment) - std::set fullyEnriched; + // Include all txids already in the viewtx cache — these never need + // another z_viewtransaction RPC call. + std::unordered_set fullyEnriched; if (doTransactions) { + // Txids we already have z_viewtransaction results for + for (const auto& [txid, _] : viewtx_cache_) { + fullyEnriched.insert(txid); + } + // Txids that are deeply confirmed and timestamped for (const auto& tx : state_.transactions) { if (tx.confirmations > 6 && tx.timestamp != 0) { fullyEnriched.insert(tx.txid); @@ -279,11 +301,26 @@ void App::refreshData() } } + // P4c: Snapshot the confirmed transaction cache for the worker thread. + // These are deeply-confirmed txns we can reuse without re-fetching. + std::vector cachedConfirmedTxns; + std::unordered_set cachedConfirmedIds; + if (doTransactions && !confirmed_tx_cache_.empty()) { + cachedConfirmedTxns = confirmed_tx_cache_; + cachedConfirmedIds = confirmed_tx_ids_; + } + + // Snapshot viewtx cache for the worker thread + auto viewtxCacheSnap = viewtx_cache_; + // Single consolidated worker task — all RPC calls happen back-to-back // on a single thread with no inter-task queue overhead. worker_->post([this, doAddresses, doPeers, doEncrypt, doTransactions, currentBlocks, txZAddrs = std::move(txZAddrs), - fullyEnriched = std::move(fullyEnriched)]() -> rpc::RPCWorker::MainCb { + fullyEnriched = std::move(fullyEnriched), + cachedConfirmedTxns = std::move(cachedConfirmedTxns), + cachedConfirmedIds = std::move(cachedConfirmedIds), + viewtxCacheSnap = std::move(viewtxCacheSnap)]() -> rpc::RPCWorker::MainCb { // ================================================================ // Phase 1: Balance + blockchain info // ================================================================ @@ -376,6 +413,7 @@ void App::refreshData() // Phase 3: Transactions (only when new blocks) // ================================================================ std::vector txns; + std::unordered_map newViewTxEntries; bool txOk = false; if (doTransactions) { txOk = true; @@ -434,65 +472,103 @@ void App::refreshData() } // Phase 3c: detect shielded sends via z_viewtransaction + // Check the in-memory viewtx cache first; only make RPC calls + // for txids we haven't seen before. + int viewTxCount = 0; + + auto applyViewTxEntry = [&](const std::string& txid, + const ViewTxCacheEntry& entry) { + for (const auto& out : entry.outgoing_outputs) { + bool alreadyTracked = false; + for (const auto& existing : txns) { + if (existing.txid == txid && existing.type == "send" + && std::abs(existing.amount + out.value) < 0.00000001) { + alreadyTracked = true; break; + } + } + if (alreadyTracked) continue; + TransactionInfo info; + info.txid = txid; + info.type = "send"; + info.address = out.address; + info.amount = -out.value; + info.memo = out.memo; + info.from_address = entry.from_address; + // Copy confirmations/timestamp from an existing entry for this txid + for (const auto& existing : txns) { + if (existing.txid == txid) { + info.confirmations = existing.confirmations; + info.timestamp = existing.timestamp; + break; + } + } + txns.push_back(info); + } + }; + for (const std::string& txid : knownTxids) { if (fullyEnriched.count(txid)) continue; + + // Check viewtx cache first — avoid RPC call + auto cit = viewtxCacheSnap.find(txid); + if (cit != viewtxCacheSnap.end()) { + applyViewTxEntry(txid, cit->second); + continue; + } + + if (viewTxCount >= MAX_VIEWTX_PER_CYCLE) break; + ++viewTxCount; + try { json vtx = rpc_->call("z_viewtransaction", json::array({txid})); if (vtx.is_null() || !vtx.is_object()) continue; + + // Build cache entry from RPC result + ViewTxCacheEntry entry; + if (vtx.contains("spends") && vtx["spends"].is_array()) { + for (const auto& spend : vtx["spends"]) { + if (spend.contains("address")) { + entry.from_address = spend["address"].get(); + break; + } + } + } if (vtx.contains("outputs") && vtx["outputs"].is_array()) { for (const auto& output : vtx["outputs"]) { bool outgoing = false; if (output.contains("outgoing")) outgoing = output["outgoing"].get(); if (!outgoing) continue; - std::string destAddr; + ViewTxCacheEntry::Output out; if (output.contains("address")) - destAddr = output["address"].get(); - double value = 0.0; + out.address = output["address"].get(); if (output.contains("value")) - value = output["value"].get(); - bool alreadyTracked = false; - for (const auto& existing : txns) { - if (existing.txid == txid && existing.type == "send" - && std::abs(existing.amount + value) < 0.00000001) { - alreadyTracked = true; break; - } - } - if (alreadyTracked) continue; - TransactionInfo info; - info.txid = txid; - info.type = "send"; - info.address = destAddr; - info.amount = -value; + out.value = output["value"].get(); if (output.contains("memoStr")) - info.memo = output["memoStr"].get(); - for (const auto& existing : txns) { - if (existing.txid == txid) { - info.confirmations = existing.confirmations; - info.timestamp = existing.timestamp; - break; - } - } - if (info.timestamp == 0) { - try { - json rawtx = rpc_->call("gettransaction", json::array({txid})); - if (!rawtx.is_null() && rawtx.contains("time")) - info.timestamp = rawtx["time"].get(); - if (!rawtx.is_null() && rawtx.contains("confirmations")) - info.confirmations = rawtx["confirmations"].get(); - } catch (...) {} - } - if (vtx.contains("spends") && vtx["spends"].is_array()) { - for (const auto& spend : vtx["spends"]) { - if (spend.contains("address")) { - info.from_address = spend["address"].get(); - break; - } - } - } - txns.push_back(info); + out.memo = output["memoStr"].get(); + entry.outgoing_outputs.push_back(std::move(out)); } } + + // Apply to current txns list + applyViewTxEntry(txid, entry); + + // Fetch timestamp if missing for any new txns we just added + for (auto& info : txns) { + if (info.txid == txid && info.timestamp == 0) { + try { + json rawtx = rpc_->call("gettransaction", json::array({txid})); + if (!rawtx.is_null() && rawtx.contains("time")) + info.timestamp = rawtx["time"].get(); + if (!rawtx.is_null() && rawtx.contains("confirmations")) + info.confirmations = rawtx["confirmations"].get(); + } catch (...) {} + break; // only need to fetch once per txid + } + } + + // Store for cache update on main thread + newViewTxEntries[txid] = std::move(entry); } catch (const std::exception& e) { (void)e; // z_viewtransaction may not be available for all txids } @@ -568,6 +644,7 @@ void App::refreshData() return [this, totalBal, blockInfo, balOk, blockOk, zAddrs = std::move(zAddrs), tAddrs = std::move(tAddrs), addrOk, txns = std::move(txns), txOk, currentBlocks, + newViewTxEntries = std::move(newViewTxEntries), peers = std::move(peers), bannedPeers = std::move(bannedPeers), peerOk, walletInfo, encryptOk]() { // --- Balance --- @@ -641,6 +718,24 @@ void App::refreshData() state_.transactions = std::move(txns); state_.last_tx_update = std::time(nullptr); last_tx_block_height_ = currentBlocks; + + // Merge new z_viewtransaction results into the persistent cache + for (auto& [txid, entry] : newViewTxEntries) { + viewtx_cache_[txid] = std::move(entry); + } + + // Rebuild confirmed transaction cache: txns with >= 10 + // confirmations are stable and won't change, so we keep + // them for future cycles. + confirmed_tx_cache_.clear(); + confirmed_tx_ids_.clear(); + for (const auto& tx : state_.transactions) { + if (tx.confirmations >= 10 && tx.timestamp != 0) { + confirmed_tx_ids_.insert(tx.txid); + confirmed_tx_cache_.push_back(tx); + } + } + confirmed_cache_block_ = currentBlocks; } // --- Peers --- @@ -849,181 +944,6 @@ void App::refreshAddresses() }); } -void App::refreshTransactions() -{ - if (!worker_ || !rpc_) return; - - // P4a: Skip if no new blocks since last full fetch - int currentBlocks = state_.sync.blocks; - bool fullRefresh = (last_tx_block_height_ < 0 || currentBlocks != last_tx_block_height_ - || state_.transactions.empty()); - if (!fullRefresh) return; - - // Capture the z-addresses list for the worker thread - std::vector zAddrs; - for (const auto& za : state_.z_addresses) { - if (!za.address.empty()) zAddrs.push_back(za.address); - } - - worker_->post([this, zAddrs = std::move(zAddrs), currentBlocks]() -> rpc::RPCWorker::MainCb { - // --- Worker thread: all blocking RPC calls happen here --- - std::vector txns; - std::set knownTxids; - - // P4b: Collect txids that are fully enriched (skip re-enrichment) - std::set fullyEnriched; - for (const auto& tx : state_.transactions) { - if (tx.confirmations > 6 && tx.timestamp != 0) { - fullyEnriched.insert(tx.txid); - } - } - - // ---- Phase 1: transparent transactions from listtransactions ---- - try { - json result = rpc_->call("listtransactions", json::array({"", 9999})); - for (const auto& tx : result) { - TransactionInfo info; - if (tx.contains("txid")) info.txid = tx["txid"].get(); - if (tx.contains("category")) info.type = tx["category"].get(); - if (tx.contains("amount")) info.amount = tx["amount"].get(); - if (tx.contains("time")) info.timestamp = tx["time"].get(); - if (tx.contains("confirmations")) info.confirmations = tx["confirmations"].get(); - if (tx.contains("address")) info.address = tx["address"].get(); - knownTxids.insert(info.txid); - txns.push_back(info); - } - } catch (const std::exception& e) { - DEBUG_LOGF("listtransactions error: %s\n", e.what()); - } - - // ---- Phase 2: shielded receives via z_listreceivedbyaddress ---- - for (const auto& addr : zAddrs) { - try { - json zresult = rpc_->call("z_listreceivedbyaddress", json::array({addr, 0})); - if (zresult.is_null() || !zresult.is_array()) continue; - - for (const auto& note : zresult) { - std::string txid; - if (note.contains("txid")) txid = note["txid"].get(); - if (txid.empty()) continue; - if (note.contains("change") && note["change"].get()) continue; - - bool dominated = false; - for (const auto& existing : txns) { - if (existing.txid == txid && existing.type == "receive") { - dominated = true; break; - } - } - if (dominated) continue; - - TransactionInfo info; - info.txid = txid; - info.type = "receive"; - info.address = addr; - if (note.contains("amount")) info.amount = note["amount"].get(); - if (note.contains("confirmations")) info.confirmations = note["confirmations"].get(); - if (note.contains("time")) info.timestamp = note["time"].get(); - if (note.contains("memoStr")) info.memo = note["memoStr"].get(); - knownTxids.insert(txid); - txns.push_back(info); - } - } catch (const std::exception& e) { - DEBUG_LOGF("z_listreceivedbyaddress error for %s: %s\n", - addr.substr(0, 12).c_str(), e.what()); - } - } - - // ---- Phase 3: detect shielded sends via z_viewtransaction ---- - // P4d: Only check new/unconfirmed txids - for (const std::string& txid : knownTxids) { - if (fullyEnriched.count(txid)) continue; // P4b: skip already-enriched - - try { - json vtx = rpc_->call("z_viewtransaction", json::array({txid})); - if (vtx.is_null() || !vtx.is_object()) continue; - - if (vtx.contains("outputs") && vtx["outputs"].is_array()) { - for (const auto& output : vtx["outputs"]) { - bool outgoing = false; - if (output.contains("outgoing")) - outgoing = output["outgoing"].get(); - if (!outgoing) continue; - - std::string destAddr; - if (output.contains("address")) - destAddr = output["address"].get(); - double value = 0.0; - if (output.contains("value")) - value = output["value"].get(); - - bool alreadyTracked = false; - for (const auto& existing : txns) { - if (existing.txid == txid && existing.type == "send" - && std::abs(existing.amount + value) < 0.00000001) { - alreadyTracked = true; break; - } - } - if (alreadyTracked) continue; - - TransactionInfo info; - info.txid = txid; - info.type = "send"; - info.address = destAddr; - info.amount = -value; - if (output.contains("memoStr")) - info.memo = output["memoStr"].get(); - - // Get confirmations/time from existing entry - for (const auto& existing : txns) { - if (existing.txid == txid) { - info.confirmations = existing.confirmations; - info.timestamp = existing.timestamp; - break; - } - } - - if (info.timestamp == 0) { - try { - json rawtx = rpc_->call("gettransaction", json::array({txid})); - if (!rawtx.is_null() && rawtx.contains("time")) - info.timestamp = rawtx["time"].get(); - if (!rawtx.is_null() && rawtx.contains("confirmations")) - info.confirmations = rawtx["confirmations"].get(); - } catch (...) {} - } - - if (vtx.contains("spends") && vtx["spends"].is_array()) { - for (const auto& spend : vtx["spends"]) { - if (spend.contains("address")) { - info.from_address = spend["address"].get(); - break; - } - } - } - - txns.push_back(info); - } - } - } catch (const std::exception& e) { - // z_viewtransaction may not be available for all txids - } - } - - // Sort by timestamp descending - std::sort(txns.begin(), txns.end(), - [](const TransactionInfo& a, const TransactionInfo& b) { - return a.timestamp > b.timestamp; - }); - - // --- Main thread: apply results --- - return [this, txns = std::move(txns), currentBlocks]() { - state_.transactions = std::move(txns); - state_.last_tx_update = std::time(nullptr); - last_tx_block_height_ = currentBlocks; // P4a: track last-fetched height - }; - }); -} - void App::refreshMiningInfo() { // Use the dedicated fast-lane worker + connection so mining polls @@ -1106,14 +1026,19 @@ void App::refreshMiningInfo() void App::refreshPeerInfo() { - if (!worker_ || !rpc_) return; - - worker_->post([this]() -> rpc::RPCWorker::MainCb { + if (!rpc_) return; + + // Use fast-lane worker to bypass head-of-line blocking behind refreshData. + auto* w = (fast_worker_ && fast_worker_->isRunning()) ? fast_worker_.get() : worker_.get(); + auto* r = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get(); + if (!w) return; + + w->post([this, r]() -> rpc::RPCWorker::MainCb { std::vector peers; std::vector bannedPeers; try { - json result = rpc_->call("getpeerinfo"); + json result = r->call("getpeerinfo"); for (const auto& peer : result) { PeerInfo info; if (peer.contains("id")) info.id = peer["id"].get(); @@ -1139,7 +1064,7 @@ void App::refreshPeerInfo() } try { - json result = rpc_->call("listbanned"); + json result = r->call("listbanned"); for (const auto& ban : result) { BannedPeer info; if (ban.contains("address")) info.address = ban["address"].get(); @@ -1340,6 +1265,12 @@ void App::startPoolMining(int threads) } } + // 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; + } + if (cfg.wallet_address.empty()) { DEBUG_LOGF("[ERROR] Pool mining: No wallet address available\n"); ui::Notifications::instance().error("No wallet address available for pool mining"); @@ -1660,6 +1591,13 @@ void App::sendTransaction(const std::string& from, const std::string& to, if (ok) { // A send changes address balances — refresh on next cycle addresses_dirty_ = true; + // Force transaction list refresh so the sent tx appears immediately + transactions_dirty_ = true; + last_tx_block_height_ = -1; + // Track the opid so we can poll for completion + if (!result_str.empty()) { + pending_opids_.push_back(result_str); + } } if (callback) callback(ok, result_str); }; diff --git a/src/app_security.cpp b/src/app_security.cpp index e1bebb1..c916612 100644 --- a/src/app_security.cpp +++ b/src/app_security.cpp @@ -1021,6 +1021,8 @@ void App::renderDecryptWalletDialog() { decrypt_step_ = 0; decrypt_in_progress_ = true; decrypt_status_ = "Unlocking wallet..."; + decrypt_overall_start_time_ = std::chrono::steady_clock::now(); + decrypt_step_start_time_ = decrypt_overall_start_time_; // Run entire decrypt flow on worker thread if (worker_) { @@ -1040,6 +1042,7 @@ void App::renderDecryptWalletDialog() { // Update step on main thread return [this]() { decrypt_step_ = 1; + decrypt_step_start_time_ = std::chrono::steady_clock::now(); decrypt_status_ = "Exporting wallet keys..."; // Continue with step 2 @@ -1050,7 +1053,7 @@ void App::renderDecryptWalletDialog() { std::string exportPath = dataDir + exportFile; try { - rpc_->call("z_exportwallet", {exportFile}); + rpc_->call("z_exportwallet", {exportFile}, 300L); } catch (const std::exception& e) { std::string err = e.what(); return [this, err]() { @@ -1062,6 +1065,7 @@ void App::renderDecryptWalletDialog() { return [this, exportPath]() { decrypt_step_ = 2; + decrypt_step_start_time_ = std::chrono::steady_clock::now(); decrypt_status_ = "Stopping daemon..."; // Continue with step 3 @@ -1077,6 +1081,7 @@ void App::renderDecryptWalletDialog() { return [this, exportPath]() { decrypt_step_ = 3; + decrypt_step_start_time_ = std::chrono::steady_clock::now(); decrypt_status_ = "Backing up encrypted wallet..."; // Continue with step 4 (rename) @@ -1100,6 +1105,7 @@ void App::renderDecryptWalletDialog() { return [this, exportPath]() { decrypt_step_ = 4; + decrypt_step_start_time_ = std::chrono::steady_clock::now(); decrypt_status_ = "Restarting daemon..."; auto restartAndImport = [this, exportPath]() { @@ -1136,28 +1142,50 @@ void App::renderDecryptWalletDialog() { return; } - // Update step on main thread + // Update step on main thread — close dialog, import in background if (worker_) { worker_->post([this]() -> rpc::RPCWorker::MainCb { return [this]() { - decrypt_step_ = 5; - decrypt_status_ = "Importing keys (this may take a while)..."; + // Close the decrypt dialog — user can use the wallet now + decrypt_in_progress_ = false; + show_decrypt_dialog_ = false; + decrypt_import_active_ = true; + + // Mark rescanning so status bar picks it up immediately + state_.sync.rescanning = true; + state_.sync.rescan_progress = 0.0f; + + // Clear encryption state early — vault/PIN removed now, + // wallet file is already unencrypted + if (vault_ && vault_->hasVault()) { + vault_->removeVault(); + } + if (settings_ && settings_->getPinEnabled()) { + settings_->setPinEnabled(false); + settings_->save(); + } + + ui::Notifications::instance().info( + "Importing keys & rescanning blockchain — wallet is usable while this runs", + 8.0f); }; }); } - // Step 6: Import wallet (use full path) + // Step 6: Import wallet in background (use full path) + // Use 20-minute timeout — import + rescan can be very slow try { - rpc_->call("z_importwallet", {exportPath}); + rpc_->call("z_importwallet", {exportPath}, 1200L); } catch (const std::exception& e) { std::string err = e.what(); if (worker_) { worker_->post([this, err]() -> rpc::RPCWorker::MainCb { return [this, err]() { - decrypt_in_progress_ = false; - decrypt_status_ = "Import failed: " + err + - "\nYour encrypted wallet backup is at wallet.dat.encrypted.bak"; - decrypt_phase_ = 3; + decrypt_import_active_ = false; + ui::Notifications::instance().error( + "Key import failed: " + err + + "\nEncrypted backup: wallet.dat.encrypted.bak", + 12.0f); }; }); } @@ -1168,19 +1196,12 @@ void App::renderDecryptWalletDialog() { if (worker_) { worker_->post([this]() -> rpc::RPCWorker::MainCb { return [this]() { - decrypt_in_progress_ = false; - decrypt_status_ = "Wallet decrypted successfully!"; - decrypt_phase_ = 2; - - if (vault_ && vault_->hasVault()) { - vault_->removeVault(); - } - if (settings_ && settings_->getPinEnabled()) { - settings_->setPinEnabled(false); - settings_->save(); - } + decrypt_import_active_ = false; refreshWalletEncryptionState(); + ui::Notifications::instance().success( + "Wallet decrypted successfully! All keys imported.", + 8.0f); DEBUG_LOGF("[App] Wallet decrypted successfully\n"); }; }); @@ -1214,11 +1235,17 @@ void App::renderDecryptWalletDialog() { "Exporting wallet keys", "Stopping daemon", "Backing up encrypted wallet", - "Restarting daemon", - "Importing keys (rescan)" + "Restarting daemon" }; - const int numSteps = 6; + const int numSteps = 5; + // Compute elapsed times + auto now = std::chrono::steady_clock::now(); + auto stepElapsed = std::chrono::duration_cast( + now - decrypt_step_start_time_).count(); + auto totalElapsed = std::chrono::duration_cast( + now - decrypt_overall_start_time_).count(); + ImGui::Spacing(); for (int i = 0; i < numSteps; i++) { ImGui::PushFont(Type().iconMed()); @@ -1237,7 +1264,16 @@ void App::renderDecryptWalletDialog() { ImGui::SameLine(); if (i == decrypt_step_) { - ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.6f, 1.0f), "%s...", stepLabels[i]); + // Show step label with elapsed time + int mins = (int)(stepElapsed / 60); + int secs = (int)(stepElapsed % 60); + if (mins > 0) { + ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.6f, 1.0f), + "%s... (%dm %02ds)", stepLabels[i], mins, secs); + } else { + ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.6f, 1.0f), + "%s... (%ds)", stepLabels[i], secs); + } } else if (i < decrypt_step_) { ImGui::TextColored(ImVec4(0.6f, 0.8f, 0.6f, 1.0f), "%s", stepLabels[i]); } else { @@ -1271,8 +1307,22 @@ void App::renderDecryptWalletDialog() { } ImGui::Spacing(); - ImGui::TextWrapped("Please wait. The daemon is exporting keys, restarting, " - "and re-importing. This may take several minutes."); + + // Step-specific hints + if (decrypt_step_ == 4) { + ImGui::TextWrapped("Waiting for the daemon to finish starting up..."); + } else { + ImGui::TextWrapped("Please wait. The daemon is exporting keys, restarting, " + "and re-importing. This may take several minutes."); + } + + // Total elapsed + { + int tMins = (int)(totalElapsed / 60); + int tSecs = (int)(totalElapsed % 60); + ImGui::Spacing(); + ImGui::TextDisabled("Total elapsed: %dm %02ds", tMins, tSecs); + } // ---- Phase 2: Success ---- } else if (decrypt_phase_ == 2) { diff --git a/src/rpc/rpc_client.cpp b/src/rpc/rpc_client.cpp index c7be81f..dd8674b 100644 --- a/src/rpc/rpc_client.cpp +++ b/src/rpc/rpc_client.cpp @@ -182,6 +182,65 @@ json RPCClient::call(const std::string& method, const json& params) return response["result"]; } +json RPCClient::call(const std::string& method, const json& params, long timeoutSec) +{ + std::lock_guard lk(curl_mutex_); + if (!impl_->curl) { + throw std::runtime_error("Not connected"); + } + + // Temporarily override timeout + long prevTimeout = 30L; + curl_easy_setopt(impl_->curl, CURLOPT_TIMEOUT, timeoutSec); + + try { + // Unlock before calling to avoid recursive lock issues — but we already hold it, + // and call() also locks with recursive_mutex, so just delegate to the body directly. + json payload = makePayload(method, params); + std::string body = payload.dump(); + std::string response_data; + + curl_easy_setopt(impl_->curl, CURLOPT_POSTFIELDS, body.c_str()); + curl_easy_setopt(impl_->curl, CURLOPT_POSTFIELDSIZE, (long)body.size()); + curl_easy_setopt(impl_->curl, CURLOPT_WRITEDATA, &response_data); + + CURLcode res = curl_easy_perform(impl_->curl); + + // Restore original timeout + curl_easy_setopt(impl_->curl, CURLOPT_TIMEOUT, prevTimeout); + + if (res != CURLE_OK) { + throw std::runtime_error("RPC request failed: " + std::string(curl_easy_strerror(res))); + } + + long http_code = 0; + curl_easy_getinfo(impl_->curl, CURLINFO_RESPONSE_CODE, &http_code); + + if (http_code != 200) { + try { + json response = json::parse(response_data); + if (response.contains("error") && !response["error"].is_null()) { + std::string err_msg = response["error"]["message"].get(); + throw std::runtime_error(err_msg); + } + } catch (const json::exception&) {} + throw std::runtime_error("RPC error: HTTP " + std::to_string(http_code)); + } + + json response = json::parse(response_data); + if (response.contains("error") && !response["error"].is_null()) { + std::string err_msg = response["error"]["message"].get(); + throw std::runtime_error("RPC error: " + err_msg); + } + + return response["result"]; + } catch (...) { + // Ensure timeout is always restored + curl_easy_setopt(impl_->curl, CURLOPT_TIMEOUT, prevTimeout); + throw; + } +} + std::string RPCClient::callRaw(const std::string& method, const json& params) { std::lock_guard lk(curl_mutex_); diff --git a/src/rpc/rpc_client.h b/src/rpc/rpc_client.h index 6a899e2..eeea3ef 100644 --- a/src/rpc/rpc_client.h +++ b/src/rpc/rpc_client.h @@ -61,6 +61,15 @@ public: */ json call(const std::string& method, const json& params = json::array()); + /** + * @brief Make a raw RPC call with a custom timeout + * @param method RPC method name + * @param params Method parameters + * @param timeoutSec Timeout in seconds (0 = no timeout) + * @return JSON response or throws on error + */ + json call(const std::string& method, const json& params, long timeoutSec); + /** * @brief Make a raw RPC call and return the result field as a string * @param method RPC method name diff --git a/src/ui/windows/mining_tab.cpp b/src/ui/windows/mining_tab.cpp index 0bafc66..9440342 100644 --- a/src/ui/windows/mining_tab.cpp +++ b/src/ui/windows/mining_tab.cpp @@ -608,7 +608,11 @@ void RenderMiningTab(App* app) bool isSyncing = state.sync.syncing; bool poolBlockedBySolo = s_pool_mode && mining.generate && !state.pool_mining.xmrig_running; bool isToggling = app->isMiningToggleInProgress(); - bool disabled = !app->isConnected() || isToggling || isSyncing || poolBlockedBySolo; + // Pool mining connects to an external pool via xmrig — it does not + // need the local blockchain synced or even the daemon connected. + bool disabled = s_pool_mode + ? (isToggling || poolBlockedBySolo) + : (!app->isConnected() || isToggling || isSyncing); // Glass panel background with state-dependent tint GlassPanelSpec btnGlass; @@ -733,7 +737,7 @@ void RenderMiningTab(App* app) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); if (isToggling) ImGui::SetTooltip(isMiningActive ? "Stopping miner..." : "Starting miner..."); - else if (isSyncing) + else if (isSyncing && !s_pool_mode) ImGui::SetTooltip("Syncing blockchain... (%.1f%%)", state.sync.verification_progress * 100.0); else if (poolBlockedBySolo) ImGui::SetTooltip("Stop solo mining before starting pool mining"); diff --git a/src/ui/windows/peers_tab.cpp b/src/ui/windows/peers_tab.cpp index b8776c1..f31d2cd 100644 --- a/src/ui/windows/peers_tab.cpp +++ b/src/ui/windows/peers_tab.cpp @@ -505,6 +505,32 @@ void RenderPeersTab(App* app) } if (ImGui::IsItemHovered()) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + // Refresh button — top-right of the toggle header line + { + ImFont* iconFont = Type().iconMed(); + float iconSz = iconFont->LegacySize; + float btnPad = Layout::spacingSm(); + float btnX = ImGui::GetWindowPos().x + availWidth - iconSz - btnPad; + float btnY = toggleY + (toggleH - iconSz) * 0.5f; + ImGui::SetCursorScreenPos(ImVec2(btnX, btnY)); + ImGui::PushID("##peersRefresh"); + if (ImGui::InvisibleButton("##btn", ImVec2(iconSz + btnPad, iconSz + btnPad))) { + app->refreshPeerInfo(); + app->refreshNow(); + } + bool hovered = ImGui::IsItemHovered(); + if (hovered) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + ImU32 iconCol = hovered ? OnSurface() : OnSurfaceMedium(); + // Centre icon within the invisible button + float drawX = btnX + (iconSz + btnPad - iconSz) * 0.5f; + float drawY = btnY + (iconSz + btnPad - iconSz) * 0.5f; + dl->AddText(iconFont, iconSz, ImVec2(drawX, drawY), iconCol, ICON_MD_REFRESH); + if (hovered) { + ImGui::SetTooltip("Refresh peers & blockchain"); + } + ImGui::PopID(); + } + ImGui::SetCursorScreenPos(ImVec2(ImGui::GetWindowPos().x, toggleY + toggleH)); } @@ -782,22 +808,13 @@ void RenderPeersTab(App* app) } // ================================================================ - // Footer — Refresh + Clear Bans (material styled) + // Footer — Clear Bans (material styled) // ================================================================ - { + if (s_show_banned && !state.bannedPeers.empty()) { ImGui::BeginDisabled(!app->isConnected()); - - if (TactileSmallButton("Refresh Peers", S.resolveFont("button"))) { - app->refreshPeerInfo(); + if (TactileSmallButton("Clear All Bans", S.resolveFont("button"))) { + app->clearBans(); } - - if (s_show_banned && !state.bannedPeers.empty()) { - ImGui::SameLine(); - if (TactileSmallButton("Clear All Bans", S.resolveFont("button"))) { - app->clearBans(); - } - } - ImGui::EndDisabled(); }