- rpc_client::callRaw: a daemon error object is no longer assumed to carry a string "message" — a malformed error now yields a clean "RPC error: <dump>" instead of throwing a json type-exception from .get<std::string>(). - sendTransaction (full-node): add a single-flight guard so a rapid double-click can't issue two z_sendmany before the first returns its opid. The lite path already guarded this; the send form guards it in the UI, but the controller entry point now does too. (#9 from the audit was mostly false positives on verification — all popen sites already null-check and the xmrig download FILE* path has no throwing calls. The payment-URI checksum idea was dropped: the send flow already checksum-validates the recipient before broadcasting, and tightening the parser would reject the placeholder addresses the existing test relies on; added a comment noting this is format-only by design.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2331 lines
99 KiB
C++
2331 lines
99 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();
|
|
}
|
|
if (fast_rpc_) {
|
|
fast_rpc_->disconnect();
|
|
}
|
|
}
|
|
|
|
std::string App::applyDaemonInitStatus(bool reachableButBusy)
|
|
{
|
|
state_.daemon_initializing = true;
|
|
|
|
// Find the most recent console line that names an init phase, so we can tell the user exactly
|
|
// what the node is doing (loading the block index, verifying, activating best chain, …).
|
|
std::string phaseLine;
|
|
if (daemon_controller_) {
|
|
const auto lines = daemon_controller_->recentLines(40);
|
|
for (auto it = lines.rbegin(); it != lines.rend() && phaseLine.empty(); ++it) {
|
|
for (const char* phase : kDaemonInitPhases) {
|
|
if (it->find(phase) != std::string::npos) { phaseLine = *it; break; }
|
|
}
|
|
}
|
|
}
|
|
|
|
WarmupText wt;
|
|
if (!phaseLine.empty()) {
|
|
wt = translateWarmup(phaseLine);
|
|
} else if (reachableButBusy) {
|
|
// The probe connected but got no RPC reply within the timeout: the node is up but busy
|
|
// initializing (it isn't printing a recognizable phase, or we didn't launch it).
|
|
wt = {"Starting DragonX node…",
|
|
"The node is reachable but still initializing and isn't answering yet. "
|
|
"This is normal after an update or on first launch — it can take a few minutes."};
|
|
} else {
|
|
// The daemon is launching but hasn't bound its RPC port yet.
|
|
wt = {"Starting DragonX node…",
|
|
"Launching dragonxd and waiting for it to come online…"};
|
|
}
|
|
|
|
std::string title = wt.title;
|
|
const int h = daemon_controller_ ? daemon_controller_->lastBlockHeight() : -1;
|
|
if (h > 0) title += " (Block " + std::to_string(h) + ")";
|
|
|
|
state_.warmup_status = title;
|
|
state_.warmup_description = wt.description ? wt.description : "";
|
|
connection_status_ = title;
|
|
return title;
|
|
}
|
|
|
|
void App::handleLostConnection(const std::string& reason)
|
|
{
|
|
DEBUG_LOGF("[Connection] %s — tearing down for reconnect\n", reason.c_str());
|
|
// Flip the main client's connected_ flag so update()'s else-branch re-enters
|
|
// tryConnect(). onDisconnected() alone only tears down the fast lane.
|
|
if (rpc_) rpc_->disconnect();
|
|
onDisconnected(reason);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Data Refresh — Tab-Aware Prioritized System
|
|
//
|
|
// Data is split into independent categories, each with its own refresh
|
|
// function, timer, and in-progress guard. The orchestrator (refreshData)
|
|
// dispatches all categories, but each can also be called independently
|
|
// (e.g. on tab switch for immediate refresh).
|
|
//
|
|
// Categories:
|
|
// Core — z_gettotalbalance + getblockchaininfo (balance, sync)
|
|
// Addresses — z_listaddresses + listunspent (address list, per-addr balances)
|
|
// Transactions — listtransactions + z_listreceivedbyaddress + z_viewtransaction
|
|
// Peers — getpeerinfo + listbanned (already standalone)
|
|
// Encryption — getwalletinfo (one-shot on connect)
|
|
//
|
|
// Intervals are adjusted by applyRefreshPolicy() based on the active tab,
|
|
// so the user sees faster updates for the data they're interacting with.
|
|
// ============================================================================
|
|
|
|
App::RefreshIntervals App::getIntervalsForPage(ui::NavPage page)
|
|
{
|
|
return services::NetworkRefreshService::intervalsForPage(page);
|
|
}
|
|
|
|
void App::applyRefreshPolicy(ui::NavPage page)
|
|
{
|
|
network_refresh_.setIntervals(getIntervalsForPage(page));
|
|
}
|
|
|
|
bool App::currentPageNeedsWalletDataRefresh() const
|
|
{
|
|
using NP = ui::NavPage;
|
|
return current_page_ == NP::Overview ||
|
|
current_page_ == NP::Send ||
|
|
current_page_ == NP::Receive ||
|
|
current_page_ == NP::History;
|
|
}
|
|
|
|
bool App::shouldRunWalletTransactionRefresh() const
|
|
{
|
|
if (currentPageNeedsWalletDataRefresh()) return true;
|
|
if (hasTransactionSendProgress() || !send_txids_.empty()) return true;
|
|
return transactions_dirty_ && !shielded_history_scan_pending_;
|
|
}
|
|
|
|
void App::setCurrentPage(ui::NavPage page)
|
|
{
|
|
if (!isPageEnabledForBuild(page)) {
|
|
page = ui::NavPage::Overview;
|
|
}
|
|
if (page == current_page_) return;
|
|
current_page_ = page;
|
|
applyRefreshPolicy(page);
|
|
using RefreshTimer = services::NetworkRefreshService::Timer;
|
|
|
|
// Immediate refresh for the incoming tab's priority data. Gate on ACTUAL RPC connectivity
|
|
// (not state_.connected, which is the lite "online" proxy) — lite has no RPC daemon and the
|
|
// lite controller refreshes wallet data itself, so these full-node RPC polls must not fire.
|
|
if (rpc_ && rpc_->isConnected() && !state_.isLocked()) {
|
|
using NP = ui::NavPage;
|
|
switch (page) {
|
|
case NP::Overview:
|
|
refreshCoreData();
|
|
network_refresh_.reset(RefreshTimer::Core);
|
|
break;
|
|
case NP::History:
|
|
transactions_dirty_ = true;
|
|
refreshTransactionData();
|
|
network_refresh_.reset(RefreshTimer::Transactions);
|
|
break;
|
|
case NP::Send:
|
|
case NP::Receive:
|
|
addresses_dirty_ = true;
|
|
refreshAddressData();
|
|
network_refresh_.reset(RefreshTimer::Addresses);
|
|
break;
|
|
case NP::Peers:
|
|
refreshPeerInfo();
|
|
network_refresh_.reset(RefreshTimer::Peers);
|
|
break;
|
|
case NP::Mining:
|
|
refreshMiningInfo();
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
bool App::shouldRefreshTransactions() const
|
|
{
|
|
// NOTE: this is block-height / dirty driven, NOT interval-gated. It returns true only when a new
|
|
// block arrived (currentBlocks != last_tx_block_height_), the history was never fetched, or
|
|
// something marked it dirty (tab entry, a send, a reorg, etc.). The Transactions timer only
|
|
// controls how often this CHECK runs; between blocks the lightweight recent-poll
|
|
// (shouldRefreshRecentTransactions / TxAge) handles mempool + unconfirmed deltas instead.
|
|
const int currentBlocks = state_.sync.blocks;
|
|
return network_refresh_.shouldRefreshTransactions(last_tx_block_height_,
|
|
currentBlocks,
|
|
transactions_dirty_);
|
|
}
|
|
|
|
bool App::shouldRefreshRecentTransactions() const
|
|
{
|
|
using RefreshTimer = services::NetworkRefreshService::Timer;
|
|
return network_refresh_.isDue(RefreshTimer::TxAge)
|
|
&& last_tx_block_height_ >= 0
|
|
&& state_.sync.blocks == last_tx_block_height_
|
|
&& !state_.transactions.empty()
|
|
&& !transactions_dirty_
|
|
&& !addresses_dirty_;
|
|
}
|
|
|
|
void App::upsertPendingSendTransaction(const std::string& opid,
|
|
const std::string& from,
|
|
const std::string& to,
|
|
double amount,
|
|
const std::string& memo)
|
|
{
|
|
if (opid.empty()) return;
|
|
|
|
bool newPending = pending_send_info_.find(opid) == pending_send_info_.end();
|
|
auto& pendingInfo = pending_send_info_[opid];
|
|
if (pendingInfo.timestamp == 0) pendingInfo.timestamp = static_cast<std::int64_t>(std::time(nullptr));
|
|
pendingInfo.from = from;
|
|
pendingInfo.to = to;
|
|
pendingInfo.amount = std::abs(amount);
|
|
pendingInfo.memo = memo;
|
|
|
|
TransactionInfo pending;
|
|
pending.txid = opid;
|
|
pending.type = "send";
|
|
pending.amount = -pendingInfo.amount;
|
|
pending.timestamp = pendingInfo.timestamp;
|
|
pending.confirmations = 0;
|
|
pending.address = pendingInfo.to;
|
|
pending.from_address = pendingInfo.from;
|
|
pending.memo = pendingInfo.memo;
|
|
|
|
auto existing = std::find_if(state_.transactions.begin(), state_.transactions.end(),
|
|
[&](const TransactionInfo& transaction) { return transaction.txid == opid; });
|
|
if (existing != state_.transactions.end()) {
|
|
*existing = std::move(pending);
|
|
} else {
|
|
state_.transactions.insert(state_.transactions.begin(), std::move(pending));
|
|
}
|
|
if (newPending) {
|
|
auto applyDelta = [&](std::vector<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;
|
|
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();
|
|
|
|
if (current_page_ == ui::NavPage::Peers)
|
|
refreshPeerInfo();
|
|
|
|
if (!state_.encryption_state_known &&
|
|
!network_refresh_.jobInProgress(services::NetworkRefreshService::Job::ConnectionInit)) {
|
|
encryption_state_prefetched_ = refreshEncryptionState();
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Core Data: balance + blockchain info (~50-100ms, 2 RPC calls)
|
|
// Uses fast_worker_ when on Overview tab for lower latency.
|
|
// ============================================================================
|
|
|
|
void App::refreshCoreData()
|
|
{
|
|
if (!state_.connected) return;
|
|
|
|
// During warmup, poll getinfo to detect when warmup ends.
|
|
// Most RPC calls (balance, blockchain info) will fail with -28 during warmup.
|
|
if (state_.warming_up) {
|
|
if (!worker_) return;
|
|
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Core, *worker_, [this]() -> rpc::RPCWorker::MainCb {
|
|
AppRefreshRpcGateway refreshRpc(*rpc_, "Startup / Warmup poll");
|
|
auto result = NetworkRefreshService::collectWarmupPollResult(refreshRpc);
|
|
return [this, result = std::move(result)]() {
|
|
if (result.ready) {
|
|
// Warmup finished — daemon is fully ready
|
|
state_.warming_up = false;
|
|
state_.warmup_status.clear();
|
|
state_.warmup_description.clear();
|
|
connection_status_ = TR("connected");
|
|
VERBOSE_LOGF("[warmup] Daemon ready, warmup complete\n");
|
|
NetworkRefreshService::applyConnectionInfoResult(state_, result.info);
|
|
// Trigger full data refresh now that daemon is ready
|
|
refreshData();
|
|
} else {
|
|
// Still warming up — update status
|
|
auto wt = translateWarmup(result.errorMessage);
|
|
state_.warmup_status = wt.title;
|
|
state_.warmup_description = wt.description;
|
|
if (daemon_controller_) {
|
|
int h = daemon_controller_->lastBlockHeight();
|
|
if (h > 0)
|
|
state_.warmup_status += " (Block " + std::to_string(h) + ")";
|
|
}
|
|
connection_status_ = state_.warmup_status;
|
|
VERBOSE_LOGF("[warmup] Still warming up: %s\n", result.errorMessage.c_str());
|
|
}
|
|
};
|
|
}, 3);
|
|
if (!enqueued.enqueued) return;
|
|
return;
|
|
}
|
|
|
|
// Use fast-lane on Overview for snappier balance updates
|
|
bool useFast = (current_page_ == ui::NavPage::Overview);
|
|
auto* w = useFast && fast_worker_ && fast_worker_->isRunning()
|
|
? fast_worker_.get() : worker_.get();
|
|
auto* rpc = useFast && fast_rpc_ && fast_rpc_->isConnected()
|
|
? fast_rpc_.get() : rpc_.get();
|
|
if (!w || !rpc) return;
|
|
ui::NavPage tracePage = current_page_;
|
|
|
|
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Core, *w, [this, rpc, tracePage]() -> rpc::RPCWorker::MainCb {
|
|
AppRefreshRpcGateway refreshRpc(*rpc, traceSource(tracePage, "Core refresh"));
|
|
auto result = NetworkRefreshService::collectCoreRefreshResult(refreshRpc);
|
|
return [this, result]() {
|
|
try {
|
|
NetworkRefreshService::applyCoreRefreshResult(state_, result, std::time(nullptr));
|
|
applyPendingSendBalanceDeltas(true);
|
|
|
|
// Mid-session connection-loss detection. During normal operation, both core
|
|
// RPCs failing together means the daemon connection is dead (a busy daemon
|
|
// fails them individually, not both at once). Warmup is excluded — both fail
|
|
// with -28 there legitimately, and counting it would cause a reconnect loop.
|
|
constexpr int kCoreFailuresBeforeDisconnect = 3;
|
|
if (!state_.warming_up) {
|
|
if (!result.balanceOk && !result.blockchainOk) {
|
|
if (++consecutive_core_failures_ >= kCoreFailuresBeforeDisconnect &&
|
|
state_.connected) {
|
|
consecutive_core_failures_ = 0;
|
|
handleLostConnection("Lost connection to daemon");
|
|
return; // state torn down — skip the rest of this callback
|
|
}
|
|
} else {
|
|
consecutive_core_failures_ = 0;
|
|
}
|
|
}
|
|
|
|
// Auto-shield transparent funds if enabled
|
|
if (result.balanceOk && settings_ && settings_->getAutoShield() &&
|
|
state_.transparent_balance > 0.0001 && !state_.sync.syncing &&
|
|
!auto_shield_pending_.exchange(true)) {
|
|
std::string targetZAddr;
|
|
for (const auto& addr : state_.addresses) {
|
|
if (addr.isShielded()) {
|
|
targetZAddr = addr.address;
|
|
break;
|
|
}
|
|
}
|
|
if (!targetZAddr.empty() && worker_) {
|
|
DEBUG_LOGF("[AutoShield] Shielding %.8f DRGX to %s\n",
|
|
state_.transparent_balance, targetZAddr.c_str());
|
|
// Use the user-configured fee, formatted fixed-decimal so the daemon's
|
|
// ParseFixedPoint accepts it (a small double would serialize to "5e-05").
|
|
const std::string feeStr =
|
|
util::formatAmountFixed(settings_ ? settings_->getDefaultFee() : 0.0001);
|
|
// This callback runs on the UI thread (drainResults). Build/broadcast
|
|
// on the worker thread — never block the UI with synchronous RPC.
|
|
worker_->post([this, targetZAddr, feeStr]() -> rpc::RPCWorker::MainCb {
|
|
std::string opid;
|
|
std::string err;
|
|
try {
|
|
rpc::RPCClient::TraceScope trace("Auto-shield / z_shieldcoinbase");
|
|
auto result = rpc_->call("z_shieldcoinbase",
|
|
{std::string("*"), targetZAddr, feeStr, 50});
|
|
opid = result.value("opid", "");
|
|
} catch (const std::exception& e) {
|
|
err = e.what();
|
|
}
|
|
return [this, opid, err]() {
|
|
auto_shield_pending_ = false;
|
|
if (!err.empty()) {
|
|
DEBUG_LOGF("[AutoShield] Error: %s\n", err.c_str());
|
|
return;
|
|
}
|
|
if (!opid.empty()) {
|
|
DEBUG_LOGF("[AutoShield] Started: %s\n", opid.c_str());
|
|
// Surface the async result + refresh balances on completion.
|
|
trackOperation(opid);
|
|
}
|
|
};
|
|
});
|
|
} else {
|
|
auto_shield_pending_ = false;
|
|
}
|
|
}
|
|
} catch (const std::exception& e) {
|
|
DEBUG_LOGF("[refreshCoreData] callback error: %s\n", e.what());
|
|
}
|
|
};
|
|
}, 3);
|
|
if (!enqueued.enqueued) return;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Address Data: z/t address lists + per-address balances
|
|
// ============================================================================
|
|
|
|
void App::refreshAddressData()
|
|
{
|
|
if (!worker_ || !rpc_ || !state_.connected) return;
|
|
const std::size_t previousAddressCount = state_.z_addresses.size() + state_.t_addresses.size();
|
|
const std::string previousWalletIdentity = transactionHistoryCacheWalletIdentity();
|
|
auto addressSnapshot = address_validation_cache_dirty_
|
|
? NetworkRefreshService::AddressRefreshSnapshot{}
|
|
: NetworkRefreshService::buildAddressRefreshSnapshot(state_);
|
|
ui::NavPage tracePage = current_page_;
|
|
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Addresses, *worker_, [this, previousAddressCount, previousWalletIdentity, addressSnapshot = std::move(addressSnapshot), tracePage]() -> rpc::RPCWorker::MainCb {
|
|
AppRefreshRpcGateway refreshRpc(*rpc_, traceSource(tracePage, "Address refresh"));
|
|
auto result = NetworkRefreshService::collectAddressRefreshResult(refreshRpc, addressSnapshot);
|
|
|
|
return [this, previousAddressCount, previousWalletIdentity, result = std::move(result)]() mutable {
|
|
NetworkRefreshService::applyAddressRefreshResult(state_, std::move(result));
|
|
applyPendingSendBalanceDeltas(false);
|
|
address_validation_cache_dirty_ = false;
|
|
address_list_dirty_ = true;
|
|
addresses_dirty_ = false;
|
|
const std::size_t currentAddressCount = state_.z_addresses.size() + state_.t_addresses.size();
|
|
const bool addressSetChanged = currentAddressCount != previousAddressCount ||
|
|
transactionHistoryCacheWalletIdentity() != previousWalletIdentity;
|
|
if (state_.transactions.empty() || addressSetChanged) {
|
|
if (addressSetChanged) {
|
|
invalidateShieldedHistoryScanProgress(false);
|
|
}
|
|
transactions_dirty_ = true;
|
|
last_tx_block_height_ = -1;
|
|
network_refresh_.markDue(services::NetworkRefreshService::Timer::Transactions);
|
|
}
|
|
if (state_.transactions.empty()) loadTransactionHistoryCacheIfAvailable();
|
|
else storeTransactionHistoryCacheIfAvailable();
|
|
maybeFinishTransactionSendProgress();
|
|
};
|
|
}, 3);
|
|
if (!enqueued.enqueued) return;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Transaction Data: transparent + shielded receives + z_viewtransaction enrichment
|
|
// ============================================================================
|
|
|
|
void App::refreshTransactionData()
|
|
{
|
|
if (!worker_ || !rpc_ || !state_.connected) return;
|
|
if (addresses_dirty_) {
|
|
refreshAddressData();
|
|
network_refresh_.markDue(services::NetworkRefreshService::Timer::Transactions);
|
|
return;
|
|
}
|
|
|
|
const int currentBlocks = state_.sync.blocks;
|
|
if (last_tx_block_height_ < 0 || currentBlocks != last_tx_block_height_ ||
|
|
!shielded_history_scan_pending_) {
|
|
shielded_history_scan_cursor_ = 0;
|
|
shielded_history_scan_pending_ = false;
|
|
}
|
|
auto transactionSnapshot = NetworkRefreshService::buildTransactionRefreshSnapshot(
|
|
state_, viewtx_cache_, send_txids_);
|
|
transactionSnapshot.pendingOpids.insert(pending_opids_.begin(), pending_opids_.end());
|
|
if (settings_) transactionSnapshot.miningAddresses = settings_->getMiningAddresses();
|
|
transactionSnapshot.shieldedScanHeights = shielded_history_scan_heights_;
|
|
transactionSnapshot.shieldedScanStartIndex = shielded_history_scan_cursor_;
|
|
transactionSnapshot.maxShieldedReceiveScans = shieldedReceiveScanBudget(current_page_);
|
|
transactionSnapshot.shieldedScanTipTolerance =
|
|
shieldedScanTipTolerance(transactionSnapshot.shieldedAddresses.size());
|
|
ui::NavPage tracePage = current_page_;
|
|
|
|
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Transactions, *worker_, [this, currentBlocks,
|
|
transactionSnapshot = std::move(transactionSnapshot), tracePage]() -> rpc::RPCWorker::MainCb {
|
|
AppRefreshRpcGateway refreshRpc(*rpc_, traceSource(tracePage, "Transaction refresh"));
|
|
auto result = NetworkRefreshService::collectTransactionRefreshResult(
|
|
refreshRpc, transactionSnapshot, currentBlocks, MAX_VIEWTX_PER_CYCLE);
|
|
|
|
return [this, result = std::move(result)]() mutable {
|
|
bool shieldedScanComplete = result.shieldedScanComplete;
|
|
std::size_t nextShieldedScanStartIndex = result.nextShieldedScanStartIndex;
|
|
auto shieldedScanHeights = std::move(result.shieldedScanHeights);
|
|
NetworkRefreshService::TransactionCacheUpdate cacheUpdate{
|
|
viewtx_cache_,
|
|
send_txids_,
|
|
confirmed_tx_cache_,
|
|
confirmed_tx_ids_,
|
|
confirmed_cache_block_,
|
|
last_tx_block_height_
|
|
};
|
|
NetworkRefreshService::applyTransactionRefreshResult(
|
|
state_, cacheUpdate, std::move(result), std::time(nullptr));
|
|
shielded_history_scan_heights_ = std::move(shieldedScanHeights);
|
|
storeTransactionHistoryCacheIfAvailable();
|
|
shielded_history_scan_cursor_ = nextShieldedScanStartIndex;
|
|
shielded_history_scan_pending_ = !shieldedScanComplete;
|
|
transactions_dirty_ = !shieldedScanComplete;
|
|
maybeFinishTransactionSendProgress();
|
|
};
|
|
}, 3);
|
|
if (!enqueued.enqueued) return;
|
|
|
|
network_refresh_.resetTxAge();
|
|
}
|
|
|
|
void App::refreshRecentTransactionData()
|
|
{
|
|
if (!worker_ || !rpc_ || !state_.connected) return;
|
|
if (!shouldRefreshRecentTransactions()) return;
|
|
|
|
const int currentBlocks = state_.sync.blocks;
|
|
auto transactionSnapshot = NetworkRefreshService::buildTransactionRefreshSnapshot(
|
|
state_, viewtx_cache_, send_txids_);
|
|
transactionSnapshot.pendingOpids.insert(pending_opids_.begin(), pending_opids_.end());
|
|
if (settings_) transactionSnapshot.miningAddresses = settings_->getMiningAddresses();
|
|
transactionSnapshot.shieldedScanHeights = shielded_history_scan_heights_;
|
|
transactionSnapshot.shieldedScanStartIndex = shielded_history_scan_cursor_;
|
|
transactionSnapshot.maxShieldedReceiveScans = 1;
|
|
transactionSnapshot.shieldedScanTipTolerance =
|
|
shieldedScanTipTolerance(transactionSnapshot.shieldedAddresses.size());
|
|
ui::NavPage tracePage = current_page_;
|
|
|
|
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Transactions, *worker_, [this, currentBlocks,
|
|
transactionSnapshot = std::move(transactionSnapshot), tracePage]() -> rpc::RPCWorker::MainCb {
|
|
AppRefreshRpcGateway refreshRpc(*rpc_, traceSource(tracePage, "Recent transaction poll"));
|
|
auto result = NetworkRefreshService::collectRecentTransactionRefreshResult(
|
|
refreshRpc, transactionSnapshot, currentBlocks);
|
|
|
|
return [this, result = std::move(result)]() mutable {
|
|
std::size_t nextShieldedScanStartIndex = result.nextShieldedScanStartIndex;
|
|
auto shieldedScanHeights = std::move(result.shieldedScanHeights);
|
|
NetworkRefreshService::TransactionCacheUpdate cacheUpdate{
|
|
viewtx_cache_,
|
|
send_txids_,
|
|
confirmed_tx_cache_,
|
|
confirmed_tx_ids_,
|
|
confirmed_cache_block_,
|
|
last_tx_block_height_
|
|
};
|
|
NetworkRefreshService::applyTransactionRefreshResult(
|
|
state_, cacheUpdate, std::move(result), std::time(nullptr));
|
|
shielded_history_scan_heights_ = std::move(shieldedScanHeights);
|
|
shielded_history_scan_cursor_ = nextShieldedScanStartIndex;
|
|
storeTransactionHistoryCacheIfAvailable();
|
|
maybeFinishTransactionSendProgress();
|
|
};
|
|
}, 3);
|
|
if (!enqueued.enqueued) return;
|
|
|
|
network_refresh_.resetTxAge();
|
|
}
|
|
|
|
// ============================================================================
|
|
// Encryption State: wallet info (one-shot on connect, lightweight)
|
|
// ============================================================================
|
|
|
|
bool App::refreshEncryptionState()
|
|
{
|
|
if (!worker_ || !rpc_ || !state_.connected) return false;
|
|
|
|
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Encryption, *worker_, [this]() -> rpc::RPCWorker::MainCb {
|
|
json walletInfo;
|
|
bool ok = false;
|
|
try {
|
|
rpc::RPCClient::TraceScope trace("Startup / Wallet encryption state");
|
|
walletInfo = rpc_->call("getwalletinfo");
|
|
ok = true;
|
|
} catch (...) {}
|
|
|
|
if (!ok) return nullptr;
|
|
|
|
auto result = NetworkRefreshService::parseWalletEncryptionResult(walletInfo);
|
|
return [this, result]() {
|
|
NetworkRefreshService::applyWalletEncryptionResult(state_, result);
|
|
if (state_.isLocked()) {
|
|
resetTransactionHistoryCacheSession();
|
|
} else if (state_.transactions.empty()) {
|
|
loadTransactionHistoryCacheIfAvailable();
|
|
} else {
|
|
storeTransactionHistoryCacheIfAvailable();
|
|
}
|
|
};
|
|
}, 3);
|
|
return enqueued.enqueued;
|
|
}
|
|
|
|
void App::refreshBalance()
|
|
{
|
|
refreshCoreData();
|
|
}
|
|
|
|
void App::refreshAddresses()
|
|
{
|
|
addresses_dirty_ = true;
|
|
refreshAddressData();
|
|
}
|
|
|
|
void App::refreshMiningInfo()
|
|
{
|
|
// Use the dedicated fast-lane worker + connection so mining polls
|
|
// never block behind the main refresh batch. Falls back to the main
|
|
// worker if the fast lane isn't ready yet (e.g. during initial connect).
|
|
auto* w = (fast_worker_ && fast_worker_->isRunning()) ? fast_worker_.get() : worker_.get();
|
|
auto* rpc = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get();
|
|
if (!w || !rpc) return;
|
|
|
|
// Capture daemon memory outside (may be accessed on main thread)
|
|
double daemonMemMb = 0.0;
|
|
if (daemon_controller_) {
|
|
daemonMemMb = daemon_controller_->memoryUsageMB();
|
|
}
|
|
|
|
// Slow-tick counter: run full getmininginfo every ~5 seconds
|
|
// to reduce RPC overhead. getlocalsolps is only needed while solo mining
|
|
// or while the Mining tab is actively showing live local hashrate.
|
|
// NOTE: getinfo is NOT called here — longestchain/notarized are updated by
|
|
// refreshBalance (via getblockchaininfo), and daemon_version/protocol_version/
|
|
// p2p_port are static for the lifetime of a connection (set in onConnected).
|
|
bool doSlowRefresh = (mining_slow_counter_++ % 5 == 0);
|
|
bool includeLocalHashrate = state_.mining.generate || current_page_ == ui::NavPage::Mining;
|
|
if (!includeLocalHashrate && !doSlowRefresh) return;
|
|
ui::NavPage tracePage = current_page_;
|
|
|
|
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Mining, *w,
|
|
[this, rpc, daemonMemMb, doSlowRefresh, includeLocalHashrate, tracePage]() -> rpc::RPCWorker::MainCb {
|
|
AppRefreshRpcGateway refreshRpc(*rpc, traceSource(tracePage, "Mining refresh"));
|
|
auto result = NetworkRefreshService::collectMiningRefreshResult(
|
|
refreshRpc, daemonMemMb, doSlowRefresh, includeLocalHashrate);
|
|
return [this, result]() {
|
|
try {
|
|
NetworkRefreshService::applyMiningRefreshResult(state_, result, std::time(nullptr));
|
|
} catch (const std::exception& e) {
|
|
DEBUG_LOGF("[refreshMiningInfo] callback error: %s\n", e.what());
|
|
}
|
|
};
|
|
}, 2);
|
|
if (!enqueued.enqueued) return;
|
|
}
|
|
|
|
void App::refreshPeerInfo()
|
|
{
|
|
if (!rpc_) return;
|
|
|
|
// Use fast-lane worker to bypass head-of-line blocking behind refreshData.
|
|
auto* w = (fast_worker_ && fast_worker_->isRunning()) ? fast_worker_.get() : worker_.get();
|
|
auto* r = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get();
|
|
if (!w) return;
|
|
ui::NavPage tracePage = current_page_;
|
|
|
|
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Peers, *w, [this, r, tracePage]() -> rpc::RPCWorker::MainCb {
|
|
AppRefreshRpcGateway refreshRpc(*r, traceSource(tracePage, "Peer refresh"));
|
|
auto result = NetworkRefreshService::collectPeerRefreshResult(refreshRpc);
|
|
return [this, result = std::move(result)]() mutable {
|
|
NetworkRefreshService::applyPeerRefreshResult(state_, std::move(result), std::time(nullptr));
|
|
};
|
|
}, 2);
|
|
if (!enqueued.enqueued) return;
|
|
}
|
|
|
|
void App::refreshPrice()
|
|
{
|
|
// Skip if price fetching is disabled
|
|
if (!settings_->getFetchPrices()) return;
|
|
if (!worker_) return;
|
|
|
|
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Price, *worker_, [this]() -> rpc::RPCWorker::MainCb {
|
|
// --- Worker thread: blocking HTTP GET to CoinGecko ---
|
|
NetworkRefreshService::PriceHttpResult result;
|
|
|
|
try {
|
|
CURL* curl = curl_easy_init();
|
|
if (!curl) {
|
|
DEBUG_LOGF("Failed to initialize curl for price fetch\n");
|
|
result.errorMessage = "Price fetch failed: failed to initialize curl";
|
|
} else {
|
|
std::string response_data;
|
|
const char* url = "https://api.coingecko.com/api/v3/simple/price?ids=dragonx-2&vs_currencies=usd,btc&include_24hr_change=true&include_24hr_vol=true&include_market_cap=true";
|
|
|
|
auto write_callback = [](void* contents, size_t size, size_t nmemb, std::string* userp) -> size_t {
|
|
size_t totalSize = size * nmemb;
|
|
userp->append((char*)contents, totalSize);
|
|
return totalSize;
|
|
};
|
|
|
|
curl_easy_setopt(curl, CURLOPT_URL, url);
|
|
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, +write_callback);
|
|
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response_data);
|
|
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 10L);
|
|
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 5L);
|
|
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
|
curl_easy_setopt(curl, CURLOPT_USERAGENT, "DragonX-Wallet/1.0");
|
|
|
|
CURLcode res = curl_easy_perform(curl);
|
|
long http_code = 0;
|
|
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
|
|
curl_easy_cleanup(curl);
|
|
|
|
NetworkRefreshService::PriceHttpResponse response{
|
|
res == CURLE_OK,
|
|
http_code,
|
|
std::move(response_data),
|
|
res == CURLE_OK ? std::string() : std::string(curl_easy_strerror(res))
|
|
};
|
|
result = NetworkRefreshService::parsePriceHttpResponse(response, std::time(nullptr));
|
|
}
|
|
} catch (const std::exception& e) {
|
|
DEBUG_LOGF("Price fetch error: %s\n", e.what());
|
|
result.errorMessage = std::string("Price fetch error: ") + e.what();
|
|
} catch (...) {
|
|
DEBUG_LOGF("Price fetch error: unknown exception\n");
|
|
result.errorMessage = "Price fetch error: unknown exception";
|
|
}
|
|
|
|
if (result.price) {
|
|
DEBUG_LOGF("Price updated: $%.6f USD\n", result.price->market.price_usd);
|
|
} else {
|
|
DEBUG_LOGF("%s\n", result.errorMessage.c_str());
|
|
}
|
|
|
|
return [this, result = std::move(result)]() mutable {
|
|
if (result.price) {
|
|
NetworkRefreshService::applyPriceRefreshResult(state_, *result.price, std::chrono::steady_clock::now());
|
|
} else {
|
|
NetworkRefreshService::applyPriceRefreshFailure(state_, result.errorMessage);
|
|
}
|
|
};
|
|
}, 0);
|
|
if (!enqueued.enqueued) return;
|
|
NetworkRefreshService::markPriceRefreshStarted(state_);
|
|
}
|
|
|
|
void App::refreshMarketData()
|
|
{
|
|
refreshPrice();
|
|
}
|
|
|
|
// ============================================================================
|
|
// Mining Operations
|
|
// ============================================================================
|
|
|
|
void App::startMining(int threads)
|
|
{
|
|
if (!supportsSoloMining()) {
|
|
(void)threads;
|
|
ui::Notifications::instance().warning("Solo mining is unavailable in lite build");
|
|
return;
|
|
}
|
|
if (!state_.connected || !rpc_ || !worker_) return;
|
|
if (mining_toggle_in_progress_.exchange(true)) return; // already in progress
|
|
|
|
worker_->post([this, threads]() -> rpc::RPCWorker::MainCb {
|
|
bool ok = false;
|
|
std::string errMsg;
|
|
try {
|
|
rpc::RPCClient::TraceScope trace("Mining tab / Start mining");
|
|
rpc_->call("setgenerate", {true, threads});
|
|
ok = true;
|
|
} catch (const std::exception& e) {
|
|
errMsg = e.what();
|
|
}
|
|
return [this, threads, ok, errMsg]() {
|
|
mining_toggle_in_progress_.store(false);
|
|
if (ok) {
|
|
state_.mining.generate = true;
|
|
state_.mining.genproclimit = threads;
|
|
DEBUG_LOGF("Mining started with %d threads\n", threads);
|
|
} else {
|
|
DEBUG_LOGF("Failed to start mining: %s\n", errMsg.c_str());
|
|
ui::Notifications::instance().error("Mining failed: " + errMsg);
|
|
}
|
|
};
|
|
});
|
|
}
|
|
|
|
void App::stopMining()
|
|
{
|
|
if (!supportsSoloMining()) return;
|
|
if (!state_.connected || !rpc_ || !worker_) return;
|
|
if (mining_toggle_in_progress_.exchange(true)) return; // already in progress
|
|
|
|
worker_->post([this]() -> rpc::RPCWorker::MainCb {
|
|
bool ok = false;
|
|
try {
|
|
rpc::RPCClient::TraceScope trace("Mining tab / Stop mining");
|
|
rpc_->call("setgenerate", {false, 0});
|
|
ok = true;
|
|
} catch (const std::exception& e) {
|
|
DEBUG_LOGF("Failed to stop mining: %s\n", e.what());
|
|
}
|
|
return [this, ok]() {
|
|
mining_toggle_in_progress_.store(false);
|
|
if (ok) {
|
|
state_.mining.generate = false;
|
|
state_.mining.localHashrate = 0.0;
|
|
DEBUG_LOGF("Mining stopped\n");
|
|
}
|
|
};
|
|
});
|
|
}
|
|
|
|
void App::startPoolMining(int threads)
|
|
{
|
|
if (!supportsPoolMining()) {
|
|
(void)threads;
|
|
ui::Notifications::instance().warning("Pool mining is unavailable in this build");
|
|
return;
|
|
}
|
|
|
|
if (!xmrig_manager_)
|
|
xmrig_manager_ = std::make_unique<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_) {
|
|
settings_->setMiningAddress(addr, mining);
|
|
settings_->save();
|
|
invalidateShieldedHistoryScanProgress(true);
|
|
transactions_dirty_ = true;
|
|
last_tx_block_height_ = -1;
|
|
network_refresh_.markDue(services::NetworkRefreshService::Timer::Transactions);
|
|
}
|
|
}
|
|
|
|
void App::invalidateAddressValidationCache()
|
|
{
|
|
address_validation_cache_dirty_ = true;
|
|
addresses_dirty_ = true;
|
|
invalidateShieldedHistoryScanProgress(true);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Key Export/Import Operations
|
|
// ============================================================================
|
|
|
|
void App::exportPrivateKey(const std::string& address, std::function<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). Pass the user-selected
|
|
// fee explicitly — formatted as a fixed-decimal string so the daemon's ParseFixedPoint
|
|
// accepts it (a small double like 0.00005 would serialize to "5e-05" and be rejected).
|
|
const std::string fee_str = util::formatAmountFixed(fee);
|
|
worker_->post([this, from, to, amount, fee_str, memo, recipients, callback]() -> rpc::RPCWorker::MainCb {
|
|
bool ok = false;
|
|
std::string result_str;
|
|
try {
|
|
rpc::RPCClient::TraceScope trace("Send tab / Submit transaction");
|
|
auto result = rpc_->call("z_sendmany", {from, recipients, 1, fee_str});
|
|
result_str = result.get<std::string>();
|
|
ok = true;
|
|
} catch (const std::exception& e) {
|
|
result_str = e.what();
|
|
}
|
|
return [this, callback, ok, result_str, from, to, amount, memo]() {
|
|
if (send_submissions_in_flight_ > 0) --send_submissions_in_flight_;
|
|
if (ok) {
|
|
// A send changes address balances — refresh on next cycle
|
|
addresses_dirty_ = true;
|
|
// Force transaction list refresh so the sent tx appears immediately
|
|
transactions_dirty_ = true;
|
|
last_tx_block_height_ = -1;
|
|
network_refresh_.markWalletMutationRefresh();
|
|
// z_sendmany only returned an opid: the transaction is built/signed/
|
|
// broadcast asynchronously by the daemon. Defer the user-facing
|
|
// success/failure to the opid poller (app.cpp) so we don't report
|
|
// "sent successfully" for an operation that may still fail.
|
|
if (!result_str.empty()) {
|
|
pending_opids_.push_back(result_str);
|
|
upsertPendingSendTransaction(result_str, from, to, amount, memo);
|
|
if (callback) pending_send_callbacks_[result_str] = callback;
|
|
} else if (callback) {
|
|
callback(true, result_str); // no opid to track — report as-is
|
|
}
|
|
} else {
|
|
send_progress_active_ = false;
|
|
if (callback) callback(false, result_str);
|
|
}
|
|
};
|
|
});
|
|
}
|
|
|
|
} // namespace dragonx
|