Files
ObsidianDragon/src/app_network.cpp
T
DanSandClaude Opus 4.8 08a4427e31 fix(send): treat a lost send reply as "outcome unknown", not a plain failure, to prevent a double-send
Resilience audit, highest-value finding: a z_sendmany (full node) or lite broadcast whose REPLY is
lost — a transport timeout / connection cut AFTER the request already left us — was reported as an
ordinary "Transaction failed". The user then retries and can broadcast the same payment twice, because
the daemon may already hold the operation (z_sendmany returns an opid and runs async).

Classify the ambiguous case and surface it distinctly, without touching the send itself:
- New pure helpers in rpc/rpc_failure.h: sendReplyMayBeLost() (an RpcTransportError that was requestSent
  with no HTTP status and a non-OK curl code — reply-timeout / got-nothing / our-own-abort; definitive
  rejections and any HTTP status are excluded), liteSendErrorMayBeInFlight() (a transport-style gRPC text
  heuristic, vetoing connect-phase and server-rejection text), and a readable-English marker tag.
- app_network.cpp: the z_sendmany catch records the flag; the failure branch (non-background only) tags
  the callback text and forces a wallet re-read so a committed send shows in History. onDisconnected()
  tags already-pending send callbacks the same way (they exist only after an opid was returned).
- Lite UserSend (app.cpp): a transport-style broadcast error is tagged identically.
- Send tab: a tagged outcome shows a non-success warning pointing at History and clears the compose form
  so any resend must be retyped deliberately; it is NOT presented as a retryable failure.

The actual rpc_->call("z_sendmany", {from, recipients, 1, fee}), the lite send/broadcast call, and all
amounts/fees/recipients/memo bytes are byte-identical; only result-handling and presentation change.
Pure classifier/marker regression tests added (testSendMaybeInFlight).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-19 14:30:16 -05:00

