Files
ObsidianDragon/src/app_network.cpp
DanS 3465acc57b fix(chat): session-guard the fast-scan callback (stale-wallet ingest)
Review of 30bd0d9 found the 0-conf fast-scan MainCb was the one async chat
callback missing the chat_session_generation_ guard the broadcast + identity-fetch
callbacks use. worker_ survives a wallet switch/lock, so a fast-scan posted under
wallet A could drain after resetChatSession() and ingest A's metadata (or toast)
against wallet B's freshly-provisioned store.

- Capture scanGen at post time and drop the result if it changed by drain time.
- Clear chat_fast_scan_in_flight_ in resetChatSession() so a switch immediately
  re-enables the fast path; the stale callback returns WITHOUT clearing the flag so
  it can't clobber the new session's own in-flight scan (generation is bumped only
  in resetChatSession, which already reset the flag).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 00:50:58 -05:00

4387 lines
211 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]
// │ │
// │ ▼ refreshData() every 5s
// │ [Running]
// │ │
// │ ├─ RPC error ──► onDisconnected()
// │ │ │
// ├─ auth 401 ──► .cookie auth ──► retry│ ▼
// │ │ [Disconnected]
// └─ failure ──► onDisconnected(reason) ┘
// may restart daemon
#include "app.h"
#include "rpc/rpc_client.h"
#include "rpc/rpc_worker.h"
#include "rpc/connection.h"
#include "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 <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 "config/version.h"
#include "daemon/daemon_controller.h"
#include "daemon/embedded_daemon.h"
#include "daemon/seed_wallet_creator.h"
#include "daemon/xmrig_manager.h"
#include "util/pool_registry.h"
#include "ui/notifications.h"
#include "ui/schema/skin_manager.h"
#include "default_banlist_embedded.h"
#include "util/amount_format.h"
#include "util/http_download.h"
#include "data/exchange_info.h"
#include "data/exchange_candles.h"
#include "util/platform.h"
#include "util/perf_log.h"
#include "util/i18n.h"
#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));
}
std::string unencryptedTransactionHistoryCacheKey(const std::string& walletIdentity)
{
return std::string("obsidian-dragon-unencrypted-tx-cache-v1:") +
data::TransactionHistoryCache::walletIdentityHash(walletIdentity);
}
class AppRefreshRpcGateway final : public NetworkRefreshService::RefreshRpcGateway {
public:
AppRefreshRpcGateway(rpc::RPCClient& rpc, std::string source)
: rpc_(rpc), source_(std::move(source)) {}
json call(const std::string& method, const json& params) override
{
rpc::RPCClient::TraceScope trace(source_);
return rpc_.call(method, params);
}
private:
rpc::RPCClient& rpc_;
std::string source_;
};
const char* tracePageName(ui::NavPage page)
{
switch (page) {
case ui::NavPage::Overview: return "Overview tab";
case ui::NavPage::Send: return "Send tab";
case ui::NavPage::Receive: return "Receive tab";
case ui::NavPage::History: return "History tab";
case ui::NavPage::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;
}
// How far the tip may drift past an address's last shielded scan before we re-scan it. A full pass
// scans ~budget addresses per refresh cycle (≈96 per block of wall time), so a wallet with many
// z-addresses takes several blocks to scan fully. With a strict (tolerance 0) "scanned at tip"
// check, new blocks arriving mid-pass would invalidate already-scanned addresses and the pass would
// never complete — leaving transactions_dirty_ (and its "refreshing history" banner + send-progress
// gate) stuck on forever. Scaling the tolerance with the address count lets the pass complete while
// keeping shielded-receive latency minimal for small wallets; it's capped for pathological sizes.
int shieldedScanTipTolerance(std::size_t shieldedAddressCount)
{
int t = 2 + static_cast<int>(shieldedAddressCount / 96);
return std::min(t, 50);
}
} // namespace
// ============================================================================
// Warmup Message Translation
// Maps raw daemon RPC warmup messages to user-friendly text.
// ============================================================================
struct WarmupText {
const char* title;
const char* description;
};
static WarmupText translateWarmup(const std::string& raw)
{
if (raw.find("Loading") != std::string::npos)
return {"Loading blockchain data...",
"Reading the block database from disk. This may take a few minutes after updates."};
if (raw.find("Verifying") != std::string::npos)
return {"Verifying blockchain...",
"Checking recent blocks to make sure your chain data is valid."};
if (raw.find("Activating") != std::string::npos)
return {"Processing blocks...",
"Applying blocks to build the current chain state."};
if (raw.find("Rewinding") != std::string::npos)
return {"Reorganizing chain...",
"A chain reorganization was detected. Reverting to the correct chain."};
if (raw.find("Rescanning") != std::string::npos)
return {"Scanning for transactions...",
"Searching the blockchain for transactions belonging to your wallet. This can take a while."};
if (raw.find("Pruning") != std::string::npos)
return {"Optimizing storage...",
"Removing old block data to free up disk space."};
// Fallback: use the raw message
return {raw.c_str(), ""};
}
// 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)
{
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;
}
// Phrases dragonxd prints to its console while initializing, in the order translateWarmup()
// understands them. The most recent matching console line tells us which stage the node is in
// even when the RPC probe just times out (no -28 reply to read).
static const char* const kDaemonInitPhases[] = {
"Rescanning", "Rewinding", "Activating", "Verifying", "Loading", "Pruning",
};
// How many consecutive "RPC port busy but no config" connect attempts to wait through before
// warning the user that whatever owns the port isn't a usable DragonX node. The core retry runs
// roughly every few seconds, so this is on the order of ~20s — long enough for a real daemon to
// write its config, short enough not to leave the user guessing.
static constexpr int kDaemonWaitWarnAttempts = 4;
// ============================================================================
// Connection Management
// ============================================================================
void App::tryConnect()
{
// Lite builds have no full node / RPC daemon, so never run the RPC connection state machine
// (it would just fail every tick). The lite controller drives the wallet; "online" status is
// derived from it each frame in App::update(), which also gates the wallet UI (isConnected()).
if (isLiteBuild()) return;
if (connection_in_progress_) return;
// Don't fight an in-progress restart/adopt orchestration: while it stops the daemon, swaps
// wallet.dat (seed migration), and restarts, tryConnect must NOT start the daemon underneath it.
if (daemon_restarting_) return;
static int connect_attempt = 0;
++connect_attempt;
connection_in_progress_ = true;
connection_status_ = TR("sb_loading_config");
// Auto-detect configuration (file I/O — fast, safe on main thread)
auto config = rpc::Connection::autoDetectConfig();
if (config.rpcuser.empty() || config.rpcpassword.empty()) {
connection_in_progress_ = false;
std::string confPath = rpc::Connection::getDefaultConfPath();
VERBOSE_LOGF("[connect #%d] No valid config — DRAGONX.conf missing or no rpcuser/rpcpassword (looked at: %s)\n",
connect_attempt, confPath.c_str());
// Re-evaluate the RPC port LIVE rather than trusting a latched "external daemon detected"
// flag: EmbeddedDaemon::start() sets that latch whenever the port was busy at a prior
// attempt and then never re-checks it, so a stale socket (or a transient squatter that has
// since died) would strand us forever "waiting for config". If the port is genuinely busy,
// a real daemon writes its config shortly and we keep waiting; if it's free, we must start
// our own.
const bool portInUse = daemon::EmbeddedDaemon::isRpcPortInUse();
if (portInUse) {
connection_status_ = TR("sb_waiting_config");
VERBOSE_LOGF("[connect #%d] RPC port in use but no config yet — waiting for the daemon to write it\n",
connect_attempt);
// After a bounded wait with no config appearing, whatever owns the port is not a usable
// DragonX node (a foreign process, or a stuck/half-dead daemon). Say so once, with the
// action, instead of leaving the user on a silent "waiting" spinner forever.
if (++daemon_wait_attempts_ == kDaemonWaitWarnAttempts) {
ui::Notifications::instance().warning(TR("daemon_port_busy_warn"), 20.0f);
}
network_refresh_.setTimer(services::NetworkRefreshService::Timer::Core,
services::RefreshScheduler::kCoreDefault - 1.0f);
return;
}
daemon_wait_attempts_ = 0; // port is free — clear the bounded-wait counter
connection_status_ = TR("sb_no_conf");
// Port is free → start our own embedded daemon (if enabled).
if (isUsingEmbeddedDaemon() && !isEmbeddedDaemonRunning()) {
connection_status_ = TR("sb_starting_daemon");
if (startEmbeddedDaemon()) {
// Will retry connection after daemon starts
VERBOSE_LOGF("[connect #%d] Embedded daemon starting, will retry connection...\n", connect_attempt);
network_refresh_.setTimer(services::NetworkRefreshService::Timer::Core,
services::RefreshScheduler::kCoreDefault - 1.0f);
} else {
// The daemon couldn't be started (binary not found, Sapling params missing, spawn
// failure, …). Surface the actual reason instead of leaving the status stuck on
// "Starting dragonxd…": connection_status_ for the overlay/status bar, plus a
// one-time sticky notification with the full, actionable detail.
std::string detail = daemon_controller_ ? daemon_controller_->lastError() : std::string();
VERBOSE_LOGF("[connect #%d] startEmbeddedDaemon() failed — lastError: %s, binary: %s\n",
connect_attempt, detail.empty() ? "(none)" : detail.c_str(),
daemon::EmbeddedDaemon::findDaemonBinary().c_str());
connection_status_ = TR("sb_daemon_start_failed");
if (!daemon_start_error_shown_) {
daemon_start_error_shown_ = true;
ui::Notifications::instance().error(
detail.empty() ? std::string(TR("sb_daemon_start_failed")) : detail, 30.0f);
}
// Keep retrying: a missing binary/params can be fixed without a restart.
network_refresh_.setTimer(services::NetworkRefreshService::Timer::Core,
services::RefreshScheduler::kCoreDefault - 1.0f);
}
} else if (!isUsingEmbeddedDaemon()) {
VERBOSE_LOGF("[connect #%d] Embedded daemon disabled (using external). No config found at %s\n",
connect_attempt, confPath.c_str());
}
return;
}
connection_status_ = TR("sb_connecting_daemon");
VERBOSE_LOGF("[connect #%d] Connecting to %s:%s (user=%s)\n",
connect_attempt, config.host.c_str(), config.port.c_str(), config.rpcuser.c_str());
if (rpc::Connection::usesPlaintextRemote(config) && !remote_rpc_plaintext_warning_shown_) {
remote_rpc_plaintext_warning_shown_ = true;
ui::Notifications::instance().warning(
"Remote RPC is using plaintext HTTP. Add rpctls=1 to DRAGONX.conf if your daemon supports TLS.",
10.0f);
}
// Run the blocking rpc_->connect() on the worker thread so the UI
// stays responsive (curl connect timeout can be up to 10 seconds).
if (!worker_) {
connection_in_progress_ = false;
VERBOSE_LOGF("[connect #%d] No worker thread available!\n", connect_attempt);
return;
}
// Capture daemon state before posting to worker
bool daemonStarting = daemon_controller_ &&
(daemon_controller_->state() == daemon::EmbeddedDaemon::State::Starting ||
daemon_controller_->state() == daemon::EmbeddedDaemon::State::Running);
bool externalDetected = daemon_controller_ && daemon_controller_->externalDaemonDetected();
int attempt = connect_attempt;
// Log detailed daemon state for diagnostics
if (daemon_controller_) {
const char* stateStr = "unknown";
switch (daemon_controller_->state()) {
case daemon::EmbeddedDaemon::State::Stopped: stateStr = "Stopped"; break;
case daemon::EmbeddedDaemon::State::Starting: stateStr = "Starting"; break;
case daemon::EmbeddedDaemon::State::Running: stateStr = "Running"; break;
case daemon::EmbeddedDaemon::State::Stopping: stateStr = "Stopping"; break;
case daemon::EmbeddedDaemon::State::Error: stateStr = "Error"; break;
}
VERBOSE_LOGF("[connect #%d] Daemon state: %s, running: %s, external: %s, crashes: %d, lastErr: %s\n",
attempt, stateStr,
daemon_controller_->isRunning() ? "yes" : "no",
externalDetected ? "yes" : "no",
daemon_controller_->crashCount(),
daemon_controller_->lastError().empty() ? "(none)" : daemon_controller_->lastError().c_str());
// The embedded daemon can spawn successfully and then exit immediately (a missing runtime
// DLL, wrong architecture, corrupt binary, datadir lock, …). The crash monitor records a
// detailed reason (translated exit code + launch command + debug.log tail) in lastError(),
// but it runs on a background thread and was never shown — so the wallet looked like it was
// "stuck connecting" while the node silently died-and-respawned. Surface each new crash once.
const int crashes = daemon_controller_->crashCount();
if (crashes > daemon_last_seen_crashes_) {
daemon_last_seen_crashes_ = crashes;
const std::string detail = daemon_controller_->lastError();
if (!detail.empty()) {
connection_status_ = TR("sb_daemon_start_failed");
ui::Notifications::instance().error(detail, 30.0f);
}
}
} else {
VERBOSE_LOGF("[connect #%d] No embedded daemon object (use_embedded=%s)\n",
attempt, isUsingEmbeddedDaemon() ? "yes" : "no");
}
worker_->post([this, config, daemonStarting, externalDetected, attempt]() -> rpc::RPCWorker::MainCb {
bool connected = rpc_->connect(config.host, config.port, config.rpcuser, config.rpcpassword, config.use_tls);
std::string connectErr = rpc_->getLastConnectError();
bool warmingUp = rpc_->isWarmingUp();
std::string warmupStatus = rpc_->getWarmupStatus();
return [this, config, connected, warmingUp, warmupStatus, daemonStarting, externalDetected, attempt, connectErr]() {
if (connected) {
VERBOSE_LOGF("[connect #%d] Connected successfully%s\n", attempt,
warmingUp ? " (daemon warming up)" : "");
saved_config_ = config; // save for fast-lane connection
onConnected();
if (warmingUp) {
// Daemon is reachable and auth works, but RPC calls will
// fail until warmup completes. Set the warmup state so
// the UI shows status instead of a blocking overlay.
state_.warming_up = true;
auto wt = translateWarmup(warmupStatus);
state_.warmup_status = wt.title;
state_.warmup_description = wt.description;
// Append current block height from daemon output
if (daemon_controller_) {
int h = daemon_controller_->lastBlockHeight();
if (h > 0)
state_.warmup_status += " (Block " + std::to_string(h) + ")";
}
connection_status_ = state_.warmup_status;
}
} else {
// HTTP 401 = authentication failure. The daemon is running
// but our rpcuser/rpcpassword don't match. Don't retry
// endlessly — tell the user what's wrong.
bool authFailure = (connectErr.find("401") != std::string::npos);
if (authFailure) {
rpc::ConnectionConfig cookieConfig;
if (rpc::Connection::buildCookieAuthConfig(config, cookieConfig)) {
VERBOSE_LOGF("[connect #%d] HTTP 401 — retrying with .cookie auth from %s\n",
attempt, cookieConfig.hush_dir.c_str());
worker_->post([this, cookieConfig, attempt]() -> rpc::RPCWorker::MainCb {
bool ok = rpc_->connect(cookieConfig.host, cookieConfig.port,
cookieConfig.rpcuser, cookieConfig.rpcpassword,
cookieConfig.use_tls);
return [this, cookieConfig, ok, attempt]() {
connection_in_progress_ = false;
if (ok) {
VERBOSE_LOGF("[connect #%d] Connected via .cookie auth\n", attempt);
saved_config_ = cookieConfig;
onConnected();
} else {
state_.connected = false;
connection_status_ = TR("sb_auth_failed");
VERBOSE_LOGF("[connect #%d] .cookie auth also failed\n", attempt);
ui::Notifications::instance().error(
"RPC authentication failed (HTTP 401). "
"The rpcuser/rpcpassword in DRAGONX.conf don't match the running daemon. "
"Restart the daemon or correct the credentials.");
}
};
});
return; // async retry in progress
}
state_.connected = false;
std::string confPath = rpc::Connection::getDefaultConfPath();
connection_status_ = TR("sb_auth_failed");
VERBOSE_LOGF("[connect #%d] HTTP 401 — rpcuser/rpcpassword in %s don't match the daemon. "
"Edit the file or restart the daemon to regenerate credentials.\n",
attempt, confPath.c_str());
ui::Notifications::instance().error(
"RPC authentication failed (HTTP 401). "
"The rpcuser/rpcpassword in DRAGONX.conf don't match the running daemon. "
"Restart the daemon or correct the credentials.");
} else if (daemonStarting) {
state_.connected = false;
// The daemon is launched but RPC isn't answering yet. A *timeout* means it
// connected but the node is busy initializing (loading the block index, etc.);
// a connect refusal means it hasn't bound the RPC port yet. Either way, show a
// clear "node initializing" overlay (status + phase + block height from the
// daemon's own console output) instead of a bare technical error.
const bool reachableButBusy = connectErr.find("Timeout") != std::string::npos;
applyDaemonInitStatus(reachableButBusy);
VERBOSE_LOGF("[connect #%d] RPC connection failed (%s) — daemon still starting, will retry...\n",
attempt, connectErr.c_str());
network_refresh_.setTimer(services::NetworkRefreshService::Timer::Core,
services::RefreshScheduler::kCoreDefault - 1.0f);
} else if (externalDetected) {
state_.connected = false;
// An external daemon is on the RPC port but not answering. A timeout means it's
// up and busy initializing; surface that as the init overlay (we can't read its
// console since we didn't launch it, so no phase line — just a clear message).
if (connectErr.find("Timeout") != std::string::npos) {
applyDaemonInitStatus(/*reachableButBusy=*/true);
} else if (!connectErr.empty()) {
char buf[256]; snprintf(buf, sizeof(buf), TR("sb_connecting_err"), connectErr.c_str());
connection_status_ = buf;
} else {
connection_status_ = TR("sb_connecting_external");
}
VERBOSE_LOGF("[connect #%d] External daemon detected but RPC failed (%s), will retry...\n",
attempt, connectErr.c_str());
network_refresh_.setTimer(services::NetworkRefreshService::Timer::Core,
services::RefreshScheduler::kCoreDefault - 1.0f);
} else {
onDisconnected("Connection failed");
VERBOSE_LOGF("[connect #%d] RPC connection failed — no daemon starting, no external detected\n", attempt);
if (isUsingEmbeddedDaemon() && !isEmbeddedDaemonRunning()) {
// Prevent infinite crash-restart loop
if (daemon_controller_ && daemon_controller_->crashCount() >= 3) {
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()) {
connection_status_ = TR("sb_connecting_generic");
VERBOSE_LOGF("[connect #%d] External daemon detected, will connect via RPC...\n", attempt);
} else {
VERBOSE_LOGF("[connect #%d] Failed to start embedded daemon — lastError: %s\n",
attempt,
daemon_controller_ ? daemon_controller_->lastError().c_str() : "(no daemon object)");
}
}
} else if (!isUsingEmbeddedDaemon()) {
VERBOSE_LOGF("[connect #%d] Embedded daemon disabled — external daemon at %s:%s not responding\n",
attempt, config.host.c_str(), config.port.c_str());
} else {
VERBOSE_LOGF("[connect #%d] Embedded daemon is running but RPC failed — daemon may be initializing\n", attempt);
}
}
}
connection_in_progress_ = false;
};
});
}
void App::onConnected()
{
state_.connected = true;
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
daemon_wait_attempts_ = 0; // re-arm the port-busy / start-failure notifications
daemon_start_error_shown_ = false;
daemon_last_seen_crashes_ = 0; // (onConnected resets the daemon's crash count too)
connection_status_ = TR("connected");
// 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;
if (worker_ && rpc_) {
auto prefetchedInfo = NetworkRefreshService::parseConnectionInfoResult(rpc_->getLastConnectInfo());
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::ConnectionInit, *worker_, [this, prefetchedInfo]() -> rpc::RPCWorker::MainCb {
AppRefreshRpcGateway refreshRpc(*rpc_, "Startup / Connection init");
auto result = NetworkRefreshService::collectConnectionInitResult(refreshRpc, prefetchedInfo);
return [this, result]() {
NetworkRefreshService::applyConnectionInitResult(state_, result);
if (state_.isLocked()) {
resetTransactionHistoryCacheSession();
} else if (state_.transactions.empty()) {
loadTransactionHistoryCacheIfAvailable();
} else {
storeTransactionHistoryCacheIfAvailable();
}
};
}, 3);
initialPrefetchQueued = enqueued.enqueued;
}
// onConnected already fetched getwalletinfo — tell refreshData to skip
// the duplicate call on the very first cycle.
encryption_state_prefetched_ = initialPrefetchQueued;
// Addresses are unknown on fresh connect — force a fetch
addresses_dirty_ = true;
// Start the fast-lane RPC connection (dedicated to 1-second mining polls).
// Uses its own curl handle + worker thread so getlocalsolps never blocks
// behind the main refresh batch.
if (!fast_rpc_) {
fast_rpc_ = std::make_unique<rpc::RPCClient>();
}
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)
{
state_.connected = false;
state_.warming_up = false;
state_.warmup_status.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;
// Clear RPC result caches
viewtx_cache_.clear();
confirmed_tx_cache_.clear();
confirmed_tx_ids_.clear();
confirmed_cache_block_ = -1;
last_tx_block_height_ = -1;
pending_opids_.clear();
pending_send_info_.clear();
// Resolve any deferred send callbacks so their UI doesn't spin forever on disconnect.
for (auto& entry : pending_send_callbacks_) {
if (entry.second) entry.second(false, reason);
}
pending_send_callbacks_.clear();
consecutive_core_failures_ = 0;
send_progress_active_ = false;
send_submissions_in_flight_ = 0;
network_refresh_.resetJobs();
rescan_status_poll_in_progress_ = false;
opid_poll_in_progress_ = false;
address_validation_cache_dirty_ = true;
resetTransactionHistoryCacheSession();
// Tear down the fast-lane connection. Signal abort first so a fast-lane call blocked in
// curl_easy_perform unblocks and stop()'s join() returns promptly (no UI freeze).
if (fast_rpc_) fast_rpc_->requestAbort();
if (fast_worker_) {
fast_worker_->stop();
// 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;
// Find the most recent console line that names an init phase, so we can tell the user exactly
// what the node is doing (loading the block index, verifying, activating best chain, …).
std::string phaseLine;
if (daemon_controller_) {
const auto lines = daemon_controller_->recentLines(40);
for (auto it = lines.rbegin(); it != lines.rend() && phaseLine.empty(); ++it) {
for (const char* phase : kDaemonInitPhases) {
if (it->find(phase) != std::string::npos) { phaseLine = *it; break; }
}
}
}
WarmupText wt;
if (!phaseLine.empty()) {
wt = translateWarmup(phaseLine);
} else if (reachableButBusy) {
// The probe connected but got no RPC reply within the timeout: the node is up but busy
// initializing (it isn't printing a recognizable phase, or we didn't launch it).
wt = {"Starting DragonX node…",
"The node is reachable but still initializing and isn't answering yet. "
"This is normal after an update or on first launch — it can take a few minutes."};
} else {
// The daemon is launching but hasn't bound its RPC port yet.
wt = {"Starting DragonX node…",
"Launching dragonxd and waiting for it to come online…"};
}
std::string title = wt.title;
const int h = daemon_controller_ ? daemon_controller_->lastBlockHeight() : -1;
if (h > 0) title += " (Block " + std::to_string(h) + ")";
state_.warmup_status = title;
state_.warmup_description = wt.description ? wt.description : "";
connection_status_ = title;
return title;
}
void App::handleLostConnection(const std::string& reason)
{
DEBUG_LOGF("[Connection] %s — tearing down for reconnect\n", reason.c_str());
// Flip the main client's connected_ flag so update()'s else-branch re-enters
// tryConnect(). onDisconnected() alone only tears down the fast lane.
if (rpc_) rpc_->disconnect();
onDisconnected(reason);
}
// ============================================================================
// Data Refresh — Tab-Aware Prioritized System
//
// Data is split into independent categories, each with its own refresh
// function, timer, and in-progress guard. The orchestrator (refreshData)
// dispatches all categories, but each can also be called independently
// (e.g. on tab switch for immediate refresh).
//
// Categories:
// Core — z_gettotalbalance + getblockchaininfo (balance, sync)
// Addresses — z_listaddresses + listunspent (address list, per-addr balances)
// Transactions — listtransactions + z_listreceivedbyaddress + z_viewtransaction
// Peers — getpeerinfo + listbanned (already standalone)
// Encryption — getwalletinfo (one-shot on connect)
//
// Intervals are adjusted by applyRefreshPolicy() based on the active tab,
// so the user sees faster updates for the data they're interacting with.
// ============================================================================
App::RefreshIntervals App::getIntervalsForPage(ui::NavPage page)
{
return services::NetworkRefreshService::intervalsForPage(page);
}
void App::applyRefreshPolicy(ui::NavPage page)
{
// 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. Reverts to the per-tab profile once sync finishes.
refresh_policy_syncing_ = state_.sync.syncing;
network_refresh_.setIntervals(refresh_policy_syncing_
? services::RefreshScheduler::kSyncProfile
: getIntervalsForPage(page));
}
bool App::currentPageNeedsWalletDataRefresh() const
{
using NP = ui::NavPage;
return current_page_ == NP::Overview ||
current_page_ == NP::Send ||
current_page_ == NP::Receive ||
current_page_ == NP::History;
}
bool App::shouldRunWalletTransactionRefresh() const
{
if (currentPageNeedsWalletDataRefresh()) return true;
if (hasTransactionSendProgress() || !send_txids_.empty()) return true;
return transactions_dirty_ && !shielded_history_scan_pending_;
}
void App::setCurrentPage(ui::NavPage page)
{
if (!isPageEnabledForBuild(page)) {
page = ui::NavPage::Overview;
}
if (page == current_page_) return;
current_page_ = page;
applyRefreshPolicy(page);
using RefreshTimer = services::NetworkRefreshService::Timer;
// Immediate refresh for the incoming tab's priority data. Gate on ACTUAL RPC connectivity
// (not state_.connected, which is the lite "online" proxy) — lite has no RPC daemon and the
// lite controller refreshes wallet data itself, so these full-node RPC polls must not fire.
if (rpc_ && rpc_->isConnected() && !state_.isLocked()) {
using NP = ui::NavPage;
switch (page) {
case NP::Overview:
refreshCoreData();
network_refresh_.reset(RefreshTimer::Core);
break;
case NP::History:
transactions_dirty_ = true;
refreshTransactionData();
network_refresh_.reset(RefreshTimer::Transactions);
break;
case NP::Send:
case NP::Receive:
addresses_dirty_ = true;
refreshAddressData();
network_refresh_.reset(RefreshTimer::Addresses);
break;
case NP::Peers:
refreshPeerInfo();
network_refresh_.reset(RefreshTimer::Peers);
break;
case NP::Mining:
refreshMiningInfo();
break;
default:
break;
}
}
}
// Screenshot sweep (tab-only + full-UI) + its demo data live in src/app_sweep.cpp.
bool App::shouldRefreshTransactions() const
{
// NOTE: this is block-height / dirty driven, NOT interval-gated. It returns true only when a new
// block arrived (currentBlocks != last_tx_block_height_), the history was never fetched, or
// something marked it dirty (tab entry, a send, a reorg, etc.). The Transactions timer only
// controls how often this CHECK runs; between blocks the lightweight recent-poll
// (shouldRefreshRecentTransactions / TxAge) handles mempool + unconfirmed deltas instead.
const int currentBlocks = state_.sync.blocks;
return network_refresh_.shouldRefreshTransactions(last_tx_block_height_,
currentBlocks,
transactions_dirty_);
}
bool App::shouldRefreshRecentTransactions() const
{
using RefreshTimer = services::NetworkRefreshService::Timer;
return network_refresh_.isDue(RefreshTimer::TxAge)
&& last_tx_block_height_ >= 0
&& state_.sync.blocks == last_tx_block_height_
&& !state_.transactions.empty()
&& !transactions_dirty_
&& !addresses_dirty_;
}
void App::upsertPendingSendTransaction(const std::string& opid,
const std::string& from,
const std::string& to,
double amount,
const std::string& memo,
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));
}
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_.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_.last_tx_update = std::time(nullptr);
}
void App::applyPendingSendDelta(const std::string& fromAddress, double signedAmount,
bool includeAggregates)
{
// 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.
auto applyToAddress = [&](std::vector<AddressInfo>& addresses) {
for (auto& address : addresses) {
if (address.address == fromAddress) {
address.balance = std::max(0.0, address.balance + signedAmount);
return true;
}
}
return false;
};
if (!applyToAddress(state_.z_addresses)) applyToAddress(state_.t_addresses);
if (includeAggregates) {
if (!fromAddress.empty() && fromAddress[0] == 'z') {
state_.privateBalance = std::max(0.0, state_.privateBalance + signedAmount);
} else {
state_.transparentBalance = std::max(0.0, state_.transparentBalance + signedAmount);
}
state_.totalBalance = std::max(0.0, state_.totalBalance + signedAmount);
}
}
void App::trackOperation(const std::string& opid)
{
if (opid.empty()) return;
// Touched only from the main thread (sendTransaction's MainCb and the opid poller's
// MainCb both run via drainResults()), so no locking is needed.
if (std::find(pending_opids_.begin(), pending_opids_.end(), opid) != pending_opids_.end())
return;
pending_opids_.push_back(opid);
}
bool App::invokeSendResultCallback(const std::string& opid, bool ok,
const std::string& result)
{
auto it = pending_send_callbacks_.find(opid);
if (it == pending_send_callbacks_.end()) return false;
auto cb = std::move(it->second);
pending_send_callbacks_.erase(it);
if (cb) cb(ok, result);
return true;
}
void App::applyPendingSendBalanceDeltas(bool includeAggregateBalances)
{
for (const auto& [opid, pending] : pending_send_info_) {
(void)opid;
applyPendingSendDelta(pending.from, -pending.amount, includeAggregateBalances);
}
}
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;
}
// 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;
}
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;
e.cachedBalance = state_.totalBalance;
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);
}
if (markOpened) {
e.lastOpenedEpoch = static_cast<long long>(std::time(nullptr));
e.syncedHere = true; // we've loaded it in this datadir -> catch-up (no full rescan) on switch
}
if (wallet_index_.upsert(e)) wallet_index_.save();
}
void App::switchToWallet(const std::string& walletFile, bool stopDaemonConfirmed, bool salvage)
{
if (!supportsFullNodeLifecycleActions()) {
ui::Notifications::instance().warning("Full-node lifecycle actions are unavailable in lite build");
return;
}
if (walletFile.empty() || !settings_) return;
if (settings_->getActiveWalletFile() == walletFile) {
ui::Notifications::instance().info("That wallet is already open.");
return;
}
if (daemon_restarting_) {
ui::Notifications::instance().warning("The node is busy restarting — try again in a moment.");
return;
}
// Don't race the other daemon-lifecycle operations. rescan/repair set state_.sync.rescanning; the
// seed-migration flow drives its own daemon stop/start; an encryption restart sets daemon_restarting_.
if (state_.sync.rescanning) {
ui::Notifications::instance().warning("A rescan or repair is in progress — try again once it finishes.");
return;
}
if (show_seed_migration_) {
ui::Notifications::instance().warning("Finish or cancel the seed migration before switching wallets.");
return;
}
if (!isUsingEmbeddedDaemon()) {
ui::Notifications::instance().warning("Switching wallets needs the embedded daemon — stop any external dragonxd first.");
return;
}
if (hasTransactionSendProgress()) {
ui::Notifications::instance().warning("Finish or cancel the pending send before switching wallets.");
return;
}
// If 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);
lock_attempts_ = 0;
lock_lockout_timer_ = 0.0f;
lock_error_msg_.clear();
lock_error_timer_ = 0.0f;
lock_unlock_in_progress_ = false;
util::SecureVault::secureZero(lock_passphrase_buf_, sizeof(lock_passphrase_buf_));
util::SecureVault::secureZero(lock_pin_buf_, sizeof(lock_pin_buf_));
// Same restart coordination as the seed-adopt flow: gate the main-loop reconnect, disconnect,
// then stop + restart on a background thread. syncSettings() re-reads active_wallet_file on
// start, so the daemon comes up on -wallet=<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;
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)
// 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();
// 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_);
}
// 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;
int unread = 0;
const auto& store = chat_service_.store();
for (const auto& cid : store.conversationIds()) {
if (settings_ && settings_->isChatMuted(cid)) continue; // muted conversations don't badge (Q10)
if (settings_ && settings_->isChatHidden(cid)) continue; // hidden conversations don't badge
std::int64_t seen = 0;
const auto it = chat_seen_watermark_.find(cid);
if (it != chat_seen_watermark_.end()) seen = it->second;
for (const auto& m : store.conversation(cid))
if (m.direction == chat::ChatDirection::Incoming && m.timestamp > seen) ++unread;
}
return unread;
}
void App::markChatConversationSeen(const std::string& cid, std::int64_t latestTs)
{
if (latestTs > 0) chat_seen_watermark_[cid] = latestTs;
}
void App::wipePendingTransactionHistoryCachePassphrase()
{
if (!pending_transaction_history_cache_passphrase_.empty()) {
util::SecureVault::secureZero(pending_transaction_history_cache_passphrase_.data(),
pending_transaction_history_cache_passphrase_.size());
pending_transaction_history_cache_passphrase_.clear();
}
}
void App::resetTransactionHistoryCacheSession()
{
transaction_history_cache_.lockKey();
wipePendingTransactionHistoryCachePassphrase();
transaction_history_cache_loaded_ = false;
invalidateShieldedHistoryScanProgress(false);
}
void App::pruneShieldedHistoryScanProgress()
{
std::unordered_set<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)
{
shielded_history_scan_cursor_ = 0;
shielded_history_scan_pending_ = false;
initial_history_scan_complete_ = false; // a full re-scan is coming — show load progress again
shielded_history_scan_heights_.clear();
if (persistCache) storeTransactionHistoryCacheIfAvailable();
}
bool App::ensureTransactionHistoryCacheUnlockedFor(const std::string& walletIdentity)
{
if (walletIdentity.empty()) return false;
if (transaction_history_cache_.isUnlockedFor(walletIdentity)) return true;
if (!pending_transaction_history_cache_passphrase_.empty()) {
std::string passphrase = pending_transaction_history_cache_passphrase_;
bool unlocked = transaction_history_cache_.unlockWithPassphrase(walletIdentity, passphrase);
if (unlocked) wipePendingTransactionHistoryCachePassphrase();
util::SecureVault::secureZero(passphrase.data(), passphrase.size());
if (unlocked) return true;
}
if (state_.encryption_state_known && !state_.encrypted) {
std::string cacheKey = unencryptedTransactionHistoryCacheKey(walletIdentity);
bool unlocked = transaction_history_cache_.unlockWithPassphrase(walletIdentity, cacheKey);
util::SecureVault::secureZero(cacheKey.data(), cacheKey.size());
return unlocked;
}
return false;
}
void App::unlockTransactionHistoryCacheWithPassphrase(const std::string& passphrase)
{
if (passphrase.empty()) return;
std::string walletIdentity = transactionHistoryCacheWalletIdentity();
if (walletIdentity.empty()) {
wipePendingTransactionHistoryCachePassphrase();
pending_transaction_history_cache_passphrase_ = passphrase;
return;
}
if (transaction_history_cache_.unlockWithPassphrase(walletIdentity, passphrase)) {
wipePendingTransactionHistoryCachePassphrase();
if (state_.transactions.empty()) loadTransactionHistoryCacheIfAvailable();
else storeTransactionHistoryCacheIfAvailable();
}
}
void App::loadTransactionHistoryCacheIfAvailable()
{
if (transaction_history_cache_loaded_ || !state_.transactions.empty()) return;
std::string walletIdentity = transactionHistoryCacheWalletIdentity();
if (walletIdentity.empty()) return;
if (!ensureTransactionHistoryCacheUnlockedFor(walletIdentity)) return;
auto loaded = transaction_history_cache_.load(walletIdentity,
state_.sync.blocks,
state_.sync.best_blockhash);
if (!loaded.loaded) return;
state_.transactions = std::move(loaded.transactions);
shielded_history_scan_heights_ = std::move(loaded.shieldedScanHeights);
pruneShieldedHistoryScanProgress();
state_.last_tx_update = loaded.updatedAt;
last_tx_block_height_ = loaded.tipHeight;
confirmed_tx_cache_.clear();
confirmed_tx_ids_.clear();
for (const auto& transaction : state_.transactions) {
if (transaction.confirmations >= 10 && transaction.timestamp != 0) {
confirmed_tx_ids_.insert(transaction.txid);
confirmed_tx_cache_.push_back(transaction);
}
}
confirmed_cache_block_ = loaded.tipHeight;
transaction_history_cache_loaded_ = true;
transactions_dirty_ = true;
network_refresh_.markDue(services::NetworkRefreshService::Timer::Transactions);
}
void App::storeTransactionHistoryCacheIfAvailable()
{
if (state_.transactions.empty()) return;
std::string walletIdentity = transactionHistoryCacheWalletIdentity();
if (walletIdentity.empty()) return;
if (!ensureTransactionHistoryCacheUnlockedFor(walletIdentity)) return;
pruneShieldedHistoryScanProgress();
std::unordered_set<std::string> pendingOpids(pending_opids_.begin(), pending_opids_.end());
std::vector<TransactionInfo> cacheTransactions;
cacheTransactions.reserve(state_.transactions.size());
for (const auto& transaction : state_.transactions) {
if (pendingOpids.find(transaction.txid) != pendingOpids.end()) continue;
cacheTransactions.push_back(transaction);
}
if (cacheTransactions.empty()) return;
std::time_t updatedAt = state_.last_tx_update != 0
? static_cast<std::time_t>(state_.last_tx_update)
: std::time(nullptr);
transaction_history_cache_.replace(walletIdentity,
state_.sync.blocks,
state_.sync.best_blockhash,
cacheTransactions,
updatedAt,
shielded_history_scan_heights_);
}
void App::refreshData()
{
if (!state_.connected || !rpc_ || !worker_) return;
// During warmup, only poll for warmup completion via refreshCoreData.
// Other RPC calls (balance, addresses, transactions) will fail with -28.
if (state_.warming_up) {
refreshCoreData();
return;
}
// Dispatch each category independently — results trickle into the UI
// as each completes, rather than waiting for the slowest phase.
refreshCoreData();
bool addressRefreshNeeded = addresses_dirty_;
bool walletDataPage = currentPageNeedsWalletDataRefresh();
if (addressRefreshNeeded)
refreshAddressData();
if (!addressRefreshNeeded && shouldRunWalletTransactionRefresh() && shouldRefreshTransactions())
refreshTransactionData();
else if (!addressRefreshNeeded && walletDataPage && shouldRefreshRecentTransactions())
refreshRecentTransactionData();
// 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()
{
if (!state_.connected) return;
// During warmup, poll getinfo to detect when warmup ends.
// Most RPC calls (balance, blockchain info) will fail with -28 during warmup.
if (state_.warming_up) {
if (!worker_) return;
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Core, *worker_, [this]() -> rpc::RPCWorker::MainCb {
AppRefreshRpcGateway refreshRpc(*rpc_, "Startup / Warmup poll");
auto result = NetworkRefreshService::collectWarmupPollResult(refreshRpc);
return [this, result = std::move(result)]() {
if (result.ready) {
// Warmup finished — daemon is fully ready
state_.warming_up = false;
state_.warmup_status.clear();
state_.warmup_description.clear();
connection_status_ = TR("connected");
VERBOSE_LOGF("[warmup] Daemon ready, warmup complete\n");
// 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();
state_.sync.rescan_progress = 1.0f;
// Notes/witnesses were rebuilt — force a fresh history + balance pull.
transactions_dirty_ = true;
last_tx_block_height_ = -1;
invalidateShieldedHistoryScanProgress(true);
ui::Notifications::instance().success("Blockchain rescan complete");
}
NetworkRefreshService::applyConnectionInfoResult(state_, result.info);
// Trigger full data refresh now that daemon is ready
refreshData();
} else {
// Still warming up — update status
auto wt = translateWarmup(result.errorMessage);
state_.warmup_status = wt.title;
state_.warmup_description = wt.description;
if (daemon_controller_) {
int h = daemon_controller_->lastBlockHeight();
if (h > 0)
state_.warmup_status += " (Block " + std::to_string(h) + ")";
}
connection_status_ = state_.warmup_status;
VERBOSE_LOGF("[warmup] Still warming up: %s\n", result.errorMessage.c_str());
}
};
}, 3);
if (!enqueued.enqueued) return;
return;
}
// Use fast-lane on Overview for snappier balance updates
bool useFast = (current_page_ == ui::NavPage::Overview);
auto* w = useFast && fast_worker_ && fast_worker_->isRunning()
? fast_worker_.get() : worker_.get();
auto* rpc = useFast && fast_rpc_ && fast_rpc_->isConnected()
? fast_rpc_.get() : rpc_.get();
if (!w || !rpc) return;
ui::NavPage tracePage = current_page_;
// Skip the balance call while syncing (it's incomplete anyway and takes the wallet lock +
// cs_main). Captured on the main thread to avoid reading state_ off the worker thread.
const bool includeBalance = !state_.sync.syncing;
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Core, *w, [this, rpc, tracePage, includeBalance]() -> rpc::RPCWorker::MainCb {
AppRefreshRpcGateway refreshRpc(*rpc, traceSource(tracePage, "Core refresh"));
auto result = NetworkRefreshService::collectCoreRefreshResult(refreshRpc, includeBalance);
return [this, result]() {
try {
NetworkRefreshService::applyCoreRefreshResult(state_, result, std::time(nullptr));
applyPendingSendBalanceDeltas(true);
// Mid-session connection-loss detection. During normal operation, both core
// RPCs failing together means the daemon connection is dead (a busy daemon
// fails them individually, not both at once). Warmup is excluded — both fail
// with -28 there legitimately, and counting it would cause a reconnect loop.
constexpr int kCoreFailuresBeforeDisconnect = 3;
if (!state_.warming_up) {
if (!result.balanceOk && !result.blockchainOk) {
if (++consecutive_core_failures_ >= kCoreFailuresBeforeDisconnect &&
state_.connected) {
consecutive_core_failures_ = 0;
handleLostConnection("Lost connection to daemon");
return; // state torn down — skip the rest of this callback
}
} else {
consecutive_core_failures_ = 0;
}
}
// Auto-shield transparent funds if enabled
if (result.balanceOk && settings_ && settings_->getAutoShield() &&
state_.transparent_balance > 0.0001 && !state_.sync.syncing &&
!auto_shield_pending_.exchange(true)) {
std::string targetZAddr;
for (const auto& addr : state_.addresses) {
if (addr.isShielded()) {
targetZAddr = addr.address;
break;
}
}
if (!targetZAddr.empty() && worker_) {
DEBUG_LOGF("[AutoShield] Shielding %.8f DRGX to %s\n",
state_.transparent_balance, targetZAddr.c_str());
// Use the user-configured fee, formatted fixed-decimal so the daemon's
// ParseFixedPoint accepts it (a small double would serialize to "5e-05").
const std::string feeStr =
util::formatAmountFixed(settings_ ? settings_->getDefaultFee() : 0.0001);
// This callback runs on the UI thread (drainResults). Build/broadcast
// on the worker thread — never block the UI with synchronous RPC.
worker_->post([this, targetZAddr, feeStr]() -> rpc::RPCWorker::MainCb {
std::string opid;
std::string err;
try {
rpc::RPCClient::TraceScope trace("Auto-shield / z_shieldcoinbase");
auto result = rpc_->call("z_shieldcoinbase",
{std::string("*"), targetZAddr, feeStr, 50});
opid = result.value("opid", "");
} catch (const std::exception& e) {
err = e.what();
}
return [this, opid, err]() {
auto_shield_pending_ = false;
if (!err.empty()) {
DEBUG_LOGF("[AutoShield] Error: %s\n", err.c_str());
return;
}
if (!opid.empty()) {
DEBUG_LOGF("[AutoShield] Started: %s\n", opid.c_str());
// Surface the async result + refresh balances on completion.
trackOperation(opid);
}
};
});
} else {
auto_shield_pending_ = false;
}
}
} catch (const std::exception& e) {
DEBUG_LOGF("[refreshCoreData] callback error: %s\n", e.what());
}
};
}, 3);
if (!enqueued.enqueued) return;
}
// ============================================================================
// Address Data: z/t address lists + per-address balances
// ============================================================================
void App::refreshAddressData()
{
if (!worker_ || !rpc_ || !state_.connected) return;
const std::size_t previousAddressCount = state_.z_addresses.size() + state_.t_addresses.size();
const std::string previousWalletIdentity = transactionHistoryCacheWalletIdentity();
auto addressSnapshot = address_validation_cache_dirty_
? NetworkRefreshService::AddressRefreshSnapshot{}
: NetworkRefreshService::buildAddressRefreshSnapshot(state_);
ui::NavPage tracePage = current_page_;
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Addresses, *worker_, [this, previousAddressCount, previousWalletIdentity, addressSnapshot = std::move(addressSnapshot), tracePage]() -> rpc::RPCWorker::MainCb {
AppRefreshRpcGateway refreshRpc(*rpc_, traceSource(tracePage, "Address refresh"));
auto result = NetworkRefreshService::collectAddressRefreshResult(refreshRpc, addressSnapshot);
return [this, previousAddressCount, previousWalletIdentity, result = std::move(result)]() mutable {
NetworkRefreshService::applyAddressRefreshResult(state_, std::move(result));
applyPendingSendBalanceDeltas(false);
address_validation_cache_dirty_ = false;
address_list_dirty_ = true;
addresses_dirty_ = false;
const std::size_t currentAddressCount = state_.z_addresses.size() + state_.t_addresses.size();
const bool addressSetChanged = currentAddressCount != previousAddressCount ||
transactionHistoryCacheWalletIdentity() != previousWalletIdentity;
if (state_.transactions.empty() || addressSetChanged) {
if (addressSetChanged) {
invalidateShieldedHistoryScanProgress(false);
}
transactions_dirty_ = true;
last_tx_block_height_ = -1;
network_refresh_.markDue(services::NetworkRefreshService::Timer::Transactions);
}
if (state_.transactions.empty()) loadTransactionHistoryCacheIfAvailable();
else storeTransactionHistoryCacheIfAvailable();
maybeFinishTransactionSendProgress();
// 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) return;
}
// ============================================================================
// Transaction Data: transparent + shielded receives + z_viewtransaction enrichment
// ============================================================================
void App::refreshTransactionData()
{
if (!worker_ || !rpc_ || !state_.connected) return;
if (addresses_dirty_) {
refreshAddressData();
network_refresh_.markDue(services::NetworkRefreshService::Timer::Transactions);
return;
}
const int currentBlocks = state_.sync.blocks;
if (last_tx_block_height_ < 0 || currentBlocks != last_tx_block_height_ ||
!shielded_history_scan_pending_) {
shielded_history_scan_cursor_ = 0;
shielded_history_scan_pending_ = false;
}
auto transactionSnapshot = NetworkRefreshService::buildTransactionRefreshSnapshot(
state_, viewtx_cache_, send_txids_);
transactionSnapshot.pendingOpids.insert(pending_opids_.begin(), pending_opids_.end());
if (settings_) transactionSnapshot.miningAddresses = settings_->getMiningAddresses();
transactionSnapshot.shieldedScanHeights = shielded_history_scan_heights_;
transactionSnapshot.shieldedScanStartIndex = shielded_history_scan_cursor_;
transactionSnapshot.maxShieldedReceiveScans = shieldedReceiveScanBudget(current_page_);
transactionSnapshot.shieldedScanTipTolerance =
shieldedScanTipTolerance(transactionSnapshot.shieldedAddresses.size());
ui::NavPage tracePage = current_page_;
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Transactions, *worker_, [this, currentBlocks,
transactionSnapshot = std::move(transactionSnapshot), tracePage]() -> rpc::RPCWorker::MainCb {
AppRefreshRpcGateway refreshRpc(*rpc_, traceSource(tracePage, "Transaction refresh"));
auto result = NetworkRefreshService::collectTransactionRefreshResult(
refreshRpc, transactionSnapshot, currentBlocks, MAX_VIEWTX_PER_CYCLE);
return [this, result = std::move(result)]() mutable {
bool shieldedScanComplete = result.shieldedScanComplete;
std::size_t nextShieldedScanStartIndex = result.nextShieldedScanStartIndex;
auto shieldedScanHeights = std::move(result.shieldedScanHeights);
NetworkRefreshService::TransactionCacheUpdate cacheUpdate{
viewtx_cache_,
send_txids_,
confirmed_tx_cache_,
confirmed_tx_ids_,
confirmed_cache_block_,
last_tx_block_height_
};
// 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));
shielded_history_scan_heights_ = std::move(shieldedScanHeights);
storeTransactionHistoryCacheIfAvailable();
shielded_history_scan_cursor_ = nextShieldedScanStartIndex;
shielded_history_scan_pending_ = !shieldedScanComplete;
if (shieldedScanComplete) initial_history_scan_complete_ = true;
transactions_dirty_ = !shieldedScanComplete;
maybeFinishTransactionSendProgress();
};
}, 3);
if (!enqueued.enqueued) return;
network_refresh_.resetTxAge();
}
void App::refreshRecentTransactionData()
{
if (!worker_ || !rpc_ || !state_.connected) return;
if (!shouldRefreshRecentTransactions()) return;
const int currentBlocks = state_.sync.blocks;
auto transactionSnapshot = NetworkRefreshService::buildTransactionRefreshSnapshot(
state_, viewtx_cache_, send_txids_);
transactionSnapshot.pendingOpids.insert(pending_opids_.begin(), pending_opids_.end());
if (settings_) transactionSnapshot.miningAddresses = settings_->getMiningAddresses();
transactionSnapshot.shieldedScanHeights = shielded_history_scan_heights_;
transactionSnapshot.shieldedScanStartIndex = shielded_history_scan_cursor_;
transactionSnapshot.maxShieldedReceiveScans = 1;
transactionSnapshot.shieldedScanTipTolerance =
shieldedScanTipTolerance(transactionSnapshot.shieldedAddresses.size());
ui::NavPage tracePage = current_page_;
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Transactions, *worker_, [this, currentBlocks,
transactionSnapshot = std::move(transactionSnapshot), tracePage]() -> rpc::RPCWorker::MainCb {
AppRefreshRpcGateway refreshRpc(*rpc_, traceSource(tracePage, "Recent transaction poll"));
auto result = NetworkRefreshService::collectRecentTransactionRefreshResult(
refreshRpc, transactionSnapshot, currentBlocks);
return [this, result = std::move(result)]() mutable {
std::size_t nextShieldedScanStartIndex = result.nextShieldedScanStartIndex;
auto shieldedScanHeights = std::move(result.shieldedScanHeights);
NetworkRefreshService::TransactionCacheUpdate cacheUpdate{
viewtx_cache_,
send_txids_,
confirmed_tx_cache_,
confirmed_tx_ids_,
confirmed_cache_block_,
last_tx_block_height_
};
// 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));
shielded_history_scan_heights_ = std::move(shieldedScanHeights);
shielded_history_scan_cursor_ = nextShieldedScanStartIndex;
storeTransactionHistoryCacheIfAvailable();
maybeFinishTransactionSendProgress();
};
}, 3);
if (!enqueued.enqueued) return;
network_refresh_.resetTxAge();
}
// ============================================================================
// Encryption State: wallet info (one-shot on connect, lightweight)
// ============================================================================
bool App::refreshEncryptionState()
{
if (!worker_ || !rpc_ || !state_.connected) return false;
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Encryption, *worker_, [this]() -> rpc::RPCWorker::MainCb {
json walletInfo;
bool ok = false;
try {
rpc::RPCClient::TraceScope trace("Startup / Wallet encryption state");
walletInfo = rpc_->call("getwalletinfo");
ok = true;
} catch (...) {}
if (!ok) return nullptr;
auto result = NetworkRefreshService::parseWalletEncryptionResult(walletInfo);
return [this, result]() {
NetworkRefreshService::applyWalletEncryptionResult(state_, result);
if (state_.isLocked()) {
resetTransactionHistoryCacheSession();
} else if (state_.transactions.empty()) {
loadTransactionHistoryCacheIfAvailable();
} else {
storeTransactionHistoryCacheIfAvailable();
}
};
}, 3);
return enqueued.enqueued;
}
void App::refreshBalance()
{
refreshCoreData();
}
void App::refreshAddresses()
{
addresses_dirty_ = true;
refreshAddressData();
}
void App::refreshMiningInfo()
{
// Use the dedicated fast-lane worker + connection so mining polls
// never block behind the main refresh batch. Falls back to the main
// worker if the fast lane isn't ready yet (e.g. during initial connect).
auto* w = (fast_worker_ && fast_worker_->isRunning()) ? fast_worker_.get() : worker_.get();
auto* rpc = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get();
if (!w || !rpc) return;
// Capture daemon memory outside (may be accessed on main thread)
double daemonMemMb = 0.0;
if (daemon_controller_) {
daemonMemMb = daemon_controller_->memoryUsageMB();
}
// Slow-tick counter: run full getmininginfo every ~5 seconds
// to reduce RPC overhead. getlocalsolps is only needed while solo mining
// or while the Mining tab is actively showing live local hashrate.
// NOTE: getinfo is NOT called here — longestchain/notarized are updated by
// refreshBalance (via getblockchaininfo), and daemon_version/protocol_version/
// p2p_port are static for the lifetime of a connection (set in onConnected).
bool doSlowRefresh = (mining_slow_counter_++ % 5 == 0);
bool includeLocalHashrate = state_.mining.generate || current_page_ == ui::NavPage::Mining;
if (!includeLocalHashrate && !doSlowRefresh) return;
// 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_;
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Mining, *w,
[this, rpc, daemonMemMb, doSlowRefresh, includeLocalHashrate, tracePage]() -> rpc::RPCWorker::MainCb {
AppRefreshRpcGateway refreshRpc(*rpc, traceSource(tracePage, "Mining refresh"));
auto result = NetworkRefreshService::collectMiningRefreshResult(
refreshRpc, daemonMemMb, doSlowRefresh, includeLocalHashrate);
return [this, result]() {
try {
NetworkRefreshService::applyMiningRefreshResult(state_, result, std::time(nullptr));
} catch (const std::exception& e) {
DEBUG_LOGF("[refreshMiningInfo] callback error: %s\n", e.what());
}
};
}, 2);
if (!enqueued.enqueued) return;
}
void App::refreshPeerInfo()
{
if (!rpc_) return;
// Use fast-lane worker to bypass head-of-line blocking behind refreshData.
auto* w = (fast_worker_ && fast_worker_->isRunning()) ? fast_worker_.get() : worker_.get();
auto* r = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get();
if (!w) return;
ui::NavPage tracePage = current_page_;
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Peers, *w, [this, r, tracePage]() -> rpc::RPCWorker::MainCb {
AppRefreshRpcGateway refreshRpc(*r, traceSource(tracePage, "Peer refresh"));
auto result = NetworkRefreshService::collectPeerRefreshResult(refreshRpc);
return [this, result = std::move(result)]() mutable {
NetworkRefreshService::applyPeerRefreshResult(state_, std::move(result), std::time(nullptr));
};
}, 2);
if (!enqueued.enqueued) return;
}
void App::refreshPrice()
{
// Skip if price fetching is disabled
if (!settings_->getFetchPrices()) return;
if (!worker_) return;
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Price, *worker_, [this]() -> rpc::RPCWorker::MainCb {
// --- Worker thread: blocking HTTP GET to CoinGecko ---
NetworkRefreshService::PriceHttpResult result;
try {
CURL* curl = curl_easy_init();
if (!curl) {
DEBUG_LOGF("Failed to initialize curl for price fetch\n");
result.errorMessage = "Price fetch failed: failed to initialize curl";
} else {
std::string response_data;
const char* url = "https://api.coingecko.com/api/v3/simple/price?ids=dragonx-2&vs_currencies=usd,btc&include_24hr_change=true&include_24hr_vol=true&include_market_cap=true";
auto write_callback = [](void* contents, size_t size, size_t nmemb, std::string* userp) -> size_t {
size_t totalSize = size * nmemb;
userp->append((char*)contents, totalSize);
return totalSize;
};
curl_easy_setopt(curl, CURLOPT_URL, url);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, +write_callback);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response_data);
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 10L);
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 5L);
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
curl_easy_setopt(curl, CURLOPT_USERAGENT, "DragonX-Wallet/1.0");
CURLcode res = curl_easy_perform(curl);
long http_code = 0;
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
curl_easy_cleanup(curl);
NetworkRefreshService::PriceHttpResponse response{
res == CURLE_OK,
http_code,
std::move(response_data),
res == CURLE_OK ? std::string() : std::string(curl_easy_strerror(res))
};
result = NetworkRefreshService::parsePriceHttpResponse(response, std::time(nullptr));
}
} catch (const std::exception& e) {
DEBUG_LOGF("Price fetch error: %s\n", e.what());
result.errorMessage = std::string("Price fetch error: ") + e.what();
} catch (...) {
DEBUG_LOGF("Price fetch error: unknown exception\n");
result.errorMessage = "Price fetch error: unknown exception";
}
if (result.price) {
DEBUG_LOGF("Price updated: $%.6f USD\n", result.price->market.price_usd);
} else {
DEBUG_LOGF("%s\n", result.errorMessage.c_str());
}
return [this, result = std::move(result)]() mutable {
if (result.price) {
NetworkRefreshService::applyPriceRefreshResult(state_, *result.price, std::chrono::steady_clock::now());
} else {
NetworkRefreshService::applyPriceRefreshFailure(state_, result.errorMessage);
}
};
}, 0);
if (!enqueued.enqueued) return;
NetworkRefreshService::markPriceRefreshStarted(state_);
}
void App::refreshMarketData()
{
refreshPrice();
refreshExchanges();
refreshMarketChart();
refreshExchangeChart();
}
void App::refreshExchangeChart()
{
if (!settings_ || !settings_->getFetchPrices() || !worker_) return;
if (exchange_chart_fetch_in_flight_) return;
// Resolve the selected pair from the same registry the Market tab renders (live tickers, else the
// static fallback), matching exchange name THEN pair to disambiguate the same pair across venues.
const auto& reg = state_.market.exchanges.empty()
? data::getExchangeRegistry() : state_.market.exchanges;
const std::string selEx = settings_->getSelectedExchange();
const std::string selPair = settings_->getSelectedPair();
const data::ExchangePair* pair = nullptr;
for (const auto& ex : reg) {
if (!selEx.empty() && ex.name != selEx) continue;
for (const auto& p : ex.pairs)
if (p.displayName == selPair) { pair = &p; break; }
if (pair) break;
}
if (!pair && !reg.empty() && !reg[0].pairs.empty()) pair = &reg[0].pairs[0]; // default like the UI
if (!pair) { state_.market.exchange_chart_active = false; exchange_chart_key_.clear(); return; }
const std::string key = pair->identifier + ":" + pair->base + "/" + pair->quote;
// No candle adapter for this venue -> draw the CoinGecko aggregate instead.
if (pair->identifier.empty() || !data::hasExchangeCandleAdapter(pair->identifier)) {
state_.market.exchange_chart_active = false;
exchange_chart_key_.clear();
return;
}
// Already loaded for this pair and still fresh (candles move slowly).
if (key == exchange_chart_key_ && state_.market.exchange_chart_active &&
std::chrono::steady_clock::now() - exchange_chart_last_fetch_ < std::chrono::minutes(30))
return;
// Cached from a recent view of this pair -> load instantly, no re-fetch (switching back is snappy).
if (auto it = exchange_chart_cache_.find(key);
it != exchange_chart_cache_.end() &&
std::chrono::steady_clock::now() - it->second.fetchedAt < std::chrono::minutes(30)) {
state_.market.exchange_chart_intraday = it->second.closeIntraday;
state_.market.exchange_chart_daily = it->second.closeDaily;
state_.market.exchange_ohlc_intraday = it->second.ohlcIntraday;
state_.market.exchange_ohlc_daily = it->second.ohlcDaily;
state_.market.exchange_chart_active = true;
exchange_chart_key_ = key;
exchange_chart_last_fetch_ = it->second.fetchedAt;
return;
}
// Switching pairs: fall back to the aggregate until the new venue's candles arrive.
if (key != exchange_chart_key_) state_.market.exchange_chart_active = false;
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);
worker_->post([this, identifier, key, urlIntra, urlDaily]() -> rpc::RPCWorker::MainCb {
// httpGetString verifies TLS and returns "" on any failure (parses to an empty series).
std::string bIntra = util::httpGetString(urlIntra, "[exch-chart]");
std::string bDaily = util::httpGetString(urlDaily, "[exch-chart]");
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);
} else {
// Both ranges empty -> the venue's API failed/changed; keep the aggregate.
state_.market.exchange_chart_active = false;
exchange_chart_key_.clear();
}
};
});
}
void App::refreshMarketChart()
{
if (!settings_ || !settings_->getFetchPrices()) return;
if (!worker_) return;
if (chart_fetch_in_flight_) return;
// Historical data moves slowly — refresh at most every 30 minutes after the first fetch.
if (state_.market.chart_loaded &&
std::chrono::steady_clock::now() - state_.market.chart_last_fetch_time < std::chrono::minutes(30))
return;
chart_fetch_in_flight_ = true;
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]");
std::string dailyBody = util::httpGetString(
"https://api.coingecko.com/api/v3/coins/dragonx-2/market_chart?vs_currency=usd&days=365",
"[market-chart]");
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 any = !intraday.empty() || !daily.empty();
if (!intraday.empty()) state_.market.price_chart_intraday = std::move(intraday);
if (!daily.empty()) state_.market.price_chart_daily = std::move(daily);
if (any) {
state_.market.chart_loaded = true;
state_.market.chart_last_fetch_time = std::chrono::steady_clock::now();
}
// On total failure, leave chart_loaded=false so the next tick retries.
};
});
}
void App::refreshExchanges()
{
if (!settings_ || !settings_->getFetchPrices()) return;
if (!worker_) return;
if (exchanges_fetch_started_) return; // once per session (venues are near-static)
exchanges_fetch_started_ = true;
worker_->post([this]() -> rpc::RPCWorker::MainCb {
std::vector<data::ExchangeInfo> exchanges;
try {
CURL* curl = curl_easy_init();
if (curl) {
std::string body;
const char* url = "https://api.coingecko.com/api/v3/coins/dragonx-2/tickers";
auto write_callback = [](void* contents, size_t size, size_t nmemb, std::string* userp) -> size_t {
size_t total = size * nmemb;
userp->append((char*)contents, total);
return total;
};
curl_easy_setopt(curl, CURLOPT_URL, url);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, +write_callback);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &body);
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 12L);
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 5L);
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
curl_easy_setopt(curl, CURLOPT_USERAGENT, "DragonX-Wallet/1.0");
CURLcode res = curl_easy_perform(curl);
long http_code = 0;
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
curl_easy_cleanup(curl);
if (res == CURLE_OK && http_code >= 200 && http_code < 300)
exchanges = data::parseCoinGeckoTickers(body);
}
} catch (...) {}
return [this, exchanges = std::move(exchanges)]() mutable {
if (!exchanges.empty())
state_.market.exchanges = std::move(exchanges);
else
exchanges_fetch_started_ = false; // allow a retry if it failed/empty
};
});
}
// ============================================================================
// Mining Operations
// ============================================================================
void App::startMining(int threads)
{
if (!supportsSoloMining()) {
(void)threads;
ui::Notifications::instance().warning("Solo mining is unavailable in lite build");
return;
}
if (!state_.connected || !rpc_ || !worker_) return;
if (mining_toggle_in_progress_.exchange(true)) return; // already in progress
worker_->post([this, threads]() -> rpc::RPCWorker::MainCb {
bool ok = false;
std::string errMsg;
try {
rpc::RPCClient::TraceScope trace("Mining tab / Start mining");
rpc_->call("setgenerate", {true, threads});
ok = true;
} catch (const std::exception& e) {
errMsg = e.what();
}
return [this, threads, ok, errMsg]() {
mining_toggle_in_progress_.store(false);
if (ok) {
state_.mining.generate = true;
state_.mining.genproclimit = threads;
DEBUG_LOGF("Mining started with %d threads\n", threads);
} else {
DEBUG_LOGF("Failed to start mining: %s\n", errMsg.c_str());
ui::Notifications::instance().error("Mining failed: " + errMsg);
}
};
});
}
void App::stopMining()
{
if (!supportsSoloMining()) return;
if (!state_.connected || !rpc_ || !worker_) return;
if (mining_toggle_in_progress_.exchange(true)) return; // already in progress
worker_->post([this]() -> rpc::RPCWorker::MainCb {
bool ok = false;
try {
rpc::RPCClient::TraceScope trace("Mining tab / Stop mining");
rpc_->call("setgenerate", {false, 0});
ok = true;
} catch (const std::exception& e) {
DEBUG_LOGF("Failed to stop mining: %s\n", e.what());
}
return [this, ok]() {
mining_toggle_in_progress_.store(false);
if (ok) {
state_.mining.generate = false;
state_.mining.localHashrate = 0.0;
DEBUG_LOGF("Mining stopped\n");
}
};
});
}
void App::startPoolMining(int threads)
{
if (!supportsPoolMining()) {
(void)threads;
ui::Notifications::instance().warning("Pool mining is unavailable in this build");
return;
}
if (!xmrig_manager_)
xmrig_manager_ = std::make_unique<daemon::XmrigManager>();
// If already running, stop first (e.g. thread count change)
if (xmrig_manager_->isRunning()) {
xmrig_manager_->stop();
}
// Stop solo mining first if active
if (state_.mining.generate) stopMining();
daemon::XmrigManager::Config cfg;
cfg.pool_url = settings_->getPoolUrl();
cfg.worker_name = settings_->getPoolWorker();
// 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();
// Use first shielded address as the mining wallet address, fall back to transparent
for (const auto& addr : state_.z_addresses) {
if (!addr.address.empty()) {
cfg.wallet_address = addr.address;
break;
}
}
if (cfg.wallet_address.empty()) {
for (const auto& addr : state_.addresses) {
if (addr.type == "transparent" && !addr.address.empty()) {
cfg.wallet_address = addr.address;
break;
}
}
}
// Fallback: use pool worker address from settings (available even before
// the daemon is connected or the blockchain is synced).
if (cfg.wallet_address.empty() && !cfg.worker_name.empty()) {
cfg.wallet_address = cfg.worker_name;
}
if (cfg.wallet_address.empty()) {
DEBUG_LOGF("[ERROR] Pool mining: No wallet address available\n");
ui::Notifications::instance().error("No wallet address available — generate a Z address in the Receive tab");
return;
}
if (!xmrig_manager_->start(cfg)) {
std::string err = xmrig_manager_->getLastError();
DEBUG_LOGF("[ERROR] Pool mining: %s\n", err.c_str());
// Check for Windows Defender blocking (error 225 = ERROR_VIRUS_INFECTED)
if (err.find("error 225") != std::string::npos ||
err.find("virus") != std::string::npos) {
ui::Notifications::instance().error(
"Windows Defender blocked xmrig. Add exclusion for %APPDATA%\\ObsidianDragon");
#ifdef _WIN32
// Offer to open Windows Security settings
pending_antivirus_dialog_ = true;
#endif
} else {
ui::Notifications::instance().error("Failed to start pool miner: " + err);
}
} else {
// Miner spawned — it still needs a few seconds to connect to the pool and start hashing.
pool_starting_.store(true, std::memory_order_relaxed);
ui::Notifications::instance().info("Starting pool miner — connecting to the pool…");
}
}
void App::stopPoolMining()
{
if (xmrig_manager_ && xmrig_manager_->isRunning()) {
xmrig_manager_->stop(3000);
}
}
// ============================================================================
// Peer Operations
// ============================================================================
void App::banPeer(const std::string& ip, int duration_seconds)
{
if (!state_.connected || !rpc_ || !worker_) return;
// Run on the worker thread — these are called straight from the Peers tab's ImGui
// handlers, and rpc_->call() blocks on synchronous curl under curl_mutex_.
worker_->post([this, ip, duration_seconds]() -> rpc::RPCWorker::MainCb {
std::string err;
try {
rpc::RPCClient::TraceScope trace("Peers / Ban");
rpc_->call("setban", {ip, "add", duration_seconds});
} catch (const std::exception& e) {
err = e.what();
}
return [this, err]() {
if (!err.empty()) ui::Notifications::instance().error("Ban failed: " + err);
else refreshPeerInfo();
};
});
}
void App::unbanPeer(const std::string& ip)
{
if (!state_.connected || !rpc_ || !worker_) return;
worker_->post([this, ip]() -> rpc::RPCWorker::MainCb {
std::string err;
try {
rpc::RPCClient::TraceScope trace("Peers / Unban");
rpc_->call("setban", {ip, "remove"});
} catch (const std::exception& e) {
err = e.what();
}
return [this, err]() {
if (!err.empty()) ui::Notifications::instance().error("Unban failed: " + err);
else refreshPeerInfo();
};
});
}
void App::clearBans()
{
if (!state_.connected || !rpc_ || !worker_) return;
worker_->post([this]() -> rpc::RPCWorker::MainCb {
std::string err;
try {
rpc::RPCClient::TraceScope trace("Peers / Clear bans");
rpc_->call("clearbanned", nlohmann::json::array());
} catch (const std::exception& e) {
err = e.what();
}
return [this, err]() {
if (!err.empty()) { ui::Notifications::instance().error("Clear bans failed: " + err); return; }
state_.banned_peers.clear();
refreshPeerInfo();
};
});
}
void App::applyDefaultBanlist()
{
if (!state_.connected || !rpc_ || !worker_) return;
// Parse the embedded default_banlist.txt (compiled from res/default_banlist.txt)
std::string data(reinterpret_cast<const char*>(embedded::default_banlist_data),
embedded::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";
auto& targetList = shielded ? state_.z_addresses : state_.t_addresses;
// Lite build: derive locally via the controller (fast, no network). The backend auto-saves
// new addresses; the next lite refresh lists it with a balance.
if (lite_wallet_) {
const auto result = lite_wallet_->newAddress(shielded);
if (result.ok) {
AddressInfo info;
info.address = result.address;
info.type = typeStr;
info.balance = 0.0;
targetList.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;
// Also trigger full refresh to get proper balances
addresses_dirty_ = true;
refreshAddresses();
}
if (callback) callback(addr);
};
});
}
void App::hideAddress(const std::string& addr)
{
if (settings_) {
settings_->hideAddress(addr);
settings_->save();
}
}
void App::unhideAddress(const std::string& addr)
{
if (settings_) {
settings_->unhideAddress(addr);
settings_->save();
}
}
bool App::isAddressHidden(const std::string& addr) const
{
return settings_ && settings_->isAddressHidden(addr);
}
int App::getHiddenAddressCount() const
{
return settings_ ? settings_->getHiddenAddressCount() : 0;
}
void App::favoriteAddress(const std::string& addr)
{
if (settings_) {
settings_->favoriteAddress(addr);
settings_->save();
}
}
void App::unfavoriteAddress(const std::string& addr)
{
if (settings_) {
settings_->unfavoriteAddress(addr);
settings_->save();
}
}
bool App::isAddressFavorite(const std::string& addr) const
{
return settings_ && settings_->isAddressFavorite(addr);
}
void App::setAddressLabel(const std::string& addr, const std::string& label)
{
if (settings_) {
settings_->setAddressLabel(addr, label);
settings_->save();
}
}
void App::setAddressIcon(const std::string& addr, const std::string& icon)
{
if (settings_) {
settings_->setAddressIcon(addr, icon);
settings_->save();
}
}
std::string App::getAddressLabel(const std::string& addr) const
{
if (!settings_) return "";
return settings_->getAddressMeta(addr).label;
}
std::string App::getAddressIcon(const std::string& addr) const
{
if (!settings_) return "";
return settings_->getAddressMeta(addr).icon;
}
int App::getAddressSortOrder(const std::string& addr) const
{
if (!settings_) return -1;
return settings_->getAddressMeta(addr).sortOrder;
}
void App::setAddressSortOrder(const std::string& addr, int order)
{
if (settings_) {
settings_->setAddressSortOrder(addr, order);
settings_->save();
}
}
int App::getNextSortOrder() const
{
return settings_ ? settings_->getNextSortOrder() : 0;
}
void App::swapAddressOrder(const std::string& a, const std::string& b)
{
if (settings_) {
settings_->swapAddressOrder(a, b);
settings_->save();
}
}
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) storeTransactionHistoryCacheIfAvailable();
}
void App::invalidateAddressValidationCache()
{
address_validation_cache_dirty_ = true;
addresses_dirty_ = true;
invalidateShieldedHistoryScanProgress(true);
}
// ============================================================================
// Key Export/Import Operations
// ============================================================================
void App::exportPrivateKey(const std::string& address, std::function<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("Key export failed: " + err);
if (callback) callback("");
} else if (callback) {
callback(key);
}
};
});
}
// ============================================================================
// HushChat identity provisioning (experimental; gated by DRAGONX_ENABLE_CHAT)
// ============================================================================
// Derive the chat identity from the wallet's own seed-phrase secret and hand it to the chat
// service. `secret` is taken by value so we own a copy to wipe; the caller must wipe its own.
void App::provisionChatIdentityFromSecret(std::string secret)
{
// Defensive: strip surrounding whitespace so a stray newline can't change the identity — the
// same wallet must derive the SAME identity on full-node and lite (both return the canonical
// single-space phrase today, so this is belt-and-suspenders). Trim into a SEPARATE buffer so
// both the original (kept full-size) and the trimmed copy (size == content) are fully wiped —
// an in-place erase/pop_back would shrink size() and leave un-scrubbed seed bytes past it.
auto isws = [](char c) { return std::isspace(static_cast<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_);
if (chat_db_.unlockWithSecret(trimmed)) {
chat_service_.loadFromDatabase();
// Baseline unread: treat everything already in the store at load as read, so only messages
// that arrive while the app is open badge as unread (Q1).
const auto& store = chat_service_.store();
for (const auto& cid : store.conversationIds()) {
const auto msgs = store.conversation(cid);
if (!msgs.empty()) chat_seen_watermark_[cid] = msgs.back().timestamp;
}
}
} else {
chat_identity_unavailable_ = true;
}
if (!trimmed.empty()) sodium_memzero(&trimmed[0], trimmed.size());
if (!secret.empty()) sodium_memzero(&secret[0], secret.size());
chat::wipeChatKeyPair(keys);
}
// Provision the chat identity once the wallet's seed phrase is reachable. Called every update()
// tick; cheap early-outs keep it idle until it can act. Both variants derive the SAME identity
// because they feed the SAME SDXLite-compatible mnemonic into the KDF (identity derivation is
// local — peers only ever exchange public keys).
void App::resetChatSession()
{
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_fast_scan_in_flight_ = false; // a stale in-flight fast-scan is dropped by its session guard
chat_identity_unavailable_ = false;
// Invalidate any in-flight identity fetch that captured the PREVIOUS wallet's secret, so its
// completion callback can't provision that secret under the new wallet.
++chat_session_generation_;
}
void App::maybeProvisionChatIdentity()
{
if (!chat::hushChatFeatureEnabledAtBuild()) return; // constexpr — folds away in OFF builds
// Locked (encrypted && locked, mirrored for both variants) → the seed is unreadable. Drop any
// in-memory identity and re-arm so it re-derives on the next unlock. Unencrypted wallets report
// isLocked()==false and fall through.
if (state_.isLocked()) {
if (chat_identity_provisioned_ || chat_service_.hasIdentity()) resetChatSession();
return;
}
if (chat_service_.hasIdentity() || chat_identity_provisioned_ ||
chat_identity_fetch_in_flight_ || chat_identity_unavailable_) return;
if (supportsLiteBackend()) {
// Lite: the backend's `seed` command returns the 24-word mnemonic synchronously.
if (!lite_wallet_ || !lite_wallet_->walletOpen()) return;
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
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;
// 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.
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 fee) const
{
// z_sendmany spends from ONE z-address, so the "from" must itself hold >= fee. Prefer the identity
// reply address (keeps from == reply-to, the simplest case); otherwise pick the highest-balance
// spendable z-address that can cover the fee. 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();
for (const auto& a : state_.z_addresses)
if (a.address == reply && a.has_spending_key && a.balance >= fee) 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.balance >= fee && a.balance > bestBal) {
best = a.address;
bestBal = a.balance;
}
return best; // empty → no z-address can cover the fee
}
// 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_) {
const bool ok = broadcastChatMemosLite(memos);
// The lite backend has no per-send completion callback here; resolve optimistically on a
// successful queue (its own broadcast log surfaces a later failure).
if (ok && sessionGen == chat_session_generation_)
chat_service_.resolveOutgoing(echoLocalId, chat::ChatDelivery::Sent);
return ok;
}
if (!state_.connected || !rpc_ || !worker_) {
ui::Notifications::instance().error(TR("chat_toast_not_connected"));
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()) {
ui::Notifications::instance().error(TR("chat_toast_need_funds"));
return false;
}
// 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.
// The callback flips the echo to Sent/Failed once the async op resolves — real delivery status.
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; // wallet locked/switched — stale
chat_service_.resolveOutgoing(echoLocalId,
ok ? chat::ChatDelivery::Sent : chat::ChatDelivery::Failed);
});
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));
}
if (!lite_wallet_->sendTransaction(req)) {
ui::Notifications::instance().error(TR("chat_toast_lite_busy"));
return false;
}
return true;
}
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
// A synchronous refusal (not connected / no funds) resolves to Failed right away so the Retry
// affordance appears; otherwise the async callback flips it to Sent/Failed.
if (!broadcastChatMemos(memos, echo.txid))
chat_service_.resolveOutgoing(echo.txid, chat::ChatDelivery::Failed);
}
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);
if (broadcastChatMemos(memos, echo.txid))
ui::Notifications::instance().success(TR("chat_toast_request_queued"));
else
chat_service_.resolveOutgoing(echo.txid, chat::ChatDelivery::Failed);
}
// 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;
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 (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);
// 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"));
}
}
void App::fastScanChatMemos()
{
// Full-node only (lite has its own harvest). Re-scan JUST the chat reply address at minconf=0 every
// cycle so peers' messages surface at mempool speed. The normal, block-tip-gated harvest still
// ingests everything on confirmation (the store dedups on txid+position, so no double-insert).
if (lite_wallet_) return;
if (!chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return;
if (!state_.connected || !rpc_ || !worker_) return;
if (chat_fast_scan_in_flight_) return; // don't stack RPCs if a previous scan is still running
const std::string addr = chatReplyZaddr();
if (addr.empty()) return;
chat_fast_scan_in_flight_ = true;
const int scanGen = chat_session_generation_; // guard: drop the result if the wallet switches/locks
worker_->post([this, addr, scanGen]() -> rpc::RPCWorker::MainCb {
std::vector<chat::HushChatTransactionMetadata> metadata;
try {
rpc::RPCClient::TraceScope trace("HushChat / 0-conf fast 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;
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));
}
}
} catch (const std::exception&) {}
return [this, scanGen, metadata = std::move(metadata)]() mutable {
// The wallet was switched/locked between post and now — this metadata belongs to the previous
// session. resetChatSession already reset the in-flight flag, so just drop; clearing it here
// would clobber a new session's own in-flight scan (mirrors the broadcast/identity guards).
if (scanGen != chat_session_generation_) return;
chat_fast_scan_in_flight_ = false;
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 (current_page_ != ui::NavPage::Chat &&
std::any_of(newChatCids.begin(), newChatCids.end(),
[this](const std::string& cid){ return !(settings_ && settings_->isChatMuted(cid)); }))
ui::Notifications::instance().info(TR("chat_new_message_toast"));
};
});
}
bool App::lockLiteWallet()
{
if (!lite_wallet_) return false;
const bool ok = lite_wallet_->lockWallet();
if (ok) {
// Full-node App::lockWallet() sets state_.locked synchronously; the lite backend `lock`
// doesn't, and state_.locked only refreshes on the next ~2s poll. Mirror the full-node
// behavior here and tear down the chat session NOW so no decrypted store / unlocked DB key
// lingers past an explicit lock. (These chat calls are safe no-ops when chat is off/idle.)
state_.locked = true;
chat_service_.clearIdentity();
chat_service_.store().clear();
chat_db_.lock();
chat_identity_provisioned_ = false;
chat_identity_unavailable_ = false;
}
return ok;
}
void App::seedChatDemoData()
{
if (!chat::hushChatFeatureEnabledAtBuild()) return;
// 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.
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);
}
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");
}
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);
}
});
}
}
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, "Unrecognized key format.", "");
return;
}
const bool viewing = services::WalletSecurityController::isViewingKey(key);
const bool shielded = services::WalletSecurityController::classifyPrivateKey(key)
== services::WalletSecurityController::KeyKind::Shielded;
// Run on the worker thread — import requests a full rescan (rescan=true), so the
// synchronous curl call can take many seconds; never block the UI thread on it.
worker_->post([this, key, viewing, shielded, startHeight, callback]() -> rpc::RPCWorker::MainCb {
std::string err, addr;
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 = rpc_->call(method, params);
// 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();
} 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)";
}
return [this, err, addr, callback]() {
if (!err.empty()) {
if (callback) callback(false, err, "");
return;
}
invalidateAddressValidationCache();
refreshAddresses();
if (callback) callback(true, "", addr);
};
});
}
// Sweep a spending key: import it (a full rescan populates its UTXOs/notes — the stock node has no
// address index, so there is no way to enumerate them without importing) then z_sendmany ALL of the
// key's funds (balance fee) to a destination the user already controls. The imported key is left in
// the wallet with an empty balance (there is no remove-key RPC) — the point is that the funds now sit
// on the user's own key. z_sendmany (not z_mergetoaddress): it moves a single-UTXO transparent source
// and fails loudly rather than silently leaving a remainder. Fund-moving — see the reviewed design.
void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMode,
const std::string& destExisting)
{
if (!state_.connected || !rpc_ || !worker_) {
sweep_status_ = "Not connected to the daemon.";
sweep_step_ = SweepStep::Error;
return;
}
// Trim (manual entry doesn't go through the dialog's Paste trimmer).
std::string key(rawKey);
while (!key.empty() && (key.front()==' '||key.front()=='\t'||key.front()=='\n'||key.front()=='\r')) key.erase(key.begin());
while (!key.empty() && (key.back()==' '||key.back()=='\t'||key.back()=='\n'||key.back()=='\r')) key.pop_back();
// Sweeping requires a SPENDING key (transparent WIF or shielded z-spending key) — a viewing key
// can't sign, and this must never accidentally route to z_importviewingkey.
if (!services::WalletSecurityController::isRecognizedPrivateKey(key)) {
sweep_status_ = "Enter a spending key (a viewing key can't move funds).";
sweep_step_ = SweepStep::Error;
return;
}
sweep_step_ = SweepStep::Running;
sweep_status_ = "Importing key & rescanning…";
sweep_txid_.clear();
sweep_dest_shown_.clear();
const bool shielded = services::WalletSecurityController::classifyPrivateKey(key)
== services::WalletSecurityController::KeyKind::Shielded;
const double fee = DRAGONX_DEFAULT_FEE;
worker_->post([this, key, startHeight, destMode, destExisting, shielded, fee]() -> 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 = rpc_->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 = rpc_->call(method, params);
// 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.
auto balAt = [&](int minconf) -> double {
try {
nlohmann::json b = rpc_->call("z_getbalance", {sourceAddr, minconf});
return b.is_number() ? b.get<double>()
: b.is_string() ? std::stod(b.get<std::string>()) : 0.0;
} catch (...) { return 0.0; }
};
double bal = balAt(1);
if (bal <= 0.0) {
throw std::runtime_error(balAt(0) > 0.0 ? "__UNCONFIRMED__" : "__NOFUNDS__");
}
if (bal <= fee) throw std::runtime_error("__DUST__");
// 4. Resolve the destination — a fresh shielded address by default, else the picked one.
if (destMode == 0) dest = rpc_->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 = "Sweep failed (unknown error)";
}
return [this, err, sourceAddr, dest, amount, amountStr, fee]() {
invalidateAddressValidationCache();
refreshAddresses();
if (!err.empty()) {
if (err == "__NOFUNDS__") sweep_status_ = "This key holds no funds to sweep.";
else if (err == "__UNCONFIRMED__") sweep_status_ = "This key's funds are still unconfirmed — try again shortly.";
else if (err == "__ALREADY__") sweep_status_ = "This key is already in your wallet — use Send to move its funds.";
else if (err == "__DUST__") sweep_status_ = "This key's balance is too small to cover the network fee.";
else sweep_status_ = err;
sweep_step_ = SweepStep::Error;
return;
}
sweep_dest_shown_ = dest;
sweep_status_ = "Sweeping funds to your address…";
// Send all funds from the swept address to the destination through the tested z_sendmany
// wrapper (fixed-decimal amount + async-op tracking). z_sendmany needs every input in one
// transaction to cover the full balance, so it fails loudly rather than partial-sweeping.
nlohmann::json recipients = nlohmann::json::array();
nlohmann::json rcp;
rcp["address"] = dest;
rcp["amount"] = amountStr; // exact integer-satoshi fixed-decimal string
recipients.push_back(rcp);
submitZSendMany(sourceAddr, dest, amount, fee, "", recipients, "Settings / Sweep key",
/*markFeeGapRetry*/ false, [this](bool ok, const std::string& result) {
if (ok) {
sweep_txid_ = result;
sweep_status_.clear();
sweep_step_ = SweepStep::Done;
} else {
sweep_status_ = result.empty() ? "The sweep transaction failed." : result;
sweep_step_ = SweepStep::Error;
}
refreshBalance();
});
};
});
}
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 (lite_wallet_) return; // lite has its own seed UX
if (!settings_ || settings_->getSeedBackupReminded()) return;
if (!state_.connected || !state_.encryption_state_known) return;
if (state_.isLocked()) return; // wait until unlocked to read it
if (seed_backup_reminder_in_flight_) return;
seed_backup_reminder_in_flight_ = true;
exportSeedPhrase([this](bool ok, bool noMnemonic, const std::string& /*phrase*/,
const std::string& /*error*/) {
seed_backup_reminder_in_flight_ = false;
// Only mark "reminded" once we have a definitive answer, so a transient failure retries
// next session rather than silently suppressing the reminder forever.
if (ok || noMnemonic) {
settings_->setSeedBackupReminded(true);
settings_->save();
}
if (ok)
ui::Notifications::instance().info(TR("seed_backup_reminder"), 12.0f);
});
}
// 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()
{
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.
if (settings_ && settings_->getSeedMigrationPending() && !settings_->getSeedMigrationDest().empty()) {
seed_migration_dest_ = settings_->getSeedMigrationDest();
seed_migration_temp_dir_ = settings_->getSeedMigrationTempDir();
seed_migration_sweep_txid_ = settings_->getSeedMigrationSweepTxid();
if (!seed_migration_sweep_txid_.empty()) {
seed_migration_sweep_confs_ = 0;
seed_migration_legacy_remaining_ = -1.0;
seed_migration_poll_timer_ = 0.0f; // poll immediately
seed_migration_step_ = SeedMigrationStep::Confirming;
} else {
seed_migration_step_ = SeedMigrationStep::Sweep;
seed_migration_balance_loaded_ = false;
seed_migration_nofunds_confirmed_ = false;
refreshSeedMigrationBalance();
}
} else {
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;
}
}
// 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 (!rpc_ || !worker_ || !state_.connected) {
seed_migration_status_ = "Not connected to the daemon.";
seed_migration_step_ = SeedMigrationStep::Error; return;
}
if (seed_migration_dest_.empty()) {
seed_migration_status_ = "No destination address for the sweep.";
seed_migration_step_ = SeedMigrationStep::Error; return;
}
seed_migration_step_ = SeedMigrationStep::Sweeping;
seed_migration_status_ = "Building the sweep transaction…";
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()) {
seed_migration_status_ = err.empty() ? "The sweep failed to start." : err;
seed_migration_step_ = SeedMigrationStep::Error;
return;
}
pending_send_callbacks_[opid] = [this](bool ok, const std::string& result) {
if (ok) {
seed_migration_sweep_txid_ = result;
// Persist the txid 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_->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 {
seed_migration_status_ = result.empty() ? "The sweep transaction failed." : result;
seed_migration_step_ = SeedMigrationStep::Error;
}
};
trackOperation(opid);
seed_migration_status_ = "Waiting for the sweep transaction to be accepted…";
};
});
}
// Confirming step: poll the sweep tx's confirmations + the legacy wallet's remaining balance. The
// adopt step is gated on the tx being mined (confs >= 1) AND the legacy balance being ~0, so we
// never swap wallet.dat while the funds could still bounce back (dropped/reorged tx) or while a
// remainder is left behind (a sweep too big for one transaction).
void App::pollSweepStatus()
{
if (capture_mode_) return; // no live ops during a UI sweep
if (!rpc_ || !worker_ || !state_.connected) return;
if (seed_migration_confirm_in_flight_ || seed_migration_sweep_txid_.empty()) return;
seed_migration_confirm_in_flight_ = true;
const std::string txid = seed_migration_sweep_txid_;
worker_->post([this, txid]() -> rpc::RPCWorker::MainCb {
int confs = 0; double remaining = -1.0; bool ok = false;
rpc::RPCClient::TraceScope trace("Migrate / confirm");
try {
auto tx = rpc_->call("gettransaction", {txid});
if (tx.contains("confirmations") && tx["confirmations"].is_number())
confs = tx["confirmations"].get<int>();
auto bal = rpc_->call("z_gettotalbalance");
if (bal.contains("total") && bal["total"].is_string())
remaining = std::stod(bal["total"].get<std::string>());
ok = true;
} catch (...) {}
return [this, confs, remaining, ok]() {
seed_migration_confirm_in_flight_ = false;
if (ok) {
seed_migration_sweep_confs_ = confs;
seed_migration_legacy_remaining_ = remaining;
}
};
});
}
// 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()
{
seed_migration_step_ = SeedMigrationStep::Adopting;
seed_migration_status_ = "Restarting with your new wallet — this rescan can take a while…";
{ 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;
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_;
async_tasks_.submit("Adopt seed wallet", [this, base](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 + "/wallet.dat";
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);
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;
});
}
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_->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_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_);
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;
}
std::ofstream file(destination);
if (!file.is_open()) {
if (callback) callback(false, "Could not open file: " + destination);
return;
}
file << keys;
file.close();
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;
recipient.amountZatoshis = static_cast<std::uint64_t>(std::llround(amount * 100000000.0));
recipient.memo = memo;
req.recipients.push_back(std::move(recipient));
if (!lite_wallet_->sendTransaction(req)) {
if (callback) callback(false, "A send is already in progress, or no wallet is open");
return;
}
lite_send_callback_ = std::move(callback); // delivered from update()
return;
}
if (!state_.connected || !rpc_) {
if (callback) callback(false, "Not connected");
return;
}
// Single-flight guard: a rapid double-click (or any second caller) must not issue two
// z_sendmany calls before the first returns its opid. The send form already guards this in the
// UI, but the controller entry point must not depend on that. (send_submissions_in_flight_ is
// main-thread only: ++ here, -- in the worker's main-thread result callback.)
if (send_submissions_in_flight_ > 0) {
if (callback) callback(false, "A transaction is already being submitted — please wait.");
return;
}
// Check that we have the spending key for the from address
if (!from.empty() && from[0] == 'z') {
bool spendable = false;
for (const auto& addr : state_.z_addresses) {
if (addr.address == from) {
spendable = addr.has_spending_key;
break;
}
}
if (!spendable) {
if (callback) callback(false, "This is a view-only address (no spending key). Import the spending key to send from this address.");
return;
}
}
// Build recipients array
nlohmann::json recipients = nlohmann::json::array();
nlohmann::json recipient;
recipient["address"] = to;
recipient["amount"] = util::formatAmountFixed(amount);
if (!memo.empty()) {
recipient["memo"] = memo;
}
recipients.push_back(recipient);
// Run z_sendmany on worker thread to avoid blocking UI
if (!worker_) {
send_progress_active_ = false;
if (callback) callback(false, "RPC worker unavailable");
return;
}
// 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)
{
send_progress_active_ = true;
++send_submissions_in_flight_;
worker_->post([this, from, to, amount, fee, memo, recipients, callback, traceLabel,
markFeeGapRetry]() -> rpc::RPCWorker::MainCb {
bool ok = false;
std::string result_str;
try {
rpc::RPCClient::TraceScope trace(traceLabel);
auto result = rpc_->call("z_sendmany", {from, recipients, 1, fee});
result_str = result.get<std::string>();
ok = true;
} catch (const std::exception& e) {
result_str = e.what();
} catch (...) {
// Non-std throw must not escape into the worker — the main-thread callback
// below would never run, leaving a stuck "Sending…" spinner.
result_str = "Send failed (unknown error)";
}
return [this, callback, ok, result_str, from, to, amount, fee, memo, markFeeGapRetry]() {
if (send_submissions_in_flight_ > 0) --send_submissions_in_flight_;
if (ok) {
// A send changes address balances — refresh on next cycle
addresses_dirty_ = true;
// Force transaction list refresh so the sent tx appears immediately
transactions_dirty_ = true;
last_tx_block_height_ = -1;
network_refresh_.markWalletMutationRefresh();
// z_sendmany only returned an opid: the transaction is built/signed/
// broadcast asynchronously by the daemon. Defer the user-facing
// success/failure to the opid poller (app.cpp) so we don't report
// "sent successfully" for an operation that may still fail.
if (!result_str.empty()) {
pending_opids_.push_back(result_str);
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 (callback) {
callback(true, result_str); // no opid to track — report as-is
}
} else {
send_progress_active_ = false;
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"] = memo;
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()) {
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;
}
// Shared search window; each probe halves it. Invariant maintained: block at `hi` is readable.
struct SearchState { int lo; int hi; std::function<void(bool, int, bool)> cb; };
auto st = std::make_shared<SearchState>(SearchState{0, tip, std::move(cb)});
auto step = std::make_shared<std::function<void()>>();
*step = [this, st, step]() {
if (st->lo >= st->hi) {
const bool fullHistory = (st->lo <= 1); // genesis/early block present → not bootstrapped
if (st->cb) st->cb(true, st->lo, fullHistory);
return;
}
const int mid = st->lo + (st->hi - st->lo) / 2;
rpc_->getBlock(mid, [st, step, mid](const nlohmann::json& result, const std::string& error) {
if (error.empty() && !result.is_null()) {
st->hi = mid; // block data present → lowest available is <= mid
} else {
st->lo = mid + 1; // block data missing → lowest available is > mid
}
(*step)();
});
};
(*step)();
}
void App::runtimeRescan(int startHeight)
{
if (!supportsFullNodeLifecycleActions()) {
ui::Notifications::instance().warning("Full-node lifecycle actions are unavailable in lite build");
return;
}
if (!state_.connected || !rpc_ || !rpc_->isConnected() || !worker_) {
ui::Notifications::instance().warning("Not connected to daemon");
return;
}
if (runtime_rescan_active_) return;
if (startHeight < 0) startHeight = 0;
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.
runtime_rescan_active_ = true;
state_.sync.rescanning = true;
rescan_confirmed_active_ = true;
state_.sync.rescan_progress = 0.0f;
state_.sync.rescan_status = "Rescanning from block " + std::to_string(startHeight) + "...";
transactions_dirty_ = true;
last_tx_block_height_ = -1;
invalidateShieldedHistoryScanProgress(true);
ui::Notifications::instance().info("Rescanning blockchain from block " + std::to_string(startHeight) +
" — this can take a while.");
worker_->post([this, startHeight]() -> rpc::RPCWorker::MainCb {
bool ok = false;
std::string err;
try {
rpc::RPCClient::TraceScope trace("Settings / Runtime rescan");
rpc_->call("rescan", {startHeight}); // hush "rescan <height>"; blocks until the scan finishes
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;
transactions_dirty_ = true;
last_tx_block_height_ = -1;
invalidateShieldedHistoryScanProgress(true);
ui::Notifications::instance().success("Blockchain rescan complete");
refreshData();
} else {
state_.sync.rescan_progress = 0.0f;
ui::Notifications::instance().error("Rescan failed: " + err);
}
};
});
}
} // namespace dragonx