// DragonX Wallet - ImGui Edition // Copyright 2024-2026 The Hush Developers // Released under the GPLv3 // // app_network.cpp — RPC connection, data refresh, and network operations. // Split from app.cpp for maintainability. // // Connection state machine: // // [Disconnected] // │ // ▼ tryConnect() every 5s // Auto-detect DRAGONX.conf (host, port, rpcuser, rpcpassword) // │ // ├─ no config found ──► start embedded daemon ──► retry // │ // ▼ post async rpc_->connect() to worker_ // [Connecting] // │ // ├─ success ──► onConnected() ──► [Connected] // │ │ // │ ▼ refreshData() every 5s // │ [Running] // │ │ // │ ├─ RPC error ──► onDisconnected() // │ │ │ // ├─ auth 401 ──► .cookie auth ──► retry│ ▼ // │ │ [Disconnected] // └─ failure ──► onDisconnected(reason) ┘ // may restart daemon #include "app.h" #include "rpc/rpc_client.h" #include "rpc/rpc_worker.h" #include "rpc/connection.h" #include "config/settings.h" #include "wallet/lite_wallet_controller.h" // lite send/new-address routing #include "daemon/daemon_controller.h" #include "daemon/embedded_daemon.h" #include "daemon/xmrig_manager.h" #include "ui/notifications.h" #include "default_banlist_embedded.h" #include "util/amount_format.h" #include "util/platform.h" #include "util/perf_log.h" #include "util/i18n.h" #include "util/secure_vault.h" #include #include #include #include #include #include #include namespace dragonx { using json = nlohmann::json; using NetworkRefreshService = services::NetworkRefreshService; namespace { bool isPageEnabledForBuild(ui::NavPage page) { return wallet::isUiSurfaceAvailable( wallet::currentWalletCapabilities(), ui::NavPageSurface(page)); } std::string unencryptedTransactionHistoryCacheKey(const std::string& walletIdentity) { return std::string("obsidian-dragon-unencrypted-tx-cache-v1:") + data::TransactionHistoryCache::walletIdentityHash(walletIdentity); } class AppRefreshRpcGateway final : public NetworkRefreshService::RefreshRpcGateway { public: AppRefreshRpcGateway(rpc::RPCClient& rpc, std::string source) : rpc_(rpc), source_(std::move(source)) {} json call(const std::string& method, const json& params) override { rpc::RPCClient::TraceScope trace(source_); return rpc_.call(method, params); } private: rpc::RPCClient& rpc_; std::string source_; }; const char* tracePageName(ui::NavPage page) { switch (page) { case ui::NavPage::Overview: return "Overview tab"; case ui::NavPage::Send: return "Send tab"; case ui::NavPage::Receive: return "Receive tab"; case ui::NavPage::History: return "History tab"; case ui::NavPage::Mining: return "Mining tab"; case ui::NavPage::Market: return "Market tab"; case ui::NavPage::Console: return "Console tab"; case ui::NavPage::Peers: return "Network tab"; case ui::NavPage::Explorer: return "Explorer tab"; case ui::NavPage::Settings: return "Settings"; case ui::NavPage::Count_: break; } return "App"; } std::string traceSource(ui::NavPage page, const char* process) { std::string source = tracePageName(page); if (process && process[0] != '\0') { source += " / "; source += process; } return source; } std::size_t shieldedReceiveScanBudget(ui::NavPage page) { return page == ui::NavPage::History ? 8u : 4u; } // How far the tip may drift past an address's last shielded scan before we re-scan it. A full pass // scans ~budget addresses per refresh cycle (≈96 per block of wall time), so a wallet with many // z-addresses takes several blocks to scan fully. With a strict (tolerance 0) "scanned at tip" // check, new blocks arriving mid-pass would invalidate already-scanned addresses and the pass would // never complete — leaving transactions_dirty_ (and its "refreshing history" banner + send-progress // gate) stuck on forever. Scaling the tolerance with the address count lets the pass complete while // keeping shielded-receive latency minimal for small wallets; it's capped for pathological sizes. int shieldedScanTipTolerance(std::size_t shieldedAddressCount) { int t = 2 + static_cast(shieldedAddressCount / 96); return std::min(t, 50); } } // namespace // ============================================================================ // Warmup Message Translation // Maps raw daemon RPC warmup messages to user-friendly text. // ============================================================================ struct WarmupText { const char* title; const char* description; }; static WarmupText translateWarmup(const std::string& raw) { if (raw.find("Loading") != std::string::npos) return {"Loading blockchain data...", "Reading the block database from disk. This may take a few minutes after updates."}; if (raw.find("Verifying") != std::string::npos) return {"Verifying blockchain...", "Checking recent blocks to make sure your chain data is valid."}; if (raw.find("Activating") != std::string::npos) return {"Processing blocks...", "Applying blocks to build the current chain state."}; if (raw.find("Rewinding") != std::string::npos) return {"Reorganizing chain...", "A chain reorganization was detected. Reverting to the correct chain."}; if (raw.find("Rescanning") != std::string::npos) return {"Scanning for transactions...", "Searching the blockchain for transactions belonging to your wallet. This can take a while."}; if (raw.find("Pruning") != std::string::npos) return {"Optimizing storage...", "Removing old block data to free up disk space."}; // Fallback: use the raw message return {raw.c_str(), ""}; } // Phrases dragonxd prints to its console while initializing, in the order translateWarmup() // understands them. The most recent matching console line tells us which stage the node is in // even when the RPC probe just times out (no -28 reply to read). static const char* const kDaemonInitPhases[] = { "Rescanning", "Rewinding", "Activating", "Verifying", "Loading", "Pruning", }; // How many consecutive "RPC port busy but no config" connect attempts to wait through before // warning the user that whatever owns the port isn't a usable DragonX node. The core retry runs // roughly every few seconds, so this is on the order of ~20s — long enough for a real daemon to // write its config, short enough not to leave the user guessing. static constexpr int kDaemonWaitWarnAttempts = 4; // ============================================================================ // Connection Management // ============================================================================ void App::tryConnect() { // Lite builds have no full node / RPC daemon, so never run the RPC connection state machine // (it would just fail every tick). The lite controller drives the wallet; "online" status is // derived from it each frame in App::update(), which also gates the wallet UI (isConnected()). if (isLiteBuild()) return; if (connection_in_progress_) return; static int connect_attempt = 0; ++connect_attempt; connection_in_progress_ = true; connection_status_ = TR("sb_loading_config"); // Auto-detect configuration (file I/O — fast, safe on main thread) auto config = rpc::Connection::autoDetectConfig(); if (config.rpcuser.empty() || config.rpcpassword.empty()) { connection_in_progress_ = false; std::string confPath = rpc::Connection::getDefaultConfPath(); VERBOSE_LOGF("[connect #%d] No valid config — DRAGONX.conf missing or no rpcuser/rpcpassword (looked at: %s)\n", connect_attempt, confPath.c_str()); // Re-evaluate the RPC port LIVE rather than trusting a latched "external daemon detected" // flag: EmbeddedDaemon::start() sets that latch whenever the port was busy at a prior // attempt and then never re-checks it, so a stale socket (or a transient squatter that has // since died) would strand us forever "waiting for config". If the port is genuinely busy, // a real daemon writes its config shortly and we keep waiting; if it's free, we must start // our own. const bool portInUse = daemon::EmbeddedDaemon::isRpcPortInUse(); if (portInUse) { connection_status_ = TR("sb_waiting_config"); VERBOSE_LOGF("[connect #%d] RPC port in use but no config yet — waiting for the daemon to write it\n", connect_attempt); // After a bounded wait with no config appearing, whatever owns the port is not a usable // DragonX node (a foreign process, or a stuck/half-dead daemon). Say so once, with the // action, instead of leaving the user on a silent "waiting" spinner forever. if (++daemon_wait_attempts_ == kDaemonWaitWarnAttempts) { ui::Notifications::instance().warning(TR("daemon_port_busy_warn"), 20.0f); } network_refresh_.setTimer(services::NetworkRefreshService::Timer::Core, services::RefreshScheduler::kCoreDefault - 1.0f); return; } daemon_wait_attempts_ = 0; // port is free — clear the bounded-wait counter connection_status_ = TR("sb_no_conf"); // Port is free → start our own embedded daemon (if enabled). if (isUsingEmbeddedDaemon() && !isEmbeddedDaemonRunning()) { connection_status_ = TR("sb_starting_daemon"); if (startEmbeddedDaemon()) { // Will retry connection after daemon starts VERBOSE_LOGF("[connect #%d] Embedded daemon starting, will retry connection...\n", connect_attempt); network_refresh_.setTimer(services::NetworkRefreshService::Timer::Core, services::RefreshScheduler::kCoreDefault - 1.0f); } else { // The daemon couldn't be started (binary not found, Sapling params missing, spawn // failure, …). Surface the actual reason instead of leaving the status stuck on // "Starting dragonxd…": connection_status_ for the overlay/status bar, plus a // one-time sticky notification with the full, actionable detail. std::string detail = daemon_controller_ ? daemon_controller_->lastError() : std::string(); VERBOSE_LOGF("[connect #%d] startEmbeddedDaemon() failed — lastError: %s, binary: %s\n", connect_attempt, detail.empty() ? "(none)" : detail.c_str(), daemon::EmbeddedDaemon::findDaemonBinary().c_str()); connection_status_ = TR("sb_daemon_start_failed"); if (!daemon_start_error_shown_) { daemon_start_error_shown_ = true; ui::Notifications::instance().error( detail.empty() ? std::string(TR("sb_daemon_start_failed")) : detail, 30.0f); } // Keep retrying: a missing binary/params can be fixed without a restart. network_refresh_.setTimer(services::NetworkRefreshService::Timer::Core, services::RefreshScheduler::kCoreDefault - 1.0f); } } else if (!isUsingEmbeddedDaemon()) { VERBOSE_LOGF("[connect #%d] Embedded daemon disabled (using external). No config found at %s\n", connect_attempt, confPath.c_str()); } return; } connection_status_ = TR("sb_connecting_daemon"); VERBOSE_LOGF("[connect #%d] Connecting to %s:%s (user=%s)\n", connect_attempt, config.host.c_str(), config.port.c_str(), config.rpcuser.c_str()); if (rpc::Connection::usesPlaintextRemote(config) && !remote_rpc_plaintext_warning_shown_) { remote_rpc_plaintext_warning_shown_ = true; ui::Notifications::instance().warning( "Remote RPC is using plaintext HTTP. Add rpctls=1 to DRAGONX.conf if your daemon supports TLS.", 10.0f); } // Run the blocking rpc_->connect() on the worker thread so the UI // stays responsive (curl connect timeout can be up to 10 seconds). if (!worker_) { connection_in_progress_ = false; VERBOSE_LOGF("[connect #%d] No worker thread available!\n", connect_attempt); return; } // Capture daemon state before posting to worker bool daemonStarting = daemon_controller_ && (daemon_controller_->state() == daemon::EmbeddedDaemon::State::Starting || daemon_controller_->state() == daemon::EmbeddedDaemon::State::Running); bool externalDetected = daemon_controller_ && daemon_controller_->externalDaemonDetected(); int attempt = connect_attempt; // Log detailed daemon state for diagnostics if (daemon_controller_) { const char* stateStr = "unknown"; switch (daemon_controller_->state()) { case daemon::EmbeddedDaemon::State::Stopped: stateStr = "Stopped"; break; case daemon::EmbeddedDaemon::State::Starting: stateStr = "Starting"; break; case daemon::EmbeddedDaemon::State::Running: stateStr = "Running"; break; case daemon::EmbeddedDaemon::State::Stopping: stateStr = "Stopping"; break; case daemon::EmbeddedDaemon::State::Error: stateStr = "Error"; break; } VERBOSE_LOGF("[connect #%d] Daemon state: %s, running: %s, external: %s, crashes: %d, lastErr: %s\n", attempt, stateStr, daemon_controller_->isRunning() ? "yes" : "no", externalDetected ? "yes" : "no", daemon_controller_->crashCount(), daemon_controller_->lastError().empty() ? "(none)" : daemon_controller_->lastError().c_str()); // The embedded daemon can spawn successfully and then exit immediately (a missing runtime // DLL, wrong architecture, corrupt binary, datadir lock, …). The crash monitor records a // detailed reason (translated exit code + launch command + debug.log tail) in lastError(), // but it runs on a background thread and was never shown — so the wallet looked like it was // "stuck connecting" while the node silently died-and-respawned. Surface each new crash once. const int crashes = daemon_controller_->crashCount(); if (crashes > daemon_last_seen_crashes_) { daemon_last_seen_crashes_ = crashes; const std::string detail = daemon_controller_->lastError(); if (!detail.empty()) { connection_status_ = TR("sb_daemon_start_failed"); ui::Notifications::instance().error(detail, 30.0f); } } } else { VERBOSE_LOGF("[connect #%d] No embedded daemon object (use_embedded=%s)\n", attempt, isUsingEmbeddedDaemon() ? "yes" : "no"); } worker_->post([this, config, daemonStarting, externalDetected, attempt]() -> rpc::RPCWorker::MainCb { bool connected = rpc_->connect(config.host, config.port, config.rpcuser, config.rpcpassword, config.use_tls); std::string connectErr = rpc_->getLastConnectError(); bool warmingUp = rpc_->isWarmingUp(); std::string warmupStatus = rpc_->getWarmupStatus(); return [this, config, connected, warmingUp, warmupStatus, daemonStarting, externalDetected, attempt, connectErr]() { if (connected) { VERBOSE_LOGF("[connect #%d] Connected successfully%s\n", attempt, warmingUp ? " (daemon warming up)" : ""); saved_config_ = config; // save for fast-lane connection onConnected(); if (warmingUp) { // Daemon is reachable and auth works, but RPC calls will // fail until warmup completes. Set the warmup state so // the UI shows status instead of a blocking overlay. state_.warming_up = true; auto wt = translateWarmup(warmupStatus); state_.warmup_status = wt.title; state_.warmup_description = wt.description; // Append current block height from daemon output if (daemon_controller_) { int h = daemon_controller_->lastBlockHeight(); if (h > 0) state_.warmup_status += " (Block " + std::to_string(h) + ")"; } connection_status_ = state_.warmup_status; } } else { // HTTP 401 = authentication failure. The daemon is running // but our rpcuser/rpcpassword don't match. Don't retry // endlessly — tell the user what's wrong. bool authFailure = (connectErr.find("401") != std::string::npos); if (authFailure) { rpc::ConnectionConfig cookieConfig; if (rpc::Connection::buildCookieAuthConfig(config, cookieConfig)) { VERBOSE_LOGF("[connect #%d] HTTP 401 — retrying with .cookie auth from %s\n", attempt, cookieConfig.hush_dir.c_str()); worker_->post([this, cookieConfig, attempt]() -> rpc::RPCWorker::MainCb { bool ok = rpc_->connect(cookieConfig.host, cookieConfig.port, cookieConfig.rpcuser, cookieConfig.rpcpassword, cookieConfig.use_tls); return [this, cookieConfig, ok, attempt]() { connection_in_progress_ = false; if (ok) { VERBOSE_LOGF("[connect #%d] Connected via .cookie auth\n", attempt); saved_config_ = cookieConfig; onConnected(); } else { state_.connected = false; connection_status_ = TR("sb_auth_failed"); VERBOSE_LOGF("[connect #%d] .cookie auth also failed\n", attempt); ui::Notifications::instance().error( "RPC authentication failed (HTTP 401). " "The rpcuser/rpcpassword in DRAGONX.conf don't match the running daemon. " "Restart the daemon or correct the credentials."); } }; }); return; // async retry in progress } state_.connected = false; std::string confPath = rpc::Connection::getDefaultConfPath(); connection_status_ = TR("sb_auth_failed"); VERBOSE_LOGF("[connect #%d] HTTP 401 — rpcuser/rpcpassword in %s don't match the daemon. " "Edit the file or restart the daemon to regenerate credentials.\n", attempt, confPath.c_str()); ui::Notifications::instance().error( "RPC authentication failed (HTTP 401). " "The rpcuser/rpcpassword in DRAGONX.conf don't match the running daemon. " "Restart the daemon or correct the credentials."); } else if (daemonStarting) { state_.connected = false; // The daemon is launched but RPC isn't answering yet. A *timeout* means it // connected but the node is busy initializing (loading the block index, etc.); // a connect refusal means it hasn't bound the RPC port yet. Either way, show a // clear "node initializing" overlay (status + phase + block height from the // daemon's own console output) instead of a bare technical error. const bool reachableButBusy = connectErr.find("Timeout") != std::string::npos; applyDaemonInitStatus(reachableButBusy); VERBOSE_LOGF("[connect #%d] RPC connection failed (%s) — daemon still starting, will retry...\n", attempt, connectErr.c_str()); network_refresh_.setTimer(services::NetworkRefreshService::Timer::Core, services::RefreshScheduler::kCoreDefault - 1.0f); } else if (externalDetected) { state_.connected = false; // An external daemon is on the RPC port but not answering. A timeout means it's // up and busy initializing; surface that as the init overlay (we can't read its // console since we didn't launch it, so no phase line — just a clear message). if (connectErr.find("Timeout") != std::string::npos) { applyDaemonInitStatus(/*reachableButBusy=*/true); } else if (!connectErr.empty()) { char buf[256]; snprintf(buf, sizeof(buf), TR("sb_connecting_err"), connectErr.c_str()); connection_status_ = buf; } else { connection_status_ = TR("sb_connecting_external"); } VERBOSE_LOGF("[connect #%d] External daemon detected but RPC failed (%s), will retry...\n", attempt, connectErr.c_str()); network_refresh_.setTimer(services::NetworkRefreshService::Timer::Core, services::RefreshScheduler::kCoreDefault - 1.0f); } else { onDisconnected("Connection failed"); VERBOSE_LOGF("[connect #%d] RPC connection failed — no daemon starting, no external detected\n", attempt); if (isUsingEmbeddedDaemon() && !isEmbeddedDaemonRunning()) { // Prevent infinite crash-restart loop if (daemon_controller_ && daemon_controller_->crashCount() >= 3) { { char buf[128]; snprintf(buf, sizeof(buf), TR("sb_daemon_crashed"), daemon_controller_->crashCount()); connection_status_ = buf; } VERBOSE_LOGF("[connect #%d] Daemon crashed %d times — not restarting (use Settings > Restart Daemon to retry)\n", attempt, daemon_controller_->crashCount()); } else { connection_status_ = TR("sb_starting_daemon"); if (startEmbeddedDaemon()) { VERBOSE_LOGF("[connect #%d] Embedded daemon starting, will retry connection...\n", attempt); } else if (daemon_controller_ && daemon_controller_->externalDaemonDetected()) { connection_status_ = TR("sb_connecting_generic"); VERBOSE_LOGF("[connect #%d] External daemon detected, will connect via RPC...\n", attempt); } else { VERBOSE_LOGF("[connect #%d] Failed to start embedded daemon — lastError: %s\n", attempt, daemon_controller_ ? daemon_controller_->lastError().c_str() : "(no daemon object)"); } } } else if (!isUsingEmbeddedDaemon()) { VERBOSE_LOGF("[connect #%d] Embedded daemon disabled — external daemon at %s:%s not responding\n", attempt, config.host.c_str(), config.port.c_str()); } else { VERBOSE_LOGF("[connect #%d] Embedded daemon is running but RPC failed — daemon may be initializing\n", attempt); } } } connection_in_progress_ = false; }; }); } void App::onConnected() { state_.connected = true; state_.daemon_initializing = false; // RPC is answering now; clear the "initializing" overlay daemon_wait_attempts_ = 0; // re-arm the port-busy / start-failure notifications daemon_start_error_shown_ = false; daemon_last_seen_crashes_ = 0; // (onConnected resets the daemon's crash count too) connection_status_ = TR("connected"); // Reset crash counter on successful connection if (daemon_controller_) { daemon_controller_->resetCrashCount(); } // Get daemon info + wallet encryption state on the worker thread. // Fetching getwalletinfo here (before refreshData) ensures the lock // screen appears immediately instead of after 6+ queued RPC calls. bool initialPrefetchQueued = false; if (worker_ && rpc_) { auto prefetchedInfo = NetworkRefreshService::parseConnectionInfoResult(rpc_->getLastConnectInfo()); auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::ConnectionInit, *worker_, [this, prefetchedInfo]() -> rpc::RPCWorker::MainCb { AppRefreshRpcGateway refreshRpc(*rpc_, "Startup / Connection init"); auto result = NetworkRefreshService::collectConnectionInitResult(refreshRpc, prefetchedInfo); return [this, result]() { NetworkRefreshService::applyConnectionInitResult(state_, result); if (state_.isLocked()) { resetTransactionHistoryCacheSession(); } else if (state_.transactions.empty()) { loadTransactionHistoryCacheIfAvailable(); } else { storeTransactionHistoryCacheIfAvailable(); } }; }, 3); initialPrefetchQueued = enqueued.enqueued; } // onConnected already fetched getwalletinfo — tell refreshData to skip // the duplicate call on the very first cycle. encryption_state_prefetched_ = initialPrefetchQueued; // Addresses are unknown on fresh connect — force a fetch addresses_dirty_ = true; // Start the fast-lane RPC connection (dedicated to 1-second mining polls). // Uses its own curl handle + worker thread so getlocalsolps never blocks // behind the main refresh batch. if (!fast_rpc_) { fast_rpc_ = std::make_unique(); } if (!fast_worker_) { fast_worker_ = std::make_unique(); fast_worker_->start(); } // Connect on the fast worker's own thread (non-blocking to main) fast_worker_->post([this]() -> rpc::RPCWorker::MainCb { bool ok = fast_rpc_->connect(saved_config_.host, saved_config_.port, saved_config_.rpcuser, saved_config_.rpcpassword, saved_config_.use_tls); return [ok]() { if (!ok) { DEBUG_LOGF("[FastLane] Failed to connect secondary RPC client\\n"); } else { DEBUG_LOGF("[FastLane] Secondary RPC client connected\\n"); } }; }); // Initial data refresh refreshData(); refreshMarketData(); // Apply compiled-in default ban list applyDefaultBanlist(); } void App::onDisconnected(const std::string& reason) { state_.connected = false; state_.warming_up = false; state_.warmup_status.clear(); 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; pending_opids_.clear(); pending_send_info_.clear(); // Resolve any deferred send callbacks so their UI doesn't spin forever on disconnect. for (auto& entry : pending_send_callbacks_) { if (entry.second) entry.second(false, reason); } pending_send_callbacks_.clear(); consecutive_core_failures_ = 0; send_progress_active_ = false; send_submissions_in_flight_ = 0; network_refresh_.resetJobs(); rescan_status_poll_in_progress_ = false; opid_poll_in_progress_ = false; address_validation_cache_dirty_ = true; resetTransactionHistoryCacheSession(); // Tear down the fast-lane connection. Signal abort first so a fast-lane call blocked in // curl_easy_perform unblocks and stop()'s join() returns promptly (no UI freeze). if (fast_rpc_) fast_rpc_->requestAbort(); if (fast_worker_) { fast_worker_->stop(); } if (fast_rpc_) { fast_rpc_->disconnect(); } } std::string App::applyDaemonInitStatus(bool reachableButBusy) { state_.daemon_initializing = true; // Find the most recent console line that names an init phase, so we can tell the user exactly // what the node is doing (loading the block index, verifying, activating best chain, …). std::string phaseLine; if (daemon_controller_) { const auto lines = daemon_controller_->recentLines(40); for (auto it = lines.rbegin(); it != lines.rend() && phaseLine.empty(); ++it) { for (const char* phase : kDaemonInitPhases) { if (it->find(phase) != std::string::npos) { phaseLine = *it; break; } } } } WarmupText wt; if (!phaseLine.empty()) { wt = translateWarmup(phaseLine); } else if (reachableButBusy) { // The probe connected but got no RPC reply within the timeout: the node is up but busy // initializing (it isn't printing a recognizable phase, or we didn't launch it). wt = {"Starting DragonX node…", "The node is reachable but still initializing and isn't answering yet. " "This is normal after an update or on first launch — it can take a few minutes."}; } else { // The daemon is launching but hasn't bound its RPC port yet. wt = {"Starting DragonX node…", "Launching dragonxd and waiting for it to come online…"}; } std::string title = wt.title; const int h = daemon_controller_ ? daemon_controller_->lastBlockHeight() : -1; if (h > 0) title += " (Block " + std::to_string(h) + ")"; state_.warmup_status = title; state_.warmup_description = wt.description ? wt.description : ""; connection_status_ = title; return title; } void App::handleLostConnection(const std::string& reason) { DEBUG_LOGF("[Connection] %s — tearing down for reconnect\n", reason.c_str()); // Flip the main client's connected_ flag so update()'s else-branch re-enters // tryConnect(). onDisconnected() alone only tears down the fast lane. if (rpc_) rpc_->disconnect(); onDisconnected(reason); } // ============================================================================ // Data Refresh — Tab-Aware Prioritized System // // Data is split into independent categories, each with its own refresh // function, timer, and in-progress guard. The orchestrator (refreshData) // dispatches all categories, but each can also be called independently // (e.g. on tab switch for immediate refresh). // // Categories: // Core — z_gettotalbalance + getblockchaininfo (balance, sync) // Addresses — z_listaddresses + listunspent (address list, per-addr balances) // Transactions — listtransactions + z_listreceivedbyaddress + z_viewtransaction // Peers — getpeerinfo + listbanned (already standalone) // Encryption — getwalletinfo (one-shot on connect) // // Intervals are adjusted by applyRefreshPolicy() based on the active tab, // so the user sees faster updates for the data they're interacting with. // ============================================================================ App::RefreshIntervals App::getIntervalsForPage(ui::NavPage page) { return services::NetworkRefreshService::intervalsForPage(page); } void App::applyRefreshPolicy(ui::NavPage page) { network_refresh_.setIntervals(getIntervalsForPage(page)); } bool App::currentPageNeedsWalletDataRefresh() const { using NP = ui::NavPage; return current_page_ == NP::Overview || current_page_ == NP::Send || current_page_ == NP::Receive || current_page_ == NP::History; } bool App::shouldRunWalletTransactionRefresh() const { if (currentPageNeedsWalletDataRefresh()) return true; if (hasTransactionSendProgress() || !send_txids_.empty()) return true; return transactions_dirty_ && !shielded_history_scan_pending_; } void App::setCurrentPage(ui::NavPage page) { if (!isPageEnabledForBuild(page)) { page = ui::NavPage::Overview; } if (page == current_page_) return; current_page_ = page; applyRefreshPolicy(page); using RefreshTimer = services::NetworkRefreshService::Timer; // Immediate refresh for the incoming tab's priority data. Gate on ACTUAL RPC connectivity // (not state_.connected, which is the lite "online" proxy) — lite has no RPC daemon and the // lite controller refreshes wallet data itself, so these full-node RPC polls must not fire. if (rpc_ && rpc_->isConnected() && !state_.isLocked()) { using NP = ui::NavPage; switch (page) { case NP::Overview: refreshCoreData(); network_refresh_.reset(RefreshTimer::Core); break; case NP::History: transactions_dirty_ = true; refreshTransactionData(); network_refresh_.reset(RefreshTimer::Transactions); break; case NP::Send: case NP::Receive: addresses_dirty_ = true; refreshAddressData(); network_refresh_.reset(RefreshTimer::Addresses); break; case NP::Peers: refreshPeerInfo(); network_refresh_.reset(RefreshTimer::Peers); break; case NP::Mining: refreshMiningInfo(); break; default: break; } } } bool App::shouldRefreshTransactions() const { // NOTE: this is block-height / dirty driven, NOT interval-gated. It returns true only when a new // block arrived (currentBlocks != last_tx_block_height_), the history was never fetched, or // something marked it dirty (tab entry, a send, a reorg, etc.). The Transactions timer only // controls how often this CHECK runs; between blocks the lightweight recent-poll // (shouldRefreshRecentTransactions / TxAge) handles mempool + unconfirmed deltas instead. const int currentBlocks = state_.sync.blocks; return network_refresh_.shouldRefreshTransactions(last_tx_block_height_, currentBlocks, transactions_dirty_); } bool App::shouldRefreshRecentTransactions() const { using RefreshTimer = services::NetworkRefreshService::Timer; return network_refresh_.isDue(RefreshTimer::TxAge) && last_tx_block_height_ >= 0 && state_.sync.blocks == last_tx_block_height_ && !state_.transactions.empty() && !transactions_dirty_ && !addresses_dirty_; } void App::upsertPendingSendTransaction(const std::string& opid, const std::string& from, const std::string& to, double amount, const std::string& memo) { if (opid.empty()) return; bool newPending = pending_send_info_.find(opid) == pending_send_info_.end(); auto& pendingInfo = pending_send_info_[opid]; if (pendingInfo.timestamp == 0) pendingInfo.timestamp = static_cast(std::time(nullptr)); pendingInfo.from = from; pendingInfo.to = to; pendingInfo.amount = std::abs(amount); pendingInfo.memo = memo; TransactionInfo pending; pending.txid = opid; pending.type = "send"; pending.amount = -pendingInfo.amount; pending.timestamp = pendingInfo.timestamp; pending.confirmations = 0; pending.address = pendingInfo.to; pending.from_address = pendingInfo.from; pending.memo = pendingInfo.memo; auto existing = std::find_if(state_.transactions.begin(), state_.transactions.end(), [&](const TransactionInfo& transaction) { return transaction.txid == opid; }); if (existing != state_.transactions.end()) { *existing = std::move(pending); } else { state_.transactions.insert(state_.transactions.begin(), std::move(pending)); } if (newPending) { auto applyDelta = [&](std::vector& addresses) { for (auto& address : addresses) { if (address.address == pendingInfo.from) { address.balance = std::max(0.0, address.balance - pendingInfo.amount); return true; } } return false; }; if (!applyDelta(state_.z_addresses)) applyDelta(state_.t_addresses); if (!pendingInfo.from.empty() && pendingInfo.from[0] == 'z') { state_.privateBalance = std::max(0.0, state_.privateBalance - pendingInfo.amount); } else { state_.transparentBalance = std::max(0.0, state_.transparentBalance - pendingInfo.amount); } state_.totalBalance = std::max(0.0, state_.totalBalance - pendingInfo.amount); } state_.last_tx_update = std::time(nullptr); } void App::markPendingSendTransactionSucceeded(const std::string& opid, const std::string& txid) { if (opid.empty() || txid.empty()) return; auto pending = std::find_if(state_.transactions.begin(), state_.transactions.end(), [&](const TransactionInfo& transaction) { return transaction.txid == opid; }); if (pending == state_.transactions.end()) return; bool duplicateRealTx = std::any_of(state_.transactions.begin(), state_.transactions.end(), [&](const TransactionInfo& transaction) { return transaction.txid == txid; }); if (duplicateRealTx) { state_.transactions.erase(pending); } else { pending->txid = txid; pending->confirmations = 0; if (pending->timestamp == 0) pending->timestamp = static_cast(std::time(nullptr)); } state_.last_tx_update = std::time(nullptr); } void App::removePendingSendTransactions(const std::vector& opids, bool restoreBalances) { if (opids.empty()) return; std::unordered_set opidSet(opids.begin(), opids.end()); if (restoreBalances) { for (const auto& opid : opidSet) { auto pending = pending_send_info_.find(opid); if (pending == pending_send_info_.end()) continue; auto restoreBalance = [&](std::vector& addresses) { for (auto& address : addresses) { if (address.address == pending->second.from) { address.balance += pending->second.amount; return true; } } return false; }; if (!restoreBalance(state_.z_addresses)) restoreBalance(state_.t_addresses); if (!pending->second.from.empty() && pending->second.from[0] == 'z') { state_.privateBalance += pending->second.amount; } else { state_.transparentBalance += pending->second.amount; } state_.totalBalance += pending->second.amount; } } state_.transactions.erase( std::remove_if(state_.transactions.begin(), state_.transactions.end(), [&](const TransactionInfo& transaction) { return opidSet.find(transaction.txid) != opidSet.end(); }), state_.transactions.end()); for (const auto& opid : opidSet) pending_send_info_.erase(opid); state_.last_tx_update = std::time(nullptr); } void App::trackOperation(const std::string& opid) { if (opid.empty()) return; // Touched only from the main thread (sendTransaction's MainCb and the opid poller's // MainCb both run via drainResults()), so no locking is needed. if (std::find(pending_opids_.begin(), pending_opids_.end(), opid) != pending_opids_.end()) return; pending_opids_.push_back(opid); } bool App::invokeSendResultCallback(const std::string& opid, bool ok, const std::string& result) { auto it = pending_send_callbacks_.find(opid); if (it == pending_send_callbacks_.end()) return false; auto cb = std::move(it->second); pending_send_callbacks_.erase(it); if (cb) cb(ok, result); return true; } void App::applyPendingSendBalanceDeltas(bool includeAggregateBalances) { for (const auto& [opid, pending] : pending_send_info_) { (void)opid; auto applyDelta = [&](std::vector& addresses) { for (auto& address : addresses) { if (address.address == pending.from) { address.balance = std::max(0.0, address.balance - pending.amount); return true; } } return false; }; if (!applyDelta(state_.z_addresses)) applyDelta(state_.t_addresses); if (includeAggregateBalances) { if (!pending.from.empty() && pending.from[0] == 'z') { state_.privateBalance = std::max(0.0, state_.privateBalance - pending.amount); } else { state_.transparentBalance = std::max(0.0, state_.transparentBalance - pending.amount); } state_.totalBalance = std::max(0.0, state_.totalBalance - pending.amount); } } } std::string App::transactionHistoryCacheWalletIdentity() const { std::vector shieldedAddresses; std::vector transparentAddresses; shieldedAddresses.reserve(state_.z_addresses.size()); transparentAddresses.reserve(state_.t_addresses.size()); for (const auto& address : state_.z_addresses) { if (!address.address.empty()) shieldedAddresses.push_back(address.address); } for (const auto& address : state_.t_addresses) { if (!address.address.empty()) transparentAddresses.push_back(address.address); } return data::TransactionHistoryCache::walletIdentityFromAddresses( shieldedAddresses, transparentAddresses); } void App::wipePendingTransactionHistoryCachePassphrase() { if (!pending_transaction_history_cache_passphrase_.empty()) { util::SecureVault::secureZero(pending_transaction_history_cache_passphrase_.data(), pending_transaction_history_cache_passphrase_.size()); pending_transaction_history_cache_passphrase_.clear(); } } void App::resetTransactionHistoryCacheSession() { transaction_history_cache_.lockKey(); wipePendingTransactionHistoryCachePassphrase(); transaction_history_cache_loaded_ = false; invalidateShieldedHistoryScanProgress(false); } void App::pruneShieldedHistoryScanProgress() { std::unordered_set currentShieldedAddresses; currentShieldedAddresses.reserve(state_.z_addresses.size()); for (const auto& address : state_.z_addresses) { if (!address.address.empty()) currentShieldedAddresses.insert(address.address); } for (auto it = shielded_history_scan_heights_.begin(); it != shielded_history_scan_heights_.end();) { if (currentShieldedAddresses.find(it->first) == currentShieldedAddresses.end()) { it = shielded_history_scan_heights_.erase(it); } else { ++it; } } } void App::invalidateShieldedHistoryScanProgress(bool persistCache) { shielded_history_scan_cursor_ = 0; shielded_history_scan_pending_ = false; shielded_history_scan_heights_.clear(); if (persistCache) storeTransactionHistoryCacheIfAvailable(); } bool App::ensureTransactionHistoryCacheUnlockedFor(const std::string& walletIdentity) { if (walletIdentity.empty()) return false; if (transaction_history_cache_.isUnlockedFor(walletIdentity)) return true; if (!pending_transaction_history_cache_passphrase_.empty()) { std::string passphrase = pending_transaction_history_cache_passphrase_; bool unlocked = transaction_history_cache_.unlockWithPassphrase(walletIdentity, passphrase); if (unlocked) wipePendingTransactionHistoryCachePassphrase(); util::SecureVault::secureZero(passphrase.data(), passphrase.size()); if (unlocked) return true; } if (state_.encryption_state_known && !state_.encrypted) { std::string cacheKey = unencryptedTransactionHistoryCacheKey(walletIdentity); bool unlocked = transaction_history_cache_.unlockWithPassphrase(walletIdentity, cacheKey); util::SecureVault::secureZero(cacheKey.data(), cacheKey.size()); return unlocked; } return false; } void App::unlockTransactionHistoryCacheWithPassphrase(const std::string& passphrase) { if (passphrase.empty()) return; std::string walletIdentity = transactionHistoryCacheWalletIdentity(); if (walletIdentity.empty()) { wipePendingTransactionHistoryCachePassphrase(); pending_transaction_history_cache_passphrase_ = passphrase; return; } if (transaction_history_cache_.unlockWithPassphrase(walletIdentity, passphrase)) { wipePendingTransactionHistoryCachePassphrase(); if (state_.transactions.empty()) loadTransactionHistoryCacheIfAvailable(); else storeTransactionHistoryCacheIfAvailable(); } } void App::loadTransactionHistoryCacheIfAvailable() { if (transaction_history_cache_loaded_ || !state_.transactions.empty()) return; std::string walletIdentity = transactionHistoryCacheWalletIdentity(); if (walletIdentity.empty()) return; if (!ensureTransactionHistoryCacheUnlockedFor(walletIdentity)) return; auto loaded = transaction_history_cache_.load(walletIdentity, state_.sync.blocks, state_.sync.best_blockhash); if (!loaded.loaded) return; state_.transactions = std::move(loaded.transactions); shielded_history_scan_heights_ = std::move(loaded.shieldedScanHeights); pruneShieldedHistoryScanProgress(); state_.last_tx_update = loaded.updatedAt; last_tx_block_height_ = loaded.tipHeight; confirmed_tx_cache_.clear(); confirmed_tx_ids_.clear(); for (const auto& transaction : state_.transactions) { if (transaction.confirmations >= 10 && transaction.timestamp != 0) { confirmed_tx_ids_.insert(transaction.txid); confirmed_tx_cache_.push_back(transaction); } } confirmed_cache_block_ = loaded.tipHeight; transaction_history_cache_loaded_ = true; transactions_dirty_ = true; network_refresh_.markDue(services::NetworkRefreshService::Timer::Transactions); } void App::storeTransactionHistoryCacheIfAvailable() { if (state_.transactions.empty()) return; std::string walletIdentity = transactionHistoryCacheWalletIdentity(); if (walletIdentity.empty()) return; if (!ensureTransactionHistoryCacheUnlockedFor(walletIdentity)) return; pruneShieldedHistoryScanProgress(); std::unordered_set pendingOpids(pending_opids_.begin(), pending_opids_.end()); std::vector cacheTransactions; cacheTransactions.reserve(state_.transactions.size()); for (const auto& transaction : state_.transactions) { if (pendingOpids.find(transaction.txid) != pendingOpids.end()) continue; cacheTransactions.push_back(transaction); } if (cacheTransactions.empty()) return; std::time_t updatedAt = state_.last_tx_update != 0 ? static_cast(state_.last_tx_update) : std::time(nullptr); transaction_history_cache_.replace(walletIdentity, state_.sync.blocks, state_.sync.best_blockhash, cacheTransactions, updatedAt, shielded_history_scan_heights_); } void App::refreshData() { if (!state_.connected || !rpc_ || !worker_) return; // During warmup, only poll for warmup completion via refreshCoreData. // Other RPC calls (balance, addresses, transactions) will fail with -28. if (state_.warming_up) { refreshCoreData(); return; } // Dispatch each category independently — results trickle into the UI // as each completes, rather than waiting for the slowest phase. refreshCoreData(); bool addressRefreshNeeded = addresses_dirty_; bool walletDataPage = currentPageNeedsWalletDataRefresh(); if (addressRefreshNeeded) refreshAddressData(); if (!addressRefreshNeeded && shouldRunWalletTransactionRefresh() && shouldRefreshTransactions()) refreshTransactionData(); else if (!addressRefreshNeeded && walletDataPage && shouldRefreshRecentTransactions()) refreshRecentTransactionData(); if (current_page_ == ui::NavPage::Peers) refreshPeerInfo(); if (!state_.encryption_state_known && !network_refresh_.jobInProgress(services::NetworkRefreshService::Job::ConnectionInit)) { encryption_state_prefetched_ = refreshEncryptionState(); } } // ============================================================================ // Core Data: balance + blockchain info (~50-100ms, 2 RPC calls) // Uses fast_worker_ when on Overview tab for lower latency. // ============================================================================ void App::refreshCoreData() { if (!state_.connected) return; // During warmup, poll getinfo to detect when warmup ends. // Most RPC calls (balance, blockchain info) will fail with -28 during warmup. if (state_.warming_up) { if (!worker_) return; auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Core, *worker_, [this]() -> rpc::RPCWorker::MainCb { AppRefreshRpcGateway refreshRpc(*rpc_, "Startup / Warmup poll"); auto result = NetworkRefreshService::collectWarmupPollResult(refreshRpc); return [this, result = std::move(result)]() { if (result.ready) { // Warmup finished — daemon is fully ready state_.warming_up = false; state_.warmup_status.clear(); state_.warmup_description.clear(); connection_status_ = TR("connected"); VERBOSE_LOGF("[warmup] Daemon ready, warmup complete\n"); NetworkRefreshService::applyConnectionInfoResult(state_, result.info); // Trigger full data refresh now that daemon is ready refreshData(); } else { // Still warming up — update status auto wt = translateWarmup(result.errorMessage); state_.warmup_status = wt.title; state_.warmup_description = wt.description; if (daemon_controller_) { int h = daemon_controller_->lastBlockHeight(); if (h > 0) state_.warmup_status += " (Block " + std::to_string(h) + ")"; } connection_status_ = state_.warmup_status; VERBOSE_LOGF("[warmup] Still warming up: %s\n", result.errorMessage.c_str()); } }; }, 3); if (!enqueued.enqueued) return; return; } // Use fast-lane on Overview for snappier balance updates bool useFast = (current_page_ == ui::NavPage::Overview); auto* w = useFast && fast_worker_ && fast_worker_->isRunning() ? fast_worker_.get() : worker_.get(); auto* rpc = useFast && fast_rpc_ && fast_rpc_->isConnected() ? fast_rpc_.get() : rpc_.get(); if (!w || !rpc) return; ui::NavPage tracePage = current_page_; auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Core, *w, [this, rpc, tracePage]() -> rpc::RPCWorker::MainCb { AppRefreshRpcGateway refreshRpc(*rpc, traceSource(tracePage, "Core refresh")); auto result = NetworkRefreshService::collectCoreRefreshResult(refreshRpc); return [this, result]() { try { NetworkRefreshService::applyCoreRefreshResult(state_, result, std::time(nullptr)); applyPendingSendBalanceDeltas(true); // Mid-session connection-loss detection. During normal operation, both core // RPCs failing together means the daemon connection is dead (a busy daemon // fails them individually, not both at once). Warmup is excluded — both fail // with -28 there legitimately, and counting it would cause a reconnect loop. constexpr int kCoreFailuresBeforeDisconnect = 3; if (!state_.warming_up) { if (!result.balanceOk && !result.blockchainOk) { if (++consecutive_core_failures_ >= kCoreFailuresBeforeDisconnect && state_.connected) { consecutive_core_failures_ = 0; handleLostConnection("Lost connection to daemon"); return; // state torn down — skip the rest of this callback } } else { consecutive_core_failures_ = 0; } } // Auto-shield transparent funds if enabled if (result.balanceOk && settings_ && settings_->getAutoShield() && state_.transparent_balance > 0.0001 && !state_.sync.syncing && !auto_shield_pending_.exchange(true)) { std::string targetZAddr; for (const auto& addr : state_.addresses) { if (addr.isShielded()) { targetZAddr = addr.address; break; } } if (!targetZAddr.empty() && worker_) { DEBUG_LOGF("[AutoShield] Shielding %.8f DRGX to %s\n", state_.transparent_balance, targetZAddr.c_str()); // Use the user-configured fee, formatted fixed-decimal so the daemon's // ParseFixedPoint accepts it (a small double would serialize to "5e-05"). const std::string feeStr = util::formatAmountFixed(settings_ ? settings_->getDefaultFee() : 0.0001); // This callback runs on the UI thread (drainResults). Build/broadcast // on the worker thread — never block the UI with synchronous RPC. worker_->post([this, targetZAddr, feeStr]() -> rpc::RPCWorker::MainCb { std::string opid; std::string err; try { rpc::RPCClient::TraceScope trace("Auto-shield / z_shieldcoinbase"); auto result = rpc_->call("z_shieldcoinbase", {std::string("*"), targetZAddr, feeStr, 50}); opid = result.value("opid", ""); } catch (const std::exception& e) { err = e.what(); } return [this, opid, err]() { auto_shield_pending_ = false; if (!err.empty()) { DEBUG_LOGF("[AutoShield] Error: %s\n", err.c_str()); return; } if (!opid.empty()) { DEBUG_LOGF("[AutoShield] Started: %s\n", opid.c_str()); // Surface the async result + refresh balances on completion. trackOperation(opid); } }; }); } else { auto_shield_pending_ = false; } } } catch (const std::exception& e) { DEBUG_LOGF("[refreshCoreData] callback error: %s\n", e.what()); } }; }, 3); if (!enqueued.enqueued) return; } // ============================================================================ // Address Data: z/t address lists + per-address balances // ============================================================================ void App::refreshAddressData() { if (!worker_ || !rpc_ || !state_.connected) return; const std::size_t previousAddressCount = state_.z_addresses.size() + state_.t_addresses.size(); const std::string previousWalletIdentity = transactionHistoryCacheWalletIdentity(); auto addressSnapshot = address_validation_cache_dirty_ ? NetworkRefreshService::AddressRefreshSnapshot{} : NetworkRefreshService::buildAddressRefreshSnapshot(state_); ui::NavPage tracePage = current_page_; auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Addresses, *worker_, [this, previousAddressCount, previousWalletIdentity, addressSnapshot = std::move(addressSnapshot), tracePage]() -> rpc::RPCWorker::MainCb { AppRefreshRpcGateway refreshRpc(*rpc_, traceSource(tracePage, "Address refresh")); auto result = NetworkRefreshService::collectAddressRefreshResult(refreshRpc, addressSnapshot); return [this, previousAddressCount, previousWalletIdentity, result = std::move(result)]() mutable { NetworkRefreshService::applyAddressRefreshResult(state_, std::move(result)); applyPendingSendBalanceDeltas(false); address_validation_cache_dirty_ = false; address_list_dirty_ = true; addresses_dirty_ = false; const std::size_t currentAddressCount = state_.z_addresses.size() + state_.t_addresses.size(); const bool addressSetChanged = currentAddressCount != previousAddressCount || transactionHistoryCacheWalletIdentity() != previousWalletIdentity; if (state_.transactions.empty() || addressSetChanged) { if (addressSetChanged) { invalidateShieldedHistoryScanProgress(false); } transactions_dirty_ = true; last_tx_block_height_ = -1; network_refresh_.markDue(services::NetworkRefreshService::Timer::Transactions); } if (state_.transactions.empty()) loadTransactionHistoryCacheIfAvailable(); else storeTransactionHistoryCacheIfAvailable(); maybeFinishTransactionSendProgress(); }; }, 3); if (!enqueued.enqueued) return; } // ============================================================================ // Transaction Data: transparent + shielded receives + z_viewtransaction enrichment // ============================================================================ void App::refreshTransactionData() { if (!worker_ || !rpc_ || !state_.connected) return; if (addresses_dirty_) { refreshAddressData(); network_refresh_.markDue(services::NetworkRefreshService::Timer::Transactions); return; } const int currentBlocks = state_.sync.blocks; if (last_tx_block_height_ < 0 || currentBlocks != last_tx_block_height_ || !shielded_history_scan_pending_) { shielded_history_scan_cursor_ = 0; shielded_history_scan_pending_ = false; } auto transactionSnapshot = NetworkRefreshService::buildTransactionRefreshSnapshot( state_, viewtx_cache_, send_txids_); transactionSnapshot.pendingOpids.insert(pending_opids_.begin(), pending_opids_.end()); if (settings_) transactionSnapshot.miningAddresses = settings_->getMiningAddresses(); transactionSnapshot.shieldedScanHeights = shielded_history_scan_heights_; transactionSnapshot.shieldedScanStartIndex = shielded_history_scan_cursor_; transactionSnapshot.maxShieldedReceiveScans = shieldedReceiveScanBudget(current_page_); transactionSnapshot.shieldedScanTipTolerance = shieldedScanTipTolerance(transactionSnapshot.shieldedAddresses.size()); ui::NavPage tracePage = current_page_; auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Transactions, *worker_, [this, currentBlocks, transactionSnapshot = std::move(transactionSnapshot), tracePage]() -> rpc::RPCWorker::MainCb { AppRefreshRpcGateway refreshRpc(*rpc_, traceSource(tracePage, "Transaction refresh")); auto result = NetworkRefreshService::collectTransactionRefreshResult( refreshRpc, transactionSnapshot, currentBlocks, MAX_VIEWTX_PER_CYCLE); return [this, result = std::move(result)]() mutable { bool shieldedScanComplete = result.shieldedScanComplete; std::size_t nextShieldedScanStartIndex = result.nextShieldedScanStartIndex; auto shieldedScanHeights = std::move(result.shieldedScanHeights); NetworkRefreshService::TransactionCacheUpdate cacheUpdate{ viewtx_cache_, send_txids_, confirmed_tx_cache_, confirmed_tx_ids_, confirmed_cache_block_, last_tx_block_height_ }; NetworkRefreshService::applyTransactionRefreshResult( state_, cacheUpdate, std::move(result), std::time(nullptr)); shielded_history_scan_heights_ = std::move(shieldedScanHeights); storeTransactionHistoryCacheIfAvailable(); shielded_history_scan_cursor_ = nextShieldedScanStartIndex; shielded_history_scan_pending_ = !shieldedScanComplete; transactions_dirty_ = !shieldedScanComplete; maybeFinishTransactionSendProgress(); }; }, 3); if (!enqueued.enqueued) return; network_refresh_.resetTxAge(); } void App::refreshRecentTransactionData() { if (!worker_ || !rpc_ || !state_.connected) return; if (!shouldRefreshRecentTransactions()) return; const int currentBlocks = state_.sync.blocks; auto transactionSnapshot = NetworkRefreshService::buildTransactionRefreshSnapshot( state_, viewtx_cache_, send_txids_); transactionSnapshot.pendingOpids.insert(pending_opids_.begin(), pending_opids_.end()); if (settings_) transactionSnapshot.miningAddresses = settings_->getMiningAddresses(); transactionSnapshot.shieldedScanHeights = shielded_history_scan_heights_; transactionSnapshot.shieldedScanStartIndex = shielded_history_scan_cursor_; transactionSnapshot.maxShieldedReceiveScans = 1; transactionSnapshot.shieldedScanTipTolerance = shieldedScanTipTolerance(transactionSnapshot.shieldedAddresses.size()); ui::NavPage tracePage = current_page_; auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Transactions, *worker_, [this, currentBlocks, transactionSnapshot = std::move(transactionSnapshot), tracePage]() -> rpc::RPCWorker::MainCb { AppRefreshRpcGateway refreshRpc(*rpc_, traceSource(tracePage, "Recent transaction poll")); auto result = NetworkRefreshService::collectRecentTransactionRefreshResult( refreshRpc, transactionSnapshot, currentBlocks); return [this, result = std::move(result)]() mutable { std::size_t nextShieldedScanStartIndex = result.nextShieldedScanStartIndex; auto shieldedScanHeights = std::move(result.shieldedScanHeights); NetworkRefreshService::TransactionCacheUpdate cacheUpdate{ viewtx_cache_, send_txids_, confirmed_tx_cache_, confirmed_tx_ids_, confirmed_cache_block_, last_tx_block_height_ }; NetworkRefreshService::applyTransactionRefreshResult( state_, cacheUpdate, std::move(result), std::time(nullptr)); shielded_history_scan_heights_ = std::move(shieldedScanHeights); shielded_history_scan_cursor_ = nextShieldedScanStartIndex; storeTransactionHistoryCacheIfAvailable(); maybeFinishTransactionSendProgress(); }; }, 3); if (!enqueued.enqueued) return; network_refresh_.resetTxAge(); } // ============================================================================ // Encryption State: wallet info (one-shot on connect, lightweight) // ============================================================================ bool App::refreshEncryptionState() { if (!worker_ || !rpc_ || !state_.connected) return false; auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Encryption, *worker_, [this]() -> rpc::RPCWorker::MainCb { json walletInfo; bool ok = false; try { rpc::RPCClient::TraceScope trace("Startup / Wallet encryption state"); walletInfo = rpc_->call("getwalletinfo"); ok = true; } catch (...) {} if (!ok) return nullptr; auto result = NetworkRefreshService::parseWalletEncryptionResult(walletInfo); return [this, result]() { NetworkRefreshService::applyWalletEncryptionResult(state_, result); if (state_.isLocked()) { resetTransactionHistoryCacheSession(); } else if (state_.transactions.empty()) { loadTransactionHistoryCacheIfAvailable(); } else { storeTransactionHistoryCacheIfAvailable(); } }; }, 3); return enqueued.enqueued; } void App::refreshBalance() { refreshCoreData(); } void App::refreshAddresses() { addresses_dirty_ = true; refreshAddressData(); } void App::refreshMiningInfo() { // Use the dedicated fast-lane worker + connection so mining polls // never block behind the main refresh batch. Falls back to the main // worker if the fast lane isn't ready yet (e.g. during initial connect). auto* w = (fast_worker_ && fast_worker_->isRunning()) ? fast_worker_.get() : worker_.get(); auto* rpc = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get(); if (!w || !rpc) return; // Capture daemon memory outside (may be accessed on main thread) double daemonMemMb = 0.0; if (daemon_controller_) { daemonMemMb = daemon_controller_->memoryUsageMB(); } // Slow-tick counter: run full getmininginfo every ~5 seconds // to reduce RPC overhead. getlocalsolps is only needed while solo mining // or while the Mining tab is actively showing live local hashrate. // NOTE: getinfo is NOT called here — longestchain/notarized are updated by // refreshBalance (via getblockchaininfo), and daemon_version/protocol_version/ // p2p_port are static for the lifetime of a connection (set in onConnected). bool doSlowRefresh = (mining_slow_counter_++ % 5 == 0); bool includeLocalHashrate = state_.mining.generate || current_page_ == ui::NavPage::Mining; if (!includeLocalHashrate && !doSlowRefresh) return; ui::NavPage tracePage = current_page_; auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Mining, *w, [this, rpc, daemonMemMb, doSlowRefresh, includeLocalHashrate, tracePage]() -> rpc::RPCWorker::MainCb { AppRefreshRpcGateway refreshRpc(*rpc, traceSource(tracePage, "Mining refresh")); auto result = NetworkRefreshService::collectMiningRefreshResult( refreshRpc, daemonMemMb, doSlowRefresh, includeLocalHashrate); return [this, result]() { try { NetworkRefreshService::applyMiningRefreshResult(state_, result, std::time(nullptr)); } catch (const std::exception& e) { DEBUG_LOGF("[refreshMiningInfo] callback error: %s\n", e.what()); } }; }, 2); if (!enqueued.enqueued) return; } void App::refreshPeerInfo() { 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; ui::NavPage tracePage = current_page_; auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Peers, *w, [this, r, tracePage]() -> rpc::RPCWorker::MainCb { AppRefreshRpcGateway refreshRpc(*r, traceSource(tracePage, "Peer refresh")); auto result = NetworkRefreshService::collectPeerRefreshResult(refreshRpc); return [this, result = std::move(result)]() mutable { NetworkRefreshService::applyPeerRefreshResult(state_, std::move(result), std::time(nullptr)); }; }, 2); if (!enqueued.enqueued) return; } void App::refreshPrice() { // Skip if price fetching is disabled if (!settings_->getFetchPrices()) return; if (!worker_) return; auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Price, *worker_, [this]() -> rpc::RPCWorker::MainCb { // --- Worker thread: blocking HTTP GET to CoinGecko --- NetworkRefreshService::PriceHttpResult result; try { CURL* curl = curl_easy_init(); if (!curl) { DEBUG_LOGF("Failed to initialize curl for price fetch\n"); result.errorMessage = "Price fetch failed: failed to initialize curl"; } else { std::string response_data; const char* url = "https://api.coingecko.com/api/v3/simple/price?ids=dragonx-2&vs_currencies=usd,btc&include_24hr_change=true&include_24hr_vol=true&include_market_cap=true"; auto write_callback = [](void* contents, size_t size, size_t nmemb, std::string* userp) -> size_t { size_t totalSize = size * nmemb; userp->append((char*)contents, totalSize); return totalSize; }; curl_easy_setopt(curl, CURLOPT_URL, url); curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, +write_callback); curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response_data); curl_easy_setopt(curl, CURLOPT_TIMEOUT, 10L); curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 5L); curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); curl_easy_setopt(curl, CURLOPT_USERAGENT, "DragonX-Wallet/1.0"); CURLcode res = curl_easy_perform(curl); long http_code = 0; curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); curl_easy_cleanup(curl); NetworkRefreshService::PriceHttpResponse response{ res == CURLE_OK, http_code, std::move(response_data), res == CURLE_OK ? std::string() : std::string(curl_easy_strerror(res)) }; result = NetworkRefreshService::parsePriceHttpResponse(response, std::time(nullptr)); } } catch (const std::exception& e) { DEBUG_LOGF("Price fetch error: %s\n", e.what()); result.errorMessage = std::string("Price fetch error: ") + e.what(); } catch (...) { DEBUG_LOGF("Price fetch error: unknown exception\n"); result.errorMessage = "Price fetch error: unknown exception"; } if (result.price) { DEBUG_LOGF("Price updated: $%.6f USD\n", result.price->market.price_usd); } else { DEBUG_LOGF("%s\n", result.errorMessage.c_str()); } return [this, result = std::move(result)]() mutable { if (result.price) { NetworkRefreshService::applyPriceRefreshResult(state_, *result.price, std::chrono::steady_clock::now()); } else { NetworkRefreshService::applyPriceRefreshFailure(state_, result.errorMessage); } }; }, 0); if (!enqueued.enqueued) return; NetworkRefreshService::markPriceRefreshStarted(state_); } void App::refreshMarketData() { refreshPrice(); } // ============================================================================ // Mining Operations // ============================================================================ void App::startMining(int threads) { if (!supportsSoloMining()) { (void)threads; ui::Notifications::instance().warning("Solo mining is unavailable in lite build"); return; } if (!state_.connected || !rpc_ || !worker_) return; if (mining_toggle_in_progress_.exchange(true)) return; // already in progress worker_->post([this, threads]() -> rpc::RPCWorker::MainCb { bool ok = false; std::string errMsg; try { rpc::RPCClient::TraceScope trace("Mining tab / Start mining"); rpc_->call("setgenerate", {true, threads}); ok = true; } catch (const std::exception& e) { errMsg = e.what(); } return [this, threads, ok, errMsg]() { mining_toggle_in_progress_.store(false); if (ok) { state_.mining.generate = true; state_.mining.genproclimit = threads; DEBUG_LOGF("Mining started with %d threads\n", threads); } else { DEBUG_LOGF("Failed to start mining: %s\n", errMsg.c_str()); ui::Notifications::instance().error("Mining failed: " + errMsg); } }; }); } void App::stopMining() { if (!supportsSoloMining()) return; if (!state_.connected || !rpc_ || !worker_) return; if (mining_toggle_in_progress_.exchange(true)) return; // already in progress worker_->post([this]() -> rpc::RPCWorker::MainCb { bool ok = false; 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()); } return [this, ok]() { mining_toggle_in_progress_.store(false); if (ok) { state_.mining.generate = false; state_.mining.localHashrate = 0.0; DEBUG_LOGF("Mining stopped\n"); } }; }); } void App::startPoolMining(int threads) { if (!supportsPoolMining()) { (void)threads; ui::Notifications::instance().warning("Pool mining is unavailable in this build"); return; } 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 if (state_.mining.generate) stopMining(); daemon::XmrigManager::Config cfg; cfg.pool_url = settings_->getPoolUrl(); cfg.worker_name = settings_->getPoolWorker(); cfg.algo = settings_->getPoolAlgo(); cfg.threads = threads; // Use the same thread selection as solo mining cfg.tls = settings_->getPoolTls(); cfg.hugepages = settings_->getPoolHugepages(); // Use first shielded address as the mining wallet address, fall back to transparent for (const auto& addr : state_.z_addresses) { if (!addr.address.empty()) { cfg.wallet_address = addr.address; break; } } if (cfg.wallet_address.empty()) { for (const auto& addr : state_.addresses) { if (addr.type == "transparent" && !addr.address.empty()) { cfg.wallet_address = addr.address; break; } } } // Fallback: use pool worker address from settings (available even before // the daemon is connected or the blockchain is synced). if (cfg.wallet_address.empty() && !cfg.worker_name.empty()) { cfg.wallet_address = cfg.worker_name; } if (cfg.wallet_address.empty()) { DEBUG_LOGF("[ERROR] Pool mining: No wallet address available\n"); ui::Notifications::instance().error("No wallet address available — generate a Z address in the Receive tab"); 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"); #ifdef _WIN32 // Offer to open Windows Security settings pending_antivirus_dialog_ = true; #endif } else { ui::Notifications::instance().error("Failed to start pool miner: " + err); } } } void App::stopPoolMining() { if (xmrig_manager_ && xmrig_manager_->isRunning()) { xmrig_manager_->stop(3000); } } // ============================================================================ // Peer Operations // ============================================================================ void App::banPeer(const std::string& ip, int duration_seconds) { if (!state_.connected || !rpc_ || !worker_) return; // Run on the worker thread — these are called straight from the Peers tab's ImGui // handlers, and rpc_->call() blocks on synchronous curl under curl_mutex_. worker_->post([this, ip, duration_seconds]() -> rpc::RPCWorker::MainCb { std::string err; try { rpc::RPCClient::TraceScope trace("Peers / Ban"); rpc_->call("setban", {ip, "add", duration_seconds}); } catch (const std::exception& e) { err = e.what(); } return [this, err]() { if (!err.empty()) ui::Notifications::instance().error("Ban failed: " + err); else refreshPeerInfo(); }; }); } void App::unbanPeer(const std::string& ip) { if (!state_.connected || !rpc_ || !worker_) return; worker_->post([this, ip]() -> rpc::RPCWorker::MainCb { std::string err; try { rpc::RPCClient::TraceScope trace("Peers / Unban"); rpc_->call("setban", {ip, "remove"}); } catch (const std::exception& e) { err = e.what(); } return [this, err]() { if (!err.empty()) ui::Notifications::instance().error("Unban failed: " + err); else refreshPeerInfo(); }; }); } void App::clearBans() { if (!state_.connected || !rpc_ || !worker_) return; worker_->post([this]() -> rpc::RPCWorker::MainCb { std::string err; try { rpc::RPCClient::TraceScope trace("Peers / Clear bans"); rpc_->call("clearbanned", nlohmann::json::array()); } catch (const std::exception& e) { err = e.what(); } return [this, err]() { if (!err.empty()) { ui::Notifications::instance().error("Clear bans failed: " + err); return; } state_.banned_peers.clear(); refreshPeerInfo(); }; }); } void App::applyDefaultBanlist() { if (!state_.connected || !rpc_ || !worker_) return; // Parse the embedded default_banlist.txt (compiled from res/default_banlist.txt) std::string data(reinterpret_cast(embedded::default_banlist_data), embedded::default_banlist_size); std::vector ips; size_t pos = 0; while (pos < data.size()) { size_t eol = data.find('\n', pos); if (eol == std::string::npos) eol = data.size(); std::string line = data.substr(pos, eol - pos); pos = eol + 1; // Strip carriage return (Windows line endings) if (!line.empty() && line.back() == '\r') line.pop_back(); // Strip leading/trailing whitespace size_t start = line.find_first_not_of(" \t"); if (start == std::string::npos) continue; line = line.substr(start, line.find_last_not_of(" \t") - start + 1); // Skip empty lines and comments if (line.empty() || line[0] == '#') continue; ips.push_back(line); } if (ips.empty()) return; // Apply bans on the worker thread to avoid blocking the UI worker_->post([this, ips]() -> rpc::RPCWorker::MainCb { int applied = 0; for (const auto& ip : ips) { try { rpc::RPCClient::TraceScope trace("Startup / Default banlist"); // 0 = permanent ban (until node restart or manual unban) // Using a very long duration (10 years) for effectively permanent bans rpc_->call("setban", {ip, "add", 315360000}); applied++; } catch (...) { // Already banned or invalid — skip silently } } return [applied]() { if (applied > 0) { DEBUG_LOGF("[Banlist] Applied %d default bans\n", applied); } }; }); } // ============================================================================ // Address Operations // ============================================================================ void App::createNewZAddress(std::function callback) { // Lite build: derive locally via the controller (fast, no network). The backend auto-saves // new addresses; the next lite refresh lists it with a balance. if (lite_wallet_) { const auto result = lite_wallet_->newAddress(/*shielded*/ true); if (result.ok) { AddressInfo info; info.address = result.address; info.type = "shielded"; info.balance = 0.0; state_.z_addresses.push_back(info); state_.addresses.push_back(info); address_list_dirty_ = true; } if (callback) callback(result.ok ? result.address : std::string()); return; } if (!state_.connected || !rpc_ || !worker_) return; worker_->post([this, callback]() -> rpc::RPCWorker::MainCb { std::string addr; try { rpc::RPCClient::TraceScope trace("Receive tab / New shielded address"); json result = rpc_->call("z_getnewaddress"); addr = result.get(); } catch (const std::exception& e) { DEBUG_LOGF("z_getnewaddress error: %s\n", e.what()); } return [this, callback, addr]() { if (!addr.empty()) { // Inject immediately so UI can select the address next frame AddressInfo info; info.address = addr; info.type = "shielded"; info.balance = 0.0; state_.z_addresses.push_back(info); address_list_dirty_ = true; // Also trigger full refresh to get proper balances addresses_dirty_ = true; refreshAddresses(); } if (callback) callback(addr); }; }); } void App::createNewTAddress(std::function callback) { // Lite build: derive locally via the controller (see createNewZAddress). if (lite_wallet_) { const auto result = lite_wallet_->newAddress(/*shielded*/ false); if (result.ok) { AddressInfo info; info.address = result.address; info.type = "transparent"; info.balance = 0.0; state_.t_addresses.push_back(info); state_.addresses.push_back(info); address_list_dirty_ = true; } if (callback) callback(result.ok ? result.address : std::string()); return; } if (!state_.connected || !rpc_ || !worker_) return; worker_->post([this, callback]() -> rpc::RPCWorker::MainCb { std::string addr; try { rpc::RPCClient::TraceScope trace("Receive tab / New transparent address"); json result = rpc_->call("getnewaddress"); addr = result.get(); } catch (const std::exception& e) { DEBUG_LOGF("getnewaddress error: %s\n", e.what()); } return [this, callback, addr]() { if (!addr.empty()) { // Inject immediately so UI can select the address next frame AddressInfo info; info.address = addr; info.type = "transparent"; info.balance = 0.0; state_.t_addresses.push_back(info); address_list_dirty_ = true; // Also trigger full refresh to get proper balances addresses_dirty_ = true; refreshAddresses(); } if (callback) callback(addr); }; }); } void App::hideAddress(const std::string& addr) { if (settings_) { settings_->hideAddress(addr); settings_->save(); } } void App::unhideAddress(const std::string& addr) { if (settings_) { settings_->unhideAddress(addr); settings_->save(); } } bool App::isAddressHidden(const std::string& addr) const { return settings_ && settings_->isAddressHidden(addr); } int App::getHiddenAddressCount() const { return settings_ ? settings_->getHiddenAddressCount() : 0; } void App::favoriteAddress(const std::string& addr) { if (settings_) { settings_->favoriteAddress(addr); settings_->save(); } } void App::unfavoriteAddress(const std::string& addr) { if (settings_) { settings_->unfavoriteAddress(addr); settings_->save(); } } bool App::isAddressFavorite(const std::string& addr) const { return settings_ && settings_->isAddressFavorite(addr); } void App::setAddressLabel(const std::string& addr, const std::string& label) { if (settings_) { settings_->setAddressLabel(addr, label); settings_->save(); } } void App::setAddressIcon(const std::string& addr, const std::string& icon) { if (settings_) { settings_->setAddressIcon(addr, icon); settings_->save(); } } std::string App::getAddressLabel(const std::string& addr) const { if (!settings_) return ""; return settings_->getAddressMeta(addr).label; } std::string App::getAddressIcon(const std::string& addr) const { if (!settings_) return ""; return settings_->getAddressMeta(addr).icon; } int App::getAddressSortOrder(const std::string& addr) const { if (!settings_) return -1; return settings_->getAddressMeta(addr).sortOrder; } void App::setAddressSortOrder(const std::string& addr, int order) { if (settings_) { settings_->setAddressSortOrder(addr, order); settings_->save(); } } int App::getNextSortOrder() const { return settings_ ? settings_->getNextSortOrder() : 0; } void App::swapAddressOrder(const std::string& a, const std::string& b) { if (settings_) { settings_->swapAddressOrder(a, b); settings_->save(); } } bool App::isMiningAddress(const std::string& addr) const { return settings_ && settings_->isMiningAddress(addr); } void App::setMiningAddress(const std::string& addr, bool mining) { if (settings_) { settings_->setMiningAddress(addr, mining); settings_->save(); invalidateShieldedHistoryScanProgress(true); transactions_dirty_ = true; last_tx_block_height_ = -1; network_refresh_.markDue(services::NetworkRefreshService::Timer::Transactions); } } void App::invalidateAddressValidationCache() { address_validation_cache_dirty_ = true; addresses_dirty_ = true; invalidateShieldedHistoryScanProgress(true); } // ============================================================================ // Key Export/Import Operations // ============================================================================ void App::exportPrivateKey(const std::string& address, std::function callback) { if (!state_.connected || !rpc_ || !worker_) { if (callback) callback(""); return; } const bool shielded = services::WalletSecurityController::classifyAddress(address) == services::WalletSecurityController::KeyKind::Shielded; const char* method = shielded ? "z_exportkey" : "dumpprivkey"; // Run on the worker thread — z_exportkey/dumpprivkey block on synchronous curl and // are invoked straight from the export dialog (UI thread). worker_->post([this, method, address, callback]() -> rpc::RPCWorker::MainCb { std::string key; std::string err; try { rpc::RPCClient::TraceScope trace("Settings / Export private key"); key = rpc_->call(method, {address}).get(); } catch (const std::exception& e) { err = e.what(); } return [callback, key, err]() { if (!err.empty()) { DEBUG_LOGF("Export key error: %s\n", err.c_str()); ui::Notifications::instance().error("Key export failed: " + err); if (callback) callback(""); } else if (callback) { callback(key); } }; }); } void App::exportAllKeys(std::function callback) { if (!state_.connected || !rpc_) { if (callback) callback(""); return; } // Collect all keys into a string auto keys_result = std::make_shared(); auto pending = std::make_shared(0); auto total = std::make_shared(0); // First get all addresses auto all_addresses = std::make_shared>(); // Add t-addresses for (const auto& addr : state_.t_addresses) { all_addresses->push_back(addr.address); } // Add z-addresses for (const auto& addr : state_.z_addresses) { all_addresses->push_back(addr.address); } *total = all_addresses->size(); *pending = *total; if (*total == 0) { if (callback) callback("# No addresses to export\n"); return; } *keys_result = "# DragonX Wallet Private Keys Export\n"; *keys_result += "# WARNING: Keep this file secure! Anyone with these keys can spend your coins!\n\n"; for (const auto& addr : *all_addresses) { exportPrivateKey(addr, [keys_result, pending, total, callback, addr](const std::string& key) { if (!key.empty()) { *keys_result += "# " + addr + "\n"; *keys_result += key + "\n\n"; } (*pending)--; if (*pending == 0 && callback) { callback(*keys_result); } }); } } void App::importPrivateKey(const std::string& key, std::function callback) { if (!state_.connected || !rpc_ || !worker_) { if (callback) callback(false, "Not connected"); return; } const bool shielded = services::WalletSecurityController::classifyPrivateKey(key) == services::WalletSecurityController::KeyKind::Shielded; // Run on the worker thread — import requests a full rescan (rescan=true), so the // synchronous curl call can take many seconds; never block the UI thread on it. worker_->post([this, key, shielded, callback]() -> rpc::RPCWorker::MainCb { std::string err; try { rpc::RPCClient::TraceScope trace("Settings / Import private key"); if (shielded) rpc_->call("z_importkey", {key, "yes"}); // rescan else rpc_->call("importprivkey", {key, "", true}); // label "", rescan } catch (const std::exception& e) { err = e.what(); } return [this, shielded, err, callback]() { if (!err.empty()) { if (callback) callback(false, err); return; } invalidateAddressValidationCache(); refreshAddresses(); if (callback) callback(true, services::WalletSecurityController::importSuccessMessage( shielded ? services::WalletSecurityController::KeyKind::Shielded : services::WalletSecurityController::KeyKind::Transparent)); }; }); } void App::backupWallet(const std::string& destination, std::function callback) { if (!state_.connected || !rpc_) { if (callback) callback(false, "Not connected"); return; } // Use z_exportwallet or similar to export all keys // For now, we'll use exportAllKeys and save to file exportAllKeys([destination, callback](const std::string& keys) { if (keys.empty()) { if (callback) callback(false, "Failed to export keys"); return; } // Write to file std::ofstream file(destination); if (!file.is_open()) { if (callback) callback(false, "Could not open file: " + destination); return; } file << keys; file.close(); if (callback) callback(true, "Wallet backup saved to: " + destination); }); } // ============================================================================ // Transaction Operations // ============================================================================ void App::sendTransaction(const std::string& from, const std::string& to, double amount, double fee, const std::string& memo, std::function callback) { // Lite build: route to the controller's async broadcast. `from`/`fee` are ignored — the // backend selects inputs and adds the network fee itself. The result (txid/error) is // delivered to `callback` from update() once takeBroadcastResult() yields it. if (lite_wallet_) { wallet::LiteSendRequest req; wallet::LiteSendRecipient recipient; recipient.address = to; recipient.amountZatoshis = static_cast(std::llround(amount * 100000000.0)); recipient.memo = memo; req.recipients.push_back(std::move(recipient)); if (!lite_wallet_->sendTransaction(req)) { if (callback) callback(false, "A send is already in progress, or no wallet is open"); return; } lite_send_callback_ = std::move(callback); // delivered from update() return; } if (!state_.connected || !rpc_) { if (callback) callback(false, "Not connected"); return; } // Single-flight guard: a rapid double-click (or any second caller) must not issue two // z_sendmany calls before the first returns its opid. The send form already guards this in the // UI, but the controller entry point must not depend on that. (send_submissions_in_flight_ is // main-thread only: ++ here, -- in the worker's main-thread result callback.) if (send_submissions_in_flight_ > 0) { if (callback) callback(false, "A transaction is already being submitted — please wait."); return; } // Check that we have the spending key for the from address if (!from.empty() && from[0] == 'z') { bool spendable = false; for (const auto& addr : state_.z_addresses) { if (addr.address == from) { spendable = addr.has_spending_key; break; } } if (!spendable) { if (callback) callback(false, "This is a view-only address (no spending key). Import the spending key to send from this address."); return; } } // Build recipients array nlohmann::json recipients = nlohmann::json::array(); nlohmann::json recipient; recipient["address"] = to; recipient["amount"] = util::formatAmountFixed(amount); if (!memo.empty()) { recipient["memo"] = memo; } recipients.push_back(recipient); // Run z_sendmany on worker thread to avoid blocking UI if (!worker_) { send_progress_active_ = false; if (callback) callback(false, "RPC worker unavailable"); return; } send_progress_active_ = true; ++send_submissions_in_flight_; // z_sendmany signature is (fromaddress, amounts, minconf, fee). Pass the user-selected // fee explicitly — formatted as a fixed-decimal string so the daemon's ParseFixedPoint // accepts it (a small double like 0.00005 would serialize to "5e-05" and be rejected). const std::string fee_str = util::formatAmountFixed(fee); worker_->post([this, from, to, amount, fee_str, memo, recipients, callback]() -> rpc::RPCWorker::MainCb { bool ok = false; std::string result_str; try { rpc::RPCClient::TraceScope trace("Send tab / Submit transaction"); auto result = rpc_->call("z_sendmany", {from, recipients, 1, fee_str}); result_str = result.get(); ok = true; } catch (const std::exception& e) { result_str = e.what(); } return [this, callback, ok, result_str, from, to, amount, memo]() { if (send_submissions_in_flight_ > 0) --send_submissions_in_flight_; 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; network_refresh_.markWalletMutationRefresh(); // z_sendmany only returned an opid: the transaction is built/signed/ // broadcast asynchronously by the daemon. Defer the user-facing // success/failure to the opid poller (app.cpp) so we don't report // "sent successfully" for an operation that may still fail. if (!result_str.empty()) { pending_opids_.push_back(result_str); upsertPendingSendTransaction(result_str, from, to, amount, memo); if (callback) pending_send_callbacks_[result_str] = callback; } else if (callback) { callback(true, result_str); // no opid to track — report as-is } } else { send_progress_active_ = false; if (callback) callback(false, result_str); } }; }); } } // namespace dragonx