The witness-rebuild bar reset repeatedly because the daemon's "Building Witnesses for block <h> <frac> complete" line reports per-call progress: BuildWitnessCache is re-invoked for each connected block and each call walks from its own start height to the tip, so the fraction restarts every time. The earlier "Setting Initial Sapling Witness for tx <i> of <m>" counter resets per call too, so neither is a usable overall metric. Derive a stable, monotonic percentage from the "<n> remaining" count instead: track the largest "remaining" seen during the phase as the full span and show how far remaining has fallen below it. The longest pass defines 0→100%; the short per-block follow-up passes only nudge the bar near the end rather than resetting it. The "Setting Initial" line now only marks the phase active. Per-phase tracking resets at phase start and every rescan-completion site. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2617 lines
114 KiB
C++
2617 lines
114 KiB
C++
// DragonX Wallet - ImGui Edition
|
|
// Copyright 2024-2026 The Hush Developers
|
|
// Released under the GPLv3
|
|
//
|
|
// app_network.cpp — RPC connection, data refresh, and network operations.
|
|
// Split from app.cpp for maintainability.
|
|
//
|
|
// Connection state machine:
|
|
//
|
|
// [Disconnected]
|
|
// │
|
|
// ▼ tryConnect() every 5s
|
|
// Auto-detect DRAGONX.conf (host, port, rpcuser, rpcpassword)
|
|
// │
|
|
// ├─ no config found ──► start embedded daemon ──► retry
|
|
// │
|
|
// ▼ post async rpc_->connect() to worker_
|
|
// [Connecting]
|
|
// │
|
|
// ├─ success ──► onConnected() ──► [Connected]
|
|
// │ │
|
|
// │ ▼ refreshData() every 5s
|
|
// │ [Running]
|
|
// │ │
|
|
// │ ├─ RPC error ──► onDisconnected()
|
|
// │ │ │
|
|
// ├─ auth 401 ──► .cookie auth ──► retry│ ▼
|
|
// │ │ [Disconnected]
|
|
// └─ failure ──► onDisconnected(reason) ┘
|
|
// may restart daemon
|
|
|
|
#include "app.h"
|
|
#include "rpc/rpc_client.h"
|
|
#include "rpc/rpc_worker.h"
|
|
#include "rpc/connection.h"
|
|
#include "config/settings.h"
|
|
#include "wallet/lite_wallet_controller.h" // lite send/new-address routing
|
|
#include "daemon/daemon_controller.h"
|
|
#include "daemon/embedded_daemon.h"
|
|
#include "daemon/xmrig_manager.h"
|
|
#include "ui/notifications.h"
|
|
#include "default_banlist_embedded.h"
|
|
#include "util/amount_format.h"
|
|
#include "util/platform.h"
|
|
#include "util/perf_log.h"
|
|
#include "util/i18n.h"
|
|
#include "util/secure_vault.h"
|
|
|
|
#include <nlohmann/json.hpp>
|
|
#include <curl/curl.h>
|
|
#include <algorithm>
|
|
#include <cmath>
|
|
#include <ctime>
|
|
#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::Mining: return "Mining tab";
|
|
case ui::NavPage::Market: return "Market tab";
|
|
case ui::NavPage::Console: return "Console tab";
|
|
case ui::NavPage::Peers: return "Network tab";
|
|
case ui::NavPage::Explorer: return "Explorer tab";
|
|
case ui::NavPage::Settings: return "Settings";
|
|
case ui::NavPage::Count_: break;
|
|
}
|
|
return "App";
|
|
}
|
|
|
|
std::string traceSource(ui::NavPage page, const char* process)
|
|
{
|
|
std::string source = tracePageName(page);
|
|
if (process && process[0] != '\0') {
|
|
source += " / ";
|
|
source += process;
|
|
}
|
|
return source;
|
|
}
|
|
|
|
std::size_t shieldedReceiveScanBudget(ui::NavPage page)
|
|
{
|
|
return page == ui::NavPage::History ? 8u : 4u;
|
|
}
|
|
|
|
// How far the tip may drift past an address's last shielded scan before we re-scan it. A full pass
|
|
// scans ~budget addresses per refresh cycle (≈96 per block of wall time), so a wallet with many
|
|
// z-addresses takes several blocks to scan fully. With a strict (tolerance 0) "scanned at tip"
|
|
// check, new blocks arriving mid-pass would invalidate already-scanned addresses and the pass would
|
|
// never complete — leaving transactions_dirty_ (and its "refreshing history" banner + send-progress
|
|
// gate) stuck on forever. Scaling the tolerance with the address count lets the pass complete while
|
|
// keeping shielded-receive latency minimal for small wallets; it's capped for pathological sizes.
|
|
int shieldedScanTipTolerance(std::size_t shieldedAddressCount)
|
|
{
|
|
int t = 2 + static_cast<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(), ""};
|
|
}
|
|
|
|
// Phrases dragonxd prints to its console while initializing, in the order translateWarmup()
|
|
// understands them. The most recent matching console line tells us which stage the node is in
|
|
// even when the RPC probe just times out (no -28 reply to read).
|
|
static const char* const kDaemonInitPhases[] = {
|
|
"Rescanning", "Rewinding", "Activating", "Verifying", "Loading", "Pruning",
|
|
};
|
|
|
|
// How many consecutive "RPC port busy but no config" connect attempts to wait through before
|
|
// warning the user that whatever owns the port isn't a usable DragonX node. The core retry runs
|
|
// roughly every few seconds, so this is on the order of ~20s — long enough for a real daemon to
|
|
// write its config, short enough not to leave the user guessing.
|
|
static constexpr int kDaemonWaitWarnAttempts = 4;
|
|
|
|
// ============================================================================
|
|
// Connection Management
|
|
// ============================================================================
|
|
|
|
void App::tryConnect()
|
|
{
|
|
// Lite builds have no full node / RPC daemon, so never run the RPC connection state machine
|
|
// (it would just fail every tick). The lite controller drives the wallet; "online" status is
|
|
// derived from it each frame in App::update(), which also gates the wallet UI (isConnected()).
|
|
if (isLiteBuild()) return;
|
|
|
|
if (connection_in_progress_) return;
|
|
|
|
static int connect_attempt = 0;
|
|
++connect_attempt;
|
|
|
|
connection_in_progress_ = true;
|
|
connection_status_ = TR("sb_loading_config");
|
|
|
|
// Auto-detect configuration (file I/O — fast, safe on main thread)
|
|
auto config = rpc::Connection::autoDetectConfig();
|
|
|
|
if (config.rpcuser.empty() || config.rpcpassword.empty()) {
|
|
connection_in_progress_ = false;
|
|
std::string confPath = rpc::Connection::getDefaultConfPath();
|
|
VERBOSE_LOGF("[connect #%d] No valid config — DRAGONX.conf missing or no rpcuser/rpcpassword (looked at: %s)\n",
|
|
connect_attempt, confPath.c_str());
|
|
|
|
// Re-evaluate the RPC port LIVE rather than trusting a latched "external daemon detected"
|
|
// flag: EmbeddedDaemon::start() sets that latch whenever the port was busy at a prior
|
|
// attempt and then never re-checks it, so a stale socket (or a transient squatter that has
|
|
// since died) would strand us forever "waiting for config". If the port is genuinely busy,
|
|
// a real daemon writes its config shortly and we keep waiting; if it's free, we must start
|
|
// our own.
|
|
const bool portInUse = daemon::EmbeddedDaemon::isRpcPortInUse();
|
|
if (portInUse) {
|
|
connection_status_ = TR("sb_waiting_config");
|
|
VERBOSE_LOGF("[connect #%d] RPC port in use but no config yet — waiting for the daemon to write it\n",
|
|
connect_attempt);
|
|
// After a bounded wait with no config appearing, whatever owns the port is not a usable
|
|
// DragonX node (a foreign process, or a stuck/half-dead daemon). Say so once, with the
|
|
// action, instead of leaving the user on a silent "waiting" spinner forever.
|
|
if (++daemon_wait_attempts_ == kDaemonWaitWarnAttempts) {
|
|
ui::Notifications::instance().warning(TR("daemon_port_busy_warn"), 20.0f);
|
|
}
|
|
network_refresh_.setTimer(services::NetworkRefreshService::Timer::Core,
|
|
services::RefreshScheduler::kCoreDefault - 1.0f);
|
|
return;
|
|
}
|
|
daemon_wait_attempts_ = 0; // port is free — clear the bounded-wait counter
|
|
|
|
connection_status_ = TR("sb_no_conf");
|
|
|
|
// Port is free → start our own embedded daemon (if enabled).
|
|
if (isUsingEmbeddedDaemon() && !isEmbeddedDaemonRunning()) {
|
|
connection_status_ = TR("sb_starting_daemon");
|
|
if (startEmbeddedDaemon()) {
|
|
// Will retry connection after daemon starts
|
|
VERBOSE_LOGF("[connect #%d] Embedded daemon starting, will retry connection...\n", connect_attempt);
|
|
network_refresh_.setTimer(services::NetworkRefreshService::Timer::Core,
|
|
services::RefreshScheduler::kCoreDefault - 1.0f);
|
|
} else {
|
|
// The daemon couldn't be started (binary not found, Sapling params missing, spawn
|
|
// failure, …). Surface the actual reason instead of leaving the status stuck on
|
|
// "Starting dragonxd…": connection_status_ for the overlay/status bar, plus a
|
|
// one-time sticky notification with the full, actionable detail.
|
|
std::string detail = daemon_controller_ ? daemon_controller_->lastError() : std::string();
|
|
VERBOSE_LOGF("[connect #%d] startEmbeddedDaemon() failed — lastError: %s, binary: %s\n",
|
|
connect_attempt, detail.empty() ? "(none)" : detail.c_str(),
|
|
daemon::EmbeddedDaemon::findDaemonBinary().c_str());
|
|
connection_status_ = TR("sb_daemon_start_failed");
|
|
if (!daemon_start_error_shown_) {
|
|
daemon_start_error_shown_ = true;
|
|
ui::Notifications::instance().error(
|
|
detail.empty() ? std::string(TR("sb_daemon_start_failed")) : detail, 30.0f);
|
|
}
|
|
// Keep retrying: a missing binary/params can be fixed without a restart.
|
|
network_refresh_.setTimer(services::NetworkRefreshService::Timer::Core,
|
|
services::RefreshScheduler::kCoreDefault - 1.0f);
|
|
}
|
|
} else if (!isUsingEmbeddedDaemon()) {
|
|
VERBOSE_LOGF("[connect #%d] Embedded daemon disabled (using external). No config found at %s\n",
|
|
connect_attempt, confPath.c_str());
|
|
}
|
|
return;
|
|
}
|
|
|
|
connection_status_ = TR("sb_connecting_daemon");
|
|
VERBOSE_LOGF("[connect #%d] Connecting to %s:%s (user=%s)\n",
|
|
connect_attempt, config.host.c_str(), config.port.c_str(), config.rpcuser.c_str());
|
|
|
|
if (rpc::Connection::usesPlaintextRemote(config) && !remote_rpc_plaintext_warning_shown_) {
|
|
remote_rpc_plaintext_warning_shown_ = true;
|
|
ui::Notifications::instance().warning(
|
|
"Remote RPC is using plaintext HTTP. Add rpctls=1 to DRAGONX.conf if your daemon supports TLS.",
|
|
10.0f);
|
|
}
|
|
|
|
// Run the blocking rpc_->connect() on the worker thread so the UI
|
|
// stays responsive (curl connect timeout can be up to 10 seconds).
|
|
if (!worker_) {
|
|
connection_in_progress_ = false;
|
|
VERBOSE_LOGF("[connect #%d] No worker thread available!\n", connect_attempt);
|
|
return;
|
|
}
|
|
|
|
// Capture daemon state before posting to worker
|
|
bool daemonStarting = daemon_controller_ &&
|
|
(daemon_controller_->state() == daemon::EmbeddedDaemon::State::Starting ||
|
|
daemon_controller_->state() == daemon::EmbeddedDaemon::State::Running);
|
|
bool externalDetected = daemon_controller_ && daemon_controller_->externalDaemonDetected();
|
|
int attempt = connect_attempt;
|
|
|
|
// Log detailed daemon state for diagnostics
|
|
if (daemon_controller_) {
|
|
const char* stateStr = "unknown";
|
|
switch (daemon_controller_->state()) {
|
|
case daemon::EmbeddedDaemon::State::Stopped: stateStr = "Stopped"; break;
|
|
case daemon::EmbeddedDaemon::State::Starting: stateStr = "Starting"; break;
|
|
case daemon::EmbeddedDaemon::State::Running: stateStr = "Running"; break;
|
|
case daemon::EmbeddedDaemon::State::Stopping: stateStr = "Stopping"; break;
|
|
case daemon::EmbeddedDaemon::State::Error: stateStr = "Error"; break;
|
|
}
|
|
VERBOSE_LOGF("[connect #%d] Daemon state: %s, running: %s, external: %s, crashes: %d, lastErr: %s\n",
|
|
attempt, stateStr,
|
|
daemon_controller_->isRunning() ? "yes" : "no",
|
|
externalDetected ? "yes" : "no",
|
|
daemon_controller_->crashCount(),
|
|
daemon_controller_->lastError().empty() ? "(none)" : daemon_controller_->lastError().c_str());
|
|
|
|
// The embedded daemon can spawn successfully and then exit immediately (a missing runtime
|
|
// DLL, wrong architecture, corrupt binary, datadir lock, …). The crash monitor records a
|
|
// detailed reason (translated exit code + launch command + debug.log tail) in lastError(),
|
|
// but it runs on a background thread and was never shown — so the wallet looked like it was
|
|
// "stuck connecting" while the node silently died-and-respawned. Surface each new crash once.
|
|
const int crashes = daemon_controller_->crashCount();
|
|
if (crashes > daemon_last_seen_crashes_) {
|
|
daemon_last_seen_crashes_ = crashes;
|
|
const std::string detail = daemon_controller_->lastError();
|
|
if (!detail.empty()) {
|
|
connection_status_ = TR("sb_daemon_start_failed");
|
|
ui::Notifications::instance().error(detail, 30.0f);
|
|
}
|
|
}
|
|
} else {
|
|
VERBOSE_LOGF("[connect #%d] No embedded daemon object (use_embedded=%s)\n",
|
|
attempt, isUsingEmbeddedDaemon() ? "yes" : "no");
|
|
}
|
|
|
|
worker_->post([this, config, daemonStarting, externalDetected, attempt]() -> rpc::RPCWorker::MainCb {
|
|
bool connected = rpc_->connect(config.host, config.port, config.rpcuser, config.rpcpassword, config.use_tls);
|
|
std::string connectErr = rpc_->getLastConnectError();
|
|
bool warmingUp = rpc_->isWarmingUp();
|
|
std::string warmupStatus = rpc_->getWarmupStatus();
|
|
|
|
return [this, config, connected, warmingUp, warmupStatus, daemonStarting, externalDetected, attempt, connectErr]() {
|
|
if (connected) {
|
|
VERBOSE_LOGF("[connect #%d] Connected successfully%s\n", attempt,
|
|
warmingUp ? " (daemon warming up)" : "");
|
|
saved_config_ = config; // save for fast-lane connection
|
|
onConnected();
|
|
if (warmingUp) {
|
|
// Daemon is reachable and auth works, but RPC calls will
|
|
// fail until warmup completes. Set the warmup state so
|
|
// the UI shows status instead of a blocking overlay.
|
|
state_.warming_up = true;
|
|
auto wt = translateWarmup(warmupStatus);
|
|
state_.warmup_status = wt.title;
|
|
state_.warmup_description = wt.description;
|
|
// Append current block height from daemon output
|
|
if (daemon_controller_) {
|
|
int h = daemon_controller_->lastBlockHeight();
|
|
if (h > 0)
|
|
state_.warmup_status += " (Block " + std::to_string(h) + ")";
|
|
}
|
|
connection_status_ = state_.warmup_status;
|
|
}
|
|
} else {
|
|
// HTTP 401 = authentication failure. The daemon is running
|
|
// but our rpcuser/rpcpassword don't match. Don't retry
|
|
// endlessly — tell the user what's wrong.
|
|
bool authFailure = (connectErr.find("401") != std::string::npos);
|
|
if (authFailure) {
|
|
rpc::ConnectionConfig cookieConfig;
|
|
if (rpc::Connection::buildCookieAuthConfig(config, cookieConfig)) {
|
|
VERBOSE_LOGF("[connect #%d] HTTP 401 — retrying with .cookie auth from %s\n",
|
|
attempt, cookieConfig.hush_dir.c_str());
|
|
worker_->post([this, cookieConfig, attempt]() -> rpc::RPCWorker::MainCb {
|
|
bool ok = rpc_->connect(cookieConfig.host, cookieConfig.port,
|
|
cookieConfig.rpcuser, cookieConfig.rpcpassword,
|
|
cookieConfig.use_tls);
|
|
return [this, cookieConfig, ok, attempt]() {
|
|
connection_in_progress_ = false;
|
|
if (ok) {
|
|
VERBOSE_LOGF("[connect #%d] Connected via .cookie auth\n", attempt);
|
|
saved_config_ = cookieConfig;
|
|
onConnected();
|
|
} else {
|
|
state_.connected = false;
|
|
connection_status_ = TR("sb_auth_failed");
|
|
VERBOSE_LOGF("[connect #%d] .cookie auth also failed\n", attempt);
|
|
ui::Notifications::instance().error(
|
|
"RPC authentication failed (HTTP 401). "
|
|
"The rpcuser/rpcpassword in DRAGONX.conf don't match the running daemon. "
|
|
"Restart the daemon or correct the credentials.");
|
|
}
|
|
};
|
|
});
|
|
return; // async retry in progress
|
|
}
|
|
state_.connected = false;
|
|
std::string confPath = rpc::Connection::getDefaultConfPath();
|
|
connection_status_ = TR("sb_auth_failed");
|
|
VERBOSE_LOGF("[connect #%d] HTTP 401 — rpcuser/rpcpassword in %s don't match the daemon. "
|
|
"Edit the file or restart the daemon to regenerate credentials.\n",
|
|
attempt, confPath.c_str());
|
|
ui::Notifications::instance().error(
|
|
"RPC authentication failed (HTTP 401). "
|
|
"The rpcuser/rpcpassword in DRAGONX.conf don't match the running daemon. "
|
|
"Restart the daemon or correct the credentials.");
|
|
} else if (daemonStarting) {
|
|
state_.connected = false;
|
|
// The daemon is launched but RPC isn't answering yet. A *timeout* means it
|
|
// connected but the node is busy initializing (loading the block index, etc.);
|
|
// a connect refusal means it hasn't bound the RPC port yet. Either way, show a
|
|
// clear "node initializing" overlay (status + phase + block height from the
|
|
// daemon's own console output) instead of a bare technical error.
|
|
const bool reachableButBusy = connectErr.find("Timeout") != std::string::npos;
|
|
applyDaemonInitStatus(reachableButBusy);
|
|
VERBOSE_LOGF("[connect #%d] RPC connection failed (%s) — daemon still starting, will retry...\n",
|
|
attempt, connectErr.c_str());
|
|
network_refresh_.setTimer(services::NetworkRefreshService::Timer::Core,
|
|
services::RefreshScheduler::kCoreDefault - 1.0f);
|
|
} else if (externalDetected) {
|
|
state_.connected = false;
|
|
// An external daemon is on the RPC port but not answering. A timeout means it's
|
|
// up and busy initializing; surface that as the init overlay (we can't read its
|
|
// console since we didn't launch it, so no phase line — just a clear message).
|
|
if (connectErr.find("Timeout") != std::string::npos) {
|
|
applyDaemonInitStatus(/*reachableButBusy=*/true);
|
|
} else if (!connectErr.empty()) {
|
|
char buf[256]; snprintf(buf, sizeof(buf), TR("sb_connecting_err"), connectErr.c_str());
|
|
connection_status_ = buf;
|
|
} else {
|
|
connection_status_ = TR("sb_connecting_external");
|
|
}
|
|
VERBOSE_LOGF("[connect #%d] External daemon detected but RPC failed (%s), will retry...\n",
|
|
attempt, connectErr.c_str());
|
|
network_refresh_.setTimer(services::NetworkRefreshService::Timer::Core,
|
|
services::RefreshScheduler::kCoreDefault - 1.0f);
|
|
} else {
|
|
onDisconnected("Connection failed");
|
|
VERBOSE_LOGF("[connect #%d] RPC connection failed — no daemon starting, no external detected\n", attempt);
|
|
|
|
if (isUsingEmbeddedDaemon() && !isEmbeddedDaemonRunning()) {
|
|
// Prevent infinite crash-restart loop
|
|
if (daemon_controller_ && daemon_controller_->crashCount() >= 3) {
|
|
{ char buf[128]; snprintf(buf, sizeof(buf), TR("sb_daemon_crashed"), daemon_controller_->crashCount());
|
|
connection_status_ = buf; }
|
|
VERBOSE_LOGF("[connect #%d] Daemon crashed %d times — not restarting (use Settings > Restart Daemon to retry)\n",
|
|
attempt, daemon_controller_->crashCount());
|
|
} else {
|
|
connection_status_ = TR("sb_starting_daemon");
|
|
if (startEmbeddedDaemon()) {
|
|
VERBOSE_LOGF("[connect #%d] Embedded daemon starting, will retry connection...\n", attempt);
|
|
} else if (daemon_controller_ && daemon_controller_->externalDaemonDetected()) {
|
|
connection_status_ = TR("sb_connecting_generic");
|
|
VERBOSE_LOGF("[connect #%d] External daemon detected, will connect via RPC...\n", attempt);
|
|
} else {
|
|
VERBOSE_LOGF("[connect #%d] Failed to start embedded daemon — lastError: %s\n",
|
|
attempt,
|
|
daemon_controller_ ? daemon_controller_->lastError().c_str() : "(no daemon object)");
|
|
}
|
|
}
|
|
} else if (!isUsingEmbeddedDaemon()) {
|
|
VERBOSE_LOGF("[connect #%d] Embedded daemon disabled — external daemon at %s:%s not responding\n",
|
|
attempt, config.host.c_str(), config.port.c_str());
|
|
} else {
|
|
VERBOSE_LOGF("[connect #%d] Embedded daemon is running but RPC failed — daemon may be initializing\n", attempt);
|
|
}
|
|
}
|
|
}
|
|
connection_in_progress_ = false;
|
|
};
|
|
});
|
|
}
|
|
|
|
void App::onConnected()
|
|
{
|
|
state_.connected = true;
|
|
state_.daemon_initializing = false; // RPC is answering now; clear the "initializing" overlay
|
|
daemon_wait_attempts_ = 0; // re-arm the port-busy / start-failure notifications
|
|
daemon_start_error_shown_ = false;
|
|
daemon_last_seen_crashes_ = 0; // (onConnected resets the daemon's crash count too)
|
|
connection_status_ = TR("connected");
|
|
|
|
// Reset crash counter on successful connection
|
|
if (daemon_controller_) {
|
|
daemon_controller_->resetCrashCount();
|
|
}
|
|
|
|
// Get daemon info + wallet encryption state on the worker thread.
|
|
// Fetching getwalletinfo here (before refreshData) ensures the lock
|
|
// screen appears immediately instead of after 6+ queued RPC calls.
|
|
bool initialPrefetchQueued = false;
|
|
if (worker_ && rpc_) {
|
|
auto prefetchedInfo = NetworkRefreshService::parseConnectionInfoResult(rpc_->getLastConnectInfo());
|
|
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::ConnectionInit, *worker_, [this, prefetchedInfo]() -> rpc::RPCWorker::MainCb {
|
|
AppRefreshRpcGateway refreshRpc(*rpc_, "Startup / Connection init");
|
|
auto result = NetworkRefreshService::collectConnectionInitResult(refreshRpc, prefetchedInfo);
|
|
return [this, result]() {
|
|
NetworkRefreshService::applyConnectionInitResult(state_, result);
|
|
if (state_.isLocked()) {
|
|
resetTransactionHistoryCacheSession();
|
|
} else if (state_.transactions.empty()) {
|
|
loadTransactionHistoryCacheIfAvailable();
|
|
} else {
|
|
storeTransactionHistoryCacheIfAvailable();
|
|
}
|
|
};
|
|
}, 3);
|
|
initialPrefetchQueued = enqueued.enqueued;
|
|
}
|
|
|
|
// onConnected already fetched getwalletinfo — tell refreshData to skip
|
|
// the duplicate call on the very first cycle.
|
|
encryption_state_prefetched_ = initialPrefetchQueued;
|
|
|
|
// Addresses are unknown on fresh connect — force a fetch
|
|
addresses_dirty_ = true;
|
|
|
|
// Start the fast-lane RPC connection (dedicated to 1-second mining polls).
|
|
// Uses its own curl handle + worker thread so getlocalsolps never blocks
|
|
// behind the main refresh batch.
|
|
if (!fast_rpc_) {
|
|
fast_rpc_ = std::make_unique<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;
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
}
|
|
|
|
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) {
|
|
auto applyDelta = [&](std::vector<AddressInfo>& addresses) {
|
|
for (auto& address : addresses) {
|
|
if (address.address == pendingInfo.from) {
|
|
address.balance = std::max(0.0, address.balance - pendingInfo.amount);
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
if (!applyDelta(state_.z_addresses)) applyDelta(state_.t_addresses);
|
|
if (!pendingInfo.from.empty() && pendingInfo.from[0] == 'z') {
|
|
state_.privateBalance = std::max(0.0, state_.privateBalance - pendingInfo.amount);
|
|
} else {
|
|
state_.transparentBalance = std::max(0.0, state_.transparentBalance - pendingInfo.amount);
|
|
}
|
|
state_.totalBalance = std::max(0.0, state_.totalBalance - pendingInfo.amount);
|
|
}
|
|
state_.last_tx_update = std::time(nullptr);
|
|
}
|
|
|
|
void App::markPendingSendTransactionSucceeded(const std::string& opid,
|
|
const std::string& txid)
|
|
{
|
|
if (opid.empty() || txid.empty()) return;
|
|
|
|
auto pending = std::find_if(state_.transactions.begin(), state_.transactions.end(),
|
|
[&](const TransactionInfo& transaction) { return transaction.txid == opid; });
|
|
if (pending == state_.transactions.end()) return;
|
|
|
|
bool duplicateRealTx = std::any_of(state_.transactions.begin(), state_.transactions.end(),
|
|
[&](const TransactionInfo& transaction) { return transaction.txid == txid; });
|
|
if (duplicateRealTx) {
|
|
state_.transactions.erase(pending);
|
|
} else {
|
|
pending->txid = txid;
|
|
pending->confirmations = 0;
|
|
if (pending->timestamp == 0) pending->timestamp = static_cast<std::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;
|
|
auto restoreBalance = [&](std::vector<AddressInfo>& addresses) {
|
|
for (auto& address : addresses) {
|
|
if (address.address == pending->second.from) {
|
|
address.balance += pending->second.amount;
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
if (!restoreBalance(state_.z_addresses)) restoreBalance(state_.t_addresses);
|
|
if (!pending->second.from.empty() && pending->second.from[0] == 'z') {
|
|
state_.privateBalance += pending->second.amount;
|
|
} else {
|
|
state_.transparentBalance += pending->second.amount;
|
|
}
|
|
state_.totalBalance += pending->second.amount;
|
|
}
|
|
}
|
|
state_.transactions.erase(
|
|
std::remove_if(state_.transactions.begin(), state_.transactions.end(),
|
|
[&](const TransactionInfo& transaction) {
|
|
return opidSet.find(transaction.txid) != opidSet.end();
|
|
}),
|
|
state_.transactions.end());
|
|
for (const auto& opid : opidSet) pending_send_info_.erase(opid);
|
|
state_.last_tx_update = std::time(nullptr);
|
|
}
|
|
|
|
void App::trackOperation(const std::string& opid)
|
|
{
|
|
if (opid.empty()) return;
|
|
// Touched only from the main thread (sendTransaction's MainCb and the opid poller's
|
|
// MainCb both run via drainResults()), so no locking is needed.
|
|
if (std::find(pending_opids_.begin(), pending_opids_.end(), opid) != pending_opids_.end())
|
|
return;
|
|
pending_opids_.push_back(opid);
|
|
}
|
|
|
|
bool App::invokeSendResultCallback(const std::string& opid, bool ok,
|
|
const std::string& result)
|
|
{
|
|
auto it = pending_send_callbacks_.find(opid);
|
|
if (it == pending_send_callbacks_.end()) return false;
|
|
auto cb = std::move(it->second);
|
|
pending_send_callbacks_.erase(it);
|
|
if (cb) cb(ok, result);
|
|
return true;
|
|
}
|
|
|
|
void App::applyPendingSendBalanceDeltas(bool includeAggregateBalances)
|
|
{
|
|
for (const auto& [opid, pending] : pending_send_info_) {
|
|
(void)opid;
|
|
auto applyDelta = [&](std::vector<AddressInfo>& addresses) {
|
|
for (auto& address : addresses) {
|
|
if (address.address == pending.from) {
|
|
address.balance = std::max(0.0, address.balance - pending.amount);
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
if (!applyDelta(state_.z_addresses)) applyDelta(state_.t_addresses);
|
|
if (includeAggregateBalances) {
|
|
if (!pending.from.empty() && pending.from[0] == 'z') {
|
|
state_.privateBalance = std::max(0.0, state_.privateBalance - pending.amount);
|
|
} else {
|
|
state_.transparentBalance = std::max(0.0, state_.transparentBalance - pending.amount);
|
|
}
|
|
state_.totalBalance = std::max(0.0, state_.totalBalance - pending.amount);
|
|
}
|
|
}
|
|
}
|
|
|
|
std::string App::transactionHistoryCacheWalletIdentity() const
|
|
{
|
|
std::vector<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);
|
|
}
|
|
|
|
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_) {
|
|
state_.sync.rescanning = false;
|
|
rescan_confirmed_active_ = false;
|
|
state_.sync.rescan_progress = 1.0f;
|
|
state_.sync.rescan_status.clear();
|
|
state_.sync.building_witnesses = false;
|
|
state_.sync.witness_progress = 0.0f;
|
|
state_.sync.witness_remaining = 0;
|
|
witness_rebuild_total_blocks_ = 0;
|
|
// 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();
|
|
};
|
|
}, 3);
|
|
if (!enqueued.enqueued) return;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Transaction Data: transparent + shielded receives + z_viewtransaction enrichment
|
|
// ============================================================================
|
|
|
|
void App::refreshTransactionData()
|
|
{
|
|
if (!worker_ || !rpc_ || !state_.connected) return;
|
|
if (addresses_dirty_) {
|
|
refreshAddressData();
|
|
network_refresh_.markDue(services::NetworkRefreshService::Timer::Transactions);
|
|
return;
|
|
}
|
|
|
|
const int currentBlocks = state_.sync.blocks;
|
|
if (last_tx_block_height_ < 0 || currentBlocks != last_tx_block_height_ ||
|
|
!shielded_history_scan_pending_) {
|
|
shielded_history_scan_cursor_ = 0;
|
|
shielded_history_scan_pending_ = false;
|
|
}
|
|
auto transactionSnapshot = NetworkRefreshService::buildTransactionRefreshSnapshot(
|
|
state_, viewtx_cache_, send_txids_);
|
|
transactionSnapshot.pendingOpids.insert(pending_opids_.begin(), pending_opids_.end());
|
|
if (settings_) transactionSnapshot.miningAddresses = settings_->getMiningAddresses();
|
|
transactionSnapshot.shieldedScanHeights = shielded_history_scan_heights_;
|
|
transactionSnapshot.shieldedScanStartIndex = shielded_history_scan_cursor_;
|
|
transactionSnapshot.maxShieldedReceiveScans = shieldedReceiveScanBudget(current_page_);
|
|
transactionSnapshot.shieldedScanTipTolerance =
|
|
shieldedScanTipTolerance(transactionSnapshot.shieldedAddresses.size());
|
|
ui::NavPage tracePage = current_page_;
|
|
|
|
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Transactions, *worker_, [this, currentBlocks,
|
|
transactionSnapshot = std::move(transactionSnapshot), tracePage]() -> rpc::RPCWorker::MainCb {
|
|
AppRefreshRpcGateway refreshRpc(*rpc_, traceSource(tracePage, "Transaction refresh"));
|
|
auto result = NetworkRefreshService::collectTransactionRefreshResult(
|
|
refreshRpc, transactionSnapshot, currentBlocks, MAX_VIEWTX_PER_CYCLE);
|
|
|
|
return [this, result = std::move(result)]() mutable {
|
|
bool shieldedScanComplete = result.shieldedScanComplete;
|
|
std::size_t nextShieldedScanStartIndex = result.nextShieldedScanStartIndex;
|
|
auto shieldedScanHeights = std::move(result.shieldedScanHeights);
|
|
NetworkRefreshService::TransactionCacheUpdate cacheUpdate{
|
|
viewtx_cache_,
|
|
send_txids_,
|
|
confirmed_tx_cache_,
|
|
confirmed_tx_ids_,
|
|
confirmed_cache_block_,
|
|
last_tx_block_height_
|
|
};
|
|
NetworkRefreshService::applyTransactionRefreshResult(
|
|
state_, cacheUpdate, std::move(result), std::time(nullptr));
|
|
shielded_history_scan_heights_ = std::move(shieldedScanHeights);
|
|
storeTransactionHistoryCacheIfAvailable();
|
|
shielded_history_scan_cursor_ = nextShieldedScanStartIndex;
|
|
shielded_history_scan_pending_ = !shieldedScanComplete;
|
|
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_
|
|
};
|
|
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();
|
|
}
|
|
|
|
// ============================================================================
|
|
// 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();
|
|
cfg.algo = settings_->getPoolAlgo();
|
|
cfg.threads = threads; // Use the same thread selection as solo mining
|
|
cfg.tls = settings_->getPoolTls();
|
|
cfg.hugepages = settings_->getPoolHugepages();
|
|
|
|
// Use first shielded address as the mining wallet address, fall back to transparent
|
|
for (const auto& addr : state_.z_addresses) {
|
|
if (!addr.address.empty()) {
|
|
cfg.wallet_address = addr.address;
|
|
break;
|
|
}
|
|
}
|
|
if (cfg.wallet_address.empty()) {
|
|
for (const auto& addr : state_.addresses) {
|
|
if (addr.type == "transparent" && !addr.address.empty()) {
|
|
cfg.wallet_address = addr.address;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback: use pool worker address from settings (available even before
|
|
// the daemon is connected or the blockchain is synced).
|
|
if (cfg.wallet_address.empty() && !cfg.worker_name.empty()) {
|
|
cfg.wallet_address = cfg.worker_name;
|
|
}
|
|
|
|
if (cfg.wallet_address.empty()) {
|
|
DEBUG_LOGF("[ERROR] Pool mining: No wallet address available\n");
|
|
ui::Notifications::instance().error("No wallet address available — generate a Z address in the Receive tab");
|
|
return;
|
|
}
|
|
|
|
if (!xmrig_manager_->start(cfg)) {
|
|
std::string err = xmrig_manager_->getLastError();
|
|
DEBUG_LOGF("[ERROR] Pool mining: %s\n", err.c_str());
|
|
|
|
// Check for Windows Defender blocking (error 225 = ERROR_VIRUS_INFECTED)
|
|
if (err.find("error 225") != std::string::npos ||
|
|
err.find("virus") != std::string::npos) {
|
|
ui::Notifications::instance().error(
|
|
"Windows Defender blocked xmrig. Add exclusion for %APPDATA%\\ObsidianDragon");
|
|
#ifdef _WIN32
|
|
// Offer to open Windows Security settings
|
|
pending_antivirus_dialog_ = true;
|
|
#endif
|
|
} else {
|
|
ui::Notifications::instance().error("Failed to start pool miner: " + err);
|
|
}
|
|
}
|
|
}
|
|
|
|
void App::stopPoolMining()
|
|
{
|
|
if (xmrig_manager_ && xmrig_manager_->isRunning()) {
|
|
xmrig_manager_->stop(3000);
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Peer Operations
|
|
// ============================================================================
|
|
|
|
void App::banPeer(const std::string& ip, int duration_seconds)
|
|
{
|
|
if (!state_.connected || !rpc_ || !worker_) return;
|
|
// Run on the worker thread — these are called straight from the Peers tab's ImGui
|
|
// handlers, and rpc_->call() blocks on synchronous curl under curl_mutex_.
|
|
worker_->post([this, ip, duration_seconds]() -> rpc::RPCWorker::MainCb {
|
|
std::string err;
|
|
try {
|
|
rpc::RPCClient::TraceScope trace("Peers / Ban");
|
|
rpc_->call("setban", {ip, "add", duration_seconds});
|
|
} catch (const std::exception& e) {
|
|
err = e.what();
|
|
}
|
|
return [this, err]() {
|
|
if (!err.empty()) ui::Notifications::instance().error("Ban failed: " + err);
|
|
else refreshPeerInfo();
|
|
};
|
|
});
|
|
}
|
|
|
|
void App::unbanPeer(const std::string& ip)
|
|
{
|
|
if (!state_.connected || !rpc_ || !worker_) return;
|
|
worker_->post([this, ip]() -> rpc::RPCWorker::MainCb {
|
|
std::string err;
|
|
try {
|
|
rpc::RPCClient::TraceScope trace("Peers / Unban");
|
|
rpc_->call("setban", {ip, "remove"});
|
|
} catch (const std::exception& e) {
|
|
err = e.what();
|
|
}
|
|
return [this, err]() {
|
|
if (!err.empty()) ui::Notifications::instance().error("Unban failed: " + err);
|
|
else refreshPeerInfo();
|
|
};
|
|
});
|
|
}
|
|
|
|
void App::clearBans()
|
|
{
|
|
if (!state_.connected || !rpc_ || !worker_) return;
|
|
worker_->post([this]() -> rpc::RPCWorker::MainCb {
|
|
std::string err;
|
|
try {
|
|
rpc::RPCClient::TraceScope trace("Peers / Clear bans");
|
|
rpc_->call("clearbanned", nlohmann::json::array());
|
|
} catch (const std::exception& e) {
|
|
err = e.what();
|
|
}
|
|
return [this, err]() {
|
|
if (!err.empty()) { ui::Notifications::instance().error("Clear bans failed: " + err); return; }
|
|
state_.banned_peers.clear();
|
|
refreshPeerInfo();
|
|
};
|
|
});
|
|
}
|
|
|
|
void App::applyDefaultBanlist()
|
|
{
|
|
if (!state_.connected || !rpc_ || !worker_) return;
|
|
|
|
// Parse the embedded default_banlist.txt (compiled from res/default_banlist.txt)
|
|
std::string data(reinterpret_cast<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)
|
|
{
|
|
// Lite build: derive locally via the controller (fast, no network). The backend auto-saves
|
|
// new addresses; the next lite refresh lists it with a balance.
|
|
if (lite_wallet_) {
|
|
const auto result = lite_wallet_->newAddress(/*shielded*/ true);
|
|
if (result.ok) {
|
|
AddressInfo info;
|
|
info.address = result.address;
|
|
info.type = "shielded";
|
|
info.balance = 0.0;
|
|
state_.z_addresses.push_back(info);
|
|
state_.addresses.push_back(info);
|
|
address_list_dirty_ = true;
|
|
}
|
|
if (callback) callback(result.ok ? result.address : std::string());
|
|
return;
|
|
}
|
|
|
|
if (!state_.connected || !rpc_ || !worker_) return;
|
|
|
|
worker_->post([this, callback]() -> rpc::RPCWorker::MainCb {
|
|
std::string addr;
|
|
try {
|
|
rpc::RPCClient::TraceScope trace("Receive tab / New shielded address");
|
|
json result = rpc_->call("z_getnewaddress");
|
|
addr = result.get<std::string>();
|
|
} catch (const std::exception& e) {
|
|
DEBUG_LOGF("z_getnewaddress error: %s\n", e.what());
|
|
}
|
|
return [this, callback, addr]() {
|
|
if (!addr.empty()) {
|
|
// Inject immediately so UI can select the address next frame
|
|
AddressInfo info;
|
|
info.address = addr;
|
|
info.type = "shielded";
|
|
info.balance = 0.0;
|
|
state_.z_addresses.push_back(info);
|
|
address_list_dirty_ = true;
|
|
// Also trigger full refresh to get proper balances
|
|
addresses_dirty_ = true;
|
|
refreshAddresses();
|
|
}
|
|
if (callback) callback(addr);
|
|
};
|
|
});
|
|
}
|
|
|
|
void App::createNewTAddress(std::function<void(const std::string&)> callback)
|
|
{
|
|
// Lite build: derive locally via the controller (see createNewZAddress).
|
|
if (lite_wallet_) {
|
|
const auto result = lite_wallet_->newAddress(/*shielded*/ false);
|
|
if (result.ok) {
|
|
AddressInfo info;
|
|
info.address = result.address;
|
|
info.type = "transparent";
|
|
info.balance = 0.0;
|
|
state_.t_addresses.push_back(info);
|
|
state_.addresses.push_back(info);
|
|
address_list_dirty_ = true;
|
|
}
|
|
if (callback) callback(result.ok ? result.address : std::string());
|
|
return;
|
|
}
|
|
|
|
if (!state_.connected || !rpc_ || !worker_) return;
|
|
|
|
worker_->post([this, callback]() -> rpc::RPCWorker::MainCb {
|
|
std::string addr;
|
|
try {
|
|
rpc::RPCClient::TraceScope trace("Receive tab / New transparent address");
|
|
json result = rpc_->call("getnewaddress");
|
|
addr = result.get<std::string>();
|
|
} catch (const std::exception& e) {
|
|
DEBUG_LOGF("getnewaddress error: %s\n", e.what());
|
|
}
|
|
return [this, callback, addr]() {
|
|
if (!addr.empty()) {
|
|
// Inject immediately so UI can select the address next frame
|
|
AddressInfo info;
|
|
info.address = addr;
|
|
info.type = "transparent";
|
|
info.balance = 0.0;
|
|
state_.t_addresses.push_back(info);
|
|
address_list_dirty_ = true;
|
|
// Also trigger full refresh to get proper balances
|
|
addresses_dirty_ = true;
|
|
refreshAddresses();
|
|
}
|
|
if (callback) callback(addr);
|
|
};
|
|
});
|
|
}
|
|
|
|
void App::hideAddress(const std::string& addr)
|
|
{
|
|
if (settings_) {
|
|
settings_->hideAddress(addr);
|
|
settings_->save();
|
|
}
|
|
}
|
|
|
|
void App::unhideAddress(const std::string& addr)
|
|
{
|
|
if (settings_) {
|
|
settings_->unhideAddress(addr);
|
|
settings_->save();
|
|
}
|
|
}
|
|
|
|
bool App::isAddressHidden(const std::string& addr) const
|
|
{
|
|
return settings_ && settings_->isAddressHidden(addr);
|
|
}
|
|
|
|
int App::getHiddenAddressCount() const
|
|
{
|
|
return settings_ ? settings_->getHiddenAddressCount() : 0;
|
|
}
|
|
|
|
void App::favoriteAddress(const std::string& addr)
|
|
{
|
|
if (settings_) {
|
|
settings_->favoriteAddress(addr);
|
|
settings_->save();
|
|
}
|
|
}
|
|
|
|
void App::unfavoriteAddress(const std::string& addr)
|
|
{
|
|
if (settings_) {
|
|
settings_->unfavoriteAddress(addr);
|
|
settings_->save();
|
|
}
|
|
}
|
|
|
|
bool App::isAddressFavorite(const std::string& addr) const
|
|
{
|
|
return settings_ && settings_->isAddressFavorite(addr);
|
|
}
|
|
|
|
void App::setAddressLabel(const std::string& addr, const std::string& label)
|
|
{
|
|
if (settings_) {
|
|
settings_->setAddressLabel(addr, label);
|
|
settings_->save();
|
|
}
|
|
}
|
|
|
|
void App::setAddressIcon(const std::string& addr, const std::string& icon)
|
|
{
|
|
if (settings_) {
|
|
settings_->setAddressIcon(addr, icon);
|
|
settings_->save();
|
|
}
|
|
}
|
|
|
|
std::string App::getAddressLabel(const std::string& addr) const
|
|
{
|
|
if (!settings_) return "";
|
|
return settings_->getAddressMeta(addr).label;
|
|
}
|
|
|
|
std::string App::getAddressIcon(const std::string& addr) const
|
|
{
|
|
if (!settings_) return "";
|
|
return settings_->getAddressMeta(addr).icon;
|
|
}
|
|
|
|
int App::getAddressSortOrder(const std::string& addr) const
|
|
{
|
|
if (!settings_) return -1;
|
|
return settings_->getAddressMeta(addr).sortOrder;
|
|
}
|
|
|
|
void App::setAddressSortOrder(const std::string& addr, int order)
|
|
{
|
|
if (settings_) {
|
|
settings_->setAddressSortOrder(addr, order);
|
|
settings_->save();
|
|
}
|
|
}
|
|
|
|
int App::getNextSortOrder() const
|
|
{
|
|
return settings_ ? settings_->getNextSortOrder() : 0;
|
|
}
|
|
|
|
void App::swapAddressOrder(const std::string& a, const std::string& b)
|
|
{
|
|
if (settings_) {
|
|
settings_->swapAddressOrder(a, b);
|
|
settings_->save();
|
|
}
|
|
}
|
|
|
|
bool App::isMiningAddress(const std::string& addr) const
|
|
{
|
|
return settings_ && settings_->isMiningAddress(addr);
|
|
}
|
|
|
|
void App::setMiningAddress(const std::string& addr, bool mining)
|
|
{
|
|
if (!settings_) 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_->call(method, {address}).get<std::string>();
|
|
} catch (const std::exception& e) {
|
|
err = e.what();
|
|
}
|
|
return [callback, key, err]() {
|
|
if (!err.empty()) {
|
|
DEBUG_LOGF("Export key error: %s\n", err.c_str());
|
|
ui::Notifications::instance().error("Key export failed: " + err);
|
|
if (callback) callback("");
|
|
} else if (callback) {
|
|
callback(key);
|
|
}
|
|
};
|
|
});
|
|
}
|
|
|
|
void App::exportAllKeys(std::function<void(const std::string&)> callback)
|
|
{
|
|
if (!state_.connected || !rpc_) {
|
|
if (callback) callback("");
|
|
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);
|
|
|
|
// 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");
|
|
return;
|
|
}
|
|
|
|
*keys_result = "# DragonX Wallet Private Keys Export\n";
|
|
*keys_result += "# WARNING: Keep this file secure! Anyone with these keys can spend your coins!\n\n";
|
|
|
|
for (const auto& addr : *all_addresses) {
|
|
exportPrivateKey(addr, [keys_result, pending, total, callback, addr](const std::string& key) {
|
|
if (!key.empty()) {
|
|
*keys_result += "# " + addr + "\n";
|
|
*keys_result += key + "\n\n";
|
|
}
|
|
(*pending)--;
|
|
if (*pending == 0 && callback) {
|
|
callback(*keys_result);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
void App::importPrivateKey(const std::string& key, std::function<void(bool, const std::string&)> callback)
|
|
{
|
|
if (!state_.connected || !rpc_ || !worker_) {
|
|
if (callback) callback(false, "Not connected");
|
|
return;
|
|
}
|
|
|
|
const bool shielded = services::WalletSecurityController::classifyPrivateKey(key)
|
|
== services::WalletSecurityController::KeyKind::Shielded;
|
|
// Run on the worker thread — import requests a full rescan (rescan=true), so the
|
|
// synchronous curl call can take many seconds; never block the UI thread on it.
|
|
worker_->post([this, key, shielded, callback]() -> rpc::RPCWorker::MainCb {
|
|
std::string err;
|
|
try {
|
|
rpc::RPCClient::TraceScope trace("Settings / Import private key");
|
|
if (shielded) rpc_->call("z_importkey", {key, "yes"}); // rescan
|
|
else rpc_->call("importprivkey", {key, "", true}); // label "", rescan
|
|
} catch (const std::exception& e) {
|
|
err = e.what();
|
|
}
|
|
return [this, shielded, err, callback]() {
|
|
if (!err.empty()) {
|
|
if (callback) callback(false, err);
|
|
return;
|
|
}
|
|
invalidateAddressValidationCache();
|
|
refreshAddresses();
|
|
if (callback) callback(true, services::WalletSecurityController::importSuccessMessage(
|
|
shielded ? services::WalletSecurityController::KeyKind::Shielded
|
|
: services::WalletSecurityController::KeyKind::Transparent));
|
|
};
|
|
});
|
|
}
|
|
|
|
void App::backupWallet(const std::string& destination, std::function<void(bool, const std::string&)> callback)
|
|
{
|
|
if (!state_.connected || !rpc_) {
|
|
if (callback) callback(false, "Not connected");
|
|
return;
|
|
}
|
|
|
|
// Use z_exportwallet or similar to export all keys
|
|
// For now, we'll use exportAllKeys and save to file
|
|
exportAllKeys([destination, callback](const std::string& keys) {
|
|
if (keys.empty()) {
|
|
if (callback) callback(false, "Failed to export keys");
|
|
return;
|
|
}
|
|
|
|
// Write to file
|
|
std::ofstream file(destination);
|
|
if (!file.is_open()) {
|
|
if (callback) callback(false, "Could not open file: " + destination);
|
|
return;
|
|
}
|
|
|
|
file << keys;
|
|
file.close();
|
|
|
|
if (callback) callback(true, "Wallet backup saved to: " + destination);
|
|
});
|
|
}
|
|
|
|
// ============================================================================
|
|
// Transaction Operations
|
|
// ============================================================================
|
|
|
|
void App::sendTransaction(const std::string& from, const std::string& to,
|
|
double amount, double fee, const std::string& memo,
|
|
std::function<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;
|
|
}
|
|
|
|
send_progress_active_ = true;
|
|
++send_submissions_in_flight_;
|
|
// 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.)
|
|
worker_->post([this, from, to, amount, fee, memo, recipients, callback]() -> rpc::RPCWorker::MainCb {
|
|
bool ok = false;
|
|
std::string result_str;
|
|
try {
|
|
rpc::RPCClient::TraceScope trace("Send tab / Submit transaction");
|
|
auto result = rpc_->call("z_sendmany", {from, recipients, 1, fee});
|
|
result_str = result.get<std::string>();
|
|
ok = true;
|
|
} catch (const std::exception& e) {
|
|
result_str = e.what();
|
|
}
|
|
return [this, callback, ok, result_str, from, to, amount, fee, memo]() {
|
|
if (send_submissions_in_flight_ > 0) --send_submissions_in_flight_;
|
|
if (ok) {
|
|
// A send changes address balances — refresh on next cycle
|
|
addresses_dirty_ = true;
|
|
// Force transaction list refresh so the sent tx appears immediately
|
|
transactions_dirty_ = true;
|
|
last_tx_block_height_ = -1;
|
|
network_refresh_.markWalletMutationRefresh();
|
|
// z_sendmany only returned an opid: the transaction is built/signed/
|
|
// broadcast asynchronously by the daemon. Defer the user-facing
|
|
// success/failure to the opid poller (app.cpp) so we don't report
|
|
// "sent successfully" for an operation that may still fail.
|
|
if (!result_str.empty()) {
|
|
pending_opids_.push_back(result_str);
|
|
upsertPendingSendTransaction(result_str, from, to, amount, memo, 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);
|
|
|
|
send_progress_active_ = true;
|
|
++send_submissions_in_flight_;
|
|
worker_->post([this, from, to, amount, fee, memo, recipients, callback]() -> rpc::RPCWorker::MainCb {
|
|
bool ok = false;
|
|
std::string result_str;
|
|
try {
|
|
rpc::RPCClient::TraceScope trace("Send tab / Fee-gap retry");
|
|
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();
|
|
}
|
|
return [this, callback, ok, result_str, from, to, amount, fee, memo]() {
|
|
if (send_submissions_in_flight_ > 0) --send_submissions_in_flight_;
|
|
if (ok) {
|
|
addresses_dirty_ = true;
|
|
transactions_dirty_ = true;
|
|
last_tx_block_height_ = -1;
|
|
network_refresh_.markWalletMutationRefresh();
|
|
if (!result_str.empty()) {
|
|
pending_opids_.push_back(result_str);
|
|
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);
|
|
}
|
|
} else {
|
|
send_progress_active_ = false;
|
|
if (callback) callback(false, result_str);
|
|
}
|
|
};
|
|
});
|
|
}
|
|
|
|
// --------------------------------------------------------------------------------------------
|
|
// 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("rescanblockchain", {startHeight}); // blocks until the scan finishes
|
|
ok = true;
|
|
} catch (const std::exception& e) {
|
|
err = e.what();
|
|
}
|
|
return [this, ok, err]() {
|
|
runtime_rescan_active_ = false;
|
|
rescan_confirmed_active_ = false;
|
|
state_.sync.rescanning = false;
|
|
state_.sync.rescan_status.clear();
|
|
state_.sync.building_witnesses = false;
|
|
state_.sync.witness_progress = 0.0f;
|
|
state_.sync.witness_remaining = 0;
|
|
witness_rebuild_total_blocks_ = 0;
|
|
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
|