// 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] // │ │ // │ ▼ per-category timers (refreshData() at transitions) // │ [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 "services/lowest_block_search.h" // detectLowestAvailableBlockHeight: pure search + probe classification #include "chat/chat_identity.h" // deriveChatIdentityFromSecret for HushChat identity provisioning #include "ui/windows/chat_tab.h" // ui::ResetChatTab — wipe chat UI plaintext on a wallet switch #include "ui/windows/shield_dialog.h" // ui::ShieldDialog — Merge to Address shortcut from the bloat nudge #include "ui/windows/mining_pool_panel.h" // ui::resolveMiningUserAddress #include // sodium_memzero for wiping the fetched mnemonic #include #include "config/settings.h" #include "wallet/lite_wallet_controller.h" // lite send/new-address routing #include "wallet/lite_wallet_state_mapper.h" // LiteWalletAppRefreshModel for lite chat harvest #include "wallet/lite_diagnostics.h" // liteLog — chat note-buffer coordinator diagnostics #include "config/version.h" #include "daemon/daemon_controller.h" #include "daemon/daemon_startup_diagnosis.h" #include "daemon/embedded_daemon.h" #include "daemon/seed_wallet_creator.h" #include "daemon/xmrig_manager.h" #include "util/pool_registry.h" #include "ui/notifications.h" #include "ui/schema/skin_manager.h" #include "default_banlist_embedded.h" #include "util/amount_format.h" #include "util/http_download.h" #include "data/exchange_info.h" #include "data/exchange_candles.h" #include "data/seed_migration_resume.h" #include "util/platform.h" #include "util/wallet_file_probe.h" // verify a salvage-backup is a real BDB before restoring it #include "resources/embedded_resources.h" // getDaemonDirectory() — locate the wallet-rebuild helper #include // popen the rebuild helper #include "util/perf_log.h" #include "util/i18n.h" #include "util/address_validation.h" // isValidRecipientAddress — payout validation at every start path (M-01) #include "util/secure_vault.h" #include #include #include #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: // `maintenanceOwner`: the App whose rpc_ this is, for callLong() (see App::MaintenanceCallScope). AppRefreshRpcGateway(rpc::RPCClient& rpc, std::string source, App* maintenanceOwner = nullptr) : rpc_(rpc), source_(std::move(source)), owner_(maintenanceOwner) {} json call(const std::string& method, const json& params) override { rpc::RPCClient::TraceScope trace(source_); return rpc_.call(method, params); } // Holds rpc_'s curl lock for up to timeoutSec: as a maintenance call, a main-thread disconnect // aborts it rather than waiting for it (App::abortMaintenanceCalls()). json callLong(const std::string& method, const json& params, long timeoutSec) override { std::optional maintenance; if (owner_) maintenance.emplace(*owner_); rpc::RPCClient::TraceScope trace(source_); return rpc_.call(method, params, timeoutSec); } private: rpc::RPCClient& rpc_; std::string source_; App* owner_ = nullptr; }; 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::Contacts: return "Contacts tab"; case ui::NavPage::Chat: return "Chat 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::LiteConsole: return "Console tab"; // lite-only sibling of Console case ui::NavPage::Peers: return "Network tab"; case ui::NavPage::LiteNetwork: return "Network tab"; // lite-only sibling of Peers 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; } } // 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(), ""}; } // A node told to open a wallet it can't prints one of these to its console: the BDB corrupt-wallet // recovery ("Failed to rename … .bak", "wallet.dat corrupt, salvage failed") or a generic load error. // Used to offer a -salvagewallet repair when a switch fails because the target wallet is corrupt. static bool walletOutputLooksCorrupt(const std::string& out) { // W1-2: the generic "Error loading wallet" fallback is ALSO printed for DB_TOO_NEW // ("...requires ... newer version..."), which -salvagewallet cannot fix — so don't misclassify a // version mismatch as salvageable corruption and offer a repair that can't help. const bool versionMismatch = out.find("newer version") != std::string::npos; return out.find("Failed to rename") != std::string::npos || out.find("salvage failed") != std::string::npos || out.find("wallet.dat corrupt") != std::string::npos || (out.find("Error loading wallet") != std::string::npos && !versionMismatch); } // 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 // ============================================================================ // dragonxd moves wallet.dat to wallet..bak and loads a salvaged copy whenever BDB verify fails — // no flag, and often a false positive (stale/cross-platform env) or an inconsistent-but-readable file. // The salvage prints to the node's captured output at STARTUP, but the node may then fail to connect // (block-index abort, long sync, crash) so we must NOT wait for onConnected — scan the output on every // tryConnect tick, early enough that the line hasn't been trimmed from the rolling buffer. Fires once // per session; the dialog offers Rebuild (fix the DB) / Restore (swap the .bak back). void App::detectWalletAutoRecovery() { if (wallet_auto_recovered_warned_) return; if (!isUsingEmbeddedDaemon() || !daemon_controller_ || !daemon_controller_->daemon()) return; if (!daemon::walletAutoRecovered(daemon_controller_->daemon()->getOutput())) return; wallet_auto_recovered_ = true; wallet_auto_recovered_warned_ = true; show_wallet_recovered_dialog_ = true; ui::Notifications::instance().info(TR("wallet_recovered_notify"), 30.0f); // calm, not red — coins are safe VERBOSE_LOGF("[recovery] Daemon auto-recovered/salvaged wallet.dat — surfacing the recovery dialog\n"); } // v1.3.0+ opens a wallet that lost its hdchain in DEGRADED mode (funds spendable) instead of aborting, // but can't derive NEW HD keys — z_getnewaddress / z_shieldcoinbase / t->z z_sendmany fail with "HD seed // not found". Only a startup log line signals it, so scan the captured output and warn once. Pre-1.3.0 // daemons never emit it, so this is a no-op there (backwards compatible). void App::detectWalletDegraded() { if (wallet_degraded_warned_) return; if (!isUsingEmbeddedDaemon() || !daemon_controller_ || !daemon_controller_->daemon()) return; if (!daemon::walletOpenedDegraded(daemon_controller_->daemon()->getOutput())) return; wallet_degraded_ = true; wallet_degraded_warned_ = true; ui::Notifications::instance().warning(TR("wallet_degraded_notify"), 30.0f); VERBOSE_LOGF("[recovery] Daemon opened wallet in DEGRADED mode — new-key derivation disabled\n"); } // One canary per busy period: a getinfo on its own client with NO client timeout, so it is the one request // we let wait in the node's queue instead of re-sending (each re-send would take another slot). Its answer // means the node serves cs_main requests again. enum CanaryState { kCanaryIdle = 0, kCanaryRunning, kCanaryAnswered, kCanaryRefused, kCanaryGone }; void App::abortRpcBusyCanary() { std::lock_guard lk(canary_mutex_); if (canary_client_) canary_client_->requestAbort(); } void App::pumpRpcBusyCanary() { const int st = canary_state_.load(); if (st >= kCanaryAnswered) { if (canary_thread_.joinable()) canary_thread_.join(); canary_state_ = kCanaryIdle; if (st == kCanaryAnswered) { DEBUG_LOGF("[busy] the node answered again after %.0fs; resuming normal polling\n", rpc_busy_gate_->busySeconds()); rpc_busy_gate_->clear(); canary_backoff_.reset(); connect_backoff_.reset(); network_refresh_.markImmediateRefresh(); // also makes a pending connect attempt due at once } else if (st == kCanaryRefused) { const float wait = canary_backoff_.next(rpc::RpcFailureKind::QueueFull); canary_retry_at_ = std::chrono::steady_clock::now() + std::chrono::milliseconds(static_cast(wait * 1000.0f)); DEBUG_LOGF("[busy] canary refused (queue full); next one in %.0fs\n", wait); } else { // Nothing listening any more (or our own abort): there's no busy node to wait for. Normal // connect / lost-connection handling takes over. DEBUG_LOGF("[busy] canary got no answer; leaving busy mode\n"); rpc_busy_gate_->clear(); canary_backoff_.reset(); } } if (!rpc_busy_gate_->busy() || canary_state_.load() != kCanaryIdle || shutting_down_) return; if (std::chrono::steady_clock::now() < canary_retry_at_) return; const rpc::ConnectionConfig cfg = (!saved_config_.rpcuser.empty() || !saved_config_.rpcpassword.empty()) ? saved_config_ : rpc::Connection::autoDetectConfig(); DEBUG_LOGF("[busy] the node is too busy to answer (%d request(s) given up on): holding back polls; " "one canary getinfo waits for it\n", rpc_busy_gate_->abandoned()); canary_state_ = kCanaryRunning; canary_thread_ = std::thread([this, cfg]() { rpc::RPCClient client; // deliberately ungated: this is the one request we allow { std::lock_guard lk(canary_mutex_); canary_client_ = &client; } int result = kCanaryGone; if (!shutting_down_ && client.open(cfg.host, cfg.port, cfg.rpcuser, cfg.rpcpassword, cfg.use_tls)) { try { rpc::RPCClient::TraceScope trace("Busy node / Canary"); client.call("getinfo", json::array(), 0L); // 0 = no client timeout result = kCanaryAnswered; } catch (const std::exception& e) { const rpc::RpcFailureKind kind = rpc::rpcFailureKindOf(e); if (kind == rpc::RpcFailureKind::AppError || kind == rpc::RpcFailureKind::Warmup) result = kCanaryAnswered; // a worker ran it: cs_main is free again else if (kind == rpc::RpcFailureKind::QueueFull) result = kCanaryRefused; } } { std::lock_guard lk(canary_mutex_); canary_client_ = nullptr; } canary_state_ = result; }); } bool App::longWalletOpRefused(std::string* why, const char** trKey) const { // The English texts equal the i18n source strings of the keys; the console shows them untranslated. const char* key = nullptr; const char* english = nullptr; if (shutting_down_ || !worker_) { key = "appx_wallet_op_shutting_down"; english = "The wallet is shutting down."; } else if (long_lane_.active()) { key = "appx_wallet_op_running"; english = "A key import or rescan is still running. Try again when it finishes."; } else if (rpc_busy_gate_ && rpc_busy_gate_->busy()) { key = "appx_wallet_op_node_busy"; english = "The node is busy and not answering. Try the import or rescan again when it finishes."; } if (!key) return false; if (why) *why = english; if (trKey) *trKey = key; return true; } bool App::runLongWalletTask(LongWalletBody body) { if (!body || !long_lane_.tryBegin()) return false; const rpc::ConnectionConfig cfg = (!saved_config_.rpcuser.empty() || !saved_config_.rpcpassword.empty()) ? saved_config_ : rpc::Connection::autoDetectConfig(); // as the canary if (shutting_down_) long_lane_.abort(); // One task name plus tryBegin()'s single flight: submit()'s join(name) only ever waits on the previous task's // last few instructions (its continuation, which frees the lane, is posted at its very end). async_tasks_.submit("Long wallet RPC", [this, cfg, body = std::move(body)](const util::AsyncTaskManager::Token& tok) mutable { rpc::RPCClient client; // ungated on purpose: it never trips or waits on the busy gate std::function cont; if (tok.cancelled()) long_lane_.abort(); try { long_lane_.run(client, cfg, [&](rpc::RPCClient& c) { cont = body(c); }); } catch (...) { // The bodies catch everything themselves; this only keeps the lane from staying taken. DEBUG_LOGF("[LongRPC] the call body threw\n"); } body = nullptr; // destroy the closure (and its scrubbed key copy) on this thread if (tok.cancelled() || !worker_) { long_lane_.end(); return; } // quitting: nobody to tell worker_->post([this, cont = std::move(cont)]() -> rpc::RPCWorker::MainCb { return [this, cont]() { long_lane_.end(); if (cont) cont(); }; }); }); return true; } 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; // Catch a startup wallet salvage as soon as it appears in the node's output — independent of whether // the node ever finishes starting or connects (skip only while an orchestrated swap is mid-flight). if (!daemon_restarting_) detectWalletAutoRecovery(); if (!daemon_restarting_) detectWalletDegraded(); if (connection_in_progress_) return; // Don't fight an in-progress restart/adopt orchestration: while it stops the daemon, swaps // wallet.dat (seed migration), and restarts, tryConnect must NOT start the daemon underneath it. if (daemon_restarting_) 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.dir_error.empty()) { // The data directory could not be created (read-only home, permission denied, // disk full). Retrying won't fix it, so surface it in the status line instead of // mislabelling it as "waiting for config" below. connection_in_progress_ = false; connection_status_ = config.dir_error; VERBOSE_LOGF("[connect #%d] data dir error: %s\n", connect_attempt, config.dir_error.c_str()); return; } 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_.deferFor(services::NetworkRefreshService::Timer::Core, 4.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_.deferFor(services::NetworkRefreshService::Timer::Core, 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_.deferFor(services::NetworkRefreshService::Timer::Core, 5.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) && !rpc::Connection::allowsPlaintextRemote(config)) { // Refuse to send Basic-auth credentials in cleartext to a remote host — a local-network // MITM would otherwise capture rpcuser:rpcpassword. This is a deliberate behaviour change // from the old warn-and-proceed: opt in explicitly with rpcallowplaintext=1 in // DRAGONX.conf (or enable TLS with rpctls=1) if the plaintext link is intended. connection_in_progress_ = false; connection_status_ = TR("sb_plaintext_remote_blocked"); if (!remote_rpc_plaintext_warning_shown_) { remote_rpc_plaintext_warning_shown_ = true; ui::Notifications::instance().warning(TR("sb_plaintext_remote_blocked"), 20.0f); } VERBOSE_LOGF("[connect #%d] refusing plaintext-remote RPC to %s:%s (set rpcallowplaintext=1 to override)\n", connect_attempt, config.host.c_str(), config.port.c_str()); return; } // 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; // consume it either way, so it can't toast later const std::string detail = daemon_controller_->lastError(); // Suppress the scary "dragonxd exited unexpectedly" toast during recovery: the stop after a // salvage, and the intentional restart-with-rescan after a repair, are both EXPECTED here and // owned by the recovery UI (dialog / calm rescan overlay). if (!detail.empty() && !wallet_auto_recovered_ && !post_recovery_rescan_) { 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(); const rpc::RpcFailureKind connectKind = rpc_->getLastConnectFailureKind(); bool warmingUp = rpc_->isWarmingUp(); std::string warmupStatus = rpc_->getWarmupStatus(); return [this, config, connected, warmingUp, warmupStatus, daemonStarting, externalDetected, attempt, connectErr, connectKind]() { noteDaemonRpcResult(connected, connectKind); // a busy node (timeouts / queue full) feeds the quit guard if (connected) connect_backoff_.reset(); if (connected) { VERBOSE_LOGF("[connect #%d] Connected successfully%s\n", attempt, warmingUp ? " (daemon warming up)" : ""); saved_config_ = config; // save for fast-lane connection // Before onConnected(): its refreshData() then only polls for the warmup's end instead of // sending wallet RPCs that can only fail with -28. if (warmingUp) state_.warming_up = true; 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; if (connect_stall_since_ <= 0.0) connect_stall_since_ = ImGui::GetTime(); // start the "taking too long" clock applyWarmupText(warmupStatus); } } 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 = (connectKind == rpc::RpcFailureKind::Auth); 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. // Reached the node but no answer (reply timeout / full queue / held back): up but busy. // A refusal or connect timeout means it hasn't bound (or has already unbound) RPC. const bool reachableButBusy = rpc::rpcKindMeansBusy(connectKind); applyDaemonInitStatus(reachableButBusy); const float retryIn = connect_backoff_.next(connectKind); VERBOSE_LOGF("[connect #%d] RPC connection failed (%s) — daemon still starting, retry in %.0fs\n", attempt, connectErr.c_str(), retryIn); network_refresh_.deferFor(services::NetworkRefreshService::Timer::Core, retryIn); } 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 (rpc::rpcKindMeansBusy(connectKind)) { 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"); } const float retryIn = connect_backoff_.next(connectKind); VERBOSE_LOGF("[connect #%d] External daemon detected but RPC failed (%s), retry in %.0fs\n", attempt, connectErr.c_str(), retryIn); network_refresh_.deferFor(services::NetworkRefreshService::Timer::Core, retryIn); } else { onDisconnected("Connection failed"); VERBOSE_LOGF("[connect #%d] RPC connection failed — no daemon starting, no external detected\n", attempt); if (isUsingEmbeddedDaemon() && !isEmbeddedDaemonRunning()) { // A repair completed and we restarted with a full rescan, but the fresh daemon has // now EXITED — a fault distinct from the wallet file (corrupt block index, disk full, // OOM). Drop out of the calm "finishing repair" state so this surfaces as a normal // daemon failure (reindex offer / crash toast / restart) instead of silently freezing // the reconnect loop on a reassuring "don't restart" screen with no way forward. if (post_recovery_rescan_) { post_recovery_rescan_ = false; wallet_auto_recovered_ = false; // repair done; the original-salvage hold is over } // If the node aborted because its BLOCK DATABASE is unreadable (a daemon-vs-chaindata // format mismatch after an update, or a corrupt index), crash-restarting just repeats // the same abort — and each attempt reloads the whole index (wasteful). Detect it once // and offer a one-click reindex instead of silently looping into a zero-balance node. if (!block_db_reindex_available_ && daemon_controller_ && daemon_controller_->daemon() && daemon::blockDbOutputLooksBroken(daemon_controller_->daemon()->getOutput())) { block_db_reindex_available_ = true; show_block_db_reindex_confirm_ = true; ui::Notifications::instance().error(TR("block_db_reindex_notify"), 20.0f); VERBOSE_LOGF("[connect #%d] Block database unreadable — offering a one-click reindex\n", attempt); } // Prevent infinite crash-restart loop if (block_db_reindex_available_) { connection_status_ = TR("sb_block_db_unreadable"); // hold; awaiting the rebuild choice } else if (wallet_auto_recovered_) { // A salvage is happening — DON'T restart into another one (each round can shrink // the wallet further). Hold while the recovery dialog (Rebuild/Restore) is up. connection_status_ = TR("sb_wallet_needs_recovery"); } else if (daemon_controller_ && daemon_controller_->crashCount() >= 3) { if (wallet_switch_pending_confirm_.load()) { // The just-switched-to wallet's daemon keeps crashing (e.g. a wallet that // fails LATE in init, past the fast start grace) — revert to the previous // wallet rather than wedging on a broken one. The main thread does the // settings revert (processWalletSwitchRevert) + resets the crash count. wallet_switch_pending_confirm_.store(false); wallet_switch_failed_.store(true); } { 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()) { // A node already running (left running, or started by hand). If our probe reached // it but got no answer, say what it is doing (its log may show a rebuild) rather // than a bare "Connecting…" until the next attempt, up to 30s away. if (rpc::rpcKindMeansBusy(connectKind)) applyDaemonInitStatus(/*reachableButBusy=*/true); else 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() { maint_abort_.store(false); // maintenance calls queued before this connect have already failed (FIFO) ++rpc_session_; if (consolidation_.abort()) ui::Notifications::instance().warning(TR("merge_run_aborted_toast")); state_.connected = true; ui::ShieldDialog::invalidateScope(); // inputs read before this connect may be another wallet's const bool completedSwitch = wallet_switch_pending_confirm_.exchange(false); // this connect confirms a switch // A successful connect completes a switch — close/clear the progress modal (kept open through the // stop → start → reconnect sequence, or already hidden via "continue in background"). if (completedSwitch || wallet_switch_dialog_open_.load()) { wallet_switch_dialog_open_.store(false); wallet_switch_phase_.store(static_cast(WalletSwitchPhase::None)); } state_.daemon_initializing = false; // RPC is answering now; clear the "initializing" overlay init_status_title_.clear(); daemon_wait_attempts_ = 0; // re-arm the port-busy / start-failure notifications connect_stall_since_ = 0.0; // connected — clear the "taking too long" clock // A repair's rescan finished and connected — retire the recovery session so its crash-toast/error-card // suppression and status-chip hold can't persist forever. (Only when we were mid-post-repair-rescan; // a pre-repair salvaged-daemon connect keeps the flag so the recovery dialog/chip stay available.) if (post_recovery_rescan_) wallet_auto_recovered_ = false; post_recovery_rescan_ = false; // repair's post-restart rescan is past the RPC-less phase now daemon_start_error_shown_ = false; daemon_last_seen_crashes_ = 0; // (onConnected resets the daemon's crash count too) connection_status_ = TR("connected"); detectWalletAutoRecovery(); // also runs every tryConnect tick — catches a salvage even if we never connect detectWalletDegraded(); // Re-arm the empty-wallet-with-funded-sibling check for this (possibly switched) wallet: re-evaluate the // on-disk state once it finishes loading + syncing. Deliberately DON'T touch empty_wallet_scan_in_flight_ // here — a scan from a prior connect self-clears it when it posts back, and resetting it while that scan // is still running would let the next submit() block the UI thread on join() (async_tasks_ is only ever // cancelled at shutdown, so the flag can't wedge during normal runtime). empty_wallet_warn_checked_ = false; // Stamp the active wallet as opened in the index (last-opened + size + synced-here). Balance + // address count fill in on the first address refresh (addresses aren't loaded yet here). updateWalletIndexForActiveWallet(/*markOpened=*/true); // 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; NetworkRefreshService::ConnectionInfoResult prefetchedInfo; if (worker_ && rpc_) { 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); reconcileEncryptionPending(result.encryption); // C88: runs on every connect, keyed on the RPC reply 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 markAddressesDirty(); // Hold the wallet scans until the node knows where the chain is (or the wallet is small), and latch the // sync profile now if the connect's getinfo already shows it behind. armScanGate(prefetchedInfo); // 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(); fast_rpc_->setBusyGate(rpc_busy_gate_); } 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) { // Persist what the history snapshot is still missing while the rows, the address set (the cache identity), // the pending-send filter and the cache key are all live: state_.clear(), pending_opids_.clear() and the // session reset below empty them, which would turn a later flush into a silent no-op. storeTransactionHistoryCacheIfAvailable(data::HistoryStoreMode::Now); state_.connected = false; state_.warming_up = false; state_.warmup_status.clear(); connect_stall_since_ = 0.0; // reset the "taking too long" clock (App member, untouched by state_.clear()) state_.clear(); connection_status_ = reason; // Re-classify the wallet's mnemonic status on the next connect (the active wallet may have // changed — a switch or a post-migration adopt both disconnect here). wallet_seed_status_ = WalletSeedStatus::Unknown; wallet_seed_status_attempts_ = 0; // Re-probe whether the daemon auto-shields coinbase on the next connect — it may have been // upgraded/swapped (e.g. v1.0.3 which has no z_autoshieldstatus -> v1.3.0 which auto-shields). daemon_autoshield_probed_ = false; daemon_autoshield_active_ = false; // Clear RPC result caches viewtx_cache_.clear(); // Tracked sends belong to this connection's wallet: kept across a switch/adopt they made // z_viewtransaction fail on the next wallet and left phantom pending "send" rows in its History. send_txids_.clear(); send_txid_heights_.clear(); confirmed_tx_cache_.clear(); confirmed_tx_ids_.clear(); confirmed_cache_block_ = -1; last_tx_block_height_ = -1; mining_tx_poll_block_ = -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; // Release the chat note-buffer's single-split guard on a plain disconnect (which doesn't run // resetChatSession) so a re-split isn't suppressed until the 1200s watchdog after reconnect. chat_split_outstanding_ = false; chat_split_submitted_at_ = 0.0; inflight_op_ = LiteInflightOp{}; // the in-flight chat send's callback was just fired above (Failed) // The chat scans' bases describe this connection's wallet (a reconnect may be another daemon). Their in-flight // flags are cleared by their replies; scan_governor_.reset() below turns their tickets into no-ops. chat_inbox_basis_txcount_ = -1; chat_inbox_scan_wanted_ = false; chat_inbox_wanted_txcount_ = -1; chat_inbox_probe_at_ = 0.0; // a reconnect may be another daemon: its probe cost is unknown chat_inbox_probe_ms_ = 0.0; chat_note_scan_tip_ = -1; chat_verified_funds_by_addr_.clear(); chat_verified_note_budget_ = 0; // nor a budget from the previous connection's view chat_split_hold_tip_ = -1; chat_spend_log_.clear(); chat_note_scan_spend_seq_ = -1; addr_pass_spend_seq_ = -1; network_refresh_.resetJobs(); rescan_status_poll_in_progress_ = false; opid_poll_in_progress_ = false; opid_filter_unsupported_ = false; // a reconnect may be to a different daemon op_origin_.clear(); send_feegap_retried_opids_.clear(); last_total_balance_zat_.reset(); history_transparent_rows_ = 0; // the history's scan state (the pass, epochs and watermark are history_transparent_tip_ = -1; // reset with the cache session below); cost estimates are kept scan_governor_.reset(); // tickets of this connection's scans complete as no-ops recent_probe_rotation_.reset(); scan_gate_.disarm(); address_retry_not_before_ = {}; address_balances_loaded_ = false; first_balance_read_tried_ = false; address_owed_next_try_ = {}; mempool_known_ = false; ++rpc_session_; if (consolidation_.abort()) ui::Notifications::instance().warning(TR("merge_run_aborted_toast")); ui::ShieldDialog::invalidateScope(); 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(); // undrained MainCbs are lost; console busy gate is released worker-side // Drop the stopped worker so onConnected recreates and starts a fresh one. Keeping a // stopped-but-present worker would defeat onConnected's `if (!fast_worker_)` guard, leaving // the fast lane dead for the rest of the session — which silently stalls the opid poll // (its post() never runs, so a completed send spins on "Waiting for operation"). fast_worker_.reset(); } if (fast_rpc_) { fast_rpc_->disconnect(); } } std::string App::applyDaemonInitStatus(bool reachableButBusy) { state_.daemon_initializing = true; if (connect_stall_since_ <= 0.0) connect_stall_since_ = ImGui::GetTime(); // start the "taking too long" clock // 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; } } } } // Most specific first: the node's own log says it is rebuilding witnesses or replaying blocks (then // "Launching dragonxd…" over a node that has been busy for an hour invited closing it), else an init // phase line, else "reachable but busy", else not bound yet. const auto kind = daemon::chooseDaemonStatus(!phaseLine.empty(), reachableButBusy, daemon_busy_); std::string busyTitle, busyDetail; WarmupText wt; switch (kind) { case daemon::DaemonStatusKind::Rebuilding: case daemon::DaemonStatusKind::Replaying: { const NodeBusyText busy = nodeBusyText(); busyTitle = busy.title; busyDetail = busy.detail; wt = {busyTitle.c_str(), busyDetail.c_str()}; break; } case daemon::DaemonStatusKind::InitPhase: wt = translateWarmup(phaseLine); break; case daemon::DaemonStatusKind::Busy: // The probe reached the node but got no answer (a reply timeout or a full queue): it is up but // too busy to answer (it isn't printing a recognizable phase, or we didn't launch it). wt = {TR("node_starting_title"), TR("node_starting_busy_desc")}; break; case daemon::DaemonStatusKind::Launching: // The daemon is launching but hasn't bound its RPC port yet. wt = {TR("node_starting_title"), TR("node_starting_launch_desc")}; break; } 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; init_status_title_ = title; init_status_busy_ = reachableButBusy; return title; } void App::applyWarmupText(const std::string& rpcMessage) { warmup_rpc_message_ = rpcMessage; if (daemon_busy_.rebuilding()) { const NodeBusyText busy = nodeBusyText(); state_.warmup_status = busy.title; state_.warmup_description = busy.detail; } else { const WarmupText wt = translateWarmup(rpcMessage); state_.warmup_status = wt.title; state_.warmup_description = wt.description; } // Append current block height from daemon output const int h = daemon_controller_ ? daemon_controller_->lastBlockHeight() : -1; if (h > 0) state_.warmup_status += " (Block " + std::to_string(h) + ")"; connection_status_ = state_.warmup_status; } 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. abortMaintenanceCalls(); // a long call holds the curl lock disconnect() needs 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 at transitions (connect, warmup end, unlock); the // periodic timers drive them otherwise. Entering a tab marks its timers due. // // Categories: // Core — getblockchaininfo + getmempoolinfo, and z_gettotalbalance when due (balance, sync) // Addresses — z_listaddresses + z_listunspent + listunspent (address list, per-addr balances) // Transactions — listtransactions + z_listreceivedbyaddress + z_listsentbyaddress + z_viewtransaction // Peers — getpeerinfo + listbanned (already standalone) // Encryption — getwalletinfo (one-shot on connect) // // Intervals are adjusted by applyRefreshPolicy() based on the active tab (or the sync / background // profiles). The wallet-wide scans (balance, address scan, history) also pass the hard gates // (services::scan_policy.h) and the scan budget (services::WalletScanGovernor): explicit work bypasses // only the budget, never the gates. // ============================================================================ App::RefreshIntervals App::getIntervalsForPage(ui::NavPage page) { return services::NetworkRefreshService::intervalsForPage(page); } void App::applyRefreshPolicy(ui::NavPage page) { // While the daemon is syncing, override the per-tab cadence with the low-impact sync profile so // the wallet stops contending for the daemon's cs_main lock (frequent getpeerinfo / per-block // transaction scans / balance polls slow block connection). This makes every tab sync as fast // as the Console tab does today. effectivelySyncing() keeps this profile on briefly after catching // up (hysteresis) so a large-wallet scan can't immediately re-starve connection and bounce the // node back into "syncing". Reverts to the per-tab profile once the settle window passes. // In the background (C134) only the chain state is polled, whatever the sync state. const bool wasHeld = refresh_policy_syncing_ || refresh_policy_background_; refresh_policy_syncing_ = effectivelySyncing(); refresh_policy_background_ = window_background_; network_refresh_.setIntervals(services::RefreshScheduler::selectIntervals( page, refresh_policy_syncing_, refresh_policy_background_)); // Leaving a profile that held the wallet timers: markDue() zeroes a 0-interval timer, so anything marked // meanwhile (a tab entry, a send's completion, F5) was lost. Mark everything due; the gates in update() // and the scan budget still decide what actually runs. Here rather than in update(): setCurrentPage() // also lands here and would swallow update()'s edge. if (wasHeld && !refresh_policy_syncing_ && !refresh_policy_background_) network_refresh_.markImmediateRefresh(); } void App::setWindowBackground(bool background) { if (background == window_background_) return; window_background_ = background; DEBUG_LOGF("[App] window background=%d\n", background ? 1 : 0); } // True while the node is behind, and for a short settle window after it first catches up. The settle // window is armed only on the syncing→caught-up edge (see the Core refresh callback), so a wallet that // was synced from the start is never throttled at connect — only a node that just finished catching up. bool App::effectivelySyncing() const { if (state_.sync.syncing) return true; if (sync_settle_until_ == 0) return false; // no pending settle → genuinely caught up return std::time(nullptr) < sync_settle_until_; } void App::abortMaintenanceCalls() { // Both stick until the next connect, which is fine here: every caller disconnects next. maint_abort_.store(true); // refuses maintenance calls still queued on the worker if (rpc_ && maint_calls_.load() > 0) rpc_->requestAbort(); // cuts off the one in flight } bool App::walletScanReady() const { if (isLiteBuild() || capture_mode_ || shutting_down_.load()) return false; // state_.warming_up, not rpc_->isWarmingUp(): the client's flag is set when a connect lands in warmup // and stays set until the next reconnect, which kept this false for the whole session. if (!rpc_ || !rpc_->isConnected()) return false; if (!state_.connected || state_.warming_up || state_.isLocked()) return false; if (state_.sync.rescanning || runtime_rescan_active_ || effectivelySyncing()) return false; return !rpc_busy_gate_->busy(); } services::ScanSignals App::currentScanSignals() const { services::ScanSignals sig; sig.tipHash = state_.sync.best_blockhash; sig.mempoolKnown = mempool_known_; sig.mempoolTxs = mempool_txs_; sig.mempoolBytes = mempool_bytes_; sig.walletEpoch = wallet_mutation_epoch_; return sig; } bool App::scanGateOpen() const { return scan_gate_.open(std::chrono::steady_clock::now(), state_.encryption_state_known, state_.wallet_tx_count); } bool App::addressScanCanRunNow() const { return services::wholeWalletScanAllowed(scanContext()) && std::chrono::steady_clock::now() >= address_retry_not_before_; } bool App::addressListStale() const { return state_.last_address_update == 0 || address_validation_cache_dirty_; } services::ScanContext App::scanContext() const { services::ScanContext c; c.connected = rpc_ && rpc_->isConnected() && state_.connected; c.locked = state_.isLocked(); c.warmingUp = state_.warming_up; c.runtimeRescan = runtime_rescan_active_; c.rpcBusy = rpc_busy_gate_ && rpc_busy_gate_->busy(); c.syncing = effectivelySyncing(); c.gateOpen = scanGateOpen(); c.background = window_background_; return c; } void App::armScanGate(const NetworkRefreshService::ConnectionInfoResult& info) { const bool tipKnown = NetworkRefreshService::connectionInfoTipKnown(info); const bool behind = NetworkRefreshService::connectionInfoShowsBehind(info); scan_gate_.arm(std::chrono::steady_clock::now(), tipKnown); address_retry_not_before_ = {}; // the node (re)started answering: an earlier failure is moot if (behind) { // Latch the sync profile before the first dispatch. The next Core result recomputes // state_.sync.syncing and keeps it latched while the node is behind. was_core_syncing_ = true; sync_settle_until_ = std::time(nullptr) + static_cast(services::RefreshScheduler::kSyncSettleSeconds); } DEBUG_LOGF("[scan-gate] armed: tip %s%s\n", tipKnown ? "known" : "unknown", behind ? ", behind" : ""); } bool App::isBalanceHeldByScanBudget() const { return scan_governor_.heldWithinFloor(services::ScanStream::Balance, scanClockNow()); } services::ScanVisibility App::currentScanVisibility() const { return services::scanVisibilityForPage(current_page_, window_background_); } double App::scanClockNow() { return std::chrono::duration(std::chrono::steady_clock::now().time_since_epoch()).count(); } 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 (current_page_ == ui::NavPage::Mining) return true; // C119: refreshTransactionData() narrows it to the Recent poll if (!send_txids_.empty()) return true; return transactions_dirty_; } 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; // Refresh for the incoming tab. 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. Wallet data isn't forced on entry any more: the timers // are marked due, and the change gate and the scan budget decide (data that changed since its last // scan comes first, and a page's own data gets the visible head start). Only what the page can't // show without is loaded now: a stale address list, a never-loaded history. if (rpc_ && rpc_->isConnected() && !state_.isLocked()) { using Job = services::NetworkRefreshService::Job; const auto plan = services::planPageEntry(page, scanContext(), addressListStale(), state_.last_tx_update == 0); if (plan.core) { refreshCoreData(); network_refresh_.reset(RefreshTimer::Core); } if (plan.peers) { refreshPeerInfo(); network_refresh_.reset(RefreshTimer::Peers); } if (plan.mining) refreshMiningInfo(); if (plan.addressesNow != services::ScanTier::None) { refreshAddressData(); if (network_refresh_.jobInProgress(Job::Addresses)) network_refresh_.reset(RefreshTimer::Addresses); } else if (plan.markAddresses) { network_refresh_.markDue(RefreshTimer::Addresses); } if (plan.transactionsNow) { refreshTransactionData(); if (network_refresh_.jobInProgress(Job::Transactions)) network_refresh_.reset(RefreshTimer::Transactions); } else if (plan.markTransactions) { network_refresh_.markDue(RefreshTimer::Transactions); } } } // Screenshot sweep (tab-only + full-UI) + its demo data live in src/app_sweep.cpp. bool App::shouldRefreshRecentTransactions() const { // No same-tip condition: at a new tip services::decideTxCycle runs a Full or Light cycle first when it // can, so the cheap poll runs there only while the scan throttle holds the Full cycle back (it keeps // transparent rows and confirmations live meanwhile, and doesn't consume the new tip). using RefreshTimer = services::NetworkRefreshService::Timer; return network_refresh_.isDue(RefreshTimer::TxAge) && last_tx_block_height_ >= 0 && !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, double fee) { 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; pendingInfo.fee = fee; 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)); } state_.touchTransactions(); // covers both the in-place replace and the insert if (newPending) { applyPendingSendDelta(pendingInfo.from, -pendingInfo.amount, /*includeAggregates*/ true); } 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_.touchTransactions(); 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; applyPendingSendDelta(pending->second.from, pending->second.amount, /*includeAggregates*/ true); } } 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_.touchTransactions(); state_.last_tx_update = std::time(nullptr); } void App::applyPendingSendDelta(const std::string& fromAddress, double signedAmount, bool includeAggregates, bool includeAddress) { // For a debit (signedAmount < 0) this clamps at >=0 exactly as the debit sites did. For a // restore (signedAmount > 0) the clamp is a no-op — max(0, bal+amt) == bal+amt when bal,amt>=0 — // so the single clamped form reproduces the original restore's unclamped bal += amt. // Debit/restore the SPENDABLE view ONLY. The DISPLAY balances now come from an honest minconf=0 RPC // that already reflects the outgoing spend AND the incoming 0-conf change, so debiting them here too // would re-crater the display. This delta only lowers what can be re-spent before the change confirms. auto applyToAddress = [&](std::vector& addresses) { for (auto& address : addresses) { if (address.address == fromAddress) { address.spendableBalance = std::max(0.0, address.spendableBalance + signedAmount); return true; } } return false; }; if (includeAddress && !applyToAddress(state_.z_addresses)) applyToAddress(state_.t_addresses); if (includeAggregates) { if (!fromAddress.empty() && fromAddress[0] == 'z') { state_.spendablePrivateBalance = std::max(0.0, state_.spendablePrivateBalance + signedAmount); } else { state_.spendableTransparentBalance = std::max(0.0, state_.spendableTransparentBalance + signedAmount); } state_.spendableTotalBalance = std::max(0.0, state_.spendableTotalBalance + signedAmount); state_.unconfirmedBalance = std::max(0.0, state_.totalBalance - state_.spendableTotalBalance); } } void App::trackOperation(const std::string& opid, services::NetworkRefreshService::OperationOrigin origin) { 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); if (origin != services::NetworkRefreshService::OperationOrigin::User) op_origin_[opid] = origin; } void App::markWalletMutated(bool forceBalance) { noteWalletMutation(); // the change-gated streams look again markAddressesDirty(); // explicit address pass; one already in flight can't clear it markTransactionsDirty(); // explicit history cycle; same guard (tx_dirty_generation_) last_tx_block_height_ = -1; if (forceBalance) { requestForcedBalanceRefresh(); // applied once no User op is pending (refreshCoreData) // A user op (send, shield, merge, sweep) may have spent notes the chat payer view still lists, and the // pending debit on state_.z_addresses omits the fee and the unverified change, so it can't stand in. // Hold chat sends and splits until the post-commit address pass (marked above) re-feeds the view. chat_verified_funds_by_addr_.clear(); chat_verified_note_budget_ = 0; chat_verified_shielded_zat_ = 0; } network_refresh_.markWalletMutationRefresh(); // Core / Transactions / Addresses due now } services::ScanTicket App::beginAdhocWalletScan() { return scan_governor_.beginExplicit(services::ScanStream::Adhoc, currentScanSignals(), scanClockNow(), /*servesRoutine=*/false); } void App::endAdhocWalletScan(const services::ScanTicket& ticket, double costMs, services::ScanOutcome outcome) { scan_governor_.complete(ticket, costMs, outcome, scanClockNow()); } 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, bool includeAddressBalances) { for (const auto& [opid, pending] : pending_send_info_) { (void)opid; applyPendingSendDelta(pending.from, -pending.amount, includeAggregateBalances, includeAddressBalances); } } void App::resetWitnessRescanProgress() { // Common core of the four rescan-completion sites. Deliberately does NOT set // rescan_progress (callers set it to 1.0 only on success) or runtime_rescan_active_ // (only the manual-rescan path uses it), and is distinct from the witness phase-transition // reset in App::update() which sets witness_phase to the new phase rather than clearing it. rescan_confirmed_active_ = false; state_.sync.rescanning = false; state_.sync.rescan_status.clear(); state_.sync.building_witnesses = false; state_.sync.witness_phase = 0; state_.sync.witness_progress = 0.0f; state_.sync.witness_remaining = 0; witness_rebuild_total_blocks_ = 0; witness_seen_txids_.clear(); witness_total_txs_ = 0; } 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); } std::string App::activeWalletIdentityHash() const { const std::string identity = transactionHistoryCacheWalletIdentity(); if (identity.empty()) return std::string(); return data::TransactionHistoryCache::walletIdentityHash(identity); } std::string App::activeWalletScopeId() { if (!settings_) return std::string(); const std::string file = settings_->getActiveWalletFile(); if (file.empty()) return std::string(); // Reuse the persisted id if we have one for this wallet file. if (const auto* existing = wallet_index_.find(file)) { if (!existing->scopeId.empty()) return existing->scopeId; } // Never mint during a UI sweep: the demo index is thrown away at sweep end, and the Contacts tab // would re-attach legacy-scoped contacts to the throwaway id and save the address book for real. if (capture_mode_) return std::string(); // Establish one now: 16 random bytes, "w:"-prefixed so it's distinguishable from the legacy // 64-hex address-hash scopes we migrate away from. unsigned char rnd[16]; randombytes_buf(rnd, sizeof(rnd)); static const char* kHex = "0123456789abcdef"; std::string id = "w:"; for (unsigned char b : rnd) { id.push_back(kHex[b >> 4]); id.push_back(kHex[b & 0x0F]); } data::WalletIndexEntry e; if (const auto* existing = wallet_index_.find(file)) e = *existing; e.fileName = file; if (e.displayName.empty()) e.displayName = file; e.scopeId = id; if (wallet_index_.upsert(e)) wallet_index_.save(); return id; } std::string App::portfolioScopeId() { if (capture_mode_) return "w:sweep-demo"; // in-memory only: Settings/WalletIndex saves are latched in a sweep if (state_.z_addresses.empty() && state_.t_addresses.empty()) return std::string(); // not loaded yet return activeWalletScopeId(); } void App::reattachLegacyPortfolioScopes() { if (!settings_ || capture_mode_ || !state_.connected) return; const std::string scopeId = portfolioScopeId(); if (scopeId.empty() || scopeId == portfolio_scope_migrated_for_) return; portfolio_scope_migrated_for_ = scopeId; // once per wallet per session (re-runs after a wallet switch) std::set mine; for (const auto& a : state_.z_addresses) if (!a.address.empty()) mine.insert(a.address); for (const auto& a : state_.t_addresses) if (!a.address.empty()) mine.insert(a.address); const int moved = settings_->reattachLegacyPortfolioScopes( scopeId, activeWalletIdentityHash(), mine, wallet_index_.entries().size() <= 1); if (moved > 0) settings_->save(); // UI thread, once } void App::updateWalletIndexForActiveWallet(bool markOpened) { if (!settings_) return; const std::string file = settings_->getActiveWalletFile(); if (file.empty()) return; // Start from the existing entry so we preserve fields we're not updating this call. data::WalletIndexEntry e; if (const auto* existing = wallet_index_.find(file)) e = *existing; e.fileName = file; if (e.displayName.empty()) e.displayName = file; // Balance + address count are only meaningful once the wallet's addresses are known (identity // non-empty); at connect time they're still 0, so don't cache bogus zeros then. const std::string idHash = state_.connected ? activeWalletIdentityHash() : std::string(); if (!idHash.empty()) { e.walletIdentityHash = idHash; if (state_.last_balance_update != 0) e.cachedBalance = state_.totalBalance; // not before one was read e.cachedAddressCount = static_cast(state_.z_addresses.size() + state_.t_addresses.size()); } // File size is always readable from disk. std::error_code ec; const std::string path = util::Platform::getDragonXDataDir() + "/" + file; if (std::filesystem::exists(path, ec)) { auto sz = std::filesystem::file_size(path, ec); if (!ec) e.sizeBytesAtLastOpen = static_cast(sz); } // W1-3: record "synced here" only once the wallet's identity is actually verified (its addresses are // known -> idHash non-empty). Stamping it on the bare connect (before any address readback) would let // a freshly-restored wallet skip its needed rescan. It's idempotent, so the post-refresh update // (updateWalletIndexForActiveWallet after addresses load) sets it once; lastOpenedEpoch is still // recorded at open time here. if (!idHash.empty()) { e.syncedHere = true; // loaded + identity-verified in this datadir -> catch-up (no full rescan) } if (markOpened) { e.lastOpenedEpoch = static_cast(std::time(nullptr)); } if (wallet_index_.upsert(e)) wallet_index_.save(); } void App::switchToWallet(const std::string& walletFile, bool stopDaemonConfirmed, bool salvage) { if (!supportsFullNodeLifecycleActions()) { ui::Notifications::instance().warning("Full-node lifecycle actions are unavailable in lite build"); return; } if (walletFile.empty() || !settings_) return; if (settings_->getActiveWalletFile() == walletFile) { ui::Notifications::instance().info("That wallet is already open."); return; } if (daemon_restarting_) { ui::Notifications::instance().warning("The node is busy restarting — try again in a moment."); return; } // Don't race the other daemon-lifecycle operations. rescan/repair set state_.sync.rescanning; the // seed-migration flow drives its own daemon stop/start; an encryption restart sets daemon_restarting_. if (state_.sync.rescanning) { ui::Notifications::instance().warning("A rescan or repair is in progress — try again once it finishes."); return; } // W3-4: block switching while a migration is PENDING, not only while its dialog is open — closing // the dialog via "Later" mid-migration leaves the pending state but previously dropped this guard. if (show_seed_migration_ || (settings_ && settings_->getSeedMigrationPending())) { ui::Notifications::instance().warning("Finish or cancel the seed migration before switching wallets."); return; } if (consolidation_.active()) { ui::Notifications::instance().warning(TR("merge_run_busy")); return; } if (!isUsingEmbeddedDaemon()) { ui::Notifications::instance().warning("Switching wallets needs the embedded daemon — stop any external dragonxd first."); return; } if (hasTransactionSendProgress()) { ui::Notifications::instance().warning("Finish or cancel the pending send before switching wallets."); return; } if (refuseIfNodeBusy()) return; // W1-1: verify the target wallet file actually exists before switching. dragonxd auto-CREATES a // fresh empty wallet for a missing -wallet=, so without this a moved/deleted wallet file would // silently "open" as a brand-new empty wallet with a zero balance — looking exactly like fund loss. // (Also closes the W1-4 stale-switcher-row race: the check runs no matter how switchToWallet is called.) { std::error_code existEc; const std::string walletPath = util::Platform::getDragonXDataDir() + "/" + walletFile; if (!std::filesystem::exists(walletPath, existEc)) { ui::Notifications::instance().warning( "Wallet file not found (moved or deleted?): " + walletFile + " — it was not opened.", 15.0f); return; } } // If we're connected to a node this session did NOT spawn (no live process handle — it was left // running by "keep node running", started by the user, or we just direct-connected to a config-provided // one), confirm before stopping it: switching must stop+restart it on the new wallet, but the user may // have kept it running on purpose. A node we started ourselves (handle held) restarts silently. if (!stopDaemonConfirmed && state_.connected && !isEmbeddedDaemonRunning()) { pending_switch_wallet_file_ = walletFile; show_switch_stop_daemon_confirm_ = true; return; } // Rescan only if this wallet was never synced in this datadir (freshly imported/new). One we've // loaded here before just catches up from its recorded block on start — fast. bool needRescan = true; if (const auto* e = wallet_index_.find(walletFile)) needRescan = !e->syncedHere; const std::string prevWallet = settings_->getActiveWalletFile(); // for revert if the new one fails wallet_switch_prev_file_ = prevWallet; wallet_switch_target_file_ = walletFile; // remembered for a "salvage" retry from the failure modal wallet_switch_failed_.store(false); switch_stop_failed_.store(false); // reason latch for the revert message; a stale one must not leak switch_wallet_corrupt_.store(false); wallet_switch_pending_confirm_.store(true); // cleared on a successful connect (onConnected) settings_->setActiveWalletFile(walletFile); settings_->save(); // A prior crash-wedge shouldn't block reconnecting to the freshly-launched wallet daemon. if (daemon_controller_) daemon_controller_->resetCrashCount(); // Re-scope the PIN vault to the new wallet (its own vault-.dat), and clear any carried-over // lock-screen attempt/lockout state + wipe the passphrase/PIN entry buffers — the previous wallet's // failed-unlock counter and typed secrets must not apply to the new wallet. if (vault_) vault_->setWalletScope(walletFile); pin_vault_stale_ = false; // the out-of-date mark was the previous wallet's vault lock_attempts_ = 0; lock_lockout_timer_ = 0.0f; lock_error_msg_.clear(); lock_error_timer_ = 0.0f; lock_unlock_in_progress_ = false; util::SecureVault::secureZero(lock_passphrase_buf_, sizeof(lock_passphrase_buf_)); util::SecureVault::secureZero(lock_pin_buf_, sizeof(lock_pin_buf_)); // Same restart coordination as the seed-adopt flow: gate the main-loop reconnect, disconnect, // then stop + restart on a background thread. syncSettings() re-reads active_wallet_file on // start, so the daemon comes up on -wallet=. WalletState clears on disconnect and the // per-wallet caches re-key off the new identity (P1), so no previous-wallet data leaks through. daemon_restarting_ = true; abortMaintenanceCalls(); // a long call holds the curl lock disconnect() needs if (rpc_ && rpc_->isConnected()) rpc_->disconnect(); onDisconnected("Switching wallet"); // The loaded wallet is changing — drop the old wallet's chat identity + decrypted store so it // can't surface (or sign outgoing chat) under the new wallet. It re-provisions from the new // wallet's seed once it connects. onDisconnected alone doesn't cover chat. resetChatSession(); // Open the switch progress modal — it stays up through stop → wait-for-exit → start → reconnect and // auto-closes when the new node connects (onConnected). The worker advances the phase from here. wallet_switch_error_.clear(); wallet_switch_started_time_ = 0.0; // re-captured on the first progress frame → elapsed starts fresh wallet_switch_phase_.store(static_cast(WalletSwitchPhase::Stopping)); wallet_switch_dialog_open_.store(true); async_tasks_.submit("Switch wallet", [this, needRescan, salvage](const util::AsyncTaskManager::Token&) { bool ok = false; // Watermark the node's captured console output so we scan only THIS start's lines for a // corruption signature (below), not stale output from a previous daemon. std::size_t out_off = 0; if (daemon_controller_) daemon_controller_->outputSince(out_off); try { // Stop the node so it releases the datadir .lock + RPC port before we relaunch. For an adopted // (external) daemon this sends a graceful RPC "stop" — stopEmbeddedDaemon()'s policy would leave // it running — and then waits for the RPC PORT to actually free, the correct readiness signal // (isEmbeddedDaemonRunning() is process-handle-only and reads false immediately for an adopted // daemon). The wait breaks promptly on shutdown so beginShutdown's join() doesn't freeze the UI. const bool port_free = stopDaemonForWalletSwitch(); if (!port_free) { // The old node wouldn't release the port in time — don't start() into a busy port (that // fast-fails on the isPortInUse check and gets misread as a bad wallet). Revert with an // accurate, distinct reason instead. switch_stop_failed_.store(true); ok = false; } else { // The relaunch is ours — clear the adopted-external latch so the fresh process is treated // as owned (stop/isRunning/exit behave normally for the next switch and app exit). wallet_switch_phase_.store(static_cast(WalletSwitchPhase::Starting)); if (daemon_controller_) daemon_controller_->clearExternalDaemonDetected(); if (salvage && daemon_controller_) daemon_controller_->setSalvageOnNextStart(true); // repair a corrupt wallet else if (needRescan && daemon_controller_) daemon_controller_->setRescanOnNextStart(true); // (salvage implies rescan) if (salvage || needRescan) user_initiated_rescan_ = true; // wallet-triggered repair/rescan → completion should toast // Start ONCE. Do NOT retry-spawn: a second start while the first is still shutting down leaves // two dragonxd holding wallet.dat against each other (BDB "Failed to rename … Error"). The // stopDaemonForWalletSwitch() wait already ensured the old node's process is gone, so a valid // wallet opens cleanly; a bad/corrupt wallet exits during init and we revert. ok = shutting_down_ ? true : startEmbeddedDaemon(); if (shutting_down_) ok = true; // a start aborted BY shutdown is not a failed switch (no revert) // A missing/corrupt wallet makes dragonxd exit during init — confirm the process survived a // moment; if not, the wallet is bad. (A valid launch keeps the process alive while it syncs.) if (ok && !shutting_down_) { std::this_thread::sleep_for(std::chrono::milliseconds(1500)); if (!isEmbeddedDaemonRunning()) ok = false; } // If it died in init, scan this start's captured output for a corrupt-wallet signature so the // failure modal can offer a repair. Give the dying node a moment to flush its final lines. if (!ok && !shutting_down_ && daemon_controller_) { for (int i = 0; i < 12 && !shutting_down_; ++i) std::this_thread::sleep_for(std::chrono::milliseconds(100)); if (walletOutputLooksCorrupt(daemon_controller_->outputSince(out_off))) switch_wallet_corrupt_.store(true); } // Process is up and staying up — now waiting for RPC to answer (onConnected closes the modal). if (ok) wallet_switch_phase_.store(static_cast(WalletSwitchPhase::Reconnecting)); } } catch (...) { ok = false; // a throw during stop/start is a failed switch — revert rather than wedge } if (ok) { daemon_restarting_ = false; // re-arm reconnect once the new daemon is up } else { // Leave the reconnect GATED (daemon_restarting_ stays true) and hand the revert to the main // thread — settings writes + vault re-scope must not run on this worker thread. The revert // re-arms the gate, so it can never get stuck true. wallet_switch_failed_.store(true); } }); } // Main-thread continuation for a failed wallet switch: restore the previous wallet file so a broken // active_wallet_file isn't persisted, re-scope the vault back, and re-arm the reconnect so the connect // loop brings the (good) previous wallet's daemon back up. Called each tick from update(). void App::processWalletSwitchRevert() { if (!wallet_switch_failed_.exchange(false)) return; wallet_switch_pending_confirm_.store(false); if (settings_ && !wallet_switch_prev_file_.empty() && settings_->getActiveWalletFile() != wallet_switch_prev_file_) { settings_->setActiveWalletFile(wallet_switch_prev_file_); settings_->save(); if (vault_) vault_->setWalletScope(wallet_switch_prev_file_); pin_vault_stale_ = false; } // Clear the crash-wedge so the (good) previous wallet's daemon is allowed to start again. if (daemon_controller_) daemon_controller_->resetCrashCount(); // Accurate reason: "port wouldn't free" (the running node kept the connection); "wallet is corrupt" // (the node's recovery couldn't open it); else a generic bad-wallet failure. const std::string reason = switch_stop_failed_.exchange(false) ? std::string("Couldn't switch wallets — the running node didn't release its connection in time. " "It's still on the previous wallet.") : switch_wallet_corrupt_.load() ? std::string(TR("switch_corrupt_body")) : std::string("Couldn't open that wallet — reverted to the previous one."); // Surface it in the progress modal if it's still up (keep it open on Failed until the user closes); // otherwise (the user chose "continue in background") fall back to a toast. if (wallet_switch_dialog_open_.load()) { wallet_switch_error_ = reason; wallet_switch_phase_.store(static_cast(WalletSwitchPhase::Failed)); } else { ui::Notifications::instance().error(reason); } daemon_restarting_ = false; // the connect loop now restarts the previous wallet's daemon } int App::chatUnreadCount() const { if (!chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return 0; // This is called every frame from render() to size the Chat nav badge. Recompute only when the store // actually changed (new/removed messages bump revision()) or after a short interval (to pick up // mute/hide/seen changes, which don't bump the store). Previously it ran a full O(conversations x // messages) scan that COPIED and stable_sorted every conversation's messages, every frame. const auto& store = chat_service_.store(); const std::uint64_t rev = store.revision(); const double now = ImGui::GetTime(); if (rev != chat_unread_rev_ || now - chat_unread_computed_at_ > 0.25) { chat_unread_cached_ = store.countUnread( [this](const std::string& cid) { // excluded from the badge return settings_ && (settings_->isChatMuted(cid) || settings_->isChatHidden(cid)); }, [this](const std::string& cid) -> std::int64_t { // seen watermark const auto it = chat_seen_watermark_.find(cid); return it != chat_seen_watermark_.end() ? it->second : 0; }); chat_unread_rev_ = rev; chat_unread_computed_at_ = now; } return chat_unread_cached_; } void App::markChatConversationSeen(const std::string& cid, std::int64_t latestTs) { if (latestTs > 0) chat_seen_watermark_[cid] = latestTs; } void App::forgetChatConversationSeen(const std::string& cid) { chat_seen_watermark_.erase(cid); } 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() { storeTransactionHistoryCacheIfAvailable(data::HistoryStoreMode::Now); // while the key is still live transaction_history_cache_.lockKey(); wipePendingTransactionHistoryCachePassphrase(); transaction_history_cache_loaded_ = false; history_store_ = {}; // a new session: nothing of it is known to be on disk yet history_store_pending_ = false; invalidateShieldedHistoryScanProgress(false); history_listed_z_.clear(); } void App::lockTransactionHistoryCacheSession() { // Write what is not on disk yet while the key is live. The queued job carries its own key copy, so the // lockKey() below cannot lose it. history_store_ is kept: the snapshot stays valid, and re-unlocking with // the same passphrase gives the same keyId(), so the unlock does not rewrite it. storeTransactionHistoryCacheIfAvailable(data::HistoryStoreMode::Now); transaction_history_cache_.lockKey(); wipePendingTransactionHistoryCachePassphrase(); transaction_history_cache_loaded_ = 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, bool keepSentScan) { shielded_history_scan_cursor_ = 0; initial_history_scan_complete_ = false; // a full re-scan is coming — show load progress again shielded_history_scan_heights_.clear(); history_pass_.reset(); ++history_scan_epoch_; // results of scans already in flight no longer apply history_hint_floors_.clear(); history_hinted_txids_.clear(); recent_probe_rotation_.reset(); if (!keepSentScan) { history_sent_scan_height_ = -1; // the next sweep covers the whole wallet again history_enrich_backlog_.clear(); history_full_sweep_failures_ = 0; history_full_sweep_retry_at_ = 0; ++history_sent_epoch_; } if (persistCache) storeTransactionHistoryCacheIfAvailable(data::HistoryStoreMode::Now); // the cleared heights must reach disk } std::vector App::shieldedAddressList() const { // The same order as NetworkRefreshService::buildTransactionRefreshSnapshot. std::vector addresses; addresses.reserve(state_.z_addresses.size()); for (const auto& address : state_.z_addresses) { if (!address.address.empty()) addresses.push_back(address.address); } return addresses; } void App::noteHistoryHintsFromUnspent( const std::vector& notes) { const int tip = state_.sync.blocks; if (tip <= 0 || notes.empty()) return; std::unordered_map rowConfirmed; // txid -> any row of it is mined for (const auto& transaction : state_.transactions) { if (transaction.txid.empty()) continue; bool& confirmed = rowConfirmed[transaction.txid]; confirmed = confirmed || transaction.confirmations >= 1; } for (const auto& note : notes) { if (note.change || note.txid.empty() || note.address.empty()) continue; if (history_hinted_txids_.count(note.txid)) continue; // once per txid: change/merges can't loop const auto row = rowConfirmed.find(note.txid); const bool noRow = row == rowConfirmed.end(); const bool staleUnconfirmed = !noRow && !row->second && note.confirmations >= 1; if (!noRow && !staleUnconfirmed) continue; int& floor = history_hint_floors_[note.address]; floor = std::max(floor, tip); // A mempool hint doesn't use up the txid: once mined, its row (still at 0 conf) needs one more scan. // No loop: an unmined note with a row matches neither case, and a mined one hints once. if (note.confirmations >= 1) history_hinted_txids_.insert(note.txid); } } services::ShieldedHistoryPass::Work App::historyScanWork() const { const auto addresses = shieldedAddressList(); return history_pass_.evaluate(state_.sync.blocks, addresses, shielded_history_scan_heights_, services::historyStaleAfterBlocks(addresses.size(), history_addr_scan_ms_), initial_history_scan_complete_); } 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_.replaceTransactions(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; // What is on disk is exactly what was just loaded: the first identical refresh must not rewrite it. history_store_.sig = data::historySnapshotSignature( state_.transactions, {}, data::TransactionHistoryCache::walletIdentityHash(walletIdentity), transaction_history_cache_.keyId(), shielded_history_scan_heights_, loaded.tipHeight, loaded.tipHash); history_store_.have = true; history_store_.lastSubmit = std::chrono::steady_clock::now(); history_store_pending_ = false; markTransactionsDirty(); network_refresh_.markDue(services::NetworkRefreshService::Timer::Transactions); } void App::storeTransactionHistoryCacheIfAvailable(data::HistoryStoreMode mode) { // Re-armed below only while a content change is still owed, so the pump can never spin on it. history_store_pending_ = false; // Lite never reads this cache (every load path is full-node RPC); the screenshot sweep's demo rows must // never reach it. if (isLiteBuild() || capture_mode_ || state_.transactions.empty()) return; try { std::string walletIdentity = transactionHistoryCacheWalletIdentity(); if (walletIdentity.empty()) return; if (!ensureTransactionHistoryCacheUnlockedFor(walletIdentity)) return; pruneShieldedHistoryScanProgress(); // What would be persisted: every row except the pending sends' provisional ones. const std::unordered_set pendingOpids(pending_opids_.begin(), pending_opids_.end()); const auto sig = data::historySnapshotSignature( state_.transactions, pendingOpids, data::TransactionHistoryCache::walletIdentityHash(walletIdentity), transaction_history_cache_.keyId(), shielded_history_scan_heights_, state_.sync.blocks, state_.sync.best_blockhash); if (sig.rows == 0) return; const auto now = std::chrono::steady_clock::now(); data::HistoryStoreInput in; in.mode = mode; in.haveStored = history_store_.have; in.stored = history_store_.sig; in.current = sig; in.sinceLastSubmitSec = history_store_.lastSubmit == std::chrono::steady_clock::time_point{} ? 1e9 : std::chrono::duration(now - history_store_.lastSubmit).count(); switch (data::decideHistoryStore(in)) { case data::HistoryStoreAction::Skip: return; case data::HistoryStoreAction::Defer: history_store_pending_ = true; // pumpTransactionHistoryStore() releases it from update() return; case data::HistoryStoreAction::Store: break; } // A self-contained job: the writer thread never touches App state. auto job = std::make_shared(); if (!transaction_history_cache_.captureKey(walletIdentity, job->key)) return; job->walletIdentity = std::move(walletIdentity); job->tipHeight = state_.sync.blocks; job->tipHash = state_.sync.best_blockhash; job->updatedAt = state_.last_tx_update != 0 ? static_cast(state_.last_tx_update) : std::time(nullptr); job->shieldedScanHeights = shielded_history_scan_heights_; job->transactions.reserve(sig.rows); for (const auto& transaction : state_.transactions) { if (pendingOpids.find(transaction.txid) != pendingOpids.end()) continue; job->transactions.push_back(transaction); } auto* cache = &transaction_history_cache_; // App member: outlives every job (see app.h) history_writer_.submit("tx-history", [cache, job]() { return cache->writeSnapshot(std::move(*job)); }); // Only once the job is queued: a throwing submit must not leave the session believing it is on disk. history_store_.have = true; history_store_.sig = sig; history_store_.lastSubmit = now; } catch (const std::exception& e) { // Never let a cache write break its caller (beginShutdown, onDisconnected, a refresh callback). DEBUG_LOGF("[History] snapshot store skipped: %s\n", e.what()); } catch (...) { DEBUG_LOGF("[History] snapshot store skipped\n"); } } void App::pumpTransactionHistoryStore() { // A failed write (disk full, locked DB) is not on disk: the next store rewrites everything, paced like // any content change. if (history_writer_.takeFailure("tx-history")) { history_store_.have = false; history_store_pending_ = true; } if (!history_store_pending_) return; if (history_store_.lastSubmit != std::chrono::steady_clock::time_point{} && std::chrono::duration(std::chrono::steady_clock::now() - history_store_.lastSubmit).count() < data::kHistoryMinStoreIntervalSec) return; // O(1) until the deadline storeTransactionHistoryCacheIfAvailable(data::HistoryStoreMode::Coalesce); } 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(); // the chain state; the balance is decided inside // The address list first: the history needs it (the address callback marks the history due). The scan // entry points own the hard gates (syncing, the connect gate, locked, busy, background): a held address // refresh still loads a stale list (list-only), a held history waits. // refreshTransactionData() picks the cycle itself (Full / Light / the between-blocks poll / none). if (addresses_dirty_) refreshAddressData(); else if (shouldRunWalletTransactionRefresh()) refreshTransactionData(); // Always refresh peers here — refreshData() only runs on one-shot transitions (connect, // warmup-complete, unlock), so this populates the status-bar peer count immediately on open // regardless of the active tab. Ongoing updates come from the periodic Peers timer. 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(bool allowBalance) { 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 consecutive_core_failures_ = 0; // The salvage / degraded-wallet lines are logged during wallet load, inside warmup, so // a connect that landed in warmup scanned too early. Look again now (both latch). The // locked-wallet poll no longer re-runs tryConnect, which used to catch them for // encrypted wallets. if (!daemon_restarting_) { detectWalletAutoRecovery(); detectWalletDegraded(); } state_.warming_up = false; state_.warmup_status.clear(); state_.warmup_description.clear(); connect_stall_since_ = 0.0; // warmup finished — clear the "taking too long" clock connection_status_ = TR("connected"); VERBOSE_LOGF("[warmup] Daemon ready, warmup complete\n"); // A -rescan runs entirely INSIDE daemon warmup (every RPC returns -28 until the // scan finishes), so warmup completing IS the rescan completing. This is the // reliable completion signal: some daemons lack getrescaninfo (it returns // "Method not found") or never print a "Done rescanning"/bench line, which left // the older detectors stuck at 99% — the user would then kill it prematurely. // rescan_confirmed_active_ ensures we actually observed this rescan running (set // by the getrescaninfo / daemon-log pollers) before declaring it done. if (state_.sync.rescanning && rescan_confirmed_active_) { resetWitnessRescanProgress(); noteWalletMutation(); state_.sync.rescan_progress = 1.0f; // Notes/witnesses were rebuilt — force a fresh history + balance pull. markTransactionsDirty(); last_tx_block_height_ = -1; invalidateShieldedHistoryScanProgress(true); if (user_initiated_rescan_) { ui::Notifications::instance().success("Blockchain rescan complete"); user_initiated_rescan_ = false; // surfaced once; not for background rebuilds } } NetworkRefreshService::applyConnectionInfoResult(state_, result.info); // The node answers now, but may not know the tip yet: hold the scans for a moment. armScanGate(result.info); // Trigger full data refresh now that daemon is ready refreshData(); } else { // A node that died during warmup answers with a refused/reset connection, not -28. // Count those like the Core poll does, so a dead node is torn down and restarted // instead of leaving the loading or lock screen over it indefinitely. if (rpc::rpcKindMeansLost(result.errorKind)) { if (++consecutive_core_failures_ >= 3 && state_.connected) { consecutive_core_failures_ = 0; handleLostConnection("Lost connection to daemon"); return; } } else { consecutive_core_failures_ = 0; // -28 warmup / busy: the node is there } // Still warming up — update status applyWarmupText(result.errorMessage); 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_; // A Core job already in flight: the enqueue would refuse this one anyway (and it may hold a ticket). if (network_refresh_.jobInProgress(services::NetworkRefreshService::Job::Core)) return; // The balance call (z_gettotalbalance — O(mapWallet), holds cs_main) is one of the wallet-wide scans // the governor budgets. Skipped while syncing (or settling after it) and for the locked wallet's // chain-state poll; only when something it depends on changed (tip, mempool, wallet mutation) or its // safety sweep is due; rarely on pages that don't show it. Forced (the user's own send) and first // fetches are explicit: never held back, but charged. const double scanNow = scanClockNow(); const auto scanSig = currentScanSignals(); services::BalancePollInputs balanceIn; balanceIn.allowBalance = allowBalance; balanceIn.syncing = effectivelySyncing(); balanceIn.gateOpen = scanGateOpen(); balanceIn.background = window_background_; balanceIn.firstReadTried = first_balance_read_tried_; balanceIn.forced = force_balance_refresh_ && !hasPendingUserOperations(); // a batch of user ops forces once, at its end balanceIn.neverFetched = state_.last_balance_update == 0; balanceIn.changed = scan_governor_.needed(services::ScanStream::Balance, scanSig, scanNow); balanceIn.visible = currentScanVisibility().balance; balanceIn.autoShieldArmed = settings_ && settings_->getAutoShield() && !daemon_autoshield_active_; balanceIn.sinceLastScanSec = scan_governor_.secondsSinceScan(services::ScanStream::Balance, scanNow); balanceIn.emaCostSec = scan_governor_.emaCostMs(services::ScanStream::Balance) / 1000.0; balanceIn.waiting = scan_governor_.waiting(services::ScanStream::Balance, scanNow); balanceIn.shownAgeSec = std::difftime(std::time(nullptr), static_cast( std::max(state_.last_balance_update, state_.balance_verified_at))); balanceIn.visibleFloorSec = scan_governor_.visibleFloorSecFor(services::ScanStream::Balance); services::ScanTicket balanceTicket; switch (services::decideBalancePoll(balanceIn)) { case services::BalancePollDecision::Explicit: balanceTicket = scan_governor_.beginExplicit(services::ScanStream::Balance, scanSig, scanNow); break; case services::BalancePollDecision::AskGovernor: balanceTicket = scan_governor_.tryAdmit(services::ScanStream::Balance, balanceIn.visible, scanSig, scanNow); break; case services::BalancePollDecision::Skip: break; } const bool includeBalance = static_cast(balanceTicket); // The forced request this poll carries. The result callback clears the flag only if the balance // call was really sent and no newer request came in; clearing it here lost it whenever the enqueue // was refused (a Core job already in flight) or the worker skipped the balance (behind, busy). Only a force // applicable now: a routine poll admitted while it is deferred (a user op still pending) doesn't clear it. const std::uint32_t forcedSeq = (includeBalance && balanceIn.forced) ? force_balance_seq_ : 0; NetworkRefreshService::CoreRefreshPlan plan; plan.includeBalance = includeBalance; plan.fetchMempool = !balanceIn.syncing; // a change signal for the scans; takes no cs_main plan.evenIfBehind = includeBalance && balanceIn.neverFetched; // the session's first read: while behind too plan.needSpendable = NetworkRefreshService::spendableBalanceNeeded( state_, !pending_send_info_.empty() || !pending_opids_.empty() || !send_txids_.empty(), force_balance_refresh_); plan.prevTotalZat = last_total_balance_zat_; auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Core, *w, [this, rpc, tracePage, plan, forcedSeq, balanceTicket]() -> rpc::RPCWorker::MainCb { AppRefreshRpcGateway refreshRpc(*rpc, traceSource(tracePage, "Core refresh")); auto result = NetworkRefreshService::collectCoreRefreshResult(refreshRpc, plan); return [this, result, forcedSeq, balanceTicket]() { try { if (result.blockchainOk) { // The scan budget shrinks while the node is behind (every chain read, not only those // that carried a balance). int lag = 0; if (result.blocks && result.longestChain && *result.longestChain > 0) lag = std::max(0, *result.longestChain - *result.blocks); scan_governor_.setTipLag(lag); } if (balanceTicket) { // The balance is current for what the worker saw just before it: its tip and mempool. services::ScanTicket t = balanceTicket; if (result.bestBlockHash) t.basis.tipHash = *result.bestBlockHash; if (result.mempoolFetched) { t.basis.mempoolKnown = result.mempoolOk; t.basis.mempoolTxs = result.mempoolTxs; t.basis.mempoolBytes = result.mempoolBytes; } const auto outcome = !result.balanceAttempted ? services::ScanOutcome::Skipped : result.balanceOk ? services::ScanOutcome::Ok : result.balanceErrKind == rpc::RpcFailureKind::ReplyTimeout ? services::ScanOutcome::TimedOut : services::ScanOutcome::Failed; scan_governor_.complete(t, result.balanceScanMs, outcome, scanClockNow()); } if (result.balanceAttempted) state_.balance_last_attempt_failed = !result.balanceOk; const std::string prevTipHash = state_.sync.best_blockhash; NetworkRefreshService::applyCoreRefreshResult(state_, result, std::time(nullptr)); // The chat note view waits a moment after a new tip: the wallet notifier stores a block's txs // ~1 s after it connects. if (state_.sync.best_blockhash != prevTipHash) tip_changed_at_ = scanClockNow(); if (result.blockchainOk && result.longestChain && *result.longestChain > 0) scan_gate_.noteTipKnown(); if (result.balanceOk) { // Fresh aggregates: re-apply the pending sends' debits to them. The per-address // balances are the address scan's to refresh and re-debit. applyPendingSendBalanceDeltas(true, false); } last_total_balance_zat_ = NetworkRefreshService::nextTotalBaseline(result, last_total_balance_zat_); if (forcedSeq != 0 && result.balanceAttempted && forcedSeq == force_balance_seq_) force_balance_refresh_ = false; if (result.mempoolFetched) { mempool_known_ = result.mempoolOk; mempool_txs_ = result.mempoolTxs; mempool_bytes_ = result.mempoolBytes; } // No balance call, but nothing the balance depends on changed since the last one: what's // shown is current (the staleness badge counts from here). if (!result.balanceAttempted && result.blockchainOk && state_.last_balance_update != 0 && scan_governor_.signalsMatch(services::ScanStream::Balance, currentScanSignals())) state_.balance_verified_at = std::time(nullptr); // Sticky-behind hysteresis: hold the low-impact profile for kSyncSettleSeconds after the // LAST time we read "behind" — not only on the caught-up edge. While actually syncing we // re-arm every Core tick, so the profile stays engaged continuously; once we truly reach // the tip (within the 2-block tolerance in applyCoreRefreshResult) we stop re-arming and it // lapses after the window. This is what breaks the near-tip feedback loop: a single stale or // optimistic "caught up" reading can no longer immediately unleash the heavy O(mapWallet) // scans and re-starve the tail (which dropped us behind again → oscillation). With the // scans suppressed, kSyncProfile keeps sync detection itself cheap, so we keep getting fresh // "behind" readings and stay latched until genuinely caught up. Window must exceed the // worst-case Core-refresh interval so it can't lapse between two behind readings mid-catchup. const bool nowSyncing = state_.sync.syncing; if (nowSyncing || was_core_syncing_) { sync_settle_until_ = std::time(nullptr) + static_cast(services::RefreshScheduler::kSyncSettleSeconds); } was_core_syncing_ = nowSyncing; // 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. noteDaemonRpcResult(result.blockchainOk || result.balanceOk, result.blockchainOk ? result.balanceErrKind : result.blockchainErrKind); constexpr int kCoreFailuresBeforeDisconnect = 3; if (!state_.warming_up) { if (!result.balanceOk && !result.blockchainOk) { // Only failures that put the connection itself in doubt count. A node that is merely // busy (reply timeouts, a full queue, calls held back) is still there: tearing down // then wiped the wallet state and restarted the connect probes that fill its queue. if (rpc::rpcKindMeansLost(result.blockchainErrKind) && ++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; } } // Probe the daemon's auto-shield status once per connection — independent of our own // toggle/balance — so both the defer-gate below and the Settings UI (O1) can read it. // Only when synced (daemon past warmup). Fail-closed: a pre-1.3.0 daemon lacks the RPC, // so the probe leaves active=false and the wallet keeps shielding client-side. if (result.balanceOk && !state_.sync.syncing && !daemon_autoshield_probed_ && worker_ && !daemon_autoshield_probe_inflight_.exchange(true)) { worker_->post([this]() -> rpc::RPCWorker::MainCb { bool active = false, seedRecoverable = false; std::string addr, reason; try { auto st = rpc_->call("z_autoshieldstatus", json::array()); if (st.is_object()) { if (st.contains("autoshield")) active = st["autoshield"].get(); if (st.contains("autoshieldaddress")) addr = st["autoshieldaddress"].get(); if (st.contains("disabled_reason")) reason = st["disabled_reason"].get(); if (st.contains("seed_recoverable")) seedRecoverable = st["seed_recoverable"].get(); } } catch (...) {} // pre-1.3.0 daemon: no such method — leave defaults (inactive) return [this, active, addr, reason, seedRecoverable]() { daemon_autoshield_active_ = active; daemon_autoshield_address_ = addr; daemon_autoshield_disabled_reason_ = reason; daemon_autoshield_seed_recoverable_ = seedRecoverable; daemon_autoshield_probed_ = true; daemon_autoshield_probe_inflight_ = false; }; }); } // Auto-shield transparent funds — but defer to the daemon's own coinbase auto-shielder // (v1.3.0+) when it's active, so we don't double-shield and split funds across z-addrs. const bool autoShieldEligible = result.balanceOk && settings_ && settings_->getAutoShield() && state_.spendableTransparentBalance > 0.0001 && !state_.sync.syncing; if (autoShieldEligible && daemon_autoshield_probed_ && !daemon_autoshield_active_ && !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_.spendableTransparentBalance, 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 (unforced). trackOperation(opid, services::NetworkRefreshService::OperationOrigin::Auto); } }; }); } else { auto_shield_pending_ = false; } } } catch (const std::exception& e) { DEBUG_LOGF("[refreshCoreData] callback error: %s\n", e.what()); } }; }, 3); if (!enqueued.enqueued && balanceTicket) scan_governor_.cancel(balanceTicket); // The first read while the node catches up is tried once per connection (a failed one would otherwise be // re-sent after every busy period, a wallet-wide scan into the catch-up each time). if (enqueued.enqueued && plan.evenIfBehind && balanceIn.syncing) first_balance_read_tried_ = true; } // ============================================================================ // Address Data: z/t address lists + per-address balances // ============================================================================ void App::refreshAddressData(services::ScanTicket ticket) { if (!worker_ || !rpc_ || !state_.connected || network_refresh_.jobInProgress(services::NetworkRefreshService::Job::Addresses)) { if (ticket) scan_governor_.cancel(ticket); // not sent after all return; } // The hard gates live here, so no caller (tab entry, refreshData, an import, the history's re-route) can // send the wallet-wide scan into a node that is syncing, warming up, rescanning or busy, into a locked // wallet, while the window is in the background, or before the connect gate opens. A stale list still // loads, without the balances (list-only, in the background too), and the session's first balances are // read once even while the node catches up. Refused work keeps its flags: the periodic refresh, or // leaving the held profile, retries it. const services::ScanTier tier = services::planAddressScan(scanContext(), addressListStale(), !address_balances_loaded_); if (tier == services::ScanTier::None || std::chrono::steady_clock::now() < address_retry_not_before_) { if (ticket) scan_governor_.cancel(ticket); return; } const bool listOnly = tier == services::ScanTier::ListOnly; if (listOnly) { if (ticket) scan_governor_.cancel(ticket); // no wallet scan: nothing to charge ticket = {}; } else if (!ticket) { // An explicit refresh (a dirty set, a send in progress, a stale list). Never held back, but charged // to the scan budget like the routine ones. ticket = scan_governor_.beginExplicit(services::ScanStream::Addresses, currentScanSignals(), scanClockNow()); } const std::size_t previousAddressCount = state_.z_addresses.size() + state_.t_addresses.size(); const std::string previousWalletIdentity = transactionHistoryCacheWalletIdentity(); const auto zList = shieldedAddressList(); std::unordered_set previousZ(zList.begin(), zList.end()); auto addressSnapshot = address_validation_cache_dirty_ ? NetworkRefreshService::AddressRefreshSnapshot{} : NetworkRefreshService::buildAddressRefreshSnapshot(state_); ui::NavPage tracePage = current_page_; const std::uint64_t addrDirtyGen = addr_dirty_generation_; // The tip the chat note view may claim from this pass: only one the wallet notifier has caught up with. const std::int64_t chatFeedTip = scanClockNow() - tip_changed_at_ >= services::kChatTipSettleSec ? state_.sync.blocks : -1; const std::uint64_t chatSpendSeq = chat_spend_seq_; addr_pass_spend_seq_ = static_cast(chatSpendSeq); auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Addresses, *worker_, [this, previousAddressCount, previousWalletIdentity, previousZ = std::move(previousZ), addressSnapshot = std::move(addressSnapshot), tracePage, ticket, listOnly, addrDirtyGen, chatFeedTip, chatSpendSeq]() -> rpc::RPCWorker::MainCb { AppRefreshRpcGateway refreshRpc(*rpc_, traceSource(tracePage, listOnly ? "Address list" : "Address refresh")); auto result = NetworkRefreshService::collectAddressRefreshResult(refreshRpc, addressSnapshot, listOnly); return [this, previousAddressCount, previousWalletIdentity, previousZ, ticket, listOnly, addrDirtyGen, chatFeedTip, chatSpendSeq, result = std::move(result)]() mutable { if (addr_pass_spend_seq_ == static_cast(chatSpendSeq)) addr_pass_spend_seq_ = -1; const bool addrListOk = result.addressListOk; // capture before the move // What was applied: a full pass whose balance scan failed degrades to list-only (it keeps the // previous balances rather than writing the zeros of the calls that didn't answer). const bool appliedListOnly = result.listOnly; const double scanMsAtApply = result.scanMs; if (ticket) { scan_governor_.complete(ticket, result.scanMs, (addrListOk && result.balancesOk) ? services::ScanOutcome::Ok : result.replyTimedOut ? services::ScanOutcome::TimedOut : services::ScanOutcome::Failed, scanClockNow()); } if (!addrListOk) { // The enumeration failed (-28, a timeout, a missing RPC): its lists may be falsely short. Keep // the previous lists and the flags, and hold address refreshes for a while (the history keeps // refreshing with the kept list meanwhile). address_retry_not_before_ = std::chrono::steady_clock::now() + std::chrono::duration_cast(std::chrono::duration( services::addressRetryDelaySeconds(result.scanMs, result.nodeBusy || result.replyTimedOut))); DEBUG_LOGF("[addr] address enumeration failed; keeping the previous list\n"); maybeFinishTransactionSendProgress(); return; } // #3: this scan already listed the wallet's unspent notes — feed the chat send-budget from it // (dedups the dedicated chat z_listunspent). Grab it before `result` is moved below. auto unspentNotes = std::move(result.unspentNotes); NetworkRefreshService::applyAddressRefreshResult(state_, std::move(result)); // list-only keeps the balances if (!appliedListOnly) { // Not from a pass sent before a commit marked since: its notes predate it (the owed pass feeds it). if (addr_dirty_generation_ == addrDirtyGen) updateChatNoteBudgetFromUnspent(unspentNotes, chatFeedTip, chatSpendSeq); applyPendingSendBalanceDeltas(false); // A mutation marked after this pass was sent isn't in it (keeps the send progress until the // post-commit balances). A list-only pass still owes the full one. if (addr_dirty_generation_ == addrDirtyGen) addresses_dirty_ = false; address_balances_loaded_ = true; } else if (!listOnly) { // The balance scan failed: retry after a hold scaled by what it cost (the abandoned call keeps // running in the daemon, so this is a lower bound), not the busy floor: explicit passes bypass // the scan budget, and this one would otherwise go again after every busy period. address_retry_not_before_ = std::chrono::steady_clock::now() + std::chrono::duration_cast( std::chrono::duration(services::addressRetryDelaySeconds(scanMsAtApply))); } state_.last_address_update = std::time(nullptr); address_validation_cache_dirty_ = false; address_list_dirty_ = true; const std::size_t currentAddressCount = state_.z_addresses.size() + state_.t_addresses.size(); const bool addressSetChanged = currentAddressCount != previousAddressCount || transactionHistoryCacheWalletIdentity() != previousWalletIdentity; // Drop removed addresses' scan heights; the others stay valid (a changed set used to wipe // every height, so one new address re-scanned the whole wallet). A z-address we never // scanned that wasn't listed before (a key imported from the Console, say) may bring // older sends with it, which a windowed sweep can't reach: sweep the whole wallet once. pruneShieldedHistoryScanProgress(); // New: in neither the list at dispatch (GUI-created addresses are added to it before the // refresh, and have no history) nor the last good listing (a blank list in between proves // nothing), and never scanned. bool newUnscannedZ = false; std::unordered_set listedZ; for (const auto& address : state_.z_addresses) { if (address.address.empty()) continue; listedZ.insert(address.address); if (!previousZ.count(address.address) && !history_listed_z_.count(address.address) && !shielded_history_scan_heights_.count(address.address)) newUnscannedZ = true; } if (newUnscannedZ && (!previousZ.empty() || !history_listed_z_.empty())) { history_sent_scan_height_ = -1; history_full_sweep_failures_ = 0; history_full_sweep_retry_at_ = 0; ++history_sent_epoch_; } history_listed_z_ = std::move(listedZ); if (addressSetChanged) scan_governor_.invalidate(services::ScanStream::Balance); // the totals moved if (state_.transactions.empty() || addressSetChanged) { markTransactionsDirty(); last_tx_block_height_ = -1; network_refresh_.markDue(services::NetworkRefreshService::Timer::Transactions); } // An address refresh changes what the snapshot holds only through the wallet identity: re-key it // under the new one at once when the set changed (the rows are the history refresh's business). if (state_.transactions.empty()) loadTransactionHistoryCacheIfAvailable(); else if (addressSetChanged) storeTransactionHistoryCacheIfAvailable(data::HistoryStoreMode::Now); // Hints compare the notes with the history on screen: only once it's there (the cache loaded // just above, or a pass is done). Before that every note looks missing, and the first pass scans // every address anyway. if (!state_.transactions.empty() || initial_history_scan_complete_) noteHistoryHintsFromUnspent(unspentNotes); // A list-only pass owes the full one: due as soon as the scans may run. if (listOnly && services::wholeWalletScanAllowed(scanContext())) network_refresh_.markDue(services::NetworkRefreshService::Timer::Addresses); maybeFinishTransactionSendProgress(); // Addresses (hence identity + count) are now known — refresh the wallet index's cached // metadata for the active wallet. Throttled: upsert() only writes on a real change. updateWalletIndexForActiveWallet(/*markOpened=*/false); }; }, 3); if (!enqueued.enqueued) scan_governor_.cancel(ticket); } // ============================================================================ // Transaction Data: transparent + shielded receives + z_viewtransaction enrichment // ============================================================================ bool App::refreshTransactionData() { using services::ScanStream; using services::TxCycle; // The one entry point for history refreshes (the periodic timer, connect, tab entry, the address // callback's re-route), so every caller gets the same rules. Wallet-wide scans hold the daemon's // cs_main: never while it can't take them. if (!worker_ || !rpc_ || !rpc_->isConnected() || !state_.connected) return false; // The hard gates (syncing, the connect gate, locked, warming up, rescanning, busy, background): nothing // runs, the dirty flags stay set, and the periodic refresh or leaving the held profile retries. if (!services::wholeWalletScanAllowed(scanContext())) return false; // A cycle in flight: the enqueue would refuse this one. if (network_refresh_.jobInProgress(services::NetworkRefreshService::Job::Transactions)) return false; const int tip = state_.sync.blocks; const auto work = historyScanWork(); services::TxCycleInputs in; in.neverFetched = last_tx_block_height_ < 0; in.newTip = tip != last_tx_block_height_; in.dirty = transactions_dirty_; in.sendTracking = !send_txids_.empty(); in.explicitScanWork = work.explicitWork; in.routineScanWork = work.routineWork; in.syncing = effectivelySyncing(); in.heightKnown = tip > 0; const bool walletPage = currentPageNeedsWalletDataRefresh(); // C119: the Mining tab shows history-derived earnings but only gets the cheap per-block Recent poll. const bool miningPoll = current_page_ == ui::NavPage::Mining && !walletPage; in.recentDue = walletPage && shouldRefreshRecentTransactions(); if (miningPoll) { using Refresh = services::NetworkRefreshService; in = services::shapeTxCycleForMiningPage(in); services::MiningTxPollInputs mp; mp.tip = tip; mp.lastPolledTip = mining_tx_poll_block_; mp.txAgeDue = network_refresh_.isDue(Refresh::Timer::TxAge); mp.historyLoaded = last_tx_block_height_ >= 0; mp.dirty = transactions_dirty_; mp.addressesDirty = addresses_dirty_; in.recentDue = services::miningTxPollDue(mp); } // What this cycle is without and with a routine wallet-wide scan: the scan budget (the governor) // decides between the two. in.gateOpen = false; const TxCycle held = services::decideTxCycle(in); in.gateOpen = true; TxCycle open = services::decideTxCycle(in); // Pool payouts are shielded receives (a z_listreceivedbyaddress probe finds them); a solo miner's earnings // are transparent coinbase rows, so on the Mining tab the probe runs only while the pool miner is. if (miningPoll && open == TxCycle::RecentWithProbe && !(xmrig_manager_ && xmrig_manager_->isRunning())) open = TxCycle::Recent; const double now = scanClockNow(); const auto sig = currentScanSignals(); TxCycle cycle = held; bool routineAllowed = false; bool heldBack = false; services::ScanTicket ticket; // The probe only when something changed since its last run (tip, mempool, a wallet mutation). const bool wantsProbe = open == TxCycle::RecentWithProbe && scan_governor_.needed(ScanStream::RecentTx, sig, now); if (open == TxCycle::Full || wantsProbe) { // Explicit work runs anyway (held is Full then); admission lets routine work ride along in it. ticket = scan_governor_.tryAdmit(open == TxCycle::Full ? ScanStream::History : ScanStream::RecentTx, currentScanVisibility().history, sig, now); if (ticket) { cycle = open; routineAllowed = true; } else { heldBack = held != open; } } switch (cycle) { case TxCycle::None: return heldBack; case TxCycle::Recent: startRecentTransactionPoll(false, {}); if (miningPoll && network_refresh_.jobInProgress(services::NetworkRefreshService::Job::Transactions)) mining_tx_poll_block_ = tip; // dispatched (the job guard was clear on entry): one poll per block return heldBack; case TxCycle::RecentWithProbe: startRecentTransactionPoll(true, ticket); if (miningPoll && network_refresh_.jobInProgress(services::NetworkRefreshService::Job::Transactions)) mining_tx_poll_block_ = tip; return false; case TxCycle::Full: case TxCycle::Light: // While an address retry hold runs, scan the history with the kept list instead of spinning. if (addresses_dirty_ && std::chrono::steady_clock::now() >= address_retry_not_before_) { if (ticket) scan_governor_.cancel(ticket); // The history needs the current address list; the address callback marks it due again. refreshAddressData(); network_refresh_.markDue(services::NetworkRefreshService::Timer::Transactions); return false; } // Explicit work, a mutation's Light cycle, the first load: never held back, but charged. Only the first // load's (or an invalidation's) pass does the routine work too: the others leave History waiting for it, // and their cost out of its estimate. if (!ticket) { ticket = scan_governor_.beginExplicit(ScanStream::History, sig, now, cycle == TxCycle::Full && !initial_history_scan_complete_); } startTransactionCycle(cycle == TxCycle::Full, routineAllowed, ticket); return heldBack; } return heldBack; } void App::startTransactionCycle(bool full, bool routineAllowed, services::ScanTicket ticket) { 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.transparentRowHint = history_transparent_rows_; transactionSnapshot.reuseTransparentRows = !transactions_dirty_ && currentBlocks > 0 && history_transparent_tip_ == currentBlocks && !state_.transactions.empty(); transactionSnapshot.enrichBacklogTxids = history_enrich_backlog_; if (full) { const auto& addresses = transactionSnapshot.shieldedAddresses; const bool wasActive = history_pass_.active(); history_pass_.begin(currentBlocks, addresses, shielded_history_scan_heights_, services::historyStaleAfterBlocks(addresses.size(), history_addr_scan_ms_), initial_history_scan_complete_, routineAllowed); if (!wasActive && history_pass_.active()) shielded_history_scan_cursor_ = 0; // a new pass: from the top transactionSnapshot.shieldedScanFloorHeight = history_pass_.scanFloor(currentBlocks, routineAllowed); transactionSnapshot.shieldedScanStartIndex = shielded_history_scan_cursor_; transactionSnapshot.maxShieldedReceiveScans = shieldedReceiveScanBudget(current_page_); auto listed = [&](const std::string& address) { return std::find(addresses.begin(), addresses.end(), address) != addresses.end(); }; // Never-scanned addresses first, whatever the cursor (the explicit work: a new or imported // address costs one scan, not a pass). for (const auto& address : addresses) { if (!shielded_history_scan_heights_.count(address)) transactionSnapshot.priorityScanFloors[address] = currentBlocks; } if (routineAllowed) { // Addresses holding notes the history doesn't show yet (noteHistoryHintsFromUnspent). for (const auto& [address, floor] : history_hint_floors_) { if (listed(address)) transactionSnapshot.priorityScanFloors.emplace(address, floor); } // The chat reply address once per new tip, so messages also surface off the Chat tab. if (!lite_wallet_ && chat::hushChatFeatureEnabledAtBuild() && chat_service_.hasIdentity() && settings_) { const std::string chatZ = settings_->getChatReplyZaddr(); if (!chatZ.empty() && listed(chatZ)) transactionSnapshot.priorityScanFloors[chatZ] = currentBlocks; } } transactionSnapshot.sentScanFromHeight = history_sent_scan_height_; // A windowed sweep mid-pass too, now and then, so an untracked send (the Console, another wallet on // the same seed) doesn't wait for a whole pass on a large wallet: at most every quarter of the pass // cadence, and only in cycles the throttle allows. const int staleAfter = services::historyStaleAfterBlocks(addresses.size(), history_addr_scan_ms_); transactionSnapshot.allowMidPassSentSweep = routineAllowed && history_sent_scan_height_ >= 0 && history_sent_scan_height_ < currentBlocks && currentBlocks - history_sent_scan_height_ >= std::max(services::kHistoryMinStaleBlocks, staleAfter / 4); // The sweep runs once per tip, when the throttle allows (or the first load is still running), // and backs off after whole-wallet sweeps that failed. transactionSnapshot.skipSentSweep = (!routineAllowed && initial_history_scan_complete_) || (history_sent_scan_height_ >= 0 && history_sent_scan_height_ == currentBlocks) || (history_sent_scan_height_ < 0 && std::time(nullptr) < history_full_sweep_retry_at_); } else { // Light: rows now (a send, a mutation), no wallet-wide z-scans. transactionSnapshot.skipShieldedScans = true; transactionSnapshot.skipSentSweep = true; transactionSnapshot.shieldedScanStartIndex = shielded_history_scan_cursor_; } const std::uint64_t scanEpoch = history_scan_epoch_; const std::uint64_t sentEpoch = history_sent_epoch_; const std::uint64_t dirtyGen = tx_dirty_generation_; const bool snapshotHadZ = !transactionSnapshot.shieldedAddresses.empty(); ui::NavPage tracePage = current_page_; auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Transactions, *worker_, [this, currentBlocks, full, transactionSnapshot = std::move(transactionSnapshot), tracePage, scanEpoch, sentEpoch, dirtyGen, snapshotHadZ, ticket]() -> rpc::RPCWorker::MainCb { AppRefreshRpcGateway refreshRpc(*rpc_, traceSource(tracePage, full ? "Transaction refresh" : "Transaction refresh (light)"), this); auto result = NetworkRefreshService::collectTransactionRefreshResult( refreshRpc, transactionSnapshot, currentBlocks, MAX_VIEWTX_PER_CYCLE); return [this, full, scanEpoch, sentEpoch, dirtyGen, snapshotHadZ, ticket, result = std::move(result)]() mutable { const std::time_t now = std::time(nullptr); const int resultTip = result.blockHeight; const std::size_t attempted = result.shieldedAddressesScanned; const std::size_t succeeded = result.shieldedScansSucceeded; const bool sameScanEpoch = scanEpoch == history_scan_epoch_; // Only history_sent_epoch_ invalidates the sent side (watermark, backoff, backlog): a plain // refresh keeps the sent scan, so it doesn't throw away an in-flight whole-wallet sweep. const bool sameSentEpoch = sentEpoch == history_sent_epoch_; // Charge the scan budget with this cycle's cost, except a whole-wallet sweep, which has its own // cadence (once per connect or full invalidation, with a backoff): charging up to 120 s of it held // routine scans back long after every connect. { double charged = result.scanMs; if (result.sentSweepRan && result.sentSweepFull) charged = std::max(1.0, result.scanMs - result.sentSweepMs); scan_governor_.complete(ticket, charged, !result.rpcError ? services::ScanOutcome::Ok : result.replyTimedOut ? services::ScanOutcome::TimedOut : services::ScanOutcome::Failed, scanClockNow()); } if (sameScanEpoch) { if (succeeded > 0) { history_addr_scan_ms_ = services::updateScanCostEstimateMs( history_addr_scan_ms_, result.receiveScanOkMs / static_cast(succeeded)); } if (result.transparentChainOk) { history_transparent_rows_ = result.transparentRowCount; history_transparent_tip_ = resultTip; } } if (sameSentEpoch) history_enrich_backlog_ = std::move(result.unenrichedTxids); if (sameSentEpoch) { if (result.sentSweepOk && result.sentSweepHeight > 0) { history_sent_scan_height_ = result.sentSweepHeight; history_full_sweep_failures_ = 0; history_full_sweep_retry_at_ = 0; } else if (result.sentSweepRan && result.sentSweepFull && services::fullSentSweepFailureCounts(result.sentSweepErrKind)) { ++history_full_sweep_failures_; history_full_sweep_retry_at_ = now + services::fullSentSweepRetryDelaySec(history_full_sweep_failures_); } } 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_ }; // HushChat: decrypt & thread any harvested chat memos BEFORE `result` is moved into // applyTransactionRefreshResult. No-op when the feature is off or no identity is set; // the store dedups (txid+position) so the full + recent refresh paths ingest safely. if (chat::hushChatFeatureEnabledAtBuild() && chat_service_.hasIdentity() && !result.hushChatMetadata.empty()) { std::unordered_map chatTxTimes; for (const auto& tx : result.transactions) chatTxTimes[tx.txid] = tx.timestamp; std::vector newChatCids; chat_service_.ingest(result.hushChatMetadata, chatTxTimes, std::time(nullptr), &newChatCids); // A new message un-hides a hidden conversation (you can't un-receive), so nothing is lost. if (settings_) { bool unhid = false; for (const auto& cid : newChatCids) if (settings_->isChatHidden(cid)) { settings_->setChatHidden(cid, false); unhid = true; } if (unhid) settings_->save(); } // Toast when a NON-muted conversation received a new message and we're off the Chat tab. // Gate on the ingest-reported cids, not a seen-watermark delta — the latter can be swallowed // by block-time vs wall-clock (echo) skew (Q10 + Q4). if (current_page_ != ui::NavPage::Chat && std::any_of(newChatCids.begin(), newChatCids.end(), [this](const std::string& cid){ return !(settings_ && settings_->isChatMuted(cid)); })) ui::Notifications::instance().info(TR("chat_new_message_toast")); } NetworkRefreshService::applyTransactionRefreshResult( state_, cacheUpdate, std::move(result), std::time(nullptr)); pruneTrackedSendTxids(); // Pass bookkeeping on the main thread, from the merged heights. Not from a cycle whose address // list came back blank (enumeration failed): it scanned nothing and knows nothing. if (full && sameScanEpoch && (snapshotHadZ || shielded_history_scan_heights_.empty())) { shielded_history_scan_heights_ = std::move(shieldedScanHeights); shielded_history_scan_cursor_ = nextShieldedScanStartIndex; const auto addresses = shieldedAddressList(); // A list blanked by a failed enumeration after dispatch proves nothing: don't end the pass // or call the first load complete from it. if (!(snapshotHadZ && addresses.empty())) { history_pass_.finish(resultTip, addresses, shielded_history_scan_heights_, attempted, succeeded); for (auto it = history_hint_floors_.begin(); it != history_hint_floors_.end();) { const auto height = shielded_history_scan_heights_.find(it->first); if (height != shielded_history_scan_heights_.end() && height->second >= it->second) it = history_hint_floors_.erase(it); else ++it; } const auto work = historyScanWork(); if (work.total > 0 && work.missing == 0 && !(history_pass_.active() && history_pass_.explicitPass())) initial_history_scan_complete_ = true; } } storeTransactionHistoryCacheIfAvailable(); // A mutation that landed while this cycle ran isn't covered by it: stay dirty for it. if (tx_dirty_generation_ == dirtyGen) transactions_dirty_ = false; maybeFinishTransactionSendProgress(); }; }, 3); if (!enqueued.enqueued) { scan_governor_.cancel(ticket); return; } network_refresh_.resetTxAge(); } // Backstop for tracked sends that never settle (expired or dropped from the mempool, an RPC error, a // txid the wallet can't view): stop tracking after kTrackedSendTtlBlocks, since every tracked id keeps // forcing full history refreshes past the scan throttle. Anything that does confirm later is still // found and enriched by the untracked history sweep (z_listsentbyaddress). void App::pruneTrackedSendTxids() { const int tip = state_.sync.blocks; for (auto it = send_txid_heights_.begin(); it != send_txid_heights_.end();) { if (!send_txids_.count(it->first)) { // already resolved by a refresh it = send_txid_heights_.erase(it); continue; } if (it->second <= 0 && tip > 0) it->second = tip; // added before the tip was known if (tip > 0 && it->second > 0 && tip - it->second > kTrackedSendTtlBlocks) { DEBUG_LOGF("[tx] stop tracking send %s: unsettled after %d blocks\n", it->first.c_str(), tip - it->second); send_txids_.erase(it->first); it = send_txid_heights_.erase(it); continue; } ++it; } } void App::startRecentTransactionPoll(bool probe, services::ScanTicket ticket) { 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_; // Between blocks: recent listtransactions rows, plus (when the scan budget allows) one receive scan: the // oldest-scanned address this rotation hasn't probed yet. It doesn't advance the pass cursor. transactionSnapshot.skipShieldedScans = !probe; transactionSnapshot.maxShieldedReceiveScans = 1; transactionSnapshot.shieldedScanStartIndex = 0; std::string probeTarget; if (probe) { const auto& addresses = transactionSnapshot.shieldedAddresses; probeTarget = recent_probe_rotation_.next(addresses, shielded_history_scan_heights_); const auto it = std::find(addresses.begin(), addresses.end(), probeTarget); if (it != addresses.end()) transactionSnapshot.shieldedScanStartIndex = static_cast(it - addresses.begin()); } const std::uint64_t scanEpoch = history_scan_epoch_; ui::NavPage tracePage = current_page_; auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Transactions, *worker_, [this, currentBlocks, transactionSnapshot = std::move(transactionSnapshot), tracePage, probe, scanEpoch, ticket, probeTarget]() -> rpc::RPCWorker::MainCb { AppRefreshRpcGateway refreshRpc(*rpc_, traceSource(tracePage, "Recent transaction poll")); auto result = NetworkRefreshService::collectRecentTransactionRefreshResult( refreshRpc, transactionSnapshot, currentBlocks); return [this, probe, scanEpoch, ticket, probeTarget, result = std::move(result)]() mutable { // The poll doesn't consume the tip: newTip stays set until a Full or Light cycle applies at it, // so the Full cycle still runs once the throttle allows. (Read here, not at dispatch, so a reset // to -1 meanwhile is kept.) int pollTip = last_tx_block_height_; if (ticket) { // The probe is a wallet-wide scan: charged to the scan budget. Partial until the rotation has // probed every z-address since what it watches changed (the change may be at any of them). auto outcome = !result.rpcError ? services::ScanOutcome::Ok : result.replyTimedOut ? services::ScanOutcome::TimedOut : services::ScanOutcome::Failed; if (outcome == services::ScanOutcome::Ok && probe) { if (scanEpoch == history_scan_epoch_ && result.shieldedScansSucceeded > 0 && !probeTarget.empty()) recent_probe_rotation_.probed(probeTarget); if (!recent_probe_rotation_.complete()) outcome = services::ScanOutcome::Partial; } scan_governor_.complete(ticket, result.scanMs, outcome, scanClockNow()); } if (probe && result.shieldedAddressesScanned > 0) { if (scanEpoch == history_scan_epoch_ && result.shieldedScansSucceeded > 0) { history_addr_scan_ms_ = services::updateScanCostEstimateMs( history_addr_scan_ms_, result.receiveScanOkMs / static_cast(result.shieldedScansSucceeded)); } } auto shieldedScanHeights = std::move(result.shieldedScanHeights); NetworkRefreshService::TransactionCacheUpdate cacheUpdate{ viewtx_cache_, send_txids_, confirmed_tx_cache_, confirmed_tx_ids_, confirmed_cache_block_, pollTip }; // HushChat: decrypt & thread any harvested chat memos BEFORE `result` is moved (see the // full-refresh path above for rationale; the store dedups across both paths). if (chat::hushChatFeatureEnabledAtBuild() && chat_service_.hasIdentity() && !result.hushChatMetadata.empty()) { std::unordered_map chatTxTimes; for (const auto& tx : result.transactions) chatTxTimes[tx.txid] = tx.timestamp; std::vector newChatCids; chat_service_.ingest(result.hushChatMetadata, chatTxTimes, std::time(nullptr), &newChatCids); // A new message un-hides a hidden conversation (you can't un-receive), so nothing is lost. if (settings_) { bool unhid = false; for (const auto& cid : newChatCids) if (settings_->isChatHidden(cid)) { settings_->setChatHidden(cid, false); unhid = true; } if (unhid) settings_->save(); } // Toast when a NON-muted conversation received a new message and we're off the Chat tab. // Gate on the ingest-reported cids, not a seen-watermark delta — the latter can be swallowed // by block-time vs wall-clock (echo) skew (Q10 + Q4). if (current_page_ != ui::NavPage::Chat && std::any_of(newChatCids.begin(), newChatCids.end(), [this](const std::string& cid){ return !(settings_ && settings_->isChatMuted(cid)); })) ui::Notifications::instance().info(TR("chat_new_message_toast")); } NetworkRefreshService::applyTransactionRefreshResult( state_, cacheUpdate, std::move(result), std::time(nullptr)); if (scanEpoch == history_scan_epoch_) shielded_history_scan_heights_ = std::move(shieldedScanHeights); storeTransactionHistoryCacheIfAvailable(); maybeFinishTransactionSendProgress(); }; }, 3); if (!enqueued.enqueued) { if (ticket) scan_governor_.cancel(ticket); 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); reconcileEncryptionPending(result); // C88: covers the slow-path fallback and the lock-state polls if (state_.isLocked()) { lockTransactionHistoryCacheSession(); } else if (state_.transactions.empty()) { loadTransactionHistoryCacheIfAvailable(); } else { storeTransactionHistoryCacheIfAvailable(); } }; }, 3); return enqueued.enqueued; } void App::refreshBalance() { refreshCoreData(); } void App::refreshAddresses() { markAddressesDirty(); 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; // 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; // While syncing, don't poll getmininginfo (another cs_main contender) unless the user is // actually on the Mining tab or mining — mining stats are irrelevant mid-sync, and this keeps // the sync throttle complete (the mining poll runs off the separate 1s Fast timer, so the // sync-profile intervals don't otherwise cover it). if (state_.sync.syncing && !includeLocalHashrate) return; ui::NavPage tracePage = current_page_; // Daemon memory is sampled off the UI thread by getDaemonMemoryUsageMB() (Mining / Peers tab); the poll // only carries the last sample into state.mining.daemon_memory_mb (no I/O here). const double daemonMemMb = daemon_mem_sampler_.value(); 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; } namespace { // Market-data GET options for the HTTP lane: a short connect timeout (a blocked endpoint must not hold the // lane for 15 s), a bounded total, and abortable at shutdown through App::http_cancel_. Deliberate change: // the CoinGecko chart and exchange candle GETs used the updaters' 15 s / 30 s. util::HttpGetOptions marketHttp(const std::atomic* cancel, long totalSec) { util::HttpGetOptions o; o.connectTimeoutSec = 5; o.timeoutSec = totalSec; o.cancel = cancel; return o; } } // namespace void App::refreshPrice() { // Skip if price fetching is disabled if (!settings_->getFetchPrices()) return; if (!http_worker_ || http_cancel_.load(std::memory_order_relaxed)) return; // A plain in-flight flag, not NetworkRefreshService::enqueue(Job::Price): that one is generation-gated, and // the resetJobs() of every disconnect would discard a good in-flight price (leaving price_loading set until // the next tick) for a fetch that has nothing to do with the daemon connection. if (price_fetch_in_flight_) return; price_fetch_in_flight_ = true; NetworkRefreshService::markPriceRefreshStarted(state_); http_worker_->post([this]() -> rpc::RPCWorker::MainCb { // --- HTTP lane: blocking GET to CoinGecko. Touches only http_cancel_ and locals. --- NetworkRefreshService::PriceHttpResult result; try { const char* const 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"; util::HttpGetResult r = util::httpGet(url, "[price]", marketHttp(&http_cancel_, 10)); NetworkRefreshService::PriceHttpResponse response{ r.transportOk, r.httpStatus, std::move(r.body), r.error}; 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()); } // Always hand back a callback: it is what clears price_fetch_in_flight_. return [this, result = std::move(result)]() mutable { price_fetch_in_flight_ = false; if (result.price) { NetworkRefreshService::applyPriceRefreshResult(state_, *result.price, std::chrono::steady_clock::now()); if (price_fetch_failed_) { // The price (60 s timer, no backoff) works again after failing: the network is back, so // retry the backed-off market fetches now rather than after up to ~12 min. Failure // counts stay, so an endpoint that keeps failing on its own still waits. price_fetch_failed_ = false; exchanges_backoff_.forceDue(); market_chart_backoff_.forceDue(); exchange_chart_backoff_.forceDue(); } } else { NetworkRefreshService::applyPriceRefreshFailure(state_, result.errorMessage); price_fetch_failed_ = true; } }; }); } void App::refreshMarketData() { refreshPrice(); refreshExchanges(); refreshMarketChart(); refreshExchangeChart(); } void App::retryMarketDataNow() { const auto now = std::chrono::steady_clock::now(); if (now >= market_manual_retry_at_) { // at most every 5 s, so click spam can't hammer the APIs market_manual_retry_at_ = now + std::chrono::seconds(5); exchanges_backoff_.forceDue(); market_chart_backoff_.forceDue(); exchange_chart_backoff_.forceDue(); } refreshMarketData(); } void App::refreshExchangeChart() { if (!settings_ || !settings_->getFetchPrices() || !http_worker_ || http_cancel_.load(std::memory_order_relaxed)) return; if (exchange_chart_fetch_in_flight_) return; // Resolve the selected pair from the same registry the Market tab renders (live tickers, else the // static fallback), matching exchange name THEN pair to disambiguate the same pair across venues. const auto& reg = state_.market.exchanges.empty() ? data::getExchangeRegistry() : state_.market.exchanges; const std::string selEx = settings_->getSelectedExchange(); const std::string selPair = settings_->getSelectedPair(); const data::ExchangePair* pair = nullptr; for (const auto& ex : reg) { if (!selEx.empty() && ex.name != selEx) continue; for (const auto& p : ex.pairs) if (p.displayName == selPair) { pair = &p; break; } if (pair) break; } if (!pair && !reg.empty() && !reg[0].pairs.empty()) pair = ®[0].pairs[0]; // default like the UI if (!pair) { state_.market.exchange_chart_active = false; exchange_chart_key_.clear(); return; } const std::string key = pair->identifier + ":" + pair->base + "/" + pair->quote; // No candle adapter for this venue -> draw the CoinGecko aggregate instead. if (pair->identifier.empty() || !data::hasExchangeCandleAdapter(pair->identifier)) { state_.market.exchange_chart_active = false; exchange_chart_key_.clear(); return; } // Already loaded for this pair and still fresh (candles move slowly). if (key == exchange_chart_key_ && state_.market.exchange_chart_active && std::chrono::steady_clock::now() - exchange_chart_last_fetch_ < std::chrono::minutes(30)) return; // Cached from a recent view of this pair -> load instantly, no re-fetch (switching back is snappy). if (auto it = exchange_chart_cache_.find(key); it != exchange_chart_cache_.end() && std::chrono::steady_clock::now() - it->second.fetchedAt < std::chrono::minutes(30)) { state_.market.exchange_chart_intraday = it->second.closeIntraday; state_.market.exchange_chart_daily = it->second.closeDaily; state_.market.exchange_ohlc_intraday = it->second.ohlcIntraday; state_.market.exchange_ohlc_daily = it->second.ohlcDaily; state_.market.exchange_chart_active = true; exchange_chart_key_ = key; exchange_chart_last_fetch_ = it->second.fetchedAt; return; } // Switching pairs: fall back to the aggregate until the new venue's candles arrive. Before the // backoff check, so switching back to a pair that is backing off doesn't keep showing the previous // pair's candles. if (key != exchange_chart_key_) state_.market.exchange_chart_active = false; // This pair's last fetch failed: wait out the backoff (switching to another pair still loads at once). if (key == exchange_chart_fail_key_ && !exchange_chart_backoff_.due(std::chrono::steady_clock::now())) return; exchange_chart_fetch_in_flight_ = true; const std::string identifier = pair->identifier, base = pair->base, quote = pair->quote; const std::time_t now = std::time(nullptr); const std::string urlIntra = data::buildExchangeCandleUrl(identifier, base, quote, data::CandleRange::Intraday, now); const std::string urlDaily = data::buildExchangeCandleUrl(identifier, base, quote, data::CandleRange::Daily, now); http_worker_->post([this, identifier, key, urlIntra, urlDaily]() -> rpc::RPCWorker::MainCb { // httpGetString verifies TLS and returns "" on any failure (parses to an empty series). HTTP lane: // touches only http_cancel_ and the captures. std::string bIntra = util::httpGetString(urlIntra, "[exch-chart]", marketHttp(&http_cancel_, 20)); std::string bDaily = util::httpGetString(urlDaily, "[exch-chart]", marketHttp(&http_cancel_, 20)); auto ohlcIntra = data::parseExchangeOHLC(identifier, bIntra); auto ohlcDaily = data::parseExchangeOHLC(identifier, bDaily); return [this, key, ohlcIntra = std::move(ohlcIntra), ohlcDaily = std::move(ohlcDaily)]() mutable { exchange_chart_fetch_in_flight_ = false; if (!ohlcDaily.empty() || !ohlcIntra.empty()) { // Build a cache entry (OHLC + derived close series for the line/change%), publish it to // the active buffer, and keep it so switching back to this pair later is instant. ExchangeChartCache e; e.ohlcIntraday = std::move(ohlcIntra); e.ohlcDaily = std::move(ohlcDaily); e.closeIntraday.reserve(e.ohlcIntraday.size()); e.closeDaily.reserve(e.ohlcDaily.size()); for (const auto& c : e.ohlcIntraday) e.closeIntraday.emplace_back(c.time, c.close); for (const auto& c : e.ohlcDaily) e.closeDaily.emplace_back(c.time, c.close); e.fetchedAt = std::chrono::steady_clock::now(); state_.market.exchange_chart_intraday = e.closeIntraday; state_.market.exchange_chart_daily = e.closeDaily; state_.market.exchange_ohlc_intraday = e.ohlcIntraday; state_.market.exchange_ohlc_daily = e.ohlcDaily; state_.market.exchange_chart_active = true; exchange_chart_key_ = key; exchange_chart_last_fetch_ = e.fetchedAt; if (exchange_chart_cache_.size() >= 16) exchange_chart_cache_.clear(); // bound it (many venues) exchange_chart_cache_[key] = std::move(e); if (key == exchange_chart_fail_key_) { exchange_chart_fail_key_.clear(); exchange_chart_backoff_.succeed(); } } else { // Both ranges empty: the venue's API failed or changed. Back off for this pair (another // pair still loads at once). A refresh of the pair on screen keeps its last candles; a // first load falls back to the aggregate chart. if (exchange_chart_fail_key_ != key) { exchange_chart_fail_key_ = key; exchange_chart_backoff_.succeed(); // a fresh failure count for this pair } exchange_chart_backoff_.fail(std::chrono::steady_clock::now(), util::FetchBackoff::randomJitter()); if (exchange_chart_key_ != key) { state_.market.exchange_chart_active = false; exchange_chart_key_.clear(); } } }; }); } void App::refreshMarketChart() { if (!settings_ || !settings_->getFetchPrices()) return; if (!http_worker_ || http_cancel_.load(std::memory_order_relaxed)) return; if (chart_fetch_in_flight_) return; const auto now = std::chrono::steady_clock::now(); // Historical data moves slowly — refresh at most every 30 minutes after the first fetch. if (state_.market.chart_loaded && now - state_.market.chart_last_fetch_time < std::chrono::minutes(30)) return; // The last attempt failed (or got only one range): wait out the backoff instead of retrying on the // next frame, as this is called every frame the Market tab is open. if (!market_chart_backoff_.due(now)) return; chart_fetch_in_flight_ = true; http_worker_->post([this]() -> rpc::RPCWorker::MainCb { // Two ranges give real granularity across every sparkline interval: // days=1 -> 5-minute samples (backs the "hour" interval) // days=365 -> daily samples (backs "day"/"week"/"month") // httpGetString verifies TLS and returns "" on any failure (parses to an empty series). std::string intradayBody = util::httpGetString( "https://api.coingecko.com/api/v3/coins/dragonx-2/market_chart?vs_currency=usd&days=1", "[market-chart]", marketHttp(&http_cancel_, 20)); std::string dailyBody = util::httpGetString( "https://api.coingecko.com/api/v3/coins/dragonx-2/market_chart?vs_currency=usd&days=365", "[market-chart]", marketHttp(&http_cancel_, 20)); auto intraday = NetworkRefreshService::parseCoinGeckoMarketChart(intradayBody); auto daily = NetworkRefreshService::parseCoinGeckoMarketChart(dailyBody); return [this, intraday = std::move(intraday), daily = std::move(daily)]() mutable { chart_fetch_in_flight_ = false; const bool gotIntraday = !intraday.empty(); const bool gotDaily = !daily.empty(); if (gotIntraday) state_.market.price_chart_intraday = std::move(intraday); if (gotDaily) state_.market.price_chart_daily = std::move(daily); if (gotIntraday && gotDaily) { // chart_loaded only with the timestamp: it arms the 30-minute gate above, which a partial // result must not (with no timestamp it would measure system uptime instead). state_.market.chart_loaded = true; state_.market.chart_last_fetch_time = std::chrono::steady_clock::now(); market_chart_backoff_.succeed(); } else { // Total or partial failure: show what arrived, and retry (both ranges) after the backoff // rather than on the next frame or, for a partial result, only in 30 minutes. market_chart_backoff_.fail(std::chrono::steady_clock::now(), util::FetchBackoff::randomJitter()); } }; }); } void App::refreshExchanges() { if (!settings_ || !settings_->getFetchPrices()) return; if (!http_worker_ || http_cancel_.load(std::memory_order_relaxed)) return; if (exchanges_fetch_started_) return; // in flight, or loaded (once per session: venues are near-static) // The last attempt failed: wait out the backoff. This is called every frame the Market tab is open, // and a failure used to retry on the very next frame. if (!exchanges_backoff_.due(std::chrono::steady_clock::now())) return; exchanges_fetch_started_ = true; http_worker_->post([this]() -> rpc::RPCWorker::MainCb { // httpGetString verifies TLS, caps the response size and returns "" on any failure. const std::string body = util::httpGetString( "https://api.coingecko.com/api/v3/coins/dragonx-2/tickers", "[tickers]", marketHttp(&http_cancel_, 12)); std::vector exchanges; if (!body.empty()) { try { exchanges = data::parseCoinGeckoTickers(body); } catch (...) {} } return [this, exchanges = std::move(exchanges)]() mutable { if (!exchanges.empty()) { state_.market.exchanges = std::move(exchanges); exchanges_backoff_.succeed(); } else { exchanges_fetch_started_ = false; // retry once the backoff is over exchanges_backoff_.fail(std::chrono::steady_clock::now(), util::FetchBackoff::randomJitter()); } }; }); } // ============================================================================ // 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; // Clamp the requested thread count to [1, logical cores] before setgenerate — an unclamped value // (from a settings field or idle-scaling) would ask the daemon to spawn arbitrarily many threads. (M-08) const int maxThreads = std::max(1, (int)std::thread::hardware_concurrency()); threads = std::clamp(threads, 1, maxThreads); if (mining_toggle_in_progress_.exchange(true)) return; // already in progress worker_->post([this, threads]() -> rpc::RPCWorker::MainCb { 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; std::string errMsg; try { rpc::RPCClient::TraceScope trace("Mining tab / Stop mining"); rpc_->call("setgenerate", {false, 0}); ok = true; } catch (const std::exception& e) { errMsg = e.what(); DEBUG_LOGF("Failed to stop mining: %s\n", errMsg.c_str()); } return [this, ok, errMsg]() { mining_toggle_in_progress_.store(false); if (ok) { state_.mining.generate = false; state_.mining.localHashrate = 0.0; DEBUG_LOGF("Mining stopped\n"); } else { // Don't silently leave generate=true as if it worked: tell the user and let the next // getmininginfo refresh reconcile the true daemon state. (M-07) ui::Notifications::instance().error("Failed to stop mining: " + errMsg); } }; }); } void App::startPoolMining(int threads) { if (!supportsPoolMining()) { (void)threads; ui::Notifications::instance().warning("Pool mining is unavailable in this build"); return; } // Clamp to [1, logical cores] before the count reaches xmrig (M-06/M-08 pool path). threads = std::clamp(threads, 1, std::max(1, (int)std::thread::hardware_concurrency())); if (!xmrig_manager_) xmrig_manager_ = std::make_unique(); // Stop solo mining first if active (async via the RPC worker). if (state_.mining.generate) stopMining(); // (the "stop the already-running miner first" step is done inside the control job below, in FIFO order) postPoolMinerJob(threads, /*retargetOnly=*/false); } // Change the thread count of the pool miner that is running, or queued to start (Mining tab picks, idle // thread scaling). Never starts a miner: the job decides on the mining-control thread (postPoolMinerJob). // Nothing is posted when there is no miner, or when the newest start/retarget already asks for `threads`. void App::retargetPoolMining(int threads) { if (!supportsPoolMining() || !xmrig_manager_) return; threads = std::clamp(threads, 1, std::max(1, (int)std::thread::hardware_concurrency())); const int target = pool_threads_target_.load(std::memory_order_relaxed); if (target <= 0 && !xmrig_manager_->isRunning()) return; // no miner, none queued if (target == threads) return; // the newest queued job already does this postPoolMinerJob(threads, /*retargetOnly=*/true); } // Builds the xmrig config from settings + wallet state on this (main) thread and posts the blocking // stop(if running)+start to the mining-control thread. retargetOnly (a thread-count change): the job does // nothing unless a miner is running when it runs, at another count; a config that no longer validates // stops the miner, as the old stop-then-start did. void App::postPoolMinerJob(int threads, bool retargetOnly) { daemon::XmrigManager::Config cfg; cfg.pool_url = settings_->getPoolUrl(); cfg.worker_name = settings_->getPoolWorker(); // Validate the payout address at EVERY start entry point (manual Start button, idle auto-start, thread // scaling) — not just the UI gate — since a stale/hand-edited/wrong-chain address here silently loses // mining rewards. (M-01) worker_name IS the pool login the rewards are credited to (see below). // A pool pays with ordinary transactions, so only a shielded address can be paid (private chain). if (!cfg.worker_name.empty() && cfg.worker_name != "x" && !util::isValidRecipientAddress(cfg.worker_name)) { ui::Notifications::instance().error(util::isTransparentAddress(cfg.worker_name) ? TR("mining_payout_needs_shielded") : "Pool payout address is not a valid DragonX address — mining not started."); if (retargetOnly) stopPoolMining(); // never keep mining on a config that no longer validates return; } // The algo follows the pool: official pools use their own algo (pool.dragonx.cc // needs rx/dragonx, pool.dragonx.is rx/hush); custom hosts keep the setting. cfg.algo = util::resolvePoolAlgo(cfg.pool_url, settings_->getPoolAlgo()); cfg.threads = threads; // Use the same thread selection as solo mining cfg.tls = settings_->getPoolTls(); cfg.hugepages = settings_->getPoolHugepages(); // xmrig "user" is the pool login the block rewards are credited to. The user's // "Payout Address" field (cfg.worker_name = getPoolWorker) is exactly that, so it // takes priority — otherwise a payout address that differs from the wallet's own // first z-address is silently ignored and rewards go to the wrong address. Only when // no payout address is set do we fall back to the wallet's own first shielded, then // transparent, address (available even before the daemon is connected/synced). std::string firstShielded, firstTransparent; for (const auto& addr : state_.z_addresses) { if (!addr.address.empty()) { firstShielded = addr.address; break; } } for (const auto& addr : state_.addresses) { if (addr.type == "transparent" && !addr.address.empty()) { firstTransparent = addr.address; break; } } cfg.wallet_address = ui::resolveMiningUserAddress(cfg.worker_name, firstShielded, firstTransparent); 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"); if (retargetOnly) stopPoolMining(); return; } // Run the blocking stop(if running)+start on the serialized mining-control thread so the render thread // never blocks on stop()'s SIGTERM->SIGKILL->join; marshal the spawn result back to the UI. (M-03/L-06/ // L-08/L-09/L-13). cfg was fully built above on this (main) thread. daemon::XmrigManager::Config cfgCopy = cfg; const int target = cfg.threads; pool_threads_target_.store(target, std::memory_order_relaxed); auto files = startup_files_; // shared_ptr copy (set once in init()): the startup node-files job postMiningControl([this, cfgCopy, target, retargetOnly, files]() { // A retarget is decided HERE, after every earlier start/stop ran: nothing happens unless a miner is // running now and at another count. So it never starts a miner the user stopped, and a restart's // stop window (isRunning() false on the UI thread) can't hide the running one. if (retargetOnly) { if (!xmrig_manager_->isRunning()) { clearPoolThreadsTarget(target); return; } if (xmrig_manager_->getRequestedThreads() == target) return; } if (xmrig_manager_->isRunning()) xmrig_manager_->stop(3000); // The startup job may still be replacing the miner binary: never launch the old one under it. Waiting // here (the mining-control thread) never blocks the UI; shutdown, or the control thread being stopped // (shutdown()'s fallback path joins it before cancelling the job), ends the wait. if (files) { auto stopping = [this]() { if (shutting_down_) return true; std::lock_guard lk(mining_ctl_mutex_); return mining_ctl_stop_; }; bool abort = false; while (!abort && !files->waitUntilDone(100)) abort = stopping(); if (abort || shutting_down_ || files->endedCancelled()) { clearPoolThreadsTarget(target); return; } } const bool ok = xmrig_manager_->start(cfgCopy); const std::string err = ok ? std::string() : xmrig_manager_->getLastError(); if (!ok) clearPoolThreadsTarget(target); if (!worker_) return; worker_->post([this, ok, err]() -> rpc::RPCWorker::MainCb { return [this, ok, err]() { if (ok) { // Miner spawned — it still needs a few seconds to connect to the pool and start hashing. pool_starting_.store(true, std::memory_order_relaxed); ui::Notifications::instance().info("Starting pool miner — connecting to the pool…"); } else { DEBUG_LOGF("[ERROR] Pool mining: %s\n", err.c_str()); // Windows Defender blocking (error 225 = ERROR_VIRUS_INFECTED) if (err.find("error 225") != std::string::npos || err.find("virus") != std::string::npos) { ui::Notifications::instance().error( "Windows Defender blocked xmrig. Add exclusion for %APPDATA%\\ObsidianDragon"); #ifdef _WIN32 pending_antivirus_dialog_ = true; #endif } else { ui::Notifications::instance().error("Failed to start pool miner: " + err); } } }; }); }); } void App::stopPoolMining() { if (!xmrig_manager_) return; pool_threads_target_.store(0, std::memory_order_relaxed); // no start/retarget is current any more // Off the render thread — stop()'s SIGTERM->SIGKILL->join can block up to ~3s. (M-03/L-06/L-08/L-09) postMiningControl([this]() { if (xmrig_manager_->isRunning()) xmrig_manager_->stop(3000); }); } // ============================================================================ // 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) { createNewAddress(/*shielded*/ true, std::move(callback)); } void App::createNewTAddress(std::function callback) { createNewAddress(/*shielded*/ false, std::move(callback)); } void App::createNewAddress(bool shielded, std::function callback) { const char* typeStr = shielded ? "shielded" : "transparent"; // Lite build: the key is derived locally, but `new` ends in the backend's do_save(), which waits on its sync_lock // (held for a whole scan and by every refresh fetch). It runs on the controller's wallet-op lane and the result is // applied here on the main thread. The callback fires exactly once: inline if no wallet is open, or with "" if the // controller is retired first. if (lite_wallet_) { lite_wallet_->newAddressAsync(shielded, [this, shielded, typeStr, callback](const wallet::LiteNewAddressResult& result) { if (result.ok) { // A refresh applied between the backend call and this delivery may already list it. const bool known = std::any_of(state_.addresses.begin(), state_.addresses.end(), [&](const AddressInfo& a) { return a.address == result.address; }); if (!known) { AddressInfo info; info.address = result.address; info.type = typeStr; info.balance = 0.0; (shielded ? state_.z_addresses : 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; const char* rpcMethod = shielded ? "z_getnewaddress" : "getnewaddress"; const char* traceLabel = shielded ? "Receive tab / New shielded address" : "Receive tab / New transparent address"; worker_->post([this, callback, shielded, typeStr, rpcMethod, traceLabel]() -> rpc::RPCWorker::MainCb { std::string addr; try { rpc::RPCClient::TraceScope trace(traceLabel); json result = rpc_->call(rpcMethod); addr = result.get(); } catch (const std::exception& e) { DEBUG_LOGF("%s error: %s\n", rpcMethod, e.what()); } return [this, callback, addr, shielded, typeStr]() { if (!addr.empty()) { // Inject immediately so UI can select the address next frame AddressInfo info; info.address = addr; info.type = typeStr; info.balance = 0.0; (shielded ? state_.z_addresses : state_.t_addresses).push_back(info); state_.addresses.push_back(info); // keep combined view in sync immediately address_list_dirty_ = true; // No forced scan: a new address has no balance and its spending key (both as injected); // the periodic refresh lists it next time. } 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(); } } void App::reorderAddresses(const std::vector& orderedAddrs) { if (!settings_) return; for (size_t i = 0; i < orderedAddrs.size(); ++i) settings_->setAddressSortOrder(orderedAddrs[i], static_cast(i)); 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_) return; settings_->setMiningAddress(addr, mining); settings_->save(); // "mined" vs "receive" is a pure function of the LOCAL mining-address set — the daemon knows // nothing about it, so there is NO need to re-scan the chain. Relabel the affected rows in the // in-memory history directly and persist them to the (SQLite) history cache. This is instant, // with no daemon round-trip; the History tab's display cache rebuilds on the type change. const auto miningAddrs = settings_->getMiningAddresses(); bool changed = false; for (auto& tx : state_.transactions) { if (tx.address.empty() || (tx.type != "receive" && tx.type != "mined")) continue; std::string newType = miningAddrs.count(tx.address) ? "mined" : "receive"; if (tx.type != newType) { tx.type = std::move(newType); changed = true; } } if (changed) state_.touchTransactions(); // rows relabelled receive<->mined in place: History must rebuild if (changed) storeTransactionHistoryCacheIfAvailable(data::HistoryStoreMode::Now); // user relabel: persist now } void App::invalidateAddressValidationCache() { address_validation_cache_dirty_ = true; markAddressesDirty(); address_retry_not_before_ = {}; // the address set changed: a refresh is owed now, whatever failed before noteWalletMutation(); // an import or sweep: the balance changes without the tip or mempool showing it 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_->callSecretString(method, {address}); // scrubs raw body + json node (B7) } 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); } }; }); } // ============================================================================ // HushChat identity provisioning (experimental; gated by DRAGONX_ENABLE_CHAT) // ============================================================================ // Derive the chat identity from the wallet's own seed-phrase secret and hand it to the chat // service. `secret` is taken by value so we own a copy to wipe; the caller must wipe its own. void App::provisionChatIdentityFromSecret(std::string secret) { // Defensive: strip surrounding whitespace so a stray newline can't change the identity — the // same wallet must derive the SAME identity on full-node and lite (both return the canonical // single-space phrase today, so this is belt-and-suspenders). Trim into a SEPARATE buffer so // both the original (kept full-size) and the trimmed copy (size == content) are fully wiped — // an in-place erase/pop_back would shrink size() and leave un-scrubbed seed bytes past it. auto isws = [](char c) { return std::isspace(static_cast(c)) != 0; }; std::size_t begin = 0, end = secret.size(); while (begin < end && isws(secret[begin])) ++begin; while (end > begin && isws(secret[end - 1])) --end; std::string trimmed = secret.substr(begin, end - begin); chat::ChatKeyPair keys; const auto result = chat::deriveChatIdentityFromSecret(trimmed, keys); if (result.status == chat::ChatIdentityStatus::Ready) { chat_service_.setIdentity(keys); // copies the keypair chat_identity_provisioned_ = true; // Persistence: unlock the seed-derived chat DB with the SAME secret and rehydrate the store // with prior messages (decrypted at rest under a key only this seed can derive). chat_service_.setPersistence(&chat_db_); // A blocked conversation's messages are dropped at ingest (old + future) until it is unblocked. chat_service_.setBlockedPredicate([this](const std::string& cid) { return settings_ && settings_->isChatBlocked(cid); }); if (chat_db_.unlockWithSecret(trimmed)) { chat_service_.loadFromDatabase(); // Baseline unread: treat everything already in the store at load as read, so only messages // that arrive while the app is open badge as unread (Q1). const auto& store = chat_service_.store(); for (const auto& cid : store.conversationIds()) { const auto msgs = store.conversation(cid); if (!msgs.empty()) chat_seen_watermark_[cid] = msgs.back().timestamp; } } } else { chat_identity_unavailable_ = true; } if (!trimmed.empty()) sodium_memzero(&trimmed[0], trimmed.size()); if (!secret.empty()) sodium_memzero(&secret[0], secret.size()); chat::wipeChatKeyPair(keys); } // Provision the chat identity once the wallet's seed phrase is reachable. Called every update() // tick; cheap early-outs keep it idle until it can act. Both variants derive the SAME identity // because they feed the SAME SDXLite-compatible mnemonic into the KDF (identity derivation is // local — peers only ever exchange public keys). void App::resetChatSession() { // Lite send channel — reset UNCONDITIONALLY (independent of the chat feature flag; user sends set // inflight_op_/lite_send_callback_ even with chat off). Clear the note-buffer coordinator so wallet A's // queued sends can't drain under wallet B, and DON'T orphan a user Send callback across a controller // rebuild: rebuildLiteWallet(force) destroys the controller holding the in-flight send, so its result // slot dies with it and the callback would hang the Send dialog forever. Fire the orphaned callback on a // moved-out copy AFTER the channel state is reset, so a re-entrant sendTransaction stays clean. auto orphanedSend = std::move(lite_send_callback_); lite_send_callback_ = nullptr; chat_send_queue_.clear(); inflight_op_ = LiteInflightOp{}; chat_verified_note_budget_ = 0; chat_pipeline_note_count_ = 0; chat_verified_shielded_zat_ = 0; chat_note_model_seen_ = false; chat_split_outstanding_ = false; chat_split_submitted_at_ = 0.0; // The chat scans' in-flight flags are cleared only by their replies, which complete their scan tickets (see // the orphan check in update()): clearing them here would let that check drop a live ticket while the // daemon still runs the scan. Their results are dropped by the session guard. chat_note_rescan_requested_ = false; chat_note_scan_tip_ = -1; chat_note_scan_last_ = 0.0; chat_note_first_look_sent_ = false; chat_verified_funds_by_addr_.clear(); chat_split_hold_tip_ = -1; chat_inbox_basis_txcount_ = -1; chat_inbox_last_scan_at_ = 0.0; chat_inbox_scan_wanted_ = false; chat_inbox_wanted_txcount_ = -1; chat_inbox_probe_at_ = 0.0; chat_inbox_probe_ms_ = 0.0; chat_spend_log_.clear(); chat_note_scan_spend_seq_ = -1; addr_pass_spend_seq_ = -1; if (orphanedSend) orphanedSend(false, "Wallet/server changed — send aborted"); if (!chat::hushChatFeatureEnabledAtBuild()) return; // constexpr — folds away in OFF builds // Drop identity + decrypted plaintext in RAM, lock the seed-encrypted DB, re-arm provisioning. chat_service_.clearIdentity(); chat_service_.store().clear(); chat_seen_watermark_.clear(); // unread state is per-wallet — don't leak it across a switch ui::ResetChatTab(); // wipe the chat UI's typed plaintext + selection so it can't leak into the next wallet chat_db_.lock(); chat_identity_provisioned_ = false; chat_identity_fetch_in_flight_ = false; chat_identity_unavailable_ = false; // Invalidate any in-flight identity fetch that captured the PREVIOUS wallet's secret, so its // completion callback can't provision that secret under the new wallet. ++chat_session_generation_; } void App::maybeProvisionChatIdentity() { if (!chat::hushChatFeatureEnabledAtBuild()) return; // constexpr — folds away in OFF builds // Locked (encrypted && locked, mirrored for both variants) → the seed is unreadable. Drop any // in-memory identity and re-arm so it re-derives on the next unlock. Unencrypted wallets report // isLocked()==false and fall through. if (state_.isLocked()) { if (chat_identity_provisioned_ || chat_service_.hasIdentity()) resetChatSession(); // A failure recorded before the lock was seen isn't final: lite opens an encrypted wallet // LOCKED, but the model saying so arrives a moment later, so the first seed export fails with // "locked" and used to disable chat for good. Re-arm for the next unlock, like lockLiteWallet(). // Nothing is retried while locked: this returns before any RPC or backend call. chat_identity_unavailable_ = false; return; } if (chat_service_.hasIdentity() || chat_identity_provisioned_ || chat_identity_fetch_in_flight_ || chat_identity_unavailable_) return; // After a transient failure (connection blip, a busy node) wait before asking again: retrying on the // next frame re-sent the request continuously. if (std::chrono::steady_clock::now() < chat_identity_retry_at_) return; if (supportsLiteBackend()) { // Lite: the backend's `seed` command returns the 24-word mnemonic synchronously. if (!lite_wallet_ || !lite_wallet_->walletOpen()) return; if (!state_.encryption_state_known) return; // wait for the first model's lock state wallet::LiteSeedResult seed = lite_wallet_->exportSeed(); if (!seed.ok || seed.seedPhrase.empty()) { wallet::secureWipeLiteSecret(seed.seedPhrase); chat_identity_unavailable_ = true; return; } provisionChatIdentityFromSecret(seed.seedPhrase); // copies internally wallet::secureWipeLiteSecret(seed.seedPhrase); } else if (supportsFullNodeLifecycleActions()) { // Full-node: fetch the identity secret off the UI thread, provision on the main thread. // Prefer the wallet's mnemonic (z_exportmnemonic) for a portable, SDXLite-compatible identity; // if the wallet has no mnemonic (a legacy random-seed wallet, or a daemon without that RPC), // fall back to a stable z-address's spending key (z_exportkey) — a functional, wallet-local // identity that works on any daemon. Both secrets are wiped after deriving. if (!state_.connected || !rpc_ || !worker_) return; if (!state_.encryption_state_known) return; // don't fetch before we know the lock state // Nor before the address list: the fallback identity (no mnemonic) derives from a z-address, and an // empty one would latch chat as unavailable for the session. if (state_.last_address_update == 0) return; const std::string fallbackZaddr = chatReplyZaddr(); // main thread: stable identity source const int fetchGen = chat_session_generation_; // wallet epoch this secret belongs to chat_identity_fetch_in_flight_ = true; worker_->post([this, fallbackZaddr, fetchGen]() -> rpc::RPCWorker::MainCb { std::string secret; // the mnemonic (portable) OR a spending key (legacy fallback) bool transientFail = false; // connection blip — allow a later retry bool unavailable = false; // no usable secret — stop retrying this session rpc::RPCClient::TraceScope trace("HushChat / identity"); try { auto response = rpc_->callSecret("z_exportmnemonic"); // zero the raw body too (B7) if (response.contains("mnemonic") && response["mnemonic"].is_string()) { // Scrub the json's own copy of the seed after taking ours (the rest of the RPC // response chain is unmanaged — a fuller fix belongs in the RPC layer). auto& phrase = response["mnemonic"].get_ref(); secret = phrase; if (!phrase.empty()) sodium_memzero(&phrase[0], phrase.size()); } } catch (const rpc::RpcError&) { // No mnemonic → derive from a spending key instead (legacy wallet / old daemon). if (fallbackZaddr.empty()) { unavailable = true; } else { try { // Mirror the mnemonic path: take our copy, then scrub the json's own copy of the // spending key so it isn't left in freed heap (B4). z_exportkey returns a bare string. auto keyResp = rpc_->callSecret("z_exportkey", {fallbackZaddr}); // zero the raw body too (B7) if (keyResp.is_string()) { auto& key = keyResp.get_ref(); secret = key; if (!key.empty()) sodium_memzero(&key[0], key.size()); } } catch (const rpc::RpcError&) { unavailable = true; // no spending key either (view-only?) — give up } catch (const std::exception&) { transientFail = true; } } } catch (const std::exception&) { transientFail = true; } return [this, secret = std::move(secret), transientFail, unavailable, fetchGen]() mutable { // The wallet changed while this fetch ran (resetChatSession bumped the generation): this // secret belongs to the PREVIOUS wallet — wipe it and do nothing so it can't provision an // identity under the new wallet. Don't touch the flags either (they belong to the new epoch). if (fetchGen != chat_session_generation_) { if (!secret.empty()) sodium_memzero(&secret[0], secret.size()); return; } chat_identity_fetch_in_flight_ = false; if (unavailable) chat_identity_unavailable_ = true; if (transientFail) chat_identity_retry_at_ = std::chrono::steady_clock::now() + std::chrono::seconds(10); // Provision only on success AND while still unlocked: a lock (e.g. auto-lock) can // land during the blocking fetch, and provisioning then would unlock the chat DB + // load decrypted history while the wallet is locked. Leaving the flag cleared here // re-arms a fresh fetch on the next unlock. if (!transientFail && !unavailable && !secret.empty() && !state_.isLocked()) { provisionChatIdentityFromSecret(secret); // copies internally } if (!secret.empty()) sodium_memzero(&secret[0], secret.size()); }; }); } } // A STABLE wallet z-address for chat: the "z" reply field + the send-from address, and (for // legacy/non-mnemonic wallets) the source of the seed-derived chat identity. Persisted on first use // so the identity + reply address don't shift when new addresses are generated. Prefers a spendable // one so replies land somewhere we control and fees can be paid. // The chat note-buffer coordinator's constants (see below), here so the payer helpers can use them. namespace { constexpr int kChatBufferTarget = 10; // verified self-notes we aim to keep constexpr int kChatRefillTrigger = 4; // (re)build only when the maturing pipeline drops below this // A note is spendable when its witness count > ANCHOR_OFFSET, i.e. block depth >= ANCHOR_OFFSET+1 = 5 // (backend data.rs:543 needs anchor_offset+1 witnesses; ANCHOR_OFFSET=4 in lib.rs:25). Depth 4 is NOT yet // spendable — counting it there wastes a proving round-trip on a guaranteed "insufficient verified funds". constexpr int kChatConfsRequired = 5; constexpr double kChatNoteSizeDrgx = 0.001; // each buffer note (change after a send stays >= fee) constexpr double kChatSplitWatchdogSecs = 1200.0; // 20 min: clear a stuck split flag if it never mines (expiry/reorg) constexpr int kMaxChatSendRetries = 20; // transient-funds requeues before giving up inline std::uint64_t chatDrgxToZat(double drgx) { return static_cast(std::llround(drgx * 100000000.0)); } } // namespace std::string App::chatReplyZaddr() { // Reuse the previously-chosen address as long as we still own it spendably. if (settings_) { const std::string saved = settings_->getChatReplyZaddr(); if (!saved.empty()) { for (const auto& addr : state_.z_addresses) if (addr.address == saved && addr.has_spending_key) return saved; } } // Otherwise pick the lexicographically-smallest spendable z-address (deterministic) and persist. std::string best; for (const auto& addr : state_.z_addresses) if (addr.has_spending_key && !addr.address.empty() && (best.empty() || addr.address < best)) best = addr.address; if (!best.empty()) { if (settings_ && settings_->getChatReplyZaddr() != best) { settings_->setChatReplyZaddr(best); settings_->save(); } return best; } // No spendable z-address yet — fall back to the smallest we have (don't persist a view-only one). for (const auto& addr : state_.z_addresses) if (!addr.address.empty() && (best.empty() || addr.address < best)) best = addr.address; return best; } std::string App::chatPayFromZaddr(double need) const { // z_sendmany spends from ONE z-address, so the "from" must itself hold >= need. Prefer the identity // reply address (keeps from == reply-to, the simplest case); otherwise pick the highest-balance // spendable z-address that can cover it. The memo still advertises the identity address as // reply-to, so paying from a different note doesn't change who the peer replies to. std::string reply; if (settings_) reply = settings_->getChatReplyZaddr(); // The chat note scan (or the address scan's feed) knows each address's verified notes; on the Chat page that // is fresher than state_.z_addresses' balances, which only the address scan refreshes. if (!chat_verified_funds_by_addr_.empty()) return services::chooseChatPayer(chat_verified_funds_by_addr_, reply, chatDrgxToZat(need)); for (const auto& a : state_.z_addresses) if (a.address == reply && a.has_spending_key && a.spendableBalance >= need) return reply; std::string best; double bestBal = -1.0; for (const auto& a : state_.z_addresses) if (a.has_spending_key && !a.address.empty() && a.spendableBalance >= need && a.spendableBalance > bestBal) { best = a.address; bestBal = a.spendableBalance; } return best; // empty → no z-address can cover it } void App::debitChatPayerLogged(const std::string& payer, services::ChatPayerSpend spend) { // Entries every outstanding reply already reflects (posted after them) can go; none outstanding: all. std::int64_t keepAfter = -1; for (std::int64_t seq : {chat_note_scan_spend_seq_, addr_pass_spend_seq_}) if (seq >= 0) keepAfter = keepAfter < 0 ? seq : std::min(keepAfter, seq); if (keepAfter < 0) chat_spend_log_.clear(); while (!chat_spend_log_.empty() && (static_cast(chat_spend_log_.front().seq) <= keepAfter || chat_spend_log_.size() >= 64)) chat_spend_log_.pop_front(); chat_spend_log_.push_back({++chat_spend_seq_, payer, spend}); services::debitChatPayer(chat_verified_funds_by_addr_, payer, spend); } void App::reapplyChatSpendsAfter(std::uint64_t seq) { for (const auto& e : chat_spend_log_) { if (e.seq <= seq) continue; services::debitChatPayer(chat_verified_funds_by_addr_, e.payer, e.spend); if (e.spend == services::ChatPayerSpend::OneNote) chat_verified_note_budget_ = std::max(0, chat_verified_note_budget_ - 1); } } std::uint64_t App::chatSplitAffordableZat() const { // What one split can spend: z_sendmany pays from ONE address, so on the full node the richest verified // payer (lite: the verified shielded balance, as before). if (!lite_wallet_ && !chat_verified_funds_by_addr_.empty()) return services::richestChatPayerZat(chat_verified_funds_by_addr_); return chat_verified_shielded_zat_; } // A unique opaque id (hex), used for the local echo id (we never harvest our own sends) and for // new conversation ids. Random — collisions are astronomically unlikely. std::string App::generateChatLocalId(const char* prefix, int numBytes) const { if (numBytes < 1) numBytes = 8; std::vector buf(static_cast(numBytes)); randombytes_buf(buf.data(), buf.size()); static const char* kHex = "0123456789abcdef"; std::string id = prefix ? prefix : ""; for (unsigned char b : buf) { id.push_back(kHex[b >> 4]); id.push_back(kHex[b & 0x0F]); } return id; } // Broadcast the header + payload as two 0-value memo outputs to memos.recipientZaddr (header first, // the lower memo position). Routes to the lite controller in lite builds, else full-node z_sendmany. // // LIVE-VERIFY (cannot be proven from source): that dragonxd returns the memo under `memoStr` verbatim // on receive, that recipient-array order maps to note position (the receive pairing needs the header // at a lower position), that a 0-value memo-only tx relays, and full SDXL<->DragonX interop. // A chat send moves 0 value, so the fee is the ONLY thing that forces a real shielded input — a // 0-value + 0-fee tx builds a degenerate, unrelayable tx (see z_sendmany targetAmount). Never let the // global default-fee setting drop chat below a working minimum (well above the node's min relay fee). static constexpr double kChatMinFeeDrgx = 0.0001; bool App::broadcastChatMemos(const chat::OutgoingChatMemos& memos, const std::string& echoLocalId) { if (memos.recipientZaddr.empty()) return false; // Guard the async resolve against a wallet lock/switch between submit and callback: if the chat // session was reset in between, this echo belongs to a stale session — don't touch the new store. const auto sessionGen = chat_session_generation_; if (lite_wallet_) { // Lite chat is owned by the note-buffer coordinator (enqueue + drain + resolve from the real // broadcast result). Route any caller here through the queue rather than resolving optimistically // (the old code marked a merely-queued send "Sent", masking real failures). Kept for safety — // the send/Retry paths call enqueueChatSend directly and don't hit this branch. (void)sessionGen; enqueueChatSend(LiteOpKind::ChatSend, memos, echoLocalId); return true; } // Coordinator-owned primitive (pumpChatNoteBuffer): the pump already gates on connected/unlocked and a // verified-note budget, so these refusals are belt-and-suspenders. Return false SILENTLY (no per-frame // toast) — the pump leaves the message queued ("Sending") and retries when the guard clears. if (!state_.connected || !rpc_ || !worker_) return false; // Chat moves 0 value, and dragonxd REJECTS a 0-value tx whose fee exceeds the default miners fee // (0.0001) — so pin chat to exactly kChatMinFeeDrgx, independent of the user's default-fee setting // (which may be higher). It's also well above the node's min relay fee. const double fee = kChatMinFeeDrgx; // Pay from a z-address that can actually cover the fee. The memo still advertises the identity // reply address, so the peer replies to the right place regardless of which note paid. const std::string from = chatPayFromZaddr(fee); if (from.empty()) return false; // no fundable note yet — pump keeps it queued, retries after a scan // Full-node: each memo needs the daemon's "utf8:" prefix (raw JSON/hex is otherwise rejected). const auto outputs = chat::chatSendOutputs(memos, /*utf8Prefix=*/true); nlohmann::json recipients = nlohmann::json::array(); for (const auto& out : outputs) { nlohmann::json recipient; recipient["address"] = out.address; recipient["amount"] = util::formatAmountFixed(0.0); // 0-value memo output recipient["memo"] = out.memo; recipients.push_back(std::move(recipient)); } // markFeeGapRetry=true is deliberate: it suppresses the fee-gap auto-retry, which rebuilds a // SINGLE-recipient tx from the scalar to/amount/memo and would drop the second (payload) output. // This is now the note-buffer coordinator's full-node submit primitive (called only from the pump). // The terminal callback (fired once by the opid poller) releases the send channel and routes the // result through onChatBroadcastResult, so a transient "Insufficient shielded funds" requeues instead // of hard-failing. The sessionGen guard drops a stale wallet's echo AND protects the inflight_op_ // clear from a callback landing after a wallet switch (which would otherwise clear a NEW op's token). submitZSendMany(from, memos.recipientZaddr, 0.0, fee, /*memo*/"", recipients, "HushChat / broadcast", /*markFeeGapRetry*/ true, [this, echoLocalId, sessionGen](bool ok, const std::string& result) { if (sessionGen != chat_session_generation_) return; // stale wallet — don't touch new session if (!ok) chat_note_rescan_requested_ = true; // covers a submit-time failure the poller never sees inflight_op_ = LiteInflightOp{}; // release the channel for this session LiteInflightOp op; op.echoLocalId = echoLocalId; op.sessionGen = sessionGen; onChatBroadcastResult(op, ok, result); }, /*background*/ true); // status is shown on the chat message, not the global send UI // Until the next note scan restores the truth: the send spends one whole note (largest first) of `from`. debitChatPayerLogged(from, services::ChatPayerSpend::OneNote); return true; // submitted (async build/broadcast; the callback resolves the final status) } // Lite variant: two 0-value recipients to the same z-address, RAW memos (the backend does // Memo::from_str directly — no "utf8:" prefix). The backend accepts duplicate addresses + 0-value // outputs for exactly this HushChat pattern. Fire-and-forget: the result surfaces via the lite // broadcast log; the message is already echoed locally. bool App::broadcastChatMemosLite(const chat::OutgoingChatMemos& memos) { if (!lite_wallet_) return false; const auto outputs = chat::chatSendOutputs(memos, /*utf8Prefix=*/false); wallet::LiteSendRequest req; for (const auto& out : outputs) { wallet::LiteSendRecipient recipient; recipient.address = out.address; recipient.amountZatoshis = 0; recipient.memo = out.memo; req.recipients.push_back(std::move(recipient)); } // No busy toast here: the coordinator (pumpChatNoteBuffer) owns this call, retries next frame when the // single broadcast channel frees, and surfaces status on the message itself. return lite_wallet_->sendTransaction(req); } void App::sendChatMessage(const std::string& conversationId, const std::string& text) { if (!chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return; if (text.empty() || conversationId.empty()) return; // The peer's z-address + public key come from a message we already have in this conversation. Pin to // the EARLIEST (establishing) values, not the latest: the memo header's `z`/`cid` ride outside the // AEAD, so trusting the newest would let a later message redirect our replies to an attacker-chosen // address / splice threads (B2). A full fix binds z+cid into the secretstream additional-data, but // that's a coordinated HushChat/SDXLite wire-format change; pinning hardens the reply target without it. std::string peerZaddr; std::string peerPubKey; for (const auto& m : chat_service_.store().conversation(conversationId)) { if (peerZaddr.empty() && !m.peer_zaddr.empty()) peerZaddr = m.peer_zaddr; if (peerPubKey.empty() && !m.peer_public_key_hex.empty()) peerPubKey = m.peer_public_key_hex; if (!peerZaddr.empty() && !peerPubKey.empty()) break; } if (peerPubKey.empty()) { ui::Notifications::instance().info(TR("chat_toast_waiting_reply")); return; } const std::string myReply = chatReplyZaddr(); if (myReply.empty()) { ui::Notifications::instance().error(TR("chat_toast_no_zaddr")); return; } chat::OutgoingChatMemos memos; if (chat_service_.composeMessage(myReply, peerPubKey, peerZaddr, conversationId, text, memos) != chat::ChatComposeStatus::Ok) { ui::Notifications::instance().error(TR("chat_toast_compose_failed")); return; } chat::ChatMessage echo; echo.direction = chat::ChatDirection::Outgoing; echo.kind = chat::ChatMessageKind::Message; echo.conversation_id = conversationId; echo.peer_zaddr = peerZaddr; echo.peer_public_key_hex = peerPubKey; echo.body = text; echo.timestamp = std::time(nullptr); echo.txid = generateChatLocalId("out:", 8); echo.payload_position = 0; echo.delivery = chat::ChatDelivery::Sending; // shows immediately; the broadcast callback resolves it chat_service_.recordOutgoingPending(echo); // in-memory now; persisted with the final status // Both variants: hand to the note-buffer coordinator, which drains the queue against verified notes // (building/refilling the buffer as needed) and resolves the echo Sent/Failed from the REAL result. enqueueChatSend(LiteOpKind::ChatSend, memos, echo.txid); } void App::startChatConversation(const std::string& peerZaddr, const std::string& text) { if (!chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return; if (peerZaddr.empty() || text.empty()) return; sendContactRequestForCid(generateChatLocalId("", 16), peerZaddr, text); // new opaque cid } // Compose + broadcast a contact request into a SPECIFIC conversation. startChatConversation() mints a // fresh cid; a Retry of a failed request reuses its existing cid so it stays in the same thread. void App::sendContactRequestForCid(const std::string& cid, const std::string& peerZaddr, const std::string& text) { if (!chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return; if (cid.empty() || peerZaddr.empty() || text.empty()) return; const std::string myReply = chatReplyZaddr(); if (myReply.empty()) { ui::Notifications::instance().error(TR("chat_toast_no_zaddr")); return; } chat::OutgoingChatMemos memos; if (chat_service_.composeContactRequest(myReply, peerZaddr, cid, text, memos) != chat::ChatComposeStatus::Ok) { ui::Notifications::instance().error(TR("chat_toast_request_compose_failed")); return; } chat::ChatMessage echo; echo.direction = chat::ChatDirection::Outgoing; echo.kind = chat::ChatMessageKind::ContactRequest; echo.conversation_id = cid; echo.peer_zaddr = peerZaddr; echo.body = text; echo.timestamp = std::time(nullptr); echo.txid = generateChatLocalId("out:", 8); echo.payload_position = 0; echo.delivery = chat::ChatDelivery::Sending; chat_service_.recordOutgoingPending(echo); // Both variants: enqueue through the coordinator (resolves the echo from the real result). enqueueChatSend(LiteOpKind::ContactRequest, memos, echo.txid); ui::Notifications::instance().success(TR("chat_toast_request_queued")); } // ── Chat note-buffer coordinator (lite only) ───────────────────────────────────────────────────── // Design in app.h. Keeps ~kChatBufferTarget verified self-notes so a burst of messages each spends a // separate note; refilled from change + background self-splits. Shares the controller's single broadcast // channel with user sends (inflight_op_ tracks ownership). Pumped from App::update(). // (The coordinator's constants and chatDrgxToZat are defined above chatReplyZaddr.) // Confirmations a self-note needs before it's spendable: lite requires ANCHOR_OFFSET+1 witnesses (=5, // data.rs:543); the full node's chat z_sendmany uses minconf=1 (app_network.cpp z_sendmany call). int App::chatConfsRequired() const { return lite_wallet_ ? kChatConfsRequired : 1; } // Status-bar summary of the chat note buffer (shown while the Chat tab is active). Empty unless chat has // an identity. Surfaces the buffer state so the user can see it filling / draining, and it doubles as a // diagnostic: "…" = no note data yet; "0/10 ready" = counted but no verified notes; "N/10 ready" = armed. std::string App::chatBufferStatusText() { if (!chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return ""; const int queued = static_cast(chat_send_queue_.size()); const int verified = std::max(0, std::min(chat_verified_note_budget_, kChatBufferTarget)); const int target = static_cast(kChatBufferTarget); char buf[128]; if (queued > 0) { snprintf(buf, sizeof(buf), TR(queued == 1 ? "chat_buffer_sending_one" : "chat_buffer_sending"), queued); return buf; } if (chat_split_outstanding_) { snprintf(buf, sizeof(buf), TR("chat_buffer_preparing"), verified, target); return buf; } if (!chat_note_model_seen_) return TR("chat_buffer_loading"); snprintf(buf, sizeof(buf), TR("chat_buffer_ready"), verified, target); return buf; } bool App::isTransientVerifiedFundsError(const std::string& error) { // Both variants' "only unconfirmed change is short" phrasing, treated as transient (retry after the // buffer matures): lite = "Insufficient verified funds" (lightwallet.rs:2402); full node = // "Insufficient shielded funds" (asyncrpcoperation_sendmany, parsed at parseInsufficientShielded). return error.find("Insufficient verified funds") != std::string::npos || error.find("Insufficient shielded funds") != std::string::npos; } // Verified notes = ANY spendable shielded note (>= a fee) with >= kChatConfsRequired confirmations — the // ones a chat send can spend RIGHT NOW. We count notes at ALL addresses, not just the reply address, // because a chat send pays via chatPayFromZaddr (any funded z-addr) — gating on reply-address-only notes // would queue a sendable message whenever funds sit elsewhere. NB: the model's per-note `spendable` flag // means "unspent", NOT verified (lite_result_parsers.cpp recomputes it), so we derive it from depth here. int App::verifiedSelfNoteCount(const wallet::LiteWalletAppRefreshModel& model) { if (!model.hasSpendableOutputs) return 0; // Use the cached tip (refreshChatNoteBudget updates it), not this model's — a notes-carrying model may // not carry sync status that cycle, which would otherwise zero the whole count. const std::int64_t tip = chat_last_chain_height_; if (tip <= 0) return 0; const std::uint64_t minVal = chatDrgxToZat(kChatMinFeeDrgx); int n = 0; for (const auto& o : model.spendableOutputs) { if (o.kind != wallet::LiteSpendableOutputKind::UnspentNote) continue; // shielded notes only if (o.spent || o.unconfirmedSpent || o.pending) continue; if (o.valueZatoshis < minVal) continue; // skip dust below a fee if (!o.createdInBlock.has_value()) continue; // unknown depth -> not verified if (tip - *o.createdInBlock + 1 >= chatConfsRequired()) ++n; } return n; } // Notes that are verified OR still maturing (any unspent shielded note of usable size) — i.e. notes that // will become spendable WITHOUT another split. Drives the refill trigger so we don't over-split while // change is confirming or when the wallet already holds enough notes anywhere. int App::pipelineSelfNoteCount(const wallet::LiteWalletAppRefreshModel& model) { if (!model.hasSpendableOutputs) return 0; const std::uint64_t minVal = chatDrgxToZat(kChatMinFeeDrgx); int n = 0; for (const auto& o : model.spendableOutputs) { if (o.kind != wallet::LiteSpendableOutputKind::UnspentNote) continue; if (o.spent || o.unconfirmedSpent) continue; // already consumed if (o.valueZatoshis < minVal) continue; ++n; // verified or maturing (any addr) } return n; } // Recompute the cached note estimates from a fresh refresh model (called from update() when one arrives). void App::refreshChatNoteBudget(const wallet::LiteWalletAppRefreshModel& model) { if (!lite_wallet_ || !chat::hushChatFeatureEnabledAtBuild()) return; // Cache the chain tip from whatever height source THIS model carries (sync status or chain info) — a // model with notes may lack sync that cycle, so verifiedSelfNoteCount reads the cache below. if (model.hasSyncStatus && model.sync.chainHeight > 0) chat_last_chain_height_ = static_cast(model.sync.chainHeight); else if (model.hasChainInfo && model.chain.latestBlockHeight.has_value() && *model.chain.latestBlockHeight > 0) chat_last_chain_height_ = static_cast(*model.chain.latestBlockHeight); if (model.hasSpendableOutputs) { chat_note_model_seen_ = true; chat_verified_note_budget_ = verifiedSelfNoteCount(model); chat_pipeline_note_count_ = pipelineSelfNoteCount(model); } if (model.hasBalance) chat_verified_shielded_zat_ = model.balance.verifiedShieldedZatoshis; // Release the single-split guard once the outstanding split's outputs are mined+visible (pipeline // recovered) — or once a watchdog window elapses, so a split that never mines can't wedge refill. if (chat_split_outstanding_ && (chat_pipeline_note_count_ >= kChatRefillTrigger || scanClockNow() - chat_split_submitted_at_ > kChatSplitWatchdogSecs)) { chat_split_outstanding_ = false; } } // Full-node twin of refreshChatNoteBudget: the shared balance poll discards per-note data, so run our own // rate-limited z_listunspent worker scan (mirrors fastScanChatMemos) to count verified/maturing spendable // notes. z_listunspent returns only the wallet's OWN notes, and a chat send pays from any of them // (chatPayFromZaddr), so we count ALL addresses — gating on the reply address only would queue a sendable // message when funds sit elsewhere. minconf=0 exposes maturing change so the pipeline count doesn't over- // split. On an older daemon that rejects z_listunspent it leaves chat_note_model_seen_ false → the pump // won't drain a phantom budget or split (graceful degradation to naive one-at-a-time sends). void App::refreshChatNoteBudgetNode() { if (lite_wallet_ || !chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return; if (!state_.connected || !rpc_ || !worker_ || state_.isLocked()) return; // Never contend the daemon's cs_main while the node is catching up. z_listunspent is O(mapWallet) // and holds cs_main for its whole duration — seconds on a large wallet — so an ungated per-few-second // chat scan here starves block connection and keeps the node from ever pinning to the tip. Chat // send-readiness can wait until we're synced; this budget is only advisory (the send path re-checks). // The same hard gates as every wallet scan, except that a queued chat send still drains while the window // is in the background (the address scan that also feeds the budget waits for the window). Not for a // split alone: nothing needs the buffer in the background, and a split that never refills would scan // every block for as long as the window stays minimized. { services::ScanContext sc = scanContext(); if (!chat_send_queue_.empty()) sc.background = false; if (!services::wholeWalletScanAllowed(sc)) return; } if (chat_note_scan_in_flight_) return; // cleared only by its reply (which completes its ticket) // The address pass reads the same z_listunspent and feeds this view: not a second one while it runs or is owed. using Job = services::NetworkRefreshService::Job; if (network_refresh_.jobInProgress(Job::Addresses) || (addresses_dirty_ && addressScanCanRunNow())) return; using services::ScanStream; const double now = scanClockNow(); // On need only: something can use the view (the Chat page, a queued send, a split) and it can have changed // (first look, a chat op finished, a settled block that matters, or the fallback). How often: the budget's. services::ChatNoteScanInputs in; in.onChatPage = currentScanVisibility().chat; in.queueWaiting = !chat_send_queue_.empty(); in.splitOutstanding = chat_split_outstanding_; in.rescanRequested = chat_note_rescan_requested_; in.modelSeen = chat_note_model_seen_; in.verifiedBudget = chat_verified_note_budget_; in.pipelineNotes = chat_pipeline_note_count_; in.tipHeight = state_.sync.blocks; in.lastScanTip = chat_note_scan_tip_; in.nowSec = now; in.lastScanAtSec = chat_note_scan_last_; in.tipChangedAtSec = tip_changed_at_; in.fallbackSec = services::chatNoteFallbackSec(scan_governor_.emaCostMs(ScanStream::ChatNotes)); if (!services::shouldScanChatNotes(in)) return; const auto sig = currentScanSignals(); services::ScanTicket ticket; if (!chat_note_model_seen_ && in.onChatPage && !chat_note_first_look_sent_) { // The page can't show the buffer, or drain a send, without one look: explicit (never held back, but // charged), once per session. ticket = scan_governor_.beginExplicit(ScanStream::ChatNotes, sig, now); chat_note_first_look_sent_ = true; } else { ticket = scan_governor_.tryAdmit(ScanStream::ChatNotes, in.onChatPage || in.queueWaiting, sig, now); if (!ticket) return; // asks again next frame while the need holds } const bool servedRescan = chat_note_rescan_requested_; chat_note_rescan_requested_ = false; if (now - tip_changed_at_ >= services::kChatTipSettleSec) chat_note_scan_tip_ = state_.sync.blocks; // settled only chat_note_scan_last_ = now; chat_note_scan_in_flight_ = true; const std::uint64_t spendSeq = chat_spend_seq_; chat_note_scan_spend_seq_ = static_cast(spendSeq); const int scanGen = chat_session_generation_; const int confsNeeded = chatConfsRequired(); // == 1 for full node const std::uint64_t minVal = chatDrgxToZat(kChatMinFeeDrgx); worker_->post([this, scanGen, confsNeeded, minVal, ticket, servedRescan, spendSeq]() -> rpc::RPCWorker::MainCb { int verified = 0, pipeline = 0; std::uint64_t verifiedZat = 0; services::ChatPayerMap funds; bool ok = false; rpc::RpcFailureKind failKind = rpc::RpcFailureKind::None; const auto scanStart = std::chrono::steady_clock::now(); try { rpc::RPCClient::TraceScope trace("HushChat / note-buffer scan"); nlohmann::json notes = rpc_->call("z_listunspent", nlohmann::json::array({0})); // 0 = include maturing if (notes.is_array()) { for (const auto& nz : notes) { if (!nz.is_object()) continue; if (nz.value("locked", false)) continue; // tied up by an in-flight send const std::uint64_t amtZat = chatDrgxToZat(nz.value("amount", 0.0)); if (amtZat < minVal) continue; // skip dust below a fee // rawconfirmations is the TRUE depth; `confirmations` is dPoW-clamped to 1 and understates it. const int confs = (nz.contains("rawconfirmations") && nz["rawconfirmations"].is_number_integer()) ? nz["rawconfirmations"].get() : nz.value("confirmations", 0); ++pipeline; // unspent self-note (verified or maturing) if (confs >= confsNeeded) { ++verified; verifiedZat += amtZat; if (nz.contains("address") && nz["address"].is_string()) { auto& f = funds[nz["address"].get()]; f.zat += amtZat; ++f.notes; } } } ok = true; // after the loop: a throw mid-loop applies nothing } } catch (const std::exception& e) { failKind = rpc::rpcFailureKindOf(e); } catch (...) { failKind = rpc::RpcFailureKind::Other; } const double scanMs = std::chrono::duration( std::chrono::steady_clock::now() - scanStart).count(); return [this, scanGen, ticket, servedRescan, spendSeq, ok, failKind, verified, pipeline, verifiedZat, funds = std::move(funds), scanMs]() mutable { chat_note_scan_in_flight_ = false; // Whatever the session: the daemon ran it. Before the guard, or a stale reply would hold the slot. scan_governor_.complete(ticket, scanMs, services::chatScanOutcome(ok, failKind), scanClockNow()); if (scanGen != chat_session_generation_) return; // wallet switched — drop // onDisconnected cleared the view and this scan's seq since the post: its notes are the previous // connection's, and the spends to re-apply are gone from the log. Drop it (the reconnect's address // pass feeds the view), like the address pass whose reply resetJobs makes stale. if (chat_note_scan_spend_seq_ != static_cast(spendSeq)) return; chat_note_scan_spend_seq_ = -1; if (!ok) { // still owed (the 4 s floor paces the retry) if (servedRescan) chat_note_rescan_requested_ = true; chat_note_scan_tip_ = -1; return; } chat_note_model_seen_ = true; chat_verified_note_budget_ = verified; chat_pipeline_note_count_ = pipeline; chat_verified_shielded_zat_ = verifiedZat; chat_verified_funds_by_addr_ = std::move(funds); reapplyChatSpendsAfter(spendSeq); // chat sends submitted after this scan was posted if (chat_split_outstanding_ && (chat_pipeline_note_count_ >= kChatRefillTrigger || scanClockNow() - chat_split_submitted_at_ > kChatSplitWatchdogSecs)) { chat_split_outstanding_ = false; } }; }); } // #3: derive the chat send-budget from an already-collected z_listunspent (refreshAddressData's scan), // so we don't issue a duplicate z_listunspent. Runs on the main thread; the notes are pre-parsed to the // fields we need (amount / locked / TRUE confirmations), so there's no RPC and no worker hop. Mirrors the // apply in refreshChatNoteBudgetNode and stamps chat_note_scan_last_ so the dedicated scan backs off. void App::updateChatNoteBudgetFromUnspent( const std::vector& unspentNotes, std::int64_t feedTip, std::uint64_t spendSeq) { if (lite_wallet_ || !chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return; const int confsNeeded = chatConfsRequired(); const std::uint64_t minVal = chatDrgxToZat(kChatMinFeeDrgx); int verified = 0, pipeline = 0; std::uint64_t verifiedZat = 0; services::ChatPayerMap funds; for (const auto& nz : unspentNotes) { if (nz.locked) continue; // tied up by an in-flight send const std::uint64_t amtZat = chatDrgxToZat(nz.amount); if (amtZat < minVal) continue; // skip dust below a fee ++pipeline; // unspent self-note (verified or maturing) if (nz.confirmations >= confsNeeded) { ++verified; verifiedZat += amtZat; if (!nz.address.empty()) { auto& f = funds[nz.address]; f.zat += amtZat; ++f.notes; } } } chat_note_model_seen_ = true; chat_verified_note_budget_ = verified; chat_pipeline_note_count_ = pipeline; chat_verified_shielded_zat_ = verifiedZat; chat_verified_funds_by_addr_ = std::move(funds); reapplyChatSpendsAfter(spendSeq); // chat sends submitted after this pass was posted // Counts as a fresh scan: the dedicated one backs off. Not the rescan request: this pass may predate the // chat op that asked for it. chat_note_scan_last_ = scanClockNow(); if (feedTip >= 0) chat_note_scan_tip_ = feedTip; if (chat_split_outstanding_ && (chat_pipeline_note_count_ >= kChatRefillTrigger || scanClockNow() - chat_split_submitted_at_ > kChatSplitWatchdogSecs)) { chat_split_outstanding_ = false; } } void App::enqueueChatSend(LiteOpKind kind, const chat::OutgoingChatMemos& memos, const std::string& echoLocalId) { QueuedChatOp op; op.kind = kind; op.memos = memos; // kept so a transient-funds retry re-broadcasts, never recomposes op.echoLocalId = echoLocalId; op.sessionGen = chat_session_generation_; chat_send_queue_.push_back(std::move(op)); wallet::liteLog("chat: queued outgoing " + std::string(kind == LiteOpKind::ContactRequest ? "contact request" : "message") + " (queue depth " + std::to_string(chat_send_queue_.size()) + ", verified notes ~" + std::to_string(chat_verified_note_budget_) + ")"); // pumpChatNoteBuffer() (next update tick) submits it when the channel is free + a verified note exists. } // Self-send that mints `noteCount` reply-address notes (funds stay in the wallet), building/refilling the // buffer. Returns false if the single broadcast channel is busy (the pump retries next frame). bool App::broadcastSelfSplitLite(int noteCount) { if (!lite_wallet_ || noteCount < 1) return false; const std::string myZ = chatReplyZaddr(); if (myZ.empty()) return false; wallet::LiteSendRequest req; const std::uint64_t noteZat = chatDrgxToZat(kChatNoteSizeDrgx); for (int i = 0; i < noteCount; ++i) { wallet::LiteSendRecipient r; r.address = myZ; r.amountZatoshis = noteZat; req.recipients.push_back(std::move(r)); } if (!lite_wallet_->sendTransaction(req)) return false; // channel busy — retry next frame wallet::liteLog("Chat buffer: splitting funds into " + std::to_string(noteCount) + " notes (self-send) so messages can be sent back-to-back"); return true; } // Full-node twin of broadcastSelfSplitLite: a z_sendmany self-send with `noteCount` outputs to our reply // address (funds stay in the wallet), building/refilling the buffer. Caps outputs to stay well under the // per-tx size limit after Sietch decoy padding. markFeeGapRetry=true is load-bearing — the fee-gap retry // rebuilds a SINGLE-recipient tx and would collapse the multi-output split. The terminal callback releases // the send channel; chat_split_outstanding_ stays set until a scan sees the outputs. bool App::broadcastSelfSplitNode(int noteCount) { if (lite_wallet_ || noteCount < 1) return false; if (!state_.connected || !rpc_ || !worker_) return false; const std::string myZ = chatReplyZaddr(); if (myZ.empty()) return false; const double fee = kChatMinFeeDrgx; const int n = std::min(noteCount, 40); // bounded so the split tx stays under max_tx_size after decoys // Pay from any funded z-address; the split's OUTPUTS still go to the reply address (building the buffer // there). ONE address pays the whole split (n notes + the fee), not just the fee: the pump sizes n to the // richest verified payer (chatSplitAffordableZat), so one covers it. Paying from wherever the funds sit // can't stall when they aren't at the reply address (which paying strictly from myZ would). const std::string from = chatPayFromZaddr(n * kChatNoteSizeDrgx + fee); if (from.empty()) return false; // no fundable payer — no RPC; the pump retries after a scan nlohmann::json recipients = nlohmann::json::array(); for (int i = 0; i < n; ++i) { nlohmann::json r; r["address"] = myZ; r["amount"] = util::formatAmountFixed(kChatNoteSizeDrgx); recipients.push_back(std::move(r)); } const auto sessionGen = chat_session_generation_; submitZSendMany(from, myZ, 0.0, fee, /*memo*/"", recipients, "HushChat / note-buffer split", /*markFeeGapRetry*/ true, [this, sessionGen](bool ok, const std::string& /*result*/) { if (sessionGen != chat_session_generation_) return; // stale wallet inflight_op_ = LiteInflightOp{}; // release the channel // On success the outputs aren't mined yet — chat_split_outstanding_ clears on the // next scan (pipeline recovered) or the watchdog. On FAILURE (e.g. a stale-scan // insufficient-funds throw) clear it now so the pump can retry after the next scan // instead of waiting out the 1200s watchdog (no notes were created, so it's safe), // and no other split before the next block. if (!ok) { chat_split_outstanding_ = false; chat_split_hold_tip_ = state_.sync.blocks; chat_note_rescan_requested_ = true; } }, /*background*/ true); // autonomous buffer maintenance — no global send UI debitChatPayerLogged(from, services::ChatPayerSpend::All); wallet::liteLog("Chat buffer: splitting funds into " + std::to_string(n) + " notes (self-send) so messages can be sent back-to-back"); return true; } bool App::shouldSplitChatBuffer() { // Only for engaged chat users (has a conversation or a queued send) — never split for someone who // never chats. Connected, unlocked, channel free, no split already outstanding, pipeline genuinely // depleted, funded. NB: a wall-clock cooldown is deliberately NOT used — a split's outputs stay // invisible until mined (~1 block, far longer than any UI cooldown), so we gate on a single-split- // in-flight flag (cleared when the pipeline recovers or the watchdog fires) to prevent runaway splits. if (chat_service_.store().empty() && chat_send_queue_.empty()) return false; // == conversationIds().empty(), O(1) if (!state_.connected || state_.isLocked()) return false; // Channel busy = our own op inflight, OR a user send holds the shared channel. Lite user sends set // lite_send_callback_; full-node user sends don't touch inflight_op_, so they're detected via the // shared single-flight counter + any outstanding opid (a chat/user/sweep z_sendmany not yet resolved). if (inflight_op_.kind != LiteOpKind::None) return false; if (lite_wallet_ ? (bool)lite_send_callback_ : (send_submissions_in_flight_ > 0 || !pending_opids_.empty())) return false; if (chat_split_outstanding_) return false; if (!lite_wallet_ && chat_split_hold_tip_ >= 0 && state_.sync.blocks <= chat_split_hold_tip_) return false; // failed here: once per block if (!chat_note_model_seen_) return false; if (chat_pipeline_note_count_ >= kChatRefillTrigger) return false; // change refill covers it // Only a split that can lift the pipeline to the trigger by itself: once it mines, the pipeline check // releases the flag. A smaller one never does, and the watchdog would re-split the same notes every 20 min, // a fee each time. (A payer below that sends one message per confirmation through its change instead.) if (chatSplitAffordableZat() < chatDrgxToZat(kChatRefillTrigger * kChatNoteSizeDrgx + kChatMinFeeDrgx)) return false; return true; } // Resolve the just-completed chat/contact broadcast onto its echo. The in-flight op is the queue FRONT // (the pump submits it without popping) so we can requeue it in place on a transient-funds failure. void App::onChatBroadcastResult(const LiteInflightOp& op, bool ok, const std::string& error) { if (op.sessionGen != chat_session_generation_) return; // wallet switched since submit — echo is gone const bool frontMatches = !chat_send_queue_.empty() && chat_send_queue_.front().echoLocalId == op.echoLocalId; if (ok) { chat_service_.resolveOutgoing(op.echoLocalId, chat::ChatDelivery::Sent); if (frontMatches) chat_send_queue_.pop_front(); wallet::liteLog("chat: message broadcast (Sent); " + std::to_string(chat_send_queue_.size()) + " still queued"); return; } if (frontMatches && isTransientVerifiedFundsError(error) && chat_send_queue_.front().retries < kMaxChatSendRetries) { // The optimistic budget was wrong (change not confirmed yet). Stop draining until the next refresh // restores the true verified-note count; keep the message queued ("Sending") and retry then. chat_verified_note_budget_ = 0; ++chat_send_queue_.front().retries; wallet::liteLog("chat: send deferred — funds still confirming (retry " + std::to_string(chat_send_queue_.front().retries) + "); message stays 'Sending'"); return; // leave at front } chat_service_.resolveOutgoing(op.echoLocalId, chat::ChatDelivery::Failed); // hard failure -> Retry affordance if (frontMatches) chat_send_queue_.pop_front(); wallet::liteLog("chat: send FAILED: " + error); } // Per-frame pump: drain the chat queue against verified notes (priority 1); otherwise build/refill the // buffer during idle (priority 2). One op per frame — the single broadcast channel serializes the rest. void App::pumpChatNoteBuffer() { if (!chat::hushChatFeatureEnabledAtBuild()) return; if (!chat_service_.hasIdentity() || !state_.connected) return; // A locked wallet can't spend: submitting would return a non-transient lock error and flap the queued // message to Failed. Hold everything (send + split) until unlock — messages stay "Sending". if (state_.isLocked()) return; // Channel busy — exactly one send must be outstanding. Lite serializes via inflight_op_ + the send // callback; the full node has no single-broadcast channel, so we also honour the shared single-flight // counter and any outstanding opid (a chat/user/sweep z_sendmany not yet terminally resolved). Without // the latter two a chat send could overlap a user Send-tab send (which never sets inflight_op_). if (inflight_op_.kind != LiteOpKind::None) return; if (lite_wallet_ ? (bool)lite_send_callback_ : (send_submissions_in_flight_ > 0 || !pending_opids_.empty())) return; // Priority 1: drain a queued chat send while we (optimistically) still have a verified note. if (!chat_send_queue_.empty()) { QueuedChatOp& front = chat_send_queue_.front(); if (front.sessionGen != chat_session_generation_) { chat_send_queue_.pop_front(); return; } if (chat_verified_note_budget_ >= 1) { // Submit onto the wallet's send channel; NOT popped until the async result resolves (so a // transient-funds failure can requeue it in place). Full-node broadcastChatMemos registers a // terminal opid callback that clears inflight_op_ + routes onChatBroadcastResult. const bool submitted = lite_wallet_ ? broadcastChatMemosLite(front.memos) : broadcastChatMemos(front.memos, front.echoLocalId); if (submitted) { inflight_op_ = LiteInflightOp{ front.kind, front.echoLocalId, front.sessionGen, ImGui::GetTime() }; --chat_verified_note_budget_; // this note is now (optimistically) spent } return; // one op per frame; the front waits until resolved } // No verified note available: leave it queued ("Sending"); fall through to maybe build the buffer. } // Priority 2: build/refill the buffer during idle. if (shouldSplitChatBuffer()) { const int need = kChatBufferTarget - chat_pipeline_note_count_; const std::uint64_t noteZat = chatDrgxToZat(kChatNoteSizeDrgx); const std::uint64_t feeZat = chatDrgxToZat(kChatMinFeeDrgx); int affordable = 0; const std::uint64_t affordableZat = chatSplitAffordableZat(); // one payer funds the whole split if (affordableZat > feeZat && noteZat > 0) affordable = static_cast((affordableZat - feeZat) / noteZat); const int n = std::min(need, affordable); const bool submitted = n >= 1 && (lite_wallet_ ? broadcastSelfSplitLite(n) : broadcastSelfSplitNode(n)); if (submitted) { inflight_op_ = LiteInflightOp{ LiteOpKind::Split, {}, chat_session_generation_, ImGui::GetTime() }; // Block further splits until this one's outputs mine in (or the watchdog fires): the outputs are // invisible to the note count for ~1 block, so without this we'd fire a fresh split every frame. chat_split_outstanding_ = true; chat_split_submitted_at_ = scanClockNow(); // steady: the watchdog also runs while minimized } } } // HushChat (lite variant): the full-node harvest works off z_viewtransaction, but the lite wallet's // transactions come from the backend. The backend lists one entry per received (non-change) note — // same txid, distinct position + decoded-UTF-8 memo — so a chat tx yields two entries (header + // payload). Group the received notes by txid, run them through the SAME parser, and thread the // result. The store dedups (txid+position), so re-listing every refresh is harmless. void App::ingestLiteChatMemos(const wallet::LiteWalletAppRefreshModel& model) { if (!chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return; std::unordered_map byTxid; std::unordered_map txTimestamps; bool anyUnconfirmedChatTx = false; // did a chat-carrying tx arrive still-unconfirmed (0-conf mempool)? for (const auto& tx : model.transactions) { if (tx.kind != wallet::LiteWalletAppTransactionKind::Receive) continue; // only incoming carry chat if (tx.memo.empty() || !tx.position) continue; auto& input = byTxid[tx.txid]; input.txid = tx.txid; input.outputs.push_back({static_cast(*tx.position), tx.memo}); txTimestamps[tx.txid] = tx.timestamp; if (tx.unconfirmed) anyUnconfirmedChatTx = true; } if (byTxid.empty()) return; std::vector metadata; for (auto& entry : byTxid) { auto extracted = chat::extractHushChatTransactionMetadata(entry.second, true); for (auto& meta : extracted.metadata) metadata.push_back(std::move(meta)); } if (!metadata.empty()) { std::vector newChatCids; chat_service_.ingest(metadata, txTimestamps, std::time(nullptr), &newChatCids); if (!newChatCids.empty()) wallet::liteLog("chat: harvested " + std::to_string(newChatCids.size()) + " new message(s) " + (anyUnconfirmedChatTx ? "(0-conf / mempool — monitor working)" : "(confirmed only — not seen at mempool speed)")); // Mirror the full-node paths: a new incoming message un-hides a hidden conversation (you can't // un-receive), and a non-muted new message toasts when we're off the Chat tab. if (settings_) { bool unhid = false; for (const auto& cid : newChatCids) if (settings_->isChatHidden(cid)) { settings_->setChatHidden(cid, false); unhid = true; } if (unhid) settings_->save(); } if (current_page_ != ui::NavPage::Chat && std::any_of(newChatCids.begin(), newChatCids.end(), [this](const std::string& cid){ return !(settings_ && settings_->isChatMuted(cid)); })) ui::Notifications::instance().info(TR("chat_new_message_toast")); } } bool App::chatInboxScanAllowed() const { // Full-node only (lite has its own harvest). if (lite_wallet_ || !chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return false; if (!state_.connected || !rpc_ || !worker_) return false; // The same hard gates as every wallet scan (catch-up, the connect gate, the background, busy, ...), and only // while the Chat page is shown: off it, incoming messages still arrive through the confirmed block-tip // harvest (+ toast) within ~1 block. return services::wholeWalletScanAllowed(scanContext()) && currentScanVisibility().chat; } void App::fastScanChatMemos() { // 0-conf delivery while the Chat page is shown: a cheap probe (getwalletinfo's txcount) each tick, and the // inbox scan (z_listreceivedbyaddress of the reply address: O(mapWallet), the daemon iterates every wtx // even when filtered to one address, under cs_main) only when the wallet gained a transaction or the safety // net is due, and only as the scan budget admits it (ChatFast). The block-tip harvest still ingests every // message on confirmation (the store dedups on txid+position). if (!chatInboxScanAllowed()) return; if (chat_fast_scan_in_flight_) return; // the probe or the scan is out if (chat_inbox_scan_wanted_) { // keep asking (a waiter must keep asking) admitChatInboxScan(); return; } // A wallet scan is in the daemon: the probe would queue behind it for cs_main, and nothing could be // admitted after it anyway. if (scan_governor_.anyInFlight()) return; const double now = scanClockNow(); if (!services::chatProbeDue(chat_inbox_probe_ms_, chat_inbox_probe_at_, now)) return; chat_inbox_probe_at_ = now; chat_fast_scan_in_flight_ = true; const int scanGen = chat_session_generation_; // guard: drop the result if the wallet switches/locks worker_->post([this, scanGen]() -> rpc::RPCWorker::MainCb { std::int64_t txCount = -1; bool answered = true; // false: nothing learned this tick (transport, busy, warmup) const auto t0 = std::chrono::steady_clock::now(); try { rpc::RPCClient::TraceScope trace("HushChat / inbox probe"); txCount = services::parseWalletTxCount(rpc_->call("getwalletinfo")); } catch (const rpc::RpcError& e) { answered = e.kind() != rpc::RpcFailureKind::Warmup; // an app error: no count, scan as the budget allows } catch (...) { answered = false; } const double probeMs = std::chrono::duration(std::chrono::steady_clock::now() - t0).count(); return [this, scanGen, txCount, answered, probeMs]() { chat_fast_scan_in_flight_ = false; // ours, whatever the session // Its cost, from probes that answered only (a timed-out one measured its wait), smoothed so one slow // probe doesn't silence the next ones. if (answered && probeMs > 0.0) { chat_inbox_probe_ms_ = chat_inbox_probe_ms_ <= 0.0 ? probeMs : std::min(probeMs, 0.7 * chat_inbox_probe_ms_ + 0.3 * probeMs); } if (scanGen != chat_session_generation_ || !answered) return; services::ChatInboxScanInputs in; in.basisTxCount = chat_inbox_basis_txcount_; in.txCount = txCount; in.nowSec = scanClockNow(); in.lastScanAtSec = chat_inbox_last_scan_at_; in.safetyNetSec = services::chatInboxSafetyNetSec(scan_governor_.emaCostMs(services::ScanStream::ChatFast)); if (!services::chatInboxScanNeeded(in)) return; chat_inbox_scan_wanted_ = true; chat_inbox_wanted_txcount_ = txCount; // read before the scan: the basis can only under-count (one extra scan) admitChatInboxScan(); }; }); } void App::admitChatInboxScan() { if (!chatInboxScanAllowed() || chat_fast_scan_in_flight_) return; // a gate may have closed since the probe const std::string addr = chatReplyZaddr(); if (addr.empty()) return; auto ticket = scan_governor_.tryAdmit(services::ScanStream::ChatFast, /*visible=*/true, currentScanSignals(), scanClockNow()); if (!ticket) return; // still wanted: the next chat tick asks again chat_fast_scan_in_flight_ = true; const std::int64_t basis = chat_inbox_wanted_txcount_; const int scanGen = chat_session_generation_; // guard: drop the result if the wallet switches/locks worker_->post([this, addr, scanGen, ticket, basis]() -> rpc::RPCWorker::MainCb { std::vector metadata; int rawMemoCount = 0; // received notes carrying a memo at the reply addr (0-conf visibility signal) std::string scanError; bool ok = false; rpc::RpcFailureKind failKind = rpc::RpcFailureKind::None; const auto scanStart = std::chrono::steady_clock::now(); try { rpc::RPCClient::TraceScope trace("HushChat / 0-conf inbox scan"); nlohmann::json received = rpc_->call("z_listreceivedbyaddress", {addr, 0}); // 0 = include mempool if (received.is_array()) { std::unordered_map byTxid; std::size_t noteIndex = 0; for (const auto& note : received) { const std::size_t fallbackPos = noteIndex++; if (!note.is_object()) continue; const std::string txid = note.value("txid", std::string()); const std::string memo = note.value("memoStr", std::string()); if (txid.empty() || memo.empty()) continue; ++rawMemoCount; std::size_t pos = fallbackPos; for (const char* key : {"position", "outputIndex", "outindex"}) if (note.contains(key) && note[key].is_number_integer() && note[key].get() >= 0) { pos = static_cast(note[key].get()); break; } auto& in = byTxid[txid]; in.txid = txid; in.outputs.push_back(chat::HushChatMemoOutput{pos, memo}); } for (auto& entry : byTxid) { auto extracted = chat::extractHushChatTransactionMetadata(entry.second); for (auto& m : extracted.metadata) metadata.push_back(std::move(m)); } ok = true; } } catch (const std::exception& e) { scanError = e.what(); failKind = rpc::rpcFailureKindOf(e); } catch (...) { scanError = "unknown error"; failKind = rpc::RpcFailureKind::Other; } const int parsedCount = static_cast(metadata.size()); const double scanMs = std::chrono::duration( std::chrono::steady_clock::now() - scanStart).count(); return [this, scanGen, ticket, basis, ok, failKind, metadata = std::move(metadata), rawMemoCount, parsedCount, scanError, scanMs]() mutable { chat_fast_scan_in_flight_ = false; // Whatever the session: the daemon ran it. Before the guard, or a stale reply would hold the slot. scan_governor_.complete(ticket, scanMs, services::chatScanOutcome(ok, failKind), scanClockNow()); // The wallet was switched/locked between post and now — this metadata belongs to the previous // session: drop it. if (scanGen != chat_session_generation_) return; if (ok) { // an empty inbox is a result too: before the early returns below chat_inbox_scan_wanted_ = false; chat_inbox_basis_txcount_ = basis; chat_inbox_last_scan_at_ = ticket.dispatchedAt; } if (!scanError.empty()) { wallet::liteLog("chat[0conf]: reply-addr scan failed: " + scanError); } else if (rawMemoCount != chat_fast_scan_last_seen_) { chat_fast_scan_last_seen_ = rawMemoCount; if (rawMemoCount > 0) wallet::liteLog("chat[0conf]: reply-addr scan sees " + std::to_string(rawMemoCount) + " memo note(s), " + std::to_string(parsedCount) + " parse as chat"); } if (metadata.empty()) return; // Skip HIDDEN conversations — the mempool fast path deliberately doesn't surface them; they // still come back through the normal confirmed harvest (which un-hides on a new message). std::vector visible; visible.reserve(metadata.size()); for (auto& m : metadata) if (!(settings_ && settings_->isChatHidden(m.conversation_id))) visible.push_back(std::move(m)); if (visible.empty()) return; std::unordered_map noTimes; // mempool: no block time → ingest uses now std::vector newChatCids; chat_service_.ingest(visible, noTimes, std::time(nullptr), &newChatCids); if (!newChatCids.empty()) wallet::liteLog("chat[0conf]: harvested " + std::to_string(newChatCids.size()) + " new message(s) from the mempool"); if (current_page_ != ui::NavPage::Chat && std::any_of(newChatCids.begin(), newChatCids.end(), [this](const std::string& cid){ return !(settings_ && settings_->isChatMuted(cid)); })) ui::Notifications::instance().info(TR("chat_new_message_toast")); }; }); } bool App::lockLiteWallet() { if (!lite_wallet_) return false; const bool ok = lite_wallet_->lockWallet(); if (ok) { // Full-node App::lockWallet() sets state_.locked synchronously; the lite backend `lock` // doesn't, and state_.locked only refreshes on the next ~2s poll. Mirror the full-node // behavior here and tear down the chat session NOW so no decrypted store / unlocked DB key // lingers past an explicit lock. (These chat calls are safe no-ops when chat is off/idle.) state_.locked = true; chat_service_.clearIdentity(); chat_service_.store().clear(); chat_db_.lock(); chat_identity_provisioned_ = false; chat_identity_unavailable_ = false; } return ok; } bool App::seedChatDemoData() { if (!chat::hushChatFeatureEnabledAtBuild()) return false; // Demo identity so the tab renders the populated UI (hasIdentity()==true). Injected messages go // straight into the in-memory store (no DB attached here → not persisted, gone on restart). // // Two hard rules, both learned the hard way: // 1. NEVER clobber a real provisioned identity. Overwriting the wallet's seed-derived identity with // a demo one made two different-seed wallets share an identity — so their own sends looped back // and DECRYPTED as incoming. Only fabricate an identity when there is no real one. // 2. Use a RANDOM secret, never a fixed string. A constant demo secret is the SAME public key on // every install/wallet, i.e. everyone who ran the demo shared one cryptographic identity. bool fabricated = false; if (!chat_service_.hasIdentity()) { chat::ChatKeyPair keys; const std::string demoSecret = generateChatLocalId("demo-chat:", 24); // random, per-run if (chat::deriveChatIdentityFromSecret(demoSecret, keys).status == chat::ChatIdentityStatus::Ready) { chat_service_.setIdentity(keys); fabricated = true; } chat::wipeChatKeyPair(keys); } auto& store = chat_service_.store(); const std::string zAlice = "zs1demoalice6xh2n8fchrz23thcgqqd2353v8ev2pr7p7lq4p3elsyrfkuenq"; const std::string zBob = "zs1demobob9k4p3elsyrfkuenq4kl79j2pg7l3h2juz4t6q9wqxkha2n8fchrz2"; auto add = [&](const std::string& cid, const std::string& z, const std::string& pk, chat::ChatDirection dir, chat::ChatMessageKind kind, const std::string& body, std::int64_t ts, const std::string& id) { chat::ChatMessage m; m.direction = dir; m.kind = kind; m.conversation_id = cid; m.peer_zaddr = z; m.peer_public_key_hex = pk; m.body = body; m.timestamp = ts; m.txid = id; m.payload_position = 0; store.append(m); }; // A full thread (peer key known → composer enabled). add("demo-1", zAlice, std::string(64, 'a'), chat::ChatDirection::Incoming, chat::ChatMessageKind::Message, "hey! got the DragonX wallet running, chat works great", 1751284800, "demo:a1"); add("demo-1", zAlice, std::string(64, 'a'), chat::ChatDirection::Outgoing, chat::ChatMessageKind::Message, "nice - this is end-to-end encrypted over shielded memos", 1751285400, "demo:a2"); add("demo-1", zAlice, std::string(64, 'a'), chat::ChatDirection::Incoming, chat::ChatMessageKind::Message, "and it survives the daemon's output shuffle now", 1751286000, "demo:a3"); // An incoming contact request (peer key known → can reply). add("demo-2", zBob, std::string(64, 'b'), chat::ChatDirection::Incoming, chat::ChatMessageKind::ContactRequest, "hi, add me? - bob", 1751200000, "demo:b1"); // An outgoing contact request awaiting a reply (no peer key → "waiting" composer state). add("demo-3", "zs1demopeerawaitingtheirfirstreplybeforewecanmessagethemyet00", "", chat::ChatDirection::Outgoing, chat::ChatMessageKind::ContactRequest, "hey, let's chat", 1751100000, "demo:c1"); return fabricated; } void App::exportAllKeys(std::function callback) { if (!state_.connected || !rpc_) { if (callback) callback("", 0, 0); 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); auto exported = std::make_shared(0); // keys actually retrieved (vs. failed/locked) // 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", 0, 0); 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, exported, callback, addr](const std::string& key) { if (!key.empty()) { *keys_result += "# " + addr + "\n"; *keys_result += key + "\n\n"; (*exported)++; } (*pending)--; if (*pending == 0 && callback) { callback(*keys_result, *exported, *total); // Scrub the concatenated all-keys buffer once the consumer (backup writer) has used it. if (!keys_result->empty()) sodium_memzero(&(*keys_result)[0], keys_result->size()); } }); } } void App::importPrivateKey(const std::string& rawKey, int startHeight, std::function callback) { if (!state_.connected || !rpc_ || !worker_) { if (callback) callback(false, "Not connected", ""); return; } // Trim whitespace/newlines — manual entry doesn't go through the dialog's Paste trimmer, and a // stray character makes the daemon reject the key with a cryptic error. std::string key(rawKey); while (!key.empty() && (key.front()==' '||key.front()=='\t'||key.front()=='\n'||key.front()=='\r')) key.erase(key.begin()); while (!key.empty() && (key.back()==' '||key.back()=='\t'||key.back()=='\n'||key.back()=='\r')) key.pop_back(); // Reject anything that isn't a recognized Z/T private key or shielded viewing key before handing // it to the daemon (the dialog's indicator and this guard share isRecognizedImportKey). if (!services::WalletSecurityController::isRecognizedImportKey(key)) { if (callback) callback(false, "Not a recognized DragonX private key or viewing key. Check for missing or " "mistyped characters, and that this is a DragonX key (not another coin).", ""); return; } // One long wallet RPC at a time (C61): refuse before the key is copied into a closure. { std::string why; const char* trKey = nullptr; if (longWalletOpRefused(&why, &trKey)) { DEBUG_LOGF("[App] key import refused: %s\n", why.c_str()); if (!key.empty()) sodium_memzero(&key[0], key.size()); if (callback) callback(false, TR(trKey), ""); return; } } const bool viewing = services::WalletSecurityController::isViewingKey(key); const bool shielded = services::WalletSecurityController::classifyPrivateKey(key) == services::WalletSecurityController::KeyKind::Shielded; // Run on the long-call lane — import requests a full rescan (rescan=true), so the call can take // minutes to hours: no client timeout, and never on the UI thread or a shared client. const bool started = runLongWalletTask([this, key, viewing, shielded, startHeight, callback](rpc::RPCClient& c) mutable -> rpc::RPCWorker::MainCb { std::string err, addr; rpc::LongCallCutOff cutOff = rpc::LongCallCutOff::None; // C61: how a transport failure ended the call try { rpc::RPCClient::TraceScope trace("Settings / Import key"); std::string method; nlohmann::json params; if (viewing) { method = "z_importviewingkey"; params = {key, "yes"}; } // watch-only else if (shielded) { method = "z_importkey"; params = {key, "yes"}; } else { method = "importprivkey"; params = {key, "", true}; } // A start height (shielded RPCs only) rescans from that block instead of genesis. if (startHeight > 0 && (viewing || shielded)) params.push_back(startHeight); nlohmann::json r = c.call(method, params, 0L); // 0 = no client timeout (the scan can take hours) // Scrub the key out of the request params (the json holds its own copy of it). if (params.is_array() && !params.empty() && params[0].is_string()) { std::string& pk = params[0].get_ref(); if (!pk.empty()) sodium_memzero(&pk[0], pk.size()); } // z_import* return {type,address}; importprivkey returns the t-address string. if (r.is_object() && r.contains("address") && r["address"].is_string()) addr = r["address"].get(); else if (r.is_string()) addr = r.get(); } catch (const std::exception& e) { err = e.what(); // Cut off after the key may have reached the node: that is not a rejected key (D6 wording below). // Only the node's own HTTP-layer refusals say the import never ran. if (const auto* t = dynamic_cast(&e)) cutOff = rpc::classifyLongCallCutOff(t->kind, t->requestSent); } catch (...) { // Non-std throw must not escape into the worker — the main-thread callback // below would never run, leaving a stuck "Importing…" spinner. err = "Import failed (unknown error)"; } // Scrub the worker's copy of the key now that the request has been sent (all paths). if (!key.empty()) sodium_memzero(&key[0], key.size()); // A checksum-valid key the daemon still rejects is almost always the right *format* but the // wrong network/coin (Komodo-family chains share version bytes) or a corrupted paste — say so, // since the bare "Invalid …" text reads like a wallet bug (F5). if (!err.empty() && err.find("Invalid") != std::string::npos && err.find("DragonX") == std::string::npos) { err += " — check the key is for DragonX (not another coin or network) and has no missing " "or altered characters."; } return [this, err, addr, callback, cutOff]() { if (!err.empty()) { // The node may still be scanning, and its z_importkey default ("whenkeyisnew") won't rescan a key // it already has: say how to make the funds appear. std::string msg = err; switch (cutOff) { case rpc::LongCallCutOff::Cancelled: msg = TR("appx_import_cancelled"); break; case rpc::LongCallCutOff::Interrupted: msg = err + " — " + TR("appx_import_interrupted"); break; case rpc::LongCallCutOff::Refused: msg = err + " — " + TR("appx_import_refused"); break; case rpc::LongCallCutOff::None: break; } if (callback) callback(false, msg, ""); return; } invalidateAddressValidationCache(); refreshAddresses(); if (callback) callback(true, "", addr); }; }); if (!started && callback) callback(false, TR("appx_wallet_op_running"), ""); if (!key.empty()) sodium_memzero(&key[0], key.size()); // scrub the calling-frame copy } // Sweep a spending key: import it (a full rescan populates its UTXOs/notes — the stock node has no // address index, so there is no way to enumerate them without importing) then z_sendmany ALL of the // key's funds (balance − fee) to a destination the user already controls. The imported key is left in // the wallet with an empty balance (there is no remove-key RPC) — the point is that the funds now sit // on the user's own key. z_sendmany (not z_mergetoaddress): it moves a single-UTXO transparent source // and fails loudly rather than silently leaving a remainder. Fund-moving — see the reviewed design. void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMode, const std::string& destExisting) { if (!state_.connected || !rpc_ || !worker_) { sweep_status_ = "Not connected to the daemon."; sweep_step_ = SweepStep::Error; return; } // Trim (manual entry doesn't go through the dialog's Paste trimmer). std::string key(rawKey); while (!key.empty() && (key.front()==' '||key.front()=='\t'||key.front()=='\n'||key.front()=='\r')) key.erase(key.begin()); while (!key.empty() && (key.back()==' '||key.back()=='\t'||key.back()=='\n'||key.back()=='\r')) key.pop_back(); // Sweeping requires a SPENDING key (transparent WIF or shielded z-spending key) — a viewing key // can't sign, and this must never accidentally route to z_importviewingkey. if (!services::WalletSecurityController::isRecognizedPrivateKey(key)) { sweep_status_ = "Enter a spending key (a viewing key can't move funds)."; sweep_step_ = SweepStep::Error; return; } // One long wallet RPC at a time (C61): refuse before Running and before the key is copied into a closure. { std::string why; const char* trKey = nullptr; if (longWalletOpRefused(&why, &trKey)) { DEBUG_LOGF("[App] sweep refused: %s\n", why.c_str()); if (!key.empty()) sodium_memzero(&key[0], key.size()); sweep_step_ = SweepStep::Error; sweep_status_ = TR(trKey); return; } } sweep_step_ = SweepStep::Running; sweep_status_ = "Importing key & rescanning…"; sweep_txid_.clear(); sweep_dest_shown_.clear(); const bool shielded = services::WalletSecurityController::classifyPrivateKey(key) == services::WalletSecurityController::KeyKind::Shielded; const double fee = DRAGONX_DEFAULT_FEE; // The body below is today's worker body, unchanged except that it calls the lane's client c (the import // with no client timeout) instead of rpc_: a fund-moving sequence, not split or reordered (C61 D3). const bool started = runLongWalletTask([this, key, startHeight, destMode, destExisting, shielded, fee](rpc::RPCClient& c) mutable -> rpc::RPCWorker::MainCb { std::string err, dest, sourceAddr, amountStr; double amount = 0.0; try { rpc::RPCClient::TraceScope trace("Settings / Sweep key"); // For a shielded key this daemon's z_importkey returns null (no address), so snapshot the // z-address set before the import and diff it after to find the newly-controlled address. std::vector preZ; bool preZok = false; auto listZ = [&](std::vector& out) { out.clear(); auto la = c.call("z_listaddresses"); if (la.is_array()) for (auto& a : la) if (a.is_string()) out.push_back(a.get()); }; if (shielded) { try { listZ(preZ); preZok = true; } catch (...) { preZok = false; } } // 1. Import the key (blocks until the rescan completes → its funds become spendable). std::string method; nlohmann::json params; if (shielded) { method = "z_importkey"; params = {key, "yes"}; } else { method = "importprivkey"; params = {key, "", true}; } if (startHeight > 0 && shielded) params.push_back(startHeight); nlohmann::json r = c.call(method, params, 0L); // Scrub the key out of the request params (the json holds its own copy of it). if (params.is_array() && !params.empty() && params[0].is_string()) { std::string& pk = params[0].get_ref(); if (!pk.empty()) sodium_memzero(&pk[0], pk.size()); } // 2. Determine the swept address. importprivkey returns the t-address string; z_importkey // returns null, so diff the z-address list to find the one the key just added. if (shielded) { // A failed pre-snapshot would make the diff treat pre-existing addresses as "new" and // could pick the WRONG source — refuse rather than risk moving another address's funds. if (!preZok) throw std::runtime_error("Couldn't read the wallet's addresses to identify the swept key. Try again."); std::vector postZ; listZ(postZ); std::vector added; for (const auto& s : postZ) if (std::find(preZ.begin(), preZ.end(), s) == preZ.end()) added.push_back(s); if (added.size() == 1) sourceAddr = added[0]; else if (added.empty()) throw std::runtime_error("__ALREADY__"); // key already in wallet else throw std::runtime_error("Could not identify the key's address after import."); } else { if (r.is_string()) sourceAddr = r.get(); else if (r.is_object() && r.contains("address") && r["address"].is_string()) sourceAddr = r["address"].get(); } if (sourceAddr.empty()) throw std::runtime_error("Could not determine the key's address."); // 3. Confirm the key holds spendable (confirmed) funds. z_getbalance covers t- and z-addrs. auto balAt = [&](int minconf) -> double { try { nlohmann::json b = c.call("z_getbalance", {sourceAddr, minconf}); return b.is_number() ? b.get() : b.is_string() ? std::stod(b.get()) : 0.0; } catch (...) { return 0.0; } }; double bal = balAt(1); if (bal <= 0.0) { throw std::runtime_error(balAt(0) > 0.0 ? "__UNCONFIRMED__" : "__NOFUNDS__"); } if (bal <= fee) throw std::runtime_error("__DUST__"); // 4. Resolve the destination — a fresh shielded address by default, else the picked one. if (destMode == 0) dest = c.call("z_getnewaddress").get(); else dest = destExisting; if (dest.empty()) throw std::runtime_error("No destination address for the sweep."); // Send everything; the fee consumes the remainder (no change/residue). Compute in integer // satoshis so the fixed-decimal amount is exact for all realistic balances (double bal-fee // is exact below 2^52 sat ≈ 45M DRGX; above that the JSON-parsed balance is itself lossy — // a swept key that large is implausible and would fail safe with funds left in place). const long long feeSats = (long long)std::llround(fee * 100000000.0); const long long amtSats = (long long)std::llround(bal * 100000000.0) - feeSats; if (amtSats <= 0) throw std::runtime_error("__DUST__"); char amtBuf[32]; snprintf(amtBuf, sizeof(amtBuf), "%lld.%08lld", amtSats / 100000000LL, amtSats % 100000000LL); amountStr = amtBuf; amount = (double)amtSats / 100000000.0; // for send bookkeeping / display only } catch (const std::exception& e) { err = e.what(); } catch (...) { err = "Sweep failed (unknown error)"; } // Scrub the worker's copy of the spending key now that the request has been sent (all paths). if (!key.empty()) sodium_memzero(&key[0], key.size()); return [this, err, sourceAddr, dest, amount, amountStr, fee]() { invalidateAddressValidationCache(); refreshAddresses(); if (!err.empty()) { if (err == "__NOFUNDS__") sweep_status_ = "This key holds no funds to sweep."; else if (err == "__UNCONFIRMED__") sweep_status_ = "This key's funds are still unconfirmed — try again shortly."; else if (err == "__ALREADY__") sweep_status_ = "This key is already in your wallet — use Send to move its funds."; else if (err == "__DUST__") sweep_status_ = "This key's balance is too small to cover the network fee."; else sweep_status_ = err; sweep_step_ = SweepStep::Error; return; } sweep_dest_shown_ = dest; sweep_status_ = "Sweeping funds to your address…"; // Send all funds from the swept address to the destination through the tested z_sendmany // wrapper (fixed-decimal amount + async-op tracking). z_sendmany needs every input in one // transaction to cover the full balance, so it fails loudly rather than partial-sweeping. nlohmann::json recipients = nlohmann::json::array(); nlohmann::json rcp; rcp["address"] = dest; rcp["amount"] = amountStr; // exact integer-satoshi fixed-decimal string recipients.push_back(rcp); submitZSendMany(sourceAddr, dest, amount, fee, "", recipients, "Settings / Sweep key", /*markFeeGapRetry*/ false, [this](bool ok, const std::string& result) { if (ok) { sweep_txid_ = result; sweep_status_.clear(); sweep_step_ = SweepStep::Done; } else { sweep_status_ = result.empty() ? "The sweep transaction failed." : result; sweep_step_ = SweepStep::Error; } refreshBalance(); }); }; }); if (!started) { sweep_step_ = SweepStep::Error; sweep_status_ = TR("appx_wallet_op_running"); } if (!key.empty()) sodium_memzero(&key[0], key.size()); // scrub the calling-frame copy } void App::exportSeedPhrase(std::function callback) { if (!state_.connected || !rpc_ || !worker_) { if (callback) callback(false, false, std::string(), "Not connected to the daemon."); return; } worker_->post([this, callback]() -> rpc::RPCWorker::MainCb { std::string phrase; // SECRET — wiped after the callback runs std::string err; bool ok = false; bool noMnemonic = false; rpc::RPCClient::TraceScope trace("Settings / Export seed phrase"); try { auto response = rpc_->callSecret("z_exportmnemonic"); // zero the raw body too (B7) if (response.contains("mnemonic") && response["mnemonic"].is_string()) { auto& m = response["mnemonic"].get_ref(); phrase = m; if (!m.empty()) sodium_memzero(&m[0], m.size()); // scrub the json's own copy ok = !phrase.empty(); } else { err = "The daemon returned no seed phrase."; } } catch (const rpc::RpcError& e) { err = e.what(); // The daemon errors with this specific message when the wallet's seed is not // mnemonic-derived (a legacy wallet). Any other error (e.g. locked) stays generic. if (err.find("not derived from a mnemonic") != std::string::npos) noMnemonic = true; } catch (const std::exception& e) { err = e.what(); } return [callback, ok, noMnemonic, phrase = std::move(phrase), err]() mutable { if (callback) callback(ok, noMnemonic, phrase, err); if (!phrase.empty()) sodium_memzero(&phrase[0], phrase.size()); }; }); } // One-time nudge to back up the seed phrase on a mnemonic-backed full-node wallet. Fires at // most one z_exportmnemonic probe per install; the phrase is not retained (the probe only // learns whether a mnemonic exists). Legacy (non-mnemonic) wallets are not nagged. void App::maybeRemindSeedBackup() { if (capture_mode_) return; // no live ops during a UI sweep if (window_background_) return; // when someone can see it if (lite_wallet_) return; // lite has its own seed UX if (!settings_ || settings_->getSeedBackupReminded()) return; if (!state_.connected || !state_.encryption_state_known) return; if (state_.isLocked()) return; // wait until unlocked to read it if (seed_backup_reminder_in_flight_) return; // An inconclusive answer (an error other than "not derived from a mnemonic" — e.g. a daemon without // the RPC, or a busy one) used to re-fire this probe on the very next frame, hammering the daemon. if (std::chrono::steady_clock::now() < seed_backup_reminder_retry_at_) return; seed_backup_reminder_in_flight_ = true; exportSeedPhrase([this](bool ok, bool noMnemonic, const std::string& /*phrase*/, const std::string& /*error*/) { seed_backup_reminder_in_flight_ = false; // Only mark "reminded" once we have a definitive answer, so a transient failure retries // next session rather than silently suppressing the reminder forever. if (ok || noMnemonic) { settings_->setSeedBackupReminded(true); settings_->save(); } else { seed_backup_reminder_retry_at_ = std::chrono::steady_clock::now() + std::chrono::minutes(5); } if (ok) ui::Notifications::instance().info(TR("seed_backup_reminder"), 12.0f); }); } // One-time nudge (full-node) when the BDB wallet.dat has bloated past the threshold. Berkeley DB never // shrinks in place and shielded-note witness data accumulates, so a mining/shielded wallet can grow // unbounded. Fires ONCE (persisted flag) a warning toast + a clickable "Consolidate notes…" entry in the // bell/alert panel that opens Merge to Address; re-arms if the file later drops back under the threshold. bool App::startConsolidation(const std::vector& groups, double fee, int batchLimit) { auto& n = ui::Notifications::instance(); if (isLiteBuild() || capture_mode_ || shutting_down_.load()) return false; if (consolidation_.active()) { n.warning(TR("merge_run_busy")); return false; } if (show_seed_migration_ || (settings_ && settings_->getSeedMigrationPending())) { n.warning(TR("merge_run_migration_pending")); return false; } if (!rpc_ || !rpc_->isConnected() || !worker_ || !state_.connected || isWalletSwitchInProgress()) { n.error(TR("merge_run_start_failed")); return false; } services::RunConfig cfg; cfg.fee = fee; cfg.limit = batchLimit; if (!consolidation_.start(groups, cfg, services::steadySeconds())) { n.warning(TR("merge_keep_each_select")); return false; } n.success(TR("merge_started")); return true; // syncing, the lock, a busy node and pending sends are the run's own pauses } void App::pumpConsolidation() { if (!consolidation_.active()) return; const double now = services::steadySeconds(); const services::ScanContext ctx = scanContext(); const bool unlockExpired = state_.encrypted && !state_.locked && state_.unlocked_until > 0 && std::time(nullptr) >= static_cast(state_.unlocked_until); services::RunEnv env; // Not walletScanReady(): the run is the user's job (no background hold, no connect gate), and it tells its // pauses apart. Not rpc_->isWarmingUp(): that client flag sticks for the session. env.nodeUsable = !isLiteBuild() && !shutting_down_.load() && !daemon_restarting_.load() && worker_ && ctx.connected && !ctx.warmingUp && !ctx.runtimeRescan && !state_.sync.rescanning; env.busy = ctx.rpcBusy; env.callsAllowed = !ctx.syncing; env.locked = ctx.locked || unlockExpired; env.userSendBusy = hasTransactionSendProgress(); // a send / shield / auto-shield / chat op of ours goes first env.scanBusy = scan_governor_.anyInFlight(); // never stack a merge scan on another GUI scan (both lanes) if ((consolidation_.consumeLockResyncRequest() || unlockExpired) && now >= run_lock_resync_at_) { run_lock_resync_at_ = now + 30.0; refreshEncryptionState(); } const services::RunStep step = consolidation_.next(env, now); if (step.action == services::RunAction::Call) postConsolidationCall(step.addrIdx); else if (step.action == services::RunAction::Poll) postConsolidationPoll(); else if (step.action == services::RunAction::Finish) finishConsolidation(); } void App::postConsolidationCall(int addrIdx) { if (!worker_ || !rpc_) return; // before markCallPosted: asked again next update const auto& addrs = consolidation_.addresses(); if (addrIdx < 0 || addrIdx >= static_cast(addrs.size())) return; const std::string address = addrs[addrIdx].group.address; const std::uint64_t gen = consolidation_.gen(); const std::uint64_t session = rpc_session_; const double fee = consolidation_.config().fee; const int limit = consolidation_.config().limit; const services::ScanTicket ticket = beginAdhocWalletScan(); // GetFilteredNotes: a whole-wallet scan consolidation_.markCallPosted(addrIdx, services::steadySeconds()); worker_->post([this, rpc = rpc_.get(), address, addrIdx, gen, fee, limit, session, ticket]() -> rpc::RPCWorker::MainCb { services::MergeCallResult r; rpc::RpcFailureKind kind = rpc::RpcFailureKind::None; const auto t0 = std::chrono::steady_clock::now(); try { MaintenanceCallScope maint(*this); // a disconnect aborts it, or refuses it unsent rpc::RPCClient::TraceScope trace("Consolidate / Merge batch"); // fromaddresses = [addr], toaddress = addr: the address's notes merge back into it. const nlohmann::json res = rpc->call("z_mergetoaddress", {nlohmann::json::array({address}), address, fee, limit, limit}, services::kRunCallTimeoutSec); services::readMergeCallResult(res, r); } catch (const std::exception& e) { r = services::MergeCallResult{}; r.error = e.what(); if (r.error.empty()) r.error = "error"; kind = services::mergeCallFailureKind(e); // sent but cut off: uncertain, never resent } catch (...) { r = services::MergeCallResult{}; r.error = "unknown error"; kind = rpc::RpcFailureKind::Other; } const double secs = std::chrono::duration(std::chrono::steady_clock::now() - t0).count(); return [this, gen, addrIdx, r, kind, secs, session, ticket]() { endAdhocWalletScan(ticket, secs * 1000.0, services::chatScanOutcome(r.error.empty(), kind)); const std::string orphan = consolidation_.onCallResult(gen, addrIdx, r, kind, secs, services::steadySeconds()); if (!orphan.empty() && session == rpc_session_) trackOperation(orphan); // never lose an accepted merge }; }); } void App::postConsolidationPoll() { if (!worker_ || !rpc_) return; const std::vector ids = consolidation_.outstandingOpids(); const auto filter = NetworkRefreshService::buildOperationFilterParams(ids); if (!filter) return; const std::set wanted(ids.begin(), ids.end()); const std::uint64_t gen = consolidation_.gen(); consolidation_.markPollPosted(services::steadySeconds()); // Not charged: z_getoperationstatus reads only the async ops map. It needs the filter fallback itself: the // shared opid poller only learns that the filter is unsupported when it has ops of its own. worker_->post([this, rpc = rpc_.get(), params = *filter, wanted, gen]() -> rpc::RPCWorker::MainCb { bool ok = false; std::vector reports; try { rpc::RPCClient::TraceScope trace("Consolidate / Batch status"); nlohmann::json result; if (!opid_filter_unsupported_.load(std::memory_order_relaxed)) { try { result = rpc->call("z_getoperationstatus", params); } catch (const rpc::RpcError& e) { if (e.kind() == rpc::RpcFailureKind::Warmup) throw; result = rpc->call("z_getoperationstatus", nlohmann::json::array()); opid_filter_unsupported_.store(true, std::memory_order_relaxed); } } else { result = rpc->call("z_getoperationstatus", nlohmann::json::array()); } ok = result.is_array(); reports = services::parseOpReports(result, wanted); } catch (...) { ok = false; reports.clear(); } return [this, gen, ok, reports = std::move(reports)]() { consolidation_.onPollResult(gen, ok, reports, services::steadySeconds()); }; }); } void App::finishConsolidation() { const services::RunSummary s = consolidation_.summary(); // 2E: a batch of user ops re-reads the wallet and forces one balance, at its end. if (s.batchesSucceeded > 0) markWalletMutated(/*forceBalance=*/true); else if (s.batchesSubmitted > 0) noteWalletMutation(); std::string err = s.firstError.empty() ? std::string(TR("shield_unknown_error")) : s.firstError; if (err.size() > 160) err = err.substr(0, 160) + "..."; auto& n = ui::Notifications::instance(); switch (services::runVerdict(s)) { case services::RunVerdict::Clean: n.success(TR("shield_merge_done")); break; case services::RunVerdict::NothingSubmitted: n.error(TR("merge_keep_each_nothing_submitted")); break; case services::RunVerdict::SomeFailed: n.error(std::string(TR("shield_op_failed")) + err); break; case services::RunVerdict::Unconfirmed: n.warning(TR("send_status_unconfirmed")); break; case services::RunVerdict::Stopped: n.info(TR("merge_run_stopped_toast")); break; case services::RunVerdict::Halted: { char b[512]; std::snprintf(b, sizeof(b), TR("merge_run_halted_fmt"), err.c_str()); n.error(b); break; } default: break; // Aborted is announced where it happens; it never reaches Finish } } void App::maybeWarnLargeWallet() { if (capture_mode_ || lite_wallet_) return; // no live nags during a UI sweep; lite has no wallet.dat if (window_background_) return; // when someone can see it if (!supportsFullNodeLifecycleActions() || !settings_) return; if (!state_.connected || !state_.encryption_state_known) return; if (state_.warming_up || state_.daemon_initializing || !state_.sync.isSynced()) return; if (large_wallet_checked_) return; // stat wallet.dat at most once per launch large_wallet_checked_ = true; static constexpr uint64_t kWalletBloatWarnBytes = 500ull * 1024 * 1024; // 500 MB (matches the Settings banner) const std::string walletPath = util::Platform::getDragonXDataDir() + "/wallet.dat"; const uint64_t sz = util::Platform::getFileSize(walletPath); if (sz <= kWalletBloatWarnBytes) { // Re-arm the one-time warning if the file shrank back under the threshold (e.g. after a fresh seed wallet). if (settings_->getLargeWalletWarned()) { settings_->setLargeWalletWarned(false); settings_->save(); } return; } if (settings_->getLargeWalletWarned()) return; // already warned once for this bloat episode settings_->setLargeWalletWarned(true); settings_->save(); ui::Notifications::instance().action( TR("wallet_size_warn"), ui::NotificationType::Warning, []() { ui::ShieldDialog::showConsolidate(); }, TR("wallet_size_consolidate"), 12.0f); } // Complementary on-disk safety net (full-node) for a wallet salvage we did NOT witness this launch — it // happened on a prior run, or under an external daemon whose startup output we never captured, so // detectWalletAutoRecovery() never fired. If the active wallet loads EMPTY while a sibling wallet file in // the datadir still holds keys, warn once: the user's funds were likely moved into a wallet..bak by a // prior BDB salvage and are not lost, just in another file. Fires at most once per wallet-open and once per // unacknowledged wallet filename; the probe runs off the UI thread (scanFundedSiblingsAsync). void App::maybeWarnEmptyWalletWithFundedSiblings() { if (capture_mode_) return; // no live ops during a UI sweep if (lite_wallet_ || !supportsFullNodeLifecycleActions()) return; // full-node only (lite = single-file dir) if (!settings_) return; if (empty_wallet_warn_checked_ || empty_wallet_scan_in_flight_) return; // at most once per wallet-open if (show_empty_wallet_warning_) return; // already surfaced // The console-driven recovery flow owns the salvage-this-launch case — don't double-warn. if (wallet_auto_recovered_ || show_wallet_recovered_dialog_) return; // Only meaningful once the wallet is truly loaded AND fully synced: a mid-sync wallet reads empty. if (!state_.connected || !state_.encryption_state_known) return; if (state_.warming_up || state_.daemon_initializing || !state_.sync.isSynced()) return; // Wait for the first Core refresh to land. The ConnectionInit prefetch sets sync.blocks but NOT headers, // so isSynced() (blocks >= headers-2) is spuriously true in the window before the Core refresh — during // which balance/addresses also read 0. last_balance_update flips non-zero only when the Core refresh // applies (network_refresh_service.cpp), by which point balance & headers are real. if (state_.last_balance_update == 0) return; // And wait for the ADDRESS list to have loaded at least once — otherwise getAddressCount()==0 is // ambiguous ("no keys" vs "not fetched yet"), which would false-fire on a spent-down wallet (0 balance // but has addresses) whose address refresh lands a beat after the balance refresh. if (state_.last_address_update == 0) return; // "Empty" = no addresses and no funds. A salvage-created fresh wallet has no keys; a legitimately // spent-down wallet keeps its addresses, so requiring zero addresses avoids nagging the latter. if (state_.getAddressCount() != 0) return; if (state_.totalBalance > 0.0 || state_.spendableTotalBalance > 0.0) return; if (settings_->isEmptyWalletWarnAcked(settings_->getActiveWalletFile())) return; // dismissed for this file empty_wallet_warn_checked_ = true; // evaluate the on-disk state once for this wallet-open scanFundedSiblingsAsync(); } // Off-UI-thread: enumerate the datadir's OTHER wallet files (incl. salvage wallet..bak backups) and // offline-probe each for key material. Read-only; never opens a file in the daemon. The probe reads up to a // bounded prefix per file, so it runs on its own task thread (not the RPC worker) and the result is // marshaled back to the main thread before touching any UI state. Routing, applied on the main thread: // • a funded salvage .bak exists → the coins were moved aside by an unwitnessed salvage; hand off to the // existing recovery dialog, whose Restore action swaps the .bak back (the correct, tested fix). // • otherwise a funded sibling .dat exists → the user simply opened the wrong (empty) wallet; show the // lightweight warning modal that routes to the wallet manager to switch. void App::scanFundedSiblingsAsync() { if (empty_wallet_scan_in_flight_) return; empty_wallet_scan_in_flight_ = true; const std::string datadir = util::Platform::getDragonXDataDir(); const std::string activeFile = settings_ ? settings_->getActiveWalletFile() : std::string("wallet.dat"); async_tasks_.submit("Empty-wallet sibling scan", [this, datadir, activeFile](const util::AsyncTaskManager::Token& tok) { std::vector funded; // funded plain-.dat wallets → the "switch wallet" modal bool hasSalvageBak = false; // a funded wallet..bak → route to the recovery/restore dialog for (const auto& path : util::enumerateDatadirWalletFiles(datadir, activeFile, /*includeSalvageBaks=*/true)) { if (tok.cancelled()) return; const auto bt = util::parseWalletBtree(path); if (!(bt.parsed && bt.addresses() > 0)) continue; // ignore junk / empty siblings const std::string name = std::filesystem::path(path).filename().string(); const bool isBak = name.size() > 4 && name.compare(name.size() - 4, 4, ".bak") == 0; if (isBak) { // Only a salvage-pattern wallet..bak has a defined restore path; other .bak files are // ignored (the wallet manager lists only .dat, so routing them there would be a dead end). if (daemon::parseWalletSalvageBakTs(name) >= 0) hasSalvageBak = true; continue; } FundedSibling s; s.fileName = name; s.transparentKeys = bt.transparentKeys; s.shieldedKeys = bt.shieldedKeys; funded.push_back(std::move(s)); } // On teardown/cancel, skip posting (shutdown only; the in-flight flag is irrelevant then). if (tok.cancelled() || !worker_) return; // Apply UI state on the main thread only (the render loop reads these members). worker_->post([this, funded, hasSalvageBak]() -> rpc::RPCWorker::MainCb { return [this, funded, hasSalvageBak]() { empty_wallet_scan_in_flight_ = false; if (wallet_auto_recovered_ || show_wallet_recovered_dialog_) return; // recovery already owns it // Re-validate emptiness on the main thread: balance/address refreshes may have landed while // the scan ran (it takes long enough to read+parse sibling files), so a warm-reconnect or a // spent-down wallet that momentarily read empty is now correctly excluded. if (!state_.connected || state_.getAddressCount() != 0 || state_.totalBalance > 0.0 || state_.spendableTotalBalance > 0.0) return; // Both cases surface OUR modal (renderEmptyWalletWarningDialog), keyed by has_salvage_bak. // We deliberately DON'T set the wallet_auto_recovered_ latch or auto-open the recovery dialog: // the daemon is healthy, and that latch gates the crash-restart loop (app_network.cpp:556) + // crash-toast suppression, so it would wedge the wallet offline on any later unrelated crash. // For the salvage case the modal's "Restore" button calls restoreOriginalWallet() directly // (self-contained: it drives the recovery dialog into its Working phase itself). if (hasSalvageBak) { empty_wallet_has_salvage_bak_ = true; empty_wallet_funded_siblings_.clear(); show_empty_wallet_warning_ = true; } else if (!funded.empty()) { empty_wallet_has_salvage_bak_ = false; empty_wallet_funded_siblings_ = funded; show_empty_wallet_warning_ = true; } }; }); }); } // One-shot (per connect) probe of the current wallet's mnemonic status, so the Settings // Migrate-to-seed button can glow for a legacy wallet without opening the migration dialog. Same // classification as the migration Intro pre-flight, but proactive and cached. Reads no secret past // the exportSeedPhrase callback (which wipes the phrase). Retries next tick on a transient failure. void App::probeWalletSeedStatus() { if (capture_mode_) return; // no live ops during a UI sweep if (lite_wallet_) return; // lite has its own seed UX if (wallet_seed_status_ != WalletSeedStatus::Unknown) return; // already classified if (!state_.connected || !state_.encryption_state_known) return; if (state_.isLocked()) return; // needs an unlocked wallet to read it if (wallet_seed_status_in_flight_) return; wallet_seed_status_in_flight_ = true; exportSeedPhrase([this](bool ok, bool noMnemonic, const std::string& /*phrase*/, const std::string& error) { wallet_seed_status_in_flight_ = false; if (ok) wallet_seed_status_ = WalletSeedStatus::HasMnemonic; else if (noMnemonic) wallet_seed_status_ = WalletSeedStatus::NoMnemonic; // legacy → migratable → glow else if (error.find("Method not found") != std::string::npos || error.find("-32601") != std::string::npos) wallet_seed_status_ = WalletSeedStatus::Incapable; // old daemon: can't migrate else if (++wallet_seed_status_attempts_ >= 3) wallet_seed_status_ = WalletSeedStatus::Incapable; // give up after a few transient errors // else: transient error → stay Unknown, retry next tick }); } void App::beginCreateSeedWallet() { if (seed_migration_in_flight_) return; seed_migration_in_flight_ = true; seed_migration_step_ = SeedMigrationStep::Working; { std::lock_guard lk(seed_migration_mutex_); seed_migration_done_ = false; seed_migration_progress_ = "Starting…"; } // Runs the isolated create on a background thread; the main thread picks up progress + the // result in pumpSeedMigration(). No funds move and the real wallet is never touched. async_tasks_.submit("Create seed wallet", [this](const util::AsyncTaskManager::Token&) { auto res = daemon::SeedWalletCreator::create(/*keepDatadir=*/true, [this](const std::string& msg) { std::lock_guard lk(seed_migration_mutex_); seed_migration_progress_ = msg; }); std::lock_guard lk(seed_migration_mutex_); seed_migration_ok_ = res.ok; seed_migration_r_seed_ = res.seedPhrase; seed_migration_r_dest_ = res.destAddress; seed_migration_r_tmp_ = res.tempDatadir; seed_migration_r_err_ = res.error; seed_migration_done_ = true; if (!res.seedPhrase.empty()) sodium_memzero(&res.seedPhrase[0], res.seedPhrase.size()); }); } void App::showSeedMigrationDialog() { if (consolidation_.active()) { ui::Notifications::instance().warning(TR("merge_run_busy")); return; } show_seed_migration_ = true; // Resume a pending migration. If a sweep was already submitted (txid persisted), resume at the // confirm/adopt stage — re-derived from the chain — rather than sweeping again; otherwise start // at the Sweep step. With no pending migration, start fresh at the intro. const bool pending = settings_ && settings_->getSeedMigrationPending(); const bool haveDest = settings_ && !settings_->getSeedMigrationDest().empty(); const std::string sweepTxid = settings_ ? settings_->getSeedMigrationSweepTxid() : std::string(); const std::string sweepOpid = settings_ ? settings_->getSeedMigrationSweepOpid() : std::string(); const bool connected = state_.connected && rpc_ && worker_; switch (decideSeedMigrationResume(pending, haveDest, sweepTxid, sweepOpid, connected)) { case MigrationResume::Confirming: seed_migration_dest_ = settings_->getSeedMigrationDest(); seed_migration_temp_dir_ = settings_->getSeedMigrationTempDir(); seed_migration_sweep_txid_ = sweepTxid; seed_migration_sweep_confs_ = 0; seed_migration_legacy_remaining_ = -1.0; seed_migration_poll_timer_ = 0.0f; // poll immediately seed_migration_step_ = SeedMigrationStep::Confirming; break; case MigrationResume::RetrackOpid: // W3-3: a sweep opid was submitted but its txid was never persisted (app closed mid-Sweeping). // Re-track it to recover the txid. If the daemon forgot it (restart), the opid poller flags it // stale and makeSweepCompletionCallback(resumed) falls back to the Sweep gate — never a hang. // Only reached when connected (decideSeedMigrationResume), so the poller can actually run and // the buttonless "Sweeping" spinner is guaranteed an exit. seed_migration_dest_ = settings_->getSeedMigrationDest(); seed_migration_temp_dir_ = settings_->getSeedMigrationTempDir(); seed_migration_sweep_txid_.clear(); pending_send_callbacks_[sweepOpid] = makeSweepCompletionCallback(/*resumed=*/true); trackOperation(sweepOpid); seed_migration_step_ = SeedMigrationStep::Sweeping; seed_migration_status_ = "Checking on the previous sweep…"; break; case MigrationResume::SweepGate: // No txid, and either no opid or not connected to re-track it (a persisted opid is left in // place so a later reconnect+reopen can re-track it). The Sweep step is dismissable and // reloads the balance, so the user is never trapped while offline. seed_migration_dest_ = settings_->getSeedMigrationDest(); seed_migration_temp_dir_ = settings_->getSeedMigrationTempDir(); seed_migration_sweep_txid_.clear(); seed_migration_step_ = SeedMigrationStep::Sweep; seed_migration_balance_loaded_ = false; seed_migration_nofunds_confirmed_ = false; refreshSeedMigrationBalance(); // If a sweep was already broadcast (the marker survives a lost opid), try to locate its tx so // we resume at the confirmation gate instead of risking a premature "no funds -> adopt". if (settings_ && settings_->getSeedMigrationSweepSubmitted()) locateSweepTxid(); break; case MigrationResume::Intro: default: seed_migration_step_ = SeedMigrationStep::Intro; // Fresh start: the Intro step will pre-flight the wallet (legacy vs already-seeded vs old // daemon) before offering to create anything. seed_migration_precheck_ = SeedMigrationPrecheck::Pending; seed_migration_precheck_started_ = false; break; } } // Intro pre-flight: probe z_exportmnemonic on the CURRENT wallet so we can branch the Intro: // ok → the wallet is already mnemonic-backed (offer backup, not a pointless migration) // "not derived" → a legacy wallet (proceed with create) // "Method not found" → the daemon is too old to create a seed wallet (explain + bail) // The probe needs an unlocked, connected wallet; the Intro gates on that before calling this. void App::beginSeedMigrationPrecheck() { seed_migration_precheck_started_ = true; seed_migration_precheck_ = SeedMigrationPrecheck::Pending; exportSeedPhrase([this](bool ok, bool noMnemonic, const std::string& /*phrase*/, const std::string& error) { // exportSeedPhrase wipes the phrase after this callback — we only need the classification. if (ok) seed_migration_precheck_ = SeedMigrationPrecheck::AlreadyMnemonic; else if (noMnemonic) seed_migration_precheck_ = SeedMigrationPrecheck::Legacy; else if (error.find("Method not found") != std::string::npos || error.find("-32601") != std::string::npos) seed_migration_precheck_ = SeedMigrationPrecheck::DaemonTooOld; else seed_migration_precheck_ = SeedMigrationPrecheck::CheckFailed; // transient — allow re-check }); } void App::refreshSeedMigrationBalance() { if (!rpc_ || !worker_ || !state_.connected) return; worker_->post([this]() -> rpc::RPCWorker::MainCb { double total = 0.0; bool ok = false; rpc::RPCClient::TraceScope trace("Migrate / balance"); try { auto res = rpc_->call("z_gettotalbalance"); if (res.contains("total") && res["total"].is_string()) total = std::stod(res["total"].get()); ok = true; } catch (...) {} return [this, total, ok]() { if (ok) { seed_migration_balance_ = total; seed_migration_balance_loaded_ = true; } }; }); } // Phase 2 step 1: sweep ALL legacy funds (transparent + shielded) into the new seed wallet's // address. z_mergetoaddress with 0,0 limits merges as many inputs as fit in one transaction; a // huge-input wallet may leave a small remainder in the (preserved) legacy backup. void App::beginSweepToSeedWallet() { if (consolidation_.active()) { ui::Notifications::instance().warning(TR("merge_run_busy")); return; } if (!rpc_ || !worker_ || !state_.connected) { seed_migration_status_ = "Not connected to the daemon."; seed_migration_step_ = SeedMigrationStep::Error; return; } if (seed_migration_dest_.empty()) { seed_migration_status_ = "No destination address for the sweep."; seed_migration_step_ = SeedMigrationStep::Error; return; } seed_migration_step_ = SeedMigrationStep::Sweeping; seed_migration_status_ = "Building the sweep transaction…"; // Mark the sweep as submitted BEFORE broadcasting (persisted synchronously). If the app or daemon // then dies after the tx reaches the mempool but before its opid/txid is persisted, a resumed // migration still knows a sweep is in flight and won't mistake the resulting ~0 legacy balance for // an empty wallet and adopt (swap wallet.dat) prematurely — funds in an unconfirmed sweep can still // revert on a drop/reorg. Cleared below only if the submit definitively fails (nothing broadcast). if (settings_) { settings_->setSeedMigrationSweepSubmitted(true); settings_->save(); } const std::string dest = seed_migration_dest_; worker_->post([this, dest]() -> rpc::RPCWorker::MainCb { std::string opid, err; rpc::RPCClient::TraceScope trace("Migrate / sweep"); try { json fromAddrs = json::array({"ANY_TADDR", "ANY_ZADDR"}); auto res = rpc_->call("z_mergetoaddress", {fromAddrs, dest, DRAGONX_DEFAULT_FEE, 0, 0}); opid = res.value("opid", std::string()); if (opid.empty()) err = "The daemon returned no operation id for the sweep."; } catch (const std::exception& e) { err = e.what(); } return [this, opid, err]() { if (!err.empty() || opid.empty()) { // Submit failed — nothing was broadcast, so drop the "sweep in flight" marker. if (settings_) { settings_->setSeedMigrationSweepSubmitted(false); settings_->save(); } seed_migration_status_ = err.empty() ? "The sweep failed to start." : err; seed_migration_step_ = SeedMigrationStep::Error; return; } // W3-3: adopt this new opid atomically — persist it AND clear any prior sweep txid in the // SAME settings write. Persisting the opid lets an app-close during Sweeping (opid // submitted, not yet resolved to a txid) re-poll it on resume instead of dropping it. Doing // the swap HERE — only once the new submit has succeeded — rather than speculatively at // function entry means a FAILED "Sweep remaining" remainder re-sweep leaves the // already-mined first sweep's txid intact and resumable to Confirming; and the txid and // opid are never both authoritative at once (torn-write safe; resume checks txid first). seed_migration_sweep_txid_.clear(); if (settings_) { settings_->setSeedMigrationSweepTxid(""); settings_->setSeedMigrationSweepOpid(opid); settings_->save(); } pending_send_callbacks_[opid] = makeSweepCompletionCallback(/*resumed=*/false); trackOperation(opid); seed_migration_status_ = "Waiting for the sweep transaction to be accepted…"; }; }); } // W3-3: terminal handling for the sweep operation, shared by the initial submit (resumed=false) and // a resume re-track (resumed=true). On success it persists the txid and clears the opid in the SAME // settings write, so the txid always outranks the opid on a later resume (torn-write safe). std::function App::makeSweepCompletionCallback(bool resumed) { return [this, resumed](bool ok, const std::string& result) { if (ok) { seed_migration_sweep_txid_ = result; // Persist the txid (and drop the now-redundant opid) so a restart resumes at the // confirm/adopt stage and never re-sweeps from scratch. The Confirming step gates adopt on // this tx being mined (>= 1 confirmation) AND the legacy balance dropping to ~0. if (settings_) { settings_->setSeedMigrationSweepTxid(result); settings_->setSeedMigrationSweepOpid(""); settings_->save(); } seed_migration_sweep_confs_ = 0; seed_migration_legacy_remaining_ = -1.0; seed_migration_poll_timer_ = 0.0f; seed_migration_status_.clear(); seed_migration_step_ = SeedMigrationStep::Confirming; } else if (resumed) { // A resumed opid the daemon no longer knows (it restarted — the op queue is in-memory // only). "Stale" can't be told apart from "failed", and the earlier sweep may in fact have // already broadcast/mined, so DON'T dead-end at Error: drop the stale opid and return to // the Sweep gate, re-fetching the legacy balance. If that sweep did complete, the balance // reads ~0 and the Sweep step short-circuits to adopt; otherwise the user can sweep again. if (settings_) { settings_->setSeedMigrationSweepOpid(""); settings_->save(); } seed_migration_sweep_txid_.clear(); seed_migration_balance_loaded_ = false; seed_migration_nofunds_confirmed_ = false; seed_migration_status_ = "Couldn't confirm the earlier sweep — it may have already completed. " "Check your balance below before sweeping again."; seed_migration_step_ = SeedMigrationStep::Sweep; refreshSeedMigrationBalance(); // else the Sweep step sits on a permanent "Checking balance…" } else { // A fresh sweep that genuinely failed. Clear the persisted opid so it can't mis-resume. if (settings_) { settings_->setSeedMigrationSweepOpid(""); settings_->save(); } seed_migration_status_ = result.empty() ? "The sweep transaction failed." : result; seed_migration_step_ = SeedMigrationStep::Error; } }; } // Confirming step: poll the sweep tx's confirmations + the legacy wallet's remaining balance. The // adopt step is gated on the tx being mined (confs >= 1) AND the legacy balance being ~0, so we // never swap wallet.dat while the funds could still bounce back (dropped/reorged tx) or while a // remainder is left behind (a sweep too big for one transaction). void App::pollSweepStatus() { if (capture_mode_) return; // no live ops during a UI sweep if (!rpc_ || !worker_ || !state_.connected) return; if (seed_migration_confirm_in_flight_ || seed_migration_sweep_txid_.empty()) return; seed_migration_confirm_in_flight_ = true; const std::string txid = seed_migration_sweep_txid_; worker_->post([this, txid]() -> rpc::RPCWorker::MainCb { int confs = 0; double remaining = -1.0; bool ok = false; rpc::RPCClient::TraceScope trace("Migrate / confirm"); try { auto tx = rpc_->call("gettransaction", {txid}); if (tx.contains("confirmations") && tx["confirmations"].is_number()) confs = tx["confirmations"].get(); auto bal = rpc_->call("z_gettotalbalance"); if (bal.contains("total") && bal["total"].is_string()) remaining = std::stod(bal["total"].get()); ok = true; } catch (...) {} return [this, confs, remaining, ok]() { seed_migration_confirm_in_flight_ = false; if (ok) { seed_migration_sweep_confs_ = confs; seed_migration_legacy_remaining_ = remaining; } }; }); } // Best-effort recovery when a sweep was broadcast but its opid was lost (e.g. a daemon restart wiped // the in-memory op queue) so we have no txid to confirm against. The sweep is an outgoing send to // seed_migration_dest_; find it among the wallet's own sends (z_listsentbyaddress) and, if present, // adopt its txid and resume at the confirmation gate (pollSweepStatus / Confirming). Read-only, and // backstopped by that gate (confs >= 1 && legacy balance ~0), so a wrong match cannot lose funds — // it only avoids the premature "no funds -> adopt" short-circuit while a sweep is unconfirmed. void App::locateSweepTxid() { if (capture_mode_) return; if (!rpc_ || !worker_ || !state_.connected) return; if (seed_migration_confirm_in_flight_ || !seed_migration_sweep_txid_.empty()) return; if (seed_migration_dest_.empty()) return; seed_migration_confirm_in_flight_ = true; const std::string dest = seed_migration_dest_; worker_->post([this, dest]() -> rpc::RPCWorker::MainCb { std::string found; rpc::RPCClient::TraceScope trace("Migrate / locate sweep"); try { // z_listsentbyaddress ignores its address argument and returns ALL wallet sends // (oldest -> newest); the sweep is the latest send whose outputs include the destination. auto sent = rpc_->call("z_listsentbyaddress", {dest, 0}); if (sent.is_array()) { for (const auto& e : sent) { if (!e.is_object() || !e.contains("sends") || !e["sends"].is_object()) continue; const auto& s = e["sends"]; bool toDest = false; for (const char* leg : {"saplingSends", "transparentSends"}) { auto it = s.find(leg); if (it == s.end() || !it->is_array()) continue; for (const auto& o : *it) { if (o.is_object() && o.value("address", std::string()) == dest) { toDest = true; break; } } if (toDest) break; } if (toDest) { auto t = e.value("txid", std::string()); if (!t.empty()) found = t; // keep scanning; entries are oldest->newest } } } } catch (...) {} return [this, found]() { seed_migration_confirm_in_flight_ = false; if (found.empty() || !seed_migration_sweep_txid_.empty()) return; // Adopt the recovered txid and hand off to the confirmation gate (which alone permits adopt). seed_migration_sweep_txid_ = found; if (settings_) { settings_->setSeedMigrationSweepTxid(found); settings_->setSeedMigrationSweepOpid(""); settings_->save(); } seed_migration_sweep_confs_ = 0; seed_migration_legacy_remaining_ = -1.0; seed_migration_poll_timer_ = 0.0f; seed_migration_status_.clear(); seed_migration_step_ = SeedMigrationStep::Confirming; }; }); } // Phase 2 step 2: adopt the new seed wallet as the primary wallet. Stop the daemon, move the // legacy wallet.dat aside to a timestamped backup (NEVER delete it), copy the new wallet in, and // restart with -rescan so the new keys pick up the swept funds. Runs on a background thread. void App::beginAdoptSeedWallet() { if (refuseIfNodeBusy()) return; // stays on its current step; the user retries once the node is free seed_migration_step_ = SeedMigrationStep::Adopting; seed_migration_status_ = "Restarting with your new wallet — this rescan can take a while…"; { std::lock_guard lk(seed_migration_mutex_); seed_migration_adopt_done_ = false; } // Gate the main-loop reconnect so tryConnect can't start the daemon while we swap wallet.dat. daemon_restarting_ = true; abortMaintenanceCalls(); // a long call holds the curl lock disconnect() needs if (rpc_ && rpc_->isConnected()) rpc_->disconnect(); onDisconnected("Adopting seed wallet"); // Adopting swaps in a brand-new seed wallet — drop the legacy wallet's chat identity + store so // it re-derives from the new seed (the file name is unchanged, so nothing else detects the swap). resetChatSession(); // The PIN vault is name-scoped, and the file name is unchanged, so the legacy wallet's stored // passphrase would otherwise stay associated with the new seed wallet — remove it (the new wallet // has its own passphrase; the user can re-enable PIN quick-unlock for it). if (vault_) vault_->removeVault(); const std::string base = seed_migration_temp_dir_; // W3-1: adopt must swap the ACTIVE wallet file (multi-wallet), not a hardcoded "wallet.dat" — // otherwise a migration run while e.g. wallet-2.dat is active would install the swept seed wallet // into an unloaded wallet.dat and leave the daemon loading the (now-emptied) legacy wallet. // Captured on the main thread; wallet switching is blocked during migration so this can't race. const std::string activeWalletName = (settings_ && !settings_->getActiveWalletFile().empty()) ? settings_->getActiveWalletFile() : std::string("wallet.dat"); async_tasks_.submit("Adopt seed wallet", [this, base, activeWalletName](const util::AsyncTaskManager::Token&) { namespace fs = std::filesystem; std::string err; // fatal (swap did not happen; migration incomplete) std::string warn; // non-fatal (swap done but the daemon did not restart) bool swapDone = false; try { std::error_code ec; // 1. Stop the main daemon and wait for it to FULLY release wallet.dat + the RPC port before we // swap the file. Use the wallet-switch stop path: for an ADOPTED (external) daemon it sends a // graceful RPC "stop" and gates on the port actually freeing — isEmbeddedDaemonRunning() is // process-handle-only and would falsely read "stopped" for an adopted daemon, letting the swap // run against a live daemon still holding wallet.dat. (For an owned daemon stopEmbeddedDaemon() // already blocks for full process exit, so the file is closed before the swap.) const bool port_free = stopDaemonForWalletSwitch(); if (!port_free) { err = "The daemon did not stop in time; your wallet was not changed."; } else { // 2. Swap wallet.dat. Move the legacy one aside to a timestamped backup (NEVER // delete), then copy the new seed wallet in. On any failure, restore the legacy. const std::string datadir = util::Platform::getDragonXDataDir(); const std::string legacy = datadir + "/" + activeWalletName; const std::string newWallet = base + "/DRAGONX/wallet.dat"; std::time_t t = std::time(nullptr); std::tm tmv{}; // thread-safe local time (the UI thread also uses localtime) #ifdef _WIN32 localtime_s(&tmv, &t); #else localtime_r(&t, &tmv); #endif char ts[32]; std::strftime(ts, sizeof(ts), "%Y%m%d-%H%M%S", &tmv); const std::string backup = legacy + ".legacy-migrated-" + ts + ".bak"; if (!fs::exists(newWallet)) { err = "The new wallet file is missing; your wallet was not changed."; } else { bool movedLegacy = false; if (fs::exists(legacy)) { fs::rename(legacy, backup, ec); if (ec) err = "Could not back up your current wallet; nothing was changed."; else movedLegacy = true; } if (err.empty()) { fs::copy_file(newWallet, legacy, fs::copy_options::overwrite_existing, ec); if (ec) { if (movedLegacy) { std::error_code ec2; fs::rename(backup, legacy, ec2); } err = "Could not install the new wallet; your original wallet was restored."; } else { swapDone = true; } } } } // 3. Rescan on next start (only if the swap happened) and bring the daemon back up — // unless we're quitting, in which case don't resurrect it. if (swapDone && daemon_controller_) { daemon_controller_->setRescanOnNextStart(true); user_initiated_rescan_ = true; } if (!shutting_down_) { // We stopped the daemon ourselves (port_free) — clear the adopted-external latch so the // relaunched process is treated as owned (stop/isRunning/exit behave normally afterward). // Skip when the stop failed: the old adopted daemon is still up, and we must not mark a // process we can't control as owned. if (port_free && daemon_controller_) daemon_controller_->clearExternalDaemonDetected(); if (!startEmbeddedDaemon() && swapDone) warn = "Your wallet was swapped, but the daemon did not restart — start it from Settings."; } } catch (const std::exception& e) { err = std::string("Migration failed: ") + e.what(); } catch (...) { err = "Migration failed due to an unexpected error."; } daemon_restarting_ = false; // ALWAYS re-arm the reconnect gate, even on a throw std::lock_guard lk(seed_migration_mutex_); // Migration counts as done once the swap succeeded (funds already moved + wallet installed); // a restart hiccup is only a warning. If the swap never happened, it's a full error and the // pending state is kept so the user can retry. seed_migration_adopt_ok_ = swapDone; seed_migration_adopt_err_ = swapDone ? warn : err; seed_migration_adopt_done_ = true; }); } // Undo a daemon wallet auto-recovery: swap the untouched original (wallet..bak) back over the // salvaged copy and clear the stale BDB env that triggered the false recovery, then restart. Modeled on // beginAdoptSeedWallet — stop daemon → file ops (copy/rename only, NEVER delete user data) → restart. void App::restoreOriginalWallet() { if (!supportsFullNodeLifecycleActions()) { ui::Notifications::instance().warning("Full-node lifecycle actions are unavailable in lite build"); return; } if (daemon_restarting_) { ui::Notifications::instance().warning(TR("wallet_restore_busy")); return; } if (refuseIfNodeBusy()) return; // Keep the recovery dialog OPEN and drive it into the Working phase — it shows progress and the // honest outcome in place (pumpWalletRestore flips it to Done/Failed). Presentation only; every // file-safety step below is unchanged. show_wallet_recovered_dialog_ = true; recovery_phase_ = RecoveryPhase::Working; recovery_last_action_rebuild_ = false; { std::lock_guard lk(wallet_restore_mutex_); wallet_restore_done_ = false; } daemon_restarting_ = true; // gate the reconnect loop while we swap files connection_status_ = TR("sb_restarting_daemon"); abortMaintenanceCalls(); // a long call holds the curl lock disconnect() needs if (rpc_ && rpc_->isConnected()) rpc_->disconnect(); onDisconnected("Restoring original wallet"); ui::Notifications::instance().info(TR("wallet_restore_started"), 12.0f); const std::string activeWalletName = (settings_ && !settings_->getActiveWalletFile().empty()) ? settings_->getActiveWalletFile() : std::string("wallet.dat"); async_tasks_.submit("Restore original wallet", [this, activeWalletName](const util::AsyncTaskManager::Token&) { namespace fs = std::filesystem; std::string err, warn; try { const std::string datadir = util::Platform::getDragonXDataDir(); // 1. Find the LARGEST salvage backup (offline — no daemon needed). Largest = least-salvaged = // the original: a salvage cascade shrinks the wallet each round, so the newest .bak can be // empty ("Salvage found no records") while the original is untouched and huge. std::vector> files; { std::error_code lec; for (const auto& e : fs::directory_iterator(datadir, lec)) { if (lec) break; std::error_code se; const auto sz = fs::is_regular_file(e, se) ? fs::file_size(e, se) : 0; files.emplace_back(e.path().filename().string(), se ? 0ull : static_cast(sz)); } } const std::string bak = daemon::largestWalletSalvageBak(files); if (bak.empty()) { err = TR("wallet_restore_no_backup"); } else if (!util::probeWalletFile(datadir + "/" + bak).isBerkeleyDB) { err = TR("wallet_restore_bad_backup"); // don't overwrite a working wallet with a bad .bak } else if (!stopDaemonForWalletSwitch()) { // 2. Release wallet.dat + the RPC port first. err = TR("wallet_restore_stop_failed"); } else { std::error_code ec; std::time_t t = std::time(nullptr); std::tm tmv{}; #ifdef _WIN32 localtime_s(&tmv, &t); #else localtime_r(&t, &tmv); #endif char ts[32]; std::strftime(ts, sizeof(ts), "%Y%m%d-%H%M%S", &tmv); const std::string active = datadir + "/" + activeWalletName; const std::string salvagedAside = active + ".salvaged-" + ts + ".dat"; // 3. Move the salvaged copy aside (NEVER delete), then copy the original .bak into place // (copy, so the .bak itself stays as a backup). Roll back the move if the copy fails. bool movedSalvaged = false; if (fs::exists(active)) { fs::rename(active, salvagedAside, ec); if (ec) err = TR("wallet_restore_move_failed"); else movedSalvaged = true; } if (err.empty()) { fs::copy_file(datadir + "/" + bak, active, fs::copy_options::overwrite_existing, ec); if (ec) { if (movedSalvaged) { std::error_code e2; fs::rename(salvagedAside, active, e2); } err = TR("wallet_restore_copy_failed"); } } // 4. Clear the stale BDB environment that triggered the false recovery — otherwise the // daemon would just re-salvage the restored wallet on the next start. Move database/ // aside (keeps its logs) and drop the transient __db.* region files. if (err.empty()) { std::error_code e2; if (fs::exists(datadir + "/database")) fs::rename(datadir + "/database", datadir + "/database.pre-restore-" + ts + ".bak", e2); for (const auto& e : fs::directory_iterator(datadir, e2)) { if (e.path().filename().string().rfind("__db.", 0) == 0) { std::error_code e3; fs::remove(e.path(), e3); } } } } // 5. Bring the daemon back up (unless quitting). Even on a restore failure we relaunch so the // node isn't left down; the connect loop reconnects and onConnected clears the gate. if (!shutting_down_) { if (daemon_controller_) daemon_controller_->clearExternalDaemonDetected(); if (!startEmbeddedDaemon() && err.empty()) warn = TR("wallet_restore_no_restart"); } } catch (const std::exception& e) { err = std::string("Restore failed: ") + e.what(); } catch (...) { err = "Restore failed due to an unexpected error."; } daemon_restarting_ = false; // ALWAYS re-arm the reconnect gate std::lock_guard lk(wallet_restore_mutex_); wallet_restore_severity_ = !err.empty() ? 2 : (!warn.empty() ? 1 : 0); wallet_restore_msg_ = !err.empty() ? err : warn; wallet_restore_done_ = true; }); } void App::pumpWalletRestore() { if (capture_mode_) return; bool done = false; int sev = 0; std::string msg; { std::lock_guard lk(wallet_restore_mutex_); if (wallet_restore_done_) { done = true; sev = wallet_restore_severity_; msg = wallet_restore_msg_; wallet_restore_done_ = false; } } if (!done) return; // Primary outcome channel: if the recovery dialog is still up (Working), flip it to Done/Failed in // place with the real result. The toast below stays as the secondary echo for a dismissed/alt-tabbed // user. sev 2 = failed (op discarded, nothing changed); sev 0/1 = done (1 carries a warning message). if (show_wallet_recovered_dialog_ && recovery_phase_ == RecoveryPhase::Working) { recovery_outcome_sev_ = sev; recovery_outcome_msg_ = msg; recovery_phase_ = (sev == 2) ? RecoveryPhase::Failed : RecoveryPhase::Done; // Clean success (sev 0) → the daemon is now restarting with a full rescan. Flag it so the loading // overlay shows a calm "finishing repair" screen (not the scary generic stall) until it connects. // NOT for sev 1 (a warning like "node didn't restart") — nothing is rescanning then, and the Done // dialog already shows that message. Timestamp is stamped on the first overlay frame. if (sev == 0) { post_recovery_rescan_ = true; post_recovery_rescan_since_ = 0.0; } } if (sev == 2) ui::Notifications::instance().error(msg, 25.0f); else if (sev == 1) ui::Notifications::instance().warning(msg, 20.0f); else ui::Notifications::instance().success(msg.empty() ? TR("wallet_restore_ok") : msg, 12.0f); } // Locate the bundled dragonx-wallet-rebuild helper (exe dir → daemon dir). "" if not present. static std::string findWalletRebuildHelper() { namespace fs = std::filesystem; #ifdef _WIN32 const char* exe = "dragonx-wallet-rebuild.exe"; #else const char* exe = "dragonx-wallet-rebuild"; #endif for (const std::string& d : { util::Platform::getExecutableDirectory(), dragonx::resources::getDaemonDirectory() }) { if (d.empty()) continue; std::error_code ec; const std::string p = d + "/" + exe; if (fs::exists(p, ec)) return p; } // Not sitting next to the app/daemon — but a self-contained exe carries it embedded. Extract it on // demand (first-run param extraction is gated on needsParamsExtraction(), so it may never have run // on a machine that already had the Sapling params). Returns "" on non-embedded builds. return dragonx::resources::ensureWalletRebuildHelperExtracted(); } bool App::walletRebuildAvailable() const { return !findWalletRebuildHelper().empty(); } // Rebuild a BDB-inconsistent wallet into a fresh, daemon-loadable one via the offline helper (see // tools/wallet_rebuild). This is the real fix for the salvage cascade: plain "Restore original" just // hands the same broken file back and the daemon re-salvages it. Modeled on restoreOriginalWallet: // stop daemon → run helper → verify → safe swap (copy/rename only, never delete) → rescan → restart. void App::rebuildWalletDatabase() { if (!supportsFullNodeLifecycleActions()) { ui::Notifications::instance().warning("Full-node lifecycle actions are unavailable in lite build"); return; } if (daemon_restarting_) { ui::Notifications::instance().warning(TR("wallet_restore_busy")); return; } if (refuseIfNodeBusy()) return; const std::string helper = findWalletRebuildHelper(); if (helper.empty()) { ui::Notifications::instance().error(TR("wallet_rebuild_no_helper"), 15.0f); return; } // Keep the recovery dialog OPEN through the rebuild (Working → Done/Failed in place). Presentation // only; the run-helper → verify-before-swap → copy/rename-never-delete steps below are unchanged. show_wallet_recovered_dialog_ = true; recovery_phase_ = RecoveryPhase::Working; recovery_last_action_rebuild_ = true; { std::lock_guard lk(wallet_restore_mutex_); wallet_restore_done_ = false; } daemon_restarting_ = true; connection_status_ = TR("sb_restarting_daemon"); abortMaintenanceCalls(); // a long call holds the curl lock disconnect() needs if (rpc_ && rpc_->isConnected()) rpc_->disconnect(); onDisconnected("Rebuilding wallet database"); ui::Notifications::instance().info(TR("wallet_rebuild_started"), 15.0f); const std::string activeWalletName = (settings_ && !settings_->getActiveWalletFile().empty()) ? settings_->getActiveWalletFile() : std::string("wallet.dat"); async_tasks_.submit("Rebuild wallet database", [this, helper, activeWalletName](const util::AsyncTaskManager::Token&) { namespace fs = std::filesystem; std::string err, warn; try { const std::string datadir = util::Platform::getDragonXDataDir(); const std::string active = datadir + "/" + activeWalletName; // 1. Rebuild SOURCE = the largest readable wallet file (the active wallet or any salvage .bak). // Largest = most records = the original / least-salvaged (a salvaged copy is tiny). std::string src; unsigned long long best = 0; { std::error_code ec; for (const auto& e : fs::directory_iterator(datadir, ec)) { if (ec) break; const std::string n = e.path().filename().string(); if (n != activeWalletName && daemon::parseWalletSalvageBakTs(n) < 0) continue; std::error_code se; const auto sz = fs::is_regular_file(e, se) ? fs::file_size(e, se) : 0; const auto usz = se ? 0ull : static_cast(sz); if (usz > best && util::probeWalletFile(e.path().string()).isBerkeleyDB) { best = usz; src = e.path().string(); } } } if (src.empty()) { err = TR("wallet_rebuild_no_source"); } else if (!stopDaemonForWalletSwitch()) { // 2. release wallet.dat + the port err = TR("wallet_restore_stop_failed"); } else { std::time_t t = std::time(nullptr); std::tm tmv{}; #ifdef _WIN32 localtime_s(&tmv, &t); #else localtime_r(&t, &tmv); #endif char ts[32]; std::strftime(ts, sizeof(ts), "%Y%m%d-%H%M%S", &tmv); const std::string tmpOut = datadir + "/wallet.rebuilt-" + std::string(ts) + ".tmp"; { std::error_code ec; fs::remove(tmpOut, ec); } // helper uses DB_EXCL — path must be fresh // 3. Run the helper (src -> tmpOut). Quote both paths; capture its JSON line. Windowless // (runHiddenCapture) so a wallet rebuild never flashes a cmd.exe console; it runs the // helper via CreateProcess directly on Windows, so no cmd.exe outer-quote wrap is needed. const std::string cmd = "\"" + helper + "\" \"" + src + "\" \"" + tmpOut + "\""; int rc = -1; const std::string jout = util::Platform::runHiddenCapture(cmd, /*mergeStderr=*/false, &rc); DEBUG_LOGF("[App] wallet-rebuild helper rc=%d out=%s\n", rc, jout.c_str()); // 4. Verify-before-swap: the output must be a readable BDB with the fund-critical keys. const auto probe = util::parseWalletBtree(tmpOut); if (rc != 0 || !probe.parsed || probe.addresses() == 0) { std::error_code ec; fs::remove(tmpOut, ec); err = TR("wallet_rebuild_failed"); } else { // 5. Swap: set the current wallet aside (kept), install the rebuilt one, clear stale env. std::error_code ec; const std::string aside = active + ".prerebuild-" + std::string(ts) + ".dat"; bool moved = false; if (fs::exists(active)) { fs::rename(active, aside, ec); if (ec) err = TR("wallet_restore_move_failed"); else moved = true; } if (err.empty()) { fs::rename(tmpOut, active, ec); if (ec) { if (moved) { std::error_code e2; fs::rename(aside, active, e2); } err = TR("wallet_rebuild_install_failed"); } } if (err.empty()) { std::error_code e2; if (fs::exists(datadir + "/database")) fs::rename(datadir + "/database", datadir + "/database.prerebuild-" + std::string(ts) + ".bak", e2); for (const auto& e : fs::directory_iterator(datadir, e2)) if (e.path().filename().string().rfind("__db.", 0) == 0) { std::error_code e3; fs::remove(e.path(), e3); } if (daemon_controller_) { daemon_controller_->setRescanOnNextStart(true); user_initiated_rescan_ = true; } } } } if (!shutting_down_) { if (daemon_controller_) daemon_controller_->clearExternalDaemonDetected(); if (!startEmbeddedDaemon() && err.empty()) warn = TR("wallet_restore_no_restart"); } } catch (const std::exception& e) { err = std::string("Rebuild failed: ") + e.what(); } catch (...) { err = "Rebuild failed due to an unexpected error."; } daemon_restarting_ = false; std::lock_guard lk(wallet_restore_mutex_); wallet_restore_severity_ = !err.empty() ? 2 : (!warn.empty() ? 1 : 0); wallet_restore_msg_ = !err.empty() ? err : (!warn.empty() ? warn : std::string(TR("wallet_rebuild_ok"))); wallet_restore_done_ = true; }); } void App::pumpSeedMigration() { if (capture_mode_) return; // no live ops during a UI sweep (steps are set directly) // --- Adopt handoff (Phase 2, background daemon swap) --- { bool done = false, ok = false; std::string err; { std::lock_guard lk(seed_migration_mutex_); if (seed_migration_adopt_done_) { done = true; ok = seed_migration_adopt_ok_; err = seed_migration_adopt_err_; seed_migration_adopt_done_ = false; } } if (done) { if (ok) { if (!seed_migration_temp_dir_.empty()) { std::error_code ec; std::filesystem::remove_all(seed_migration_temp_dir_, ec); } if (settings_) { settings_->setSeedMigrationPending(false); settings_->setSeedMigrationDest(""); settings_->setSeedMigrationTempDir(""); settings_->setSeedMigrationSweepTxid(""); settings_->setSeedMigrationSweepOpid(""); // W3-3 settings_->setSeedMigrationSweepSubmitted(false); settings_->save(); } seed_migration_status_ = err; // a non-empty warning here (e.g. restart hiccup) is shown on Done seed_migration_step_ = SeedMigrationStep::Done; } else { seed_migration_status_ = err.empty() ? "Adopting the new wallet failed." : err; seed_migration_step_ = SeedMigrationStep::Error; } } } // --- Create handoff (Phase 1, background isolated create) --- if (!seed_migration_in_flight_) return; bool done = false, ok = false; std::string seed, dest, tmp, err; { std::lock_guard lk(seed_migration_mutex_); seed_migration_status_ = seed_migration_progress_; // live progress for the Working step done = seed_migration_done_; if (done) { ok = seed_migration_ok_; seed = std::move(seed_migration_r_seed_); dest = std::move(seed_migration_r_dest_); tmp = std::move(seed_migration_r_tmp_); err = std::move(seed_migration_r_err_); seed_migration_done_ = false; seed_migration_r_seed_.clear(); } } if (!done) return; seed_migration_in_flight_ = false; if (ok) { seed_migration_seed_ = std::move(seed); seed_migration_dest_ = std::move(dest); seed_migration_temp_dir_ = std::move(tmp); seed_migration_backed_up_ = false; seed_migration_step_ = SeedMigrationStep::ShowSeed; // Persist the pending migration so a later sweep/adopt step can find the new wallet. if (settings_) { settings_->setSeedMigrationPending(true); settings_->setSeedMigrationDest(seed_migration_dest_); settings_->setSeedMigrationTempDir(seed_migration_temp_dir_); // W3-3: a brand-new migration has done no sweep yet — clear any sweep artifacts left over // from a prior aborted run so reopening this fresh migration can't mis-resume on a stale // txid/opid (the resume block reads these whenever the migration is pending). settings_->setSeedMigrationSweepTxid(""); settings_->setSeedMigrationSweepOpid(""); settings_->setSeedMigrationSweepSubmitted(false); settings_->save(); } } else { seed_migration_status_ = err.empty() ? std::string("Could not create the seed wallet.") : err; seed_migration_step_ = SeedMigrationStep::Error; } } void App::backupWallet(const std::string& destination, std::function callback) { if (!state_.connected || !rpc_) { if (callback) callback(false, "Not connected"); return; } // Export all keys and save to file. exportAllKeys([destination, callback](const std::string& keys, int exported, int total) { // NEVER write (or report success for) a keyless backup — the usual cause is an encrypted+locked // wallet, and a "success" here would leave the user believing they have a recovery file when the // file contains only the header comments and ZERO keys. if (exported == 0) { if (callback) callback(false, total == 0 ? "No addresses to back up." : "Backup failed: 0 of " + std::to_string(total) + " keys could be exported. " "Unlock the wallet (if encrypted) and try again."); return; } // Write the key backup atomically and owner-only (0600) — it must never be even briefly // world-readable, and the previous std::ofstream left it at the umask default. if (!util::Platform::writeFileAtomically(destination, keys, /*restrictPermissions=*/true)) { if (callback) callback(false, "Could not write file: " + destination); return; } std::string msg = "Wallet backup saved to " + destination + " — " + std::to_string(exported) + " of " + std::to_string(total) + " keys."; if (exported < total) msg += " NOTE: some addresses had no spending key or the wallet is locked — this backup is INCOMPLETE."; // A partial backup is a real risk for recovery, so surface it as a warning (not a green success). if (callback) callback(exported == total, msg); }); } // ============================================================================ // 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() // Claim the shared broadcast channel so the chat note-buffer coordinator yields to this user send // and routes the one global broadcast result back to lite_send_callback_ (not a chat echo). inflight_op_ = LiteInflightOp{ LiteOpKind::UserSend, {}, chat_session_generation_, ImGui::GetTime() }; 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()) { // The daemon rejects a raw memo — it wants hex or a "utf8:" prefix. Prefix the user's plain-text // memo so it's UTF-8-encoded into the note (same as the chat path). The unprefixed `memo` is still // passed to submitZSendMany below for the tx-history display. recipient["memo"] = "utf8:" + 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; } // z_sendmany signature is (fromaddress, amounts, minconf, fee). The fee MUST be a JSON number: // the daemon reads it with get_real(), so a string is rejected ("JSON value is not a number as // expected"). get_real() parses the double directly and accepts any number notation (incl. the // "5e-05" form of a small fee), so passing the raw double is correct for every fee value. // (The recipient "amount" above is intentionally a fixed-decimal STRING — that field is parsed // with ParseFixedPoint, which a scientific-notation double would break.) submitZSendMany(from, to, amount, fee, memo, recipients, "Send tab / Submit transaction", /*markFeeGapRetry*/ false, std::move(callback)); } void App::submitZSendMany(const std::string& from, const std::string& to, double amount, double fee, const std::string& memo, const nlohmann::json& recipients, const char* traceLabel, bool markFeeGapRetry, std::function callback, bool background) { if (!background) send_progress_active_ = true; // autonomous chat/split: status shown on the message ++send_submissions_in_flight_; worker_->post([this, from, to, amount, fee, memo, recipients, callback, traceLabel, markFeeGapRetry, background]() -> rpc::RPCWorker::MainCb { bool ok = false; std::string result_str; try { rpc::RPCClient::TraceScope trace(traceLabel); auto result = rpc_->call("z_sendmany", {from, recipients, 1, fee}); result_str = result.get(); ok = true; } catch (const std::exception& e) { result_str = e.what(); } catch (...) { // Non-std throw must not escape into the worker — the main-thread callback // below would never run, leaving a stuck "Sending…" spinner. result_str = "Send failed (unknown error)"; } return [this, callback, ok, result_str, from, to, amount, fee, memo, markFeeGapRetry, background]() { if (send_submissions_in_flight_ > 0) --send_submissions_in_flight_; if (ok) { // 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. The wallet changes // when the op commits, and the poller re-reads it then, by origin // (markWalletMutated); meanwhile the pending row and the local spendable debit // show the send. `background` = a chat message or buffer split. if (!result_str.empty()) { pending_opids_.push_back(result_str); if (background) op_origin_[result_str] = services::NetworkRefreshService::OperationOrigin::Chat; if (markFeeGapRetry) send_feegap_retried_opids_.insert(result_str); // a retry of a retry is a real error upsertPendingSendTransaction(result_str, from, to, amount, memo, fee); if (callback) pending_send_callbacks_[result_str] = callback; } else { if (!background) markWalletMutated(/*forceBalance=*/true); // no opid: no completion event if (callback) callback(true, result_str); // no opid to track — report as-is } } else { if (!background) send_progress_active_ = false; if (callback) callback(false, result_str); } }; }); } // Parse the daemon's "Insufficient shielded funds, have , need " message. std::stod stops at // the trailing comma/text, so it extracts each amount cleanly. Returns false if it isn't that error. static bool parseInsufficientShielded(const std::string& msg, double& have, double& need) { if (msg.find("Insufficient shielded funds") == std::string::npos) return false; auto hp = msg.find("have "); auto np = msg.find("need "); if (hp == std::string::npos || np == std::string::npos) return false; try { have = std::stod(msg.substr(hp + 5)); need = std::stod(msg.substr(np + 5)); } catch (...) { return false; } return true; } bool App::maybeRetrySendForFeeGap(const std::string& opid, const std::string& rawMsg) { double have = 0.0, need = 0.0; if (!parseInsufficientShielded(rawMsg, have, need)) return false; auto it = pending_send_info_.find(opid); if (it == pending_send_info_.end()) return false; const PendingSendInfo info = it->second; // copy before any cleanup // Only shielded sends hit the bug; a transparent "from" uses a different (correct) selector. if (info.from.empty() || info.from[0] != 'z') return false; // Discriminator: the daemon stopped once it covered the amount but short of the fee, so the // selected total (have) is >= the amount. A genuine shortfall reports have < amount (it grabbed // every note and still couldn't reach the amount) — don't "retry" those. if (have + 1e-9 < info.amount) return false; // Never retry a retry (the self-output already widened the target; a second failure is real). if (send_feegap_retried_opids_.count(opid)) return false; const double fee = info.fee > 0.0 ? info.fee : 0.0001; // Hand the waiting UI callback to the retry so the user sees the final outcome, not the // intermediate "insufficient" we're working around. std::function cb; auto cbIt = pending_send_callbacks_.find(opid); if (cbIt != pending_send_callbacks_.end()) { cb = cbIt->second; pending_send_callbacks_.erase(cbIt); } DEBUG_LOGF("[send] fee-gap workaround: retrying %s with self-output (have=%.8f need=%.8f amount=%.8f fee=%.8f)\n", opid.c_str(), have, need, info.amount, fee); resendWithFeeGapWorkaround(info.from, info.to, info.amount, fee, info.memo, std::move(cb)); return true; } void App::resendWithFeeGapWorkaround(const std::string& from, const std::string& to, double amount, double fee, const std::string& memo, std::function callback) { if (!state_.connected || !rpc_ || !worker_) { if (callback) callback(false, "Not connected"); return; } // recipients = the real recipient + a tiny self-output (= fee) back to `from`. The extra output // raises the daemon's note-selection target (nTotalOut) above the single largest note, so its // greedy picker grabs another note and can therefore also cover the miner fee. The recipient // still receives EXACTLY `amount`; the self-output and any change return to `from`. nlohmann::json recipients = nlohmann::json::array(); nlohmann::json primary; primary["address"] = to; primary["amount"] = util::formatAmountFixed(amount); if (!memo.empty()) primary["memo"] = "utf8:" + memo; // daemon needs hex or a "utf8:" prefix (see submit) recipients.push_back(primary); nlohmann::json selfOut; selfOut["address"] = from; selfOut["amount"] = util::formatAmountFixed(fee); recipients.push_back(selfOut); submitZSendMany(from, to, amount, fee, memo, recipients, "Send tab / Fee-gap retry", /*markFeeGapRetry*/ true, std::move(callback)); } // -------------------------------------------------------------------------------------------- // Bootstrapped/pruned-node rescan support // // A node restored from a bootstrap snapshot only has block data from the snapshot base upward; // blocks below it are absent on disk. The startup -rescan flag rescans from genesis and fails on // such a node ("error in HDD data"), and rescanblockchain(0) hits the same missing blocks. The // fix is to rescan from a height the snapshot includes — found here by binary-searching for the // lowest height whose block data the node can actually read. // -------------------------------------------------------------------------------------------- void App::detectLowestAvailableBlockHeight(std::function cb) { if (!rpc_ || !rpc_->isConnected() || !worker_) { if (cb) cb(false, 0, false); return; } const int tip = state_.sync.blocks; if (tip <= 1) { // No usable tip yet (or a brand-new chain) — treat as full history, nothing to probe. if (cb) cb(false, 0, true); return; } // A runtime rescan holds worker_ (and rpc_'s curl lock) inside one `rescan` call for minutes: a probe queued // behind it would sit on "detecting" that long. Answer "could not tell" now. if (runtime_rescan_active_) { if (cb) cb(false, 0, false); return; } // Off the UI thread: this used to run ~42 synchronous getblockhash/getblock calls inside the caller's frame // (behind rpc_'s curl lock, i.e. behind any 10-20 s wallet scan on worker_). tip and the session are read // here; the worker touches only rpc_ (it serializes its own calls) and locals; cb runs on the UI thread from // worker_->drainResults(), whose pending result also wakes the idle loop. const std::uint64_t session = rpc_session_; worker_->post([this, rpc = rpc_.get(), tip, session, cb = std::move(cb)]() mutable -> rpc::RPCWorker::MainCb { services::LowestBlockResult res; try { rpc::RPCClient::TraceScope trace("Rescan / Block range probe"); // Bound how long this holds the wallet lane: each call has the 30 s RPC timeout, and a node that // answers every probe slowly must not keep worker_ for ~44 x 30 s. const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(90); res = services::findLowestAvailableBlock(tip, [rpc, deadline](int height) { // No isConnected() here (an unlocked read off the UI thread): a disconnected client's call() // throws "Not connected" under its own lock, which probeBlockPresence classifies as Unknown. if (std::chrono::steady_clock::now() > deadline) return services::BlockProbe::Unknown; return services::probeBlockPresence( [rpc](const std::string& method, const nlohmann::json& params) { return rpc->call(method, params); }, height); }); } catch (...) { res = services::LowestBlockResult{}; // never leave the caller on "detecting": "could not tell" } return [this, session, res, cb = std::move(cb)]() { // Disconnected / reconnected since the post (another wallet or daemon): not this session's answer. const services::LowestBlockResult r = (session == rpc_session_) ? res : services::LowestBlockResult{}; if (cb) cb(r.ok, r.lowest, r.fullHistory); }; }); } bool App::runtimeRescan(int startHeight) { if (!supportsFullNodeLifecycleActions()) { ui::Notifications::instance().warning("Full-node lifecycle actions are unavailable in lite build"); return false; } if (!state_.connected || !rpc_ || !rpc_->isConnected() || !worker_) { ui::Notifications::instance().warning("Not connected to daemon"); return false; } if (runtime_rescan_active_) return false; if (startHeight < 0) startHeight = 0; // One long wallet RPC at a time (C61). { std::string why; const char* trKey = nullptr; if (longWalletOpRefused(&why, &trKey)) { DEBUG_LOGF("[App] runtime rescan refused: %s\n", why.c_str()); ui::Notifications::instance().warning(TR(trKey)); return false; } } DEBUG_LOGF("[App] Starting runtime rescanblockchain from height %d\n", startHeight); // The rescan runs inside the daemon (holds cs_main/cs_wallet) — it does not restart. We own // completion via this RPC's callback; rescan_confirmed_active_ keeps the -rescan-style pollers // from misreading state, and runtime_rescan_active_ suppresses the per-second pollers that // would otherwise pile up behind the held lock. const bool prevRescanning = state_.sync.rescanning; const bool prevConfirmed = rescan_confirmed_active_; const bool prevUserInitiated = user_initiated_rescan_.load(); const float prevProgress = state_.sync.rescan_progress; const std::string prevStatus = state_.sync.rescan_status; runtime_rescan_active_ = true; state_.sync.rescanning = true; rescan_confirmed_active_ = true; user_initiated_rescan_ = true; // user clicked Rescan → completion should toast state_.sync.rescan_progress = 0.0f; state_.sync.rescan_status = "Rescanning from block " + std::to_string(startHeight) + "..."; markTransactionsDirty(); last_tx_block_height_ = -1; invalidateShieldedHistoryScanProgress(true); ui::Notifications::instance().info("Rescanning blockchain from block " + std::to_string(startHeight) + " — this can take a while."); const bool started = runLongWalletTask([this, startHeight](rpc::RPCClient& c) -> rpc::RPCWorker::MainCb { bool ok = false; std::string err; try { rpc::RPCClient::TraceScope trace("Settings / Runtime rescan"); c.call("rescan", {startHeight}, 0L); // hush "rescan "; blocks until the scan finishes (no client timeout) ok = true; } catch (const std::exception& e) { err = e.what(); } return [this, ok, err]() { runtime_rescan_active_ = false; resetWitnessRescanProgress(); if (ok) { state_.sync.rescan_progress = 1.0f; markTransactionsDirty(); noteWalletMutation(); last_tx_block_height_ = -1; invalidateShieldedHistoryScanProgress(true); ui::Notifications::instance().success("Blockchain rescan complete"); refreshData(); } else { state_.sync.rescan_progress = 0.0f; ui::Notifications::instance().error("Rescan failed: " + err); } }; }); if (!started) { // The lane was taken after all: undo the state set up above. runtime_rescan_active_ = false; state_.sync.rescanning = prevRescanning; rescan_confirmed_active_ = prevConfirmed; user_initiated_rescan_ = prevUserInitiated; state_.sync.rescan_progress = prevProgress; state_.sync.rescan_status = prevStatus; ui::Notifications::instance().warning(TR("appx_wallet_op_running")); return false; } return true; } } // namespace dragonx