7498 lines
406 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// 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/seed_migration_poll.h" // migrationWantsBalanceScan: gate the Confirming balance scan
#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.h> // sodium_memzero for wiping the fetched mnemonic
#include <cctype>
#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 "embedded/embedded_text.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 <cstdio> // 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 <nlohmann/json.hpp>
#include <curl/curl.h>
#include <algorithm>
#include <cmath>
#include <ctime>
#include <filesystem>
#include <thread>
#include <chrono>
#include <fstream>
#include <utility>
namespace dragonx {
using json = nlohmann::json;
using NetworkRefreshService = services::NetworkRefreshService;
namespace {
bool isPageEnabledForBuild(ui::NavPage page)
{
return wallet::isUiSurfaceAvailable(
wallet::currentWalletCapabilities(), ui::NavPageSurface(page));
}
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<App::MaintenanceCallScope> 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.<ts>.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<std::mutex> 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<int>(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<std::mutex> 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<std::mutex> 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<void()> 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 (shutting_down_) return; // exit screen: no connect/start attempt, no spurious failure toast
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;
rpc_auth_failed_ = false; // re-arm: a fresh connect attempt clears any prior auth failure
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");
rpc_auth_failed_ = true;
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");
rpc_auth_failed_ = true;
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)
sync_eta_reset_pending_.store(true, std::memory_order_relaxed); // fresh connect → recompute sync ETA
++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<int>(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<rpc::RPCClient>();
fast_rpc_->setBusyGate(rpc_busy_gate_);
}
if (!fast_worker_) {
fast_worker_ = std::make_unique<rpc::RPCWorker>();
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)
{
sync_eta_reset_pending_.store(true, std::memory_order_relaxed);
// 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.
// Each callback here exists only because z_sendmany already returned an opid, so the op may still
// commit (or have committed) while we lost the connection: report "outcome unknown", not a failure.
for (auto& entry : pending_send_callbacks_) {
if (entry.second) entry.second(false, rpc::markSendMaybeInFlight(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) { char hb[48]; std::snprintf(hb, sizeof(hb), TR("warmup_block_fmt"), h); title += " "; title += hb; }
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) { char hb[48]; std::snprintf(hb, sizeof(hb), TR("warmup_block_fmt"), h); state_.warmup_status += " "; state_.warmup_status += hb; }
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<std::time_t>(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<double>(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::int64_t>(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::int64_t>(std::time(nullptr));
}
state_.touchTransactions();
state_.last_tx_update = std::time(nullptr);
}
void App::removePendingSendTransactions(const std::vector<std::string>& opids,
bool restoreBalances)
{
if (opids.empty()) return;
std::unordered_set<std::string> 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<AddressInfo>& 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<std::string> shieldedAddresses;
std::vector<std::string> 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<std::string> 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<long long>(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<long long>(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<long long>(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(TR("toast_lite_lifecycle_unavailable"));
return;
}
if (walletFile.empty() || !settings_) return;
if (settings_->getActiveWalletFile() == walletFile) {
ui::Notifications::instance().info(TR("toast_wallet_already_open"));
return;
}
if (daemon_restarting_) {
ui::Notifications::instance().warning(TR("toast_node_busy_restarting"));
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(TR("toast_rescan_in_progress"));
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(TR("toast_finish_migration_first"));
return;
}
if (consolidation_.active()) {
ui::Notifications::instance().warning(TR("merge_run_busy"));
return;
}
if (!isUsingEmbeddedDaemon()) {
ui::Notifications::instance().warning(TR("toast_switch_needs_embedded"));
return;
}
if (hasTransactionSendProgress()) {
ui::Notifications::instance().warning(TR("toast_finish_send_first"));
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=<name>, 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-<scope>.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_state_restored_ = false; // re-restore persisted lockout for the newly-active wallet
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=<name>. 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<int>(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<int>(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<int>(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<int>(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<std::string> 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_sent_anchor_height_ = -1;
history_sent_anchor_hash_.clear();
history_sent_verify_pending_ = false;
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<std::string> App::shieldedAddressList() const
{
// The same order as NetworkRefreshService::buildTransactionRefreshSnapshot.
std::vector<std::string> 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<services::NetworkRefreshService::UnspentNoteLite>& notes)
{
const int tip = state_.sync.blocks;
if (tip <= 0 || notes.empty()) return;
std::unordered_map<std::string, bool> 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) {
return transaction_history_cache_.unlockUnencrypted(walletIdentity);
}
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;
// Restore the sent-sweep watermark so the first sweep is windowed, not a 400 s full sweep. A verify is
// owed before the window is trusted (the next sweep checks getblockhash(A) == H_A), and the enrichment
// backlog must come back too: a pure z->z send's txid lives only in a sweep result, and would be lost if
// the window moved past an un-enriched one.
history_sent_scan_height_ = loaded.sentSweep.height;
history_sent_anchor_height_ = loaded.sentSweep.anchorHeight;
history_sent_anchor_hash_ = loaded.sentSweep.anchorHash;
history_sent_verify_pending_ = loaded.sentSweep.valid();
history_enrich_backlog_.clear();
history_enrich_backlog_.insert(loaded.sentSweep.backlog.begin(), loaded.sentSweep.backlog.end());
history_full_sweep_failures_ = loaded.sentSweep.fullFailures;
history_full_sweep_retry_at_ = loaded.sentSweep.fullRetryDelaySec > 0
? std::time(nullptr) + loaded.sentSweep.fullRetryDelaySec : 0;
history_sent_last_full_time_ = loaded.sentSweep.lastFullTime;
history_sent_last_full_height_ = loaded.sentSweep.lastFullHeight;
// Start the safety-net clock if a watermark came back without one (an older cache, or one seeded before
// this field existed), so a windowed-only wallet still gets a full re-sweep ~7 days from now.
if (history_sent_last_full_time_ == 0 && loaded.sentSweep.valid())
history_sent_last_full_time_ = std::time(nullptr);
++history_sent_epoch_; // the loaded watermark replaces any in-flight sweep's result
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_sent_scan_height_, history_sent_anchor_height_, history_sent_anchor_hash_, history_enrich_backlog_);
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<std::string> 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,
history_sent_scan_height_, history_sent_anchor_height_, history_sent_anchor_hash_,
history_enrich_backlog_);
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<double>(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<data::TransactionHistoryCache::SnapshotJob>();
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<std::time_t>(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);
}
// The sent-sweep watermark + enrichment backlog travel with the rows (writeSnapshot emits them only
// when self-consistent). The backoff is stored as a delay relative to now (retry_at is absolute).
job->sentSweep.height = history_sent_scan_height_;
job->sentSweep.anchorHeight = history_sent_anchor_height_;
job->sentSweep.anchorHash = history_sent_anchor_hash_;
job->sentSweep.backlog.assign(history_enrich_backlog_.begin(), history_enrich_backlog_.end());
job->sentSweep.fullFailures = history_full_sweep_failures_;
{
const std::time_t nowSec = std::time(nullptr);
job->sentSweep.fullRetryDelaySec = history_full_sweep_retry_at_ > nowSec
? static_cast<long>(history_full_sweep_retry_at_ - nowSec) : 0;
}
job->sentSweep.lastFullTime = history_sent_last_full_time_;
job->sentSweep.lastFullHeight = history_sent_last_full_height_;
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<double>(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(TR("toast_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::time_t>(
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<bool>(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<std::time_t>(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<bool>();
if (st.contains("autoshieldaddress")) addr = st["autoshieldaddress"].get<std::string>();
if (st.contains("disabled_reason")) reason = st["disabled_reason"].get<std::string>();
if (st.contains("seed_recoverable")) seedRecoverable = st["seed_recoverable"].get<bool>();
}
} 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<std::string> 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<std::int64_t>(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<std::int64_t>(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::steady_clock::duration>(std::chrono::duration<double>(
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::steady_clock::duration>(
std::chrono::duration<double>(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<std::string> 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_sent_anchor_height_ = -1;
history_sent_anchor_hash_.clear();
history_sent_verify_pending_ = false;
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;
}
}
// Weekly safety net: on a watermarked wallet, force a full re-sweep now and then so a change made
// outside the GUI (a Console import, an external same-seed restore) that the per-event guards can't
// see is still caught within ~7 days. Not on the Mining page; needs a routine (non-syncing) cycle.
const bool sentSafetyNetDue = routineAllowed && history_sent_scan_height_ >= 0 &&
current_page_ != ui::NavPage::Mining &&
std::time(nullptr) >= history_full_sweep_retry_at_ && // a failed full sweep backs off, not retries
services::sentSweepSafetyNetDue(std::time(nullptr), history_sent_last_full_time_);
transactionSnapshot.sentScanFromHeight = sentSafetyNetDue ? -1 : history_sent_scan_height_;
transactionSnapshot.sentVerifyPending = history_sent_verify_pending_ && !sentSafetyNetDue;
transactionSnapshot.sentVerifyAnchorHeight = history_sent_anchor_height_;
transactionSnapshot.sentVerifyAnchorHash = history_sent_anchor_hash_;
// 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_) ||
(!sentSafetyNetDue && 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<double>(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_sent_anchor_height_ = result.sentSweepAnchorHeight;
history_sent_anchor_hash_ = result.sentSweepAnchorHash;
history_sent_verify_pending_ = false; // this sweep covered the window; nothing owed
history_full_sweep_failures_ = 0;
history_full_sweep_retry_at_ = 0;
if (result.sentSweepFull) { // a full sweep resets the safety-net clock
history_sent_last_full_time_ = now;
history_sent_last_full_height_ = result.sentSweepHeight;
}
} 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<std::string, std::int64_t> chatTxTimes;
for (const auto& tx : result.transactions) chatTxTimes[tx.txid] = tx.timestamp;
std::vector<std::string> 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<std::size_t>(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<double>(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<std::string, std::int64_t> chatTxTimes;
for (const auto& tx : result.transactions) chatTxTimes[tx.txid] = tx.timestamp;
std::vector<std::string> 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<bool>* 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::HttpGetOptions priceOpt = marketHttp(&http_cancel_, 10);
priceOpt.maxBytes = 256 * 1024;
util::HttpGetResult r = util::httpGet(url, "[price]", priceOpt);
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();
// Same resolver as the Market tab chips (not found => registry's first pair, the UI default).
const data::ExchangePair* pair = nullptr;
if (!reg.empty()) {
const auto sel = data::resolveExchangeSelection(reg, selEx, selPair);
if (sel.exchangeIdx < (int)reg.size() && sel.pairIdx < (int)reg[sel.exchangeIdx].pairs.size())
pair = &reg[sel.exchangeIdx].pairs[sel.pairIdx];
}
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.
util::HttpGetOptions tickersOpt = marketHttp(&http_cancel_, 12);
tickersOpt.maxBytes = 2 * 1024 * 1024;
const std::string body = util::httpGetString(
"https://api.coingecko.com/api/v3/coins/dragonx-2/tickers", "[tickers]",
tickersOpt);
std::vector<data::ExchangeInfo> 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(TR("toast_solo_unavailable_lite"));
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
clearMiningError(); // a fresh attempt wipes any stale error
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());
const std::string merr = std::string(TR("toast_mining_failed")) + util::friendlyRpcError(errMsg);
setMiningError(merr);
ui::Notifications::instance().error(merr);
}
};
});
}
void App::stopMining()
{
if (!supportsSoloMining()) return;
if (!state_.connected || !rpc_ || !worker_) return;
if (mining_toggle_in_progress_.exchange(true)) return; // already in progress
clearMiningError();
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)
const std::string merr = std::string(TR("toast_stop_mining_failed")) + util::friendlyRpcError(errMsg);
setMiningError(merr);
ui::Notifications::instance().error(merr);
}
};
});
}
void App::startPoolMining(int threads)
{
if (!supportsPoolMining()) {
(void)threads;
ui::Notifications::instance().warning(TR("toast_pool_unavailable"));
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<daemon::XmrigManager>();
// 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)) {
const std::string merr = util::isTransparentAddress(cfg.worker_name)
? std::string(TR("mining_payout_needs_shielded"))
: std::string(TR("toast_pool_payout_invalid"));
setMiningError(merr);
ui::Notifications::instance().error(merr);
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");
setMiningError(TR("toast_no_address_generate_z"));
ui::Notifications::instance().error(TR("toast_no_address_generate_z"));
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<std::mutex> 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(TR("toast_pool_connecting"));
} 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) {
setMiningError(TR("toast_av_blocked_xmrig"), /*av=*/true);
ui::Notifications::instance().error(TR("toast_av_blocked_xmrig"));
#ifdef _WIN32
pending_antivirus_dialog_ = true;
#endif
} else {
const std::string merr = std::string(TR("toast_pool_start_failed")) + util::friendlyRpcError(err);
setMiningError(merr);
ui::Notifications::instance().error(merr);
}
}
};
});
});
}
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(std::string(TR("toast_ban_failed")) + util::friendlyRpcError(err));
else { ui::Notifications::instance().success(TR("toast_peer_banned")); 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(std::string(TR("toast_unban_failed")) + util::friendlyRpcError(err));
else { ui::Notifications::instance().success(TR("toast_peer_unbanned")); 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(std::string(TR("toast_clear_bans_failed")) + util::friendlyRpcError(err)); return; }
state_.banned_peers.clear();
ui::Notifications::instance().success(TR("toast_bans_cleared"));
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<const char*>(g_default_banlist_data),
g_default_banlist_size);
std::vector<std::string> 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<void(const std::string&)> callback)
{
createNewAddress(/*shielded*/ true, std::move(callback));
}
void App::createNewTAddress(std::function<void(const std::string&)> callback)
{
createNewAddress(/*shielded*/ false, std::move(callback));
}
void App::createNewAddress(bool shielded, std::function<void(const std::string&)> 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<std::string>();
} 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<std::string>& orderedAddrs)
{
if (!settings_) return;
for (size_t i = 0; i < orderedAddrs.size(); ++i)
settings_->setAddressSortOrder(orderedAddrs[i], static_cast<int>(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<void(const std::string&)> 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(std::string(TR("toast_key_export_failed")) + util::friendlyRpcError(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<unsigned char>(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, ts] : store.latestTimestampByConversation())
chat_seen_watermark_[cid] = ts;
}
} 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_;
}
App::ChatIdentityStatus App::chatIdentityStatus() const
{
if (chat_service_.hasIdentity()) return ChatIdentityStatus::Ready;
if (state_.isLocked()) return ChatIdentityStatus::Locked;
if (chat_identity_unavailable_) return ChatIdentityStatus::Unavailable;
return ChatIdentityStatus::Preparing; // fetch in flight, or waiting on the node / address list
}
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<std::string&>();
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<std::string&>();
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::uint64_t>(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<std::int64_t>(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<unsigned char> buf(static_cast<std::size_t>(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<int>(chat_send_queue_.size());
const int verified = std::max(0, std::min(chat_verified_note_budget_, kChatBufferTarget));
const int target = static_cast<int>(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<std::int64_t>(model.sync.chainHeight);
else if (model.hasChainInfo && model.chain.latestBlockHeight.has_value() &&
*model.chain.latestBlockHeight > 0)
chat_last_chain_height_ = static_cast<std::int64_t>(*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<std::int64_t>(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<int>()
: 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<std::string>()];
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<double, std::milli>(
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<std::int64_t>(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<services::NetworkRefreshService::UnspentNoteLite>& 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,
util::friendlyRpcError(error).substr(0, 160)); // hard failure -> Retry affordance + reason
if (frontMatches) chat_send_queue_.pop_front();
wallet::liteLog("chat: send FAILED: " + error);
}
// A queued chat message stays "Sending" and auto-retries while the wallet is offline or can't cover the
// fee — correct, but silent. Tell the user WHY: once per cause, after a short dwell (so brief blips stay
// quiet) and then throttled, reusing the ready-made strings that were never wired up. Informational only;
// the message is still queued and goes out when the condition clears.
void App::maybeNoticeStuckChatSend() {
int reason = 0; // 1 = offline, 2 = no shielded funds for the fee
if (!chat_send_queue_.empty() && !state_.isLocked()) {
if (!state_.connected) {
reason = 1;
} else if (inflight_op_.kind == LiteOpKind::None && chatPayFromZaddr(kChatMinFeeDrgx).empty()) {
// Connected, nothing in flight, and no z-note can cover the fee → genuinely unfunded (not
// merely a split/refill confirming, which chatPayFromZaddr/inflight_op_ would reflect).
reason = 2;
}
}
if (reason == 0) { chat_stuck_cause_ = 0; return; }
const double now = ImGui::GetTime();
if (reason != chat_stuck_cause_) { // a new cause → restart the dwell + throttle
chat_stuck_cause_ = reason;
chat_stuck_since_ = now;
chat_stuck_notice_at_ = 0.0;
}
if (now - chat_stuck_since_ < 15.0) return; // dwell: ignore transient states
if (now < chat_stuck_notice_at_) return; // throttle repeats
chat_stuck_notice_at_ = now + 45.0;
ui::Notifications::instance().warning(
TR(reason == 1 ? "chat_toast_not_connected" : "chat_toast_need_funds"));
}
// 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()) { chat_stuck_cause_ = 0; return; }
maybeNoticeStuckChatSend(); // runs even while offline — that's one of the reasons we report
if (!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<int>((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<std::string, chat::HushChatTransactionInput> byTxid;
std::unordered_map<std::string, std::int64_t> 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<std::size_t>(*tx.position), tx.memo});
txTimestamps[tx.txid] = tx.timestamp;
if (tx.unconfirmed) anyUnconfirmedChatTx = true;
}
if (byTxid.empty()) return;
std::vector<chat::HushChatTransactionMetadata> 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<std::string> 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<double, std::milli>(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<chat::HushChatTransactionMetadata> 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<std::string, chat::HushChatTransactionInput> 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<int>() >= 0) {
pos = static_cast<std::size_t>(note[key].get<int>());
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<int>(metadata.size());
const double scanMs = std::chrono::duration<double, std::milli>(
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<chat::HushChatTransactionMetadata> 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<std::string, std::int64_t> noTimes; // mempool: no block time → ingest uses now
std::vector<std::string> 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) {
wipeDisplayedSecrets(); // scrub any revealed seed/key display on lock (matches full-node)
// 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<void(const std::string&, int, int)> callback)
{
if (!state_.connected || !rpc_) {
if (callback) callback("", 0, 0);
return;
}
// Collect all keys into a string
auto keys_result = std::make_shared<std::string>();
auto pending = std::make_shared<int>(0);
auto total = std::make_shared<int>(0);
auto exported = std::make_shared<int>(0); // keys actually retrieved (vs. failed/locked)
// First get all addresses
auto all_addresses = std::make_shared<std::vector<std::string>>();
// 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<void(bool, const std::string&, const std::string&)> 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<std::string&>();
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<std::string>();
else if (r.is_string())
addr = r.get<std::string>();
} 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<const rpc::RpcTransportError*>(&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_ = TR("err_not_connected_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_ = TR("sweep_err_need_spending_key");
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_ = TR("sweep_status_importing");
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<std::string> preZ;
bool preZok = false;
auto listZ = [&](std::vector<std::string>& 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<std::string>());
};
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<std::string&>();
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<std::string> postZ;
listZ(postZ);
std::vector<std::string> 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<std::string>();
else if (r.is_object() && r.contains("address") && r["address"].is_string())
sourceAddr = r["address"].get<std::string>();
}
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.
// Let an RPC failure PROPAGATE to the outer catch (→ a real error) instead of swallowing it as
// 0.0, which would misclassify a funded key as "__NOFUNDS__" after a transient timeout/warmup.
auto balAt = [&](int minconf) -> double {
nlohmann::json b = c.call("z_getbalance", {sourceAddr, minconf});
return b.is_number() ? b.get<double>()
: b.is_string() ? std::stod(b.get<std::string>()) : 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<std::string>();
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 = TR("sweep_err_unknown");
}
// 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_ = TR("sweep_err_nofunds");
else if (err == "__UNCONFIRMED__") sweep_status_ = TR("sweep_err_unconfirmed");
else if (err == "__ALREADY__") sweep_status_ = TR("sweep_err_already");
else if (err == "__DUST__") sweep_status_ = TR("sweep_err_dust");
else sweep_status_ = err;
sweep_step_ = SweepStep::Error;
return;
}
sweep_dest_shown_ = dest;
sweep_status_ = TR("sweep_status_sweeping");
// 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<void(bool, bool, const std::string&, const std::string&)> 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<std::string&>();
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; // already backed up (saw the phrase)
if (seed_backup_nudged_this_session_) return; // already nudged/classified this run
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;
if (ok) {
// The wallet HAS a mnemonic and the user hasn't backed it up yet. Don't mark "reminded"
// here — that permanent flag now means "the user has seen the phrase" (set from the backup
// dialog), so leaving it clear re-nudges next session until they actually do. Nudge once
// per session as an ACTIONABLE alert (survives in the bell panel with a "Back up now" link),
// not a 12s toast that's gone before it's read. Escalate to a warning once funds exist.
seed_backup_nudged_this_session_ = true;
const bool hasFunds = state_.totalBalance > 0.0;
ui::Notifications::instance().action(
TR("seed_backup_reminder"),
hasFunds ? ui::NotificationType::Warning : ui::NotificationType::Info,
[this]() { showSeedBackupDialog(); },
TR("seed_backup_reminder_action"),
hasFunds ? 15.0f : 12.0f);
} else if (noMnemonic) {
// A legacy (non-mnemonic) wallet has nothing to back up this way — suppress for good.
seed_backup_nudged_this_session_ = true;
settings_->setSeedBackupReminded(true);
settings_->save();
} else {
// Inconclusive (busy/old daemon) — retry later this session rather than suppressing forever.
seed_backup_reminder_retry_at_ = std::chrono::steady_clock::now() + std::chrono::minutes(5);
}
});
}
// 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<services::NoteGroup>& 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<std::time_t>(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<int>(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<double>(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<std::string> ids = consolidation_.outstandingOpids();
const auto filter = NetworkRefreshService::buildOperationFilterParams(ids);
if (!filter) return;
const std::set<std::string> 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<services::OpReport> 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.<ts>.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.<ts>.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<FundedSibling> funded; // funded plain-.dat wallets → the "switch wallet" modal
bool hasSalvageBak = false; // a funded wallet.<ts>.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.<ts>.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<std::mutex> 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<std::mutex> lk(seed_migration_mutex_);
seed_migration_progress_ = msg;
});
std::lock_guard<std::mutex> 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_ = TR("migrate_status_checking_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<std::string>());
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_ = TR("err_not_connected_daemon");
seed_migration_step_ = SeedMigrationStep::Error; return;
}
if (seed_migration_dest_.empty()) {
seed_migration_status_ = TR("migrate_err_no_dest");
seed_migration_step_ = SeedMigrationStep::Error; return;
}
seed_migration_step_ = SeedMigrationStep::Sweeping;
seed_migration_status_ = TR("migrate_status_building");
// 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_ = TR("migrate_status_waiting_accept");
};
});
}
// 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<void(bool, const std::string&)> 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(bool forceBalance)
{
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_;
// Snapshot main-thread state for the worker - never read members off the worker thread.
const bool syncing = effectivelySyncing();
const double priorRemaining = seed_migration_legacy_remaining_;
worker_->post([this, txid, forceBalance, syncing, priorRemaining]() -> rpc::RPCWorker::MainCb {
int confs = 0; double remaining = -1.0; bool ok = false; bool scanned = 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<int>();
if (services::migrationWantsBalanceScan(forceBalance, syncing, confs, priorRemaining)) {
auto bal = rpc_->call("z_gettotalbalance");
if (bal.contains("total") && bal["total"].is_string())
remaining = std::stod(bal["total"].get<std::string>());
scanned = true;
}
ok = true;
} catch (...) {}
return [this, confs, remaining, ok, scanned]() {
seed_migration_confirm_in_flight_ = false;
if (ok) {
seed_migration_sweep_confs_ = confs;
if (scanned) 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_ = TR("migrate_status_restarting");
{ std::lock_guard<std::mutex> 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<std::mutex> 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.<ts>.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(TR("toast_lite_lifecycle_unavailable"));
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<std::mutex> 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();
// Swapping the wallet + restarting with -rescan changes the history: invalidate the sent-sweep watermark
// (and persist that) before the disconnect, as rescanBlockchain() and the zap path already do.
invalidateShieldedHistoryScanProgress(true);
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<std::pair<std::string, unsigned long long>> 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<unsigned long long>(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<std::mutex> 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<std::mutex> 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(TR("toast_lite_lifecycle_unavailable"));
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<std::mutex> 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<unsigned long long>(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.
// runHiddenCapture runs this via popen("sh -c …") on POSIX, where $( ), backticks and an
// embedded \" still expand inside the double-quotes — so a crafted wallet filename (src is
// datadir + the activeWalletName setting) could inject a shell command. Refuse any path
// carrying a shell metacharacter; rc stays -1 and the verify-before-swap below fails safe.
auto shellSafe = [](const std::string& p) {
return p.find_first_of("\"'`$;&|<>^\n\r") == std::string::npos;
};
int rc = -1;
std::string jout;
if (shellSafe(helper) && shellSafe(src) && shellSafe(tmpOut)) {
const std::string cmd = "\"" + helper + "\" \"" + src + "\" \"" + tmpOut + "\"";
jout = util::Platform::runHiddenCapture(cmd, /*mergeStderr=*/false, &rc);
} else {
DEBUG_LOGF("[App] wallet-rebuild refused: shell metacharacter in a path\n");
}
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<std::mutex> 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<std::mutex> 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<std::mutex> 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_understood_ = 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<void(bool, const std::string&)> 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<void(bool success, const std::string& result)> 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;
// Guard the double→zatoshi conversion: a negative/NaN amount would wrap to a huge uint64, and a
// sub-zatoshi amount rounds to 0. Reject rather than broadcast a bogus value.
std::uint64_t zat = 0;
if (!util::coinsToZatoshis(amount, zat)) {
if (callback) callback(false, "Invalid send amount");
return;
}
recipient.amountZatoshis = zat;
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<void(bool, const std::string&)> 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;
bool maybeInFlight = false; // reply lost after the request left: the node may already hold the op
std::string result_str;
try {
rpc::RPCClient::TraceScope trace(traceLabel);
auto result = rpc_->call("z_sendmany", {from, recipients, 1, fee});
result_str = result.get<std::string>();
ok = true;
} catch (const std::exception& e) {
result_str = e.what();
maybeInFlight = rpc::sendReplyMayBeLost(e);
} 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, maybeInFlight, 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 (maybeInFlight && !background) {
// Ambiguous: z_sendmany's reply never arrived, so the op may be running. Surface the
// distinct "check History before resending" outcome and re-read the wallet so a
// committed send shows up promptly. Definitive rejections stay plain failures below.
DEBUG_LOGF("[send] z_sendmany reply lost (%s): outcome unknown, not a definitive failure\n",
result_str.c_str());
markWalletMutated(/*forceBalance=*/true);
if (callback) callback(false, rpc::markSendMaybeInFlight(result_str));
} else if (callback) {
callback(false, result_str);
}
}
};
});
}
// Parse the daemon's "Insufficient shielded funds, have <H>, need <N>" 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<void(bool, const std::string&)> 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<void(bool, const std::string&)> 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<void(bool, int, bool)> 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(TR("toast_lite_lifecycle_unavailable"));
return false;
}
if (!state_.connected || !rpc_ || !rpc_->isConnected() || !worker_) {
ui::Notifications::instance().warning(TR("toast_not_connected_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;
{ char rb[64]; snprintf(rb, sizeof(rb), TR("rescan_status_from_block"), startHeight); state_.sync.rescan_status = rb; }
markTransactionsDirty();
last_tx_block_height_ = -1;
invalidateShieldedHistoryScanProgress(true);
char rescanMsg[192];
snprintf(rescanMsg, sizeof(rescanMsg), TR("toast_rescan_from_fmt"), static_cast<int>(startHeight));
ui::Notifications::instance().info(rescanMsg);
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 <height>"; 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(TR("toast_rescan_complete"));
refreshData();
} else {
state_.sync.rescan_progress = 0.0f;
ui::Notifications::instance().error(std::string(TR("toast_rescan_failed")) + util::friendlyRpcError(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