Review of 30bd0d9 found the 0-conf fast-scan MainCb was the one async chat
callback missing the chat_session_generation_ guard the broadcast + identity-fetch
callbacks use. worker_ survives a wallet switch/lock, so a fast-scan posted under
wallet A could drain after resetChatSession() and ingest A's metadata (or toast)
against wallet B's freshly-provisioned store.
- Capture scanGen at post time and drop the result if it changed by drain time.
- Clear chat_fast_scan_in_flight_ in resetChatSession() so a switch immediately
re-enables the fast path; the stale callback returns WITHOUT clearing the flag so
it can't clobber the new session's own in-flight scan (generation is bumped only
in resetChatSession, which already reset the flag).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4387 lines
211 KiB
C++
4387 lines
211 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 "chat/chat_identity.h" // deriveChatIdentityFromSecret for HushChat identity provisioning
|
||
#include "ui/windows/chat_tab.h" // ui::ResetChatTab — wipe chat UI plaintext on a wallet switch
|
||
#include <sodium.h> // sodium_memzero for wiping the fetched mnemonic
|
||
#include <cctype>
|
||
#include "config/settings.h"
|
||
#include "wallet/lite_wallet_controller.h" // lite send/new-address routing
|
||
#include "wallet/lite_wallet_state_mapper.h" // LiteWalletAppRefreshModel for lite chat harvest
|
||
#include "config/version.h"
|
||
#include "daemon/daemon_controller.h"
|
||
#include "daemon/embedded_daemon.h"
|
||
#include "daemon/seed_wallet_creator.h"
|
||
#include "daemon/xmrig_manager.h"
|
||
#include "util/pool_registry.h"
|
||
#include "ui/notifications.h"
|
||
#include "ui/schema/skin_manager.h"
|
||
#include "default_banlist_embedded.h"
|
||
#include "util/amount_format.h"
|
||
#include "util/http_download.h"
|
||
#include "data/exchange_info.h"
|
||
#include "data/exchange_candles.h"
|
||
#include "util/platform.h"
|
||
#include "util/perf_log.h"
|
||
#include "util/i18n.h"
|
||
#include "util/secure_vault.h"
|
||
|
||
#include <nlohmann/json.hpp>
|
||
#include <curl/curl.h>
|
||
#include <algorithm>
|
||
#include <cmath>
|
||
#include <ctime>
|
||
#include <filesystem>
|
||
#include <thread>
|
||
#include <chrono>
|
||
#include <fstream>
|
||
#include <utility>
|
||
|
||
namespace dragonx {
|
||
|
||
using json = nlohmann::json;
|
||
using NetworkRefreshService = services::NetworkRefreshService;
|
||
|
||
namespace {
|
||
|
||
bool isPageEnabledForBuild(ui::NavPage page)
|
||
{
|
||
return wallet::isUiSurfaceAvailable(
|
||
wallet::currentWalletCapabilities(), ui::NavPageSurface(page));
|
||
}
|
||
|
||
std::string unencryptedTransactionHistoryCacheKey(const std::string& walletIdentity)
|
||
{
|
||
return std::string("obsidian-dragon-unencrypted-tx-cache-v1:") +
|
||
data::TransactionHistoryCache::walletIdentityHash(walletIdentity);
|
||
}
|
||
|
||
class AppRefreshRpcGateway final : public NetworkRefreshService::RefreshRpcGateway {
|
||
public:
|
||
AppRefreshRpcGateway(rpc::RPCClient& rpc, std::string source)
|
||
: rpc_(rpc), source_(std::move(source)) {}
|
||
|
||
json call(const std::string& method, const json& params) override
|
||
{
|
||
rpc::RPCClient::TraceScope trace(source_);
|
||
return rpc_.call(method, params);
|
||
}
|
||
|
||
private:
|
||
rpc::RPCClient& rpc_;
|
||
std::string source_;
|
||
};
|
||
|
||
const char* tracePageName(ui::NavPage page)
|
||
{
|
||
switch (page) {
|
||
case ui::NavPage::Overview: return "Overview tab";
|
||
case ui::NavPage::Send: return "Send tab";
|
||
case ui::NavPage::Receive: return "Receive tab";
|
||
case ui::NavPage::History: return "History tab";
|
||
case ui::NavPage::Contacts: return "Contacts tab";
|
||
case ui::NavPage::Chat: return "Chat tab";
|
||
case ui::NavPage::Mining: return "Mining tab";
|
||
case ui::NavPage::Market: return "Market tab";
|
||
case ui::NavPage::Console: return "Console tab";
|
||
case ui::NavPage::LiteConsole: return "Console tab"; // lite-only sibling of Console
|
||
case ui::NavPage::Peers: return "Network tab";
|
||
case ui::NavPage::LiteNetwork: return "Network tab"; // lite-only sibling of Peers
|
||
case ui::NavPage::Explorer: return "Explorer tab";
|
||
case ui::NavPage::Settings: return "Settings";
|
||
case ui::NavPage::Count_: break;
|
||
}
|
||
return "App";
|
||
}
|
||
|
||
std::string traceSource(ui::NavPage page, const char* process)
|
||
{
|
||
std::string source = tracePageName(page);
|
||
if (process && process[0] != '\0') {
|
||
source += " / ";
|
||
source += process;
|
||
}
|
||
return source;
|
||
}
|
||
|
||
std::size_t shieldedReceiveScanBudget(ui::NavPage page)
|
||
{
|
||
return page == ui::NavPage::History ? 8u : 4u;
|
||
}
|
||
|
||
// How far the tip may drift past an address's last shielded scan before we re-scan it. A full pass
|
||
// scans ~budget addresses per refresh cycle (≈96 per block of wall time), so a wallet with many
|
||
// z-addresses takes several blocks to scan fully. With a strict (tolerance 0) "scanned at tip"
|
||
// check, new blocks arriving mid-pass would invalidate already-scanned addresses and the pass would
|
||
// never complete — leaving transactions_dirty_ (and its "refreshing history" banner + send-progress
|
||
// gate) stuck on forever. Scaling the tolerance with the address count lets the pass complete while
|
||
// keeping shielded-receive latency minimal for small wallets; it's capped for pathological sizes.
|
||
int shieldedScanTipTolerance(std::size_t shieldedAddressCount)
|
||
{
|
||
int t = 2 + static_cast<int>(shieldedAddressCount / 96);
|
||
return std::min(t, 50);
|
||
}
|
||
|
||
} // namespace
|
||
|
||
// ============================================================================
|
||
// Warmup Message Translation
|
||
// Maps raw daemon RPC warmup messages to user-friendly text.
|
||
// ============================================================================
|
||
|
||
struct WarmupText {
|
||
const char* title;
|
||
const char* description;
|
||
};
|
||
|
||
static WarmupText translateWarmup(const std::string& raw)
|
||
{
|
||
if (raw.find("Loading") != std::string::npos)
|
||
return {"Loading blockchain data...",
|
||
"Reading the block database from disk. This may take a few minutes after updates."};
|
||
if (raw.find("Verifying") != std::string::npos)
|
||
return {"Verifying blockchain...",
|
||
"Checking recent blocks to make sure your chain data is valid."};
|
||
if (raw.find("Activating") != std::string::npos)
|
||
return {"Processing blocks...",
|
||
"Applying blocks to build the current chain state."};
|
||
if (raw.find("Rewinding") != std::string::npos)
|
||
return {"Reorganizing chain...",
|
||
"A chain reorganization was detected. Reverting to the correct chain."};
|
||
if (raw.find("Rescanning") != std::string::npos)
|
||
return {"Scanning for transactions...",
|
||
"Searching the blockchain for transactions belonging to your wallet. This can take a while."};
|
||
if (raw.find("Pruning") != std::string::npos)
|
||
return {"Optimizing storage...",
|
||
"Removing old block data to free up disk space."};
|
||
// Fallback: use the raw message
|
||
return {raw.c_str(), ""};
|
||
}
|
||
|
||
// A node told to open a wallet it can't prints one of these to its console: the BDB corrupt-wallet
|
||
// recovery ("Failed to rename … .bak", "wallet.dat corrupt, salvage failed") or a generic load error.
|
||
// Used to offer a -salvagewallet repair when a switch fails because the target wallet is corrupt.
|
||
static bool walletOutputLooksCorrupt(const std::string& out)
|
||
{
|
||
return out.find("Failed to rename") != std::string::npos
|
||
|| out.find("salvage failed") != std::string::npos
|
||
|| out.find("wallet.dat corrupt") != std::string::npos
|
||
|| out.find("Error loading wallet") != std::string::npos;
|
||
}
|
||
|
||
// Phrases dragonxd prints to its console while initializing, in the order translateWarmup()
|
||
// understands them. The most recent matching console line tells us which stage the node is in
|
||
// even when the RPC probe just times out (no -28 reply to read).
|
||
static const char* const kDaemonInitPhases[] = {
|
||
"Rescanning", "Rewinding", "Activating", "Verifying", "Loading", "Pruning",
|
||
};
|
||
|
||
// How many consecutive "RPC port busy but no config" connect attempts to wait through before
|
||
// warning the user that whatever owns the port isn't a usable DragonX node. The core retry runs
|
||
// roughly every few seconds, so this is on the order of ~20s — long enough for a real daemon to
|
||
// write its config, short enough not to leave the user guessing.
|
||
static constexpr int kDaemonWaitWarnAttempts = 4;
|
||
|
||
// ============================================================================
|
||
// Connection Management
|
||
// ============================================================================
|
||
|
||
void App::tryConnect()
|
||
{
|
||
// Lite builds have no full node / RPC daemon, so never run the RPC connection state machine
|
||
// (it would just fail every tick). The lite controller drives the wallet; "online" status is
|
||
// derived from it each frame in App::update(), which also gates the wallet UI (isConnected()).
|
||
if (isLiteBuild()) return;
|
||
|
||
if (connection_in_progress_) return;
|
||
|
||
// Don't fight an in-progress restart/adopt orchestration: while it stops the daemon, swaps
|
||
// wallet.dat (seed migration), and restarts, tryConnect must NOT start the daemon underneath it.
|
||
if (daemon_restarting_) return;
|
||
|
||
static int connect_attempt = 0;
|
||
++connect_attempt;
|
||
|
||
connection_in_progress_ = true;
|
||
connection_status_ = TR("sb_loading_config");
|
||
|
||
// Auto-detect configuration (file I/O — fast, safe on main thread)
|
||
auto config = rpc::Connection::autoDetectConfig();
|
||
|
||
if (config.rpcuser.empty() || config.rpcpassword.empty()) {
|
||
connection_in_progress_ = false;
|
||
std::string confPath = rpc::Connection::getDefaultConfPath();
|
||
VERBOSE_LOGF("[connect #%d] No valid config — DRAGONX.conf missing or no rpcuser/rpcpassword (looked at: %s)\n",
|
||
connect_attempt, confPath.c_str());
|
||
|
||
// Re-evaluate the RPC port LIVE rather than trusting a latched "external daemon detected"
|
||
// flag: EmbeddedDaemon::start() sets that latch whenever the port was busy at a prior
|
||
// attempt and then never re-checks it, so a stale socket (or a transient squatter that has
|
||
// since died) would strand us forever "waiting for config". If the port is genuinely busy,
|
||
// a real daemon writes its config shortly and we keep waiting; if it's free, we must start
|
||
// our own.
|
||
const bool portInUse = daemon::EmbeddedDaemon::isRpcPortInUse();
|
||
if (portInUse) {
|
||
connection_status_ = TR("sb_waiting_config");
|
||
VERBOSE_LOGF("[connect #%d] RPC port in use but no config yet — waiting for the daemon to write it\n",
|
||
connect_attempt);
|
||
// After a bounded wait with no config appearing, whatever owns the port is not a usable
|
||
// DragonX node (a foreign process, or a stuck/half-dead daemon). Say so once, with the
|
||
// action, instead of leaving the user on a silent "waiting" spinner forever.
|
||
if (++daemon_wait_attempts_ == kDaemonWaitWarnAttempts) {
|
||
ui::Notifications::instance().warning(TR("daemon_port_busy_warn"), 20.0f);
|
||
}
|
||
network_refresh_.setTimer(services::NetworkRefreshService::Timer::Core,
|
||
services::RefreshScheduler::kCoreDefault - 1.0f);
|
||
return;
|
||
}
|
||
daemon_wait_attempts_ = 0; // port is free — clear the bounded-wait counter
|
||
|
||
connection_status_ = TR("sb_no_conf");
|
||
|
||
// Port is free → start our own embedded daemon (if enabled).
|
||
if (isUsingEmbeddedDaemon() && !isEmbeddedDaemonRunning()) {
|
||
connection_status_ = TR("sb_starting_daemon");
|
||
if (startEmbeddedDaemon()) {
|
||
// Will retry connection after daemon starts
|
||
VERBOSE_LOGF("[connect #%d] Embedded daemon starting, will retry connection...\n", connect_attempt);
|
||
network_refresh_.setTimer(services::NetworkRefreshService::Timer::Core,
|
||
services::RefreshScheduler::kCoreDefault - 1.0f);
|
||
} else {
|
||
// The daemon couldn't be started (binary not found, Sapling params missing, spawn
|
||
// failure, …). Surface the actual reason instead of leaving the status stuck on
|
||
// "Starting dragonxd…": connection_status_ for the overlay/status bar, plus a
|
||
// one-time sticky notification with the full, actionable detail.
|
||
std::string detail = daemon_controller_ ? daemon_controller_->lastError() : std::string();
|
||
VERBOSE_LOGF("[connect #%d] startEmbeddedDaemon() failed — lastError: %s, binary: %s\n",
|
||
connect_attempt, detail.empty() ? "(none)" : detail.c_str(),
|
||
daemon::EmbeddedDaemon::findDaemonBinary().c_str());
|
||
connection_status_ = TR("sb_daemon_start_failed");
|
||
if (!daemon_start_error_shown_) {
|
||
daemon_start_error_shown_ = true;
|
||
ui::Notifications::instance().error(
|
||
detail.empty() ? std::string(TR("sb_daemon_start_failed")) : detail, 30.0f);
|
||
}
|
||
// Keep retrying: a missing binary/params can be fixed without a restart.
|
||
network_refresh_.setTimer(services::NetworkRefreshService::Timer::Core,
|
||
services::RefreshScheduler::kCoreDefault - 1.0f);
|
||
}
|
||
} else if (!isUsingEmbeddedDaemon()) {
|
||
VERBOSE_LOGF("[connect #%d] Embedded daemon disabled (using external). No config found at %s\n",
|
||
connect_attempt, confPath.c_str());
|
||
}
|
||
return;
|
||
}
|
||
|
||
connection_status_ = TR("sb_connecting_daemon");
|
||
VERBOSE_LOGF("[connect #%d] Connecting to %s:%s (user=%s)\n",
|
||
connect_attempt, config.host.c_str(), config.port.c_str(), config.rpcuser.c_str());
|
||
|
||
if (rpc::Connection::usesPlaintextRemote(config) && !remote_rpc_plaintext_warning_shown_) {
|
||
remote_rpc_plaintext_warning_shown_ = true;
|
||
ui::Notifications::instance().warning(
|
||
"Remote RPC is using plaintext HTTP. Add rpctls=1 to DRAGONX.conf if your daemon supports TLS.",
|
||
10.0f);
|
||
}
|
||
|
||
// Run the blocking rpc_->connect() on the worker thread so the UI
|
||
// stays responsive (curl connect timeout can be up to 10 seconds).
|
||
if (!worker_) {
|
||
connection_in_progress_ = false;
|
||
VERBOSE_LOGF("[connect #%d] No worker thread available!\n", connect_attempt);
|
||
return;
|
||
}
|
||
|
||
// Capture daemon state before posting to worker
|
||
bool daemonStarting = daemon_controller_ &&
|
||
(daemon_controller_->state() == daemon::EmbeddedDaemon::State::Starting ||
|
||
daemon_controller_->state() == daemon::EmbeddedDaemon::State::Running);
|
||
bool externalDetected = daemon_controller_ && daemon_controller_->externalDaemonDetected();
|
||
int attempt = connect_attempt;
|
||
|
||
// Log detailed daemon state for diagnostics
|
||
if (daemon_controller_) {
|
||
const char* stateStr = "unknown";
|
||
switch (daemon_controller_->state()) {
|
||
case daemon::EmbeddedDaemon::State::Stopped: stateStr = "Stopped"; break;
|
||
case daemon::EmbeddedDaemon::State::Starting: stateStr = "Starting"; break;
|
||
case daemon::EmbeddedDaemon::State::Running: stateStr = "Running"; break;
|
||
case daemon::EmbeddedDaemon::State::Stopping: stateStr = "Stopping"; break;
|
||
case daemon::EmbeddedDaemon::State::Error: stateStr = "Error"; break;
|
||
}
|
||
VERBOSE_LOGF("[connect #%d] Daemon state: %s, running: %s, external: %s, crashes: %d, lastErr: %s\n",
|
||
attempt, stateStr,
|
||
daemon_controller_->isRunning() ? "yes" : "no",
|
||
externalDetected ? "yes" : "no",
|
||
daemon_controller_->crashCount(),
|
||
daemon_controller_->lastError().empty() ? "(none)" : daemon_controller_->lastError().c_str());
|
||
|
||
// The embedded daemon can spawn successfully and then exit immediately (a missing runtime
|
||
// DLL, wrong architecture, corrupt binary, datadir lock, …). The crash monitor records a
|
||
// detailed reason (translated exit code + launch command + debug.log tail) in lastError(),
|
||
// but it runs on a background thread and was never shown — so the wallet looked like it was
|
||
// "stuck connecting" while the node silently died-and-respawned. Surface each new crash once.
|
||
const int crashes = daemon_controller_->crashCount();
|
||
if (crashes > daemon_last_seen_crashes_) {
|
||
daemon_last_seen_crashes_ = crashes;
|
||
const std::string detail = daemon_controller_->lastError();
|
||
if (!detail.empty()) {
|
||
connection_status_ = TR("sb_daemon_start_failed");
|
||
ui::Notifications::instance().error(detail, 30.0f);
|
||
}
|
||
}
|
||
} else {
|
||
VERBOSE_LOGF("[connect #%d] No embedded daemon object (use_embedded=%s)\n",
|
||
attempt, isUsingEmbeddedDaemon() ? "yes" : "no");
|
||
}
|
||
|
||
worker_->post([this, config, daemonStarting, externalDetected, attempt]() -> rpc::RPCWorker::MainCb {
|
||
bool connected = rpc_->connect(config.host, config.port, config.rpcuser, config.rpcpassword, config.use_tls);
|
||
std::string connectErr = rpc_->getLastConnectError();
|
||
bool warmingUp = rpc_->isWarmingUp();
|
||
std::string warmupStatus = rpc_->getWarmupStatus();
|
||
|
||
return [this, config, connected, warmingUp, warmupStatus, daemonStarting, externalDetected, attempt, connectErr]() {
|
||
if (connected) {
|
||
VERBOSE_LOGF("[connect #%d] Connected successfully%s\n", attempt,
|
||
warmingUp ? " (daemon warming up)" : "");
|
||
saved_config_ = config; // save for fast-lane connection
|
||
onConnected();
|
||
if (warmingUp) {
|
||
// Daemon is reachable and auth works, but RPC calls will
|
||
// fail until warmup completes. Set the warmup state so
|
||
// the UI shows status instead of a blocking overlay.
|
||
state_.warming_up = true;
|
||
auto wt = translateWarmup(warmupStatus);
|
||
state_.warmup_status = wt.title;
|
||
state_.warmup_description = wt.description;
|
||
// Append current block height from daemon output
|
||
if (daemon_controller_) {
|
||
int h = daemon_controller_->lastBlockHeight();
|
||
if (h > 0)
|
||
state_.warmup_status += " (Block " + std::to_string(h) + ")";
|
||
}
|
||
connection_status_ = state_.warmup_status;
|
||
}
|
||
} else {
|
||
// HTTP 401 = authentication failure. The daemon is running
|
||
// but our rpcuser/rpcpassword don't match. Don't retry
|
||
// endlessly — tell the user what's wrong.
|
||
bool authFailure = (connectErr.find("401") != std::string::npos);
|
||
if (authFailure) {
|
||
rpc::ConnectionConfig cookieConfig;
|
||
if (rpc::Connection::buildCookieAuthConfig(config, cookieConfig)) {
|
||
VERBOSE_LOGF("[connect #%d] HTTP 401 — retrying with .cookie auth from %s\n",
|
||
attempt, cookieConfig.hush_dir.c_str());
|
||
worker_->post([this, cookieConfig, attempt]() -> rpc::RPCWorker::MainCb {
|
||
bool ok = rpc_->connect(cookieConfig.host, cookieConfig.port,
|
||
cookieConfig.rpcuser, cookieConfig.rpcpassword,
|
||
cookieConfig.use_tls);
|
||
return [this, cookieConfig, ok, attempt]() {
|
||
connection_in_progress_ = false;
|
||
if (ok) {
|
||
VERBOSE_LOGF("[connect #%d] Connected via .cookie auth\n", attempt);
|
||
saved_config_ = cookieConfig;
|
||
onConnected();
|
||
} else {
|
||
state_.connected = false;
|
||
connection_status_ = TR("sb_auth_failed");
|
||
VERBOSE_LOGF("[connect #%d] .cookie auth also failed\n", attempt);
|
||
ui::Notifications::instance().error(
|
||
"RPC authentication failed (HTTP 401). "
|
||
"The rpcuser/rpcpassword in DRAGONX.conf don't match the running daemon. "
|
||
"Restart the daemon or correct the credentials.");
|
||
}
|
||
};
|
||
});
|
||
return; // async retry in progress
|
||
}
|
||
state_.connected = false;
|
||
std::string confPath = rpc::Connection::getDefaultConfPath();
|
||
connection_status_ = TR("sb_auth_failed");
|
||
VERBOSE_LOGF("[connect #%d] HTTP 401 — rpcuser/rpcpassword in %s don't match the daemon. "
|
||
"Edit the file or restart the daemon to regenerate credentials.\n",
|
||
attempt, confPath.c_str());
|
||
ui::Notifications::instance().error(
|
||
"RPC authentication failed (HTTP 401). "
|
||
"The rpcuser/rpcpassword in DRAGONX.conf don't match the running daemon. "
|
||
"Restart the daemon or correct the credentials.");
|
||
} else if (daemonStarting) {
|
||
state_.connected = false;
|
||
// The daemon is launched but RPC isn't answering yet. A *timeout* means it
|
||
// connected but the node is busy initializing (loading the block index, etc.);
|
||
// a connect refusal means it hasn't bound the RPC port yet. Either way, show a
|
||
// clear "node initializing" overlay (status + phase + block height from the
|
||
// daemon's own console output) instead of a bare technical error.
|
||
const bool reachableButBusy = connectErr.find("Timeout") != std::string::npos;
|
||
applyDaemonInitStatus(reachableButBusy);
|
||
VERBOSE_LOGF("[connect #%d] RPC connection failed (%s) — daemon still starting, will retry...\n",
|
||
attempt, connectErr.c_str());
|
||
network_refresh_.setTimer(services::NetworkRefreshService::Timer::Core,
|
||
services::RefreshScheduler::kCoreDefault - 1.0f);
|
||
} else if (externalDetected) {
|
||
state_.connected = false;
|
||
// An external daemon is on the RPC port but not answering. A timeout means it's
|
||
// up and busy initializing; surface that as the init overlay (we can't read its
|
||
// console since we didn't launch it, so no phase line — just a clear message).
|
||
if (connectErr.find("Timeout") != std::string::npos) {
|
||
applyDaemonInitStatus(/*reachableButBusy=*/true);
|
||
} else if (!connectErr.empty()) {
|
||
char buf[256]; snprintf(buf, sizeof(buf), TR("sb_connecting_err"), connectErr.c_str());
|
||
connection_status_ = buf;
|
||
} else {
|
||
connection_status_ = TR("sb_connecting_external");
|
||
}
|
||
VERBOSE_LOGF("[connect #%d] External daemon detected but RPC failed (%s), will retry...\n",
|
||
attempt, connectErr.c_str());
|
||
network_refresh_.setTimer(services::NetworkRefreshService::Timer::Core,
|
||
services::RefreshScheduler::kCoreDefault - 1.0f);
|
||
} else {
|
||
onDisconnected("Connection failed");
|
||
VERBOSE_LOGF("[connect #%d] RPC connection failed — no daemon starting, no external detected\n", attempt);
|
||
|
||
if (isUsingEmbeddedDaemon() && !isEmbeddedDaemonRunning()) {
|
||
// Prevent infinite crash-restart loop
|
||
if (daemon_controller_ && daemon_controller_->crashCount() >= 3) {
|
||
if (wallet_switch_pending_confirm_.load()) {
|
||
// The just-switched-to wallet's daemon keeps crashing (e.g. a wallet that
|
||
// fails LATE in init, past the fast start grace) — revert to the previous
|
||
// wallet rather than wedging on a broken one. The main thread does the
|
||
// settings revert (processWalletSwitchRevert) + resets the crash count.
|
||
wallet_switch_pending_confirm_.store(false);
|
||
wallet_switch_failed_.store(true);
|
||
}
|
||
{ char buf[128]; snprintf(buf, sizeof(buf), TR("sb_daemon_crashed"), daemon_controller_->crashCount());
|
||
connection_status_ = buf; }
|
||
VERBOSE_LOGF("[connect #%d] Daemon crashed %d times — not restarting (use Settings > Restart Daemon to retry)\n",
|
||
attempt, daemon_controller_->crashCount());
|
||
} else {
|
||
connection_status_ = TR("sb_starting_daemon");
|
||
if (startEmbeddedDaemon()) {
|
||
VERBOSE_LOGF("[connect #%d] Embedded daemon starting, will retry connection...\n", attempt);
|
||
} else if (daemon_controller_ && daemon_controller_->externalDaemonDetected()) {
|
||
connection_status_ = TR("sb_connecting_generic");
|
||
VERBOSE_LOGF("[connect #%d] External daemon detected, will connect via RPC...\n", attempt);
|
||
} else {
|
||
VERBOSE_LOGF("[connect #%d] Failed to start embedded daemon — lastError: %s\n",
|
||
attempt,
|
||
daemon_controller_ ? daemon_controller_->lastError().c_str() : "(no daemon object)");
|
||
}
|
||
}
|
||
} else if (!isUsingEmbeddedDaemon()) {
|
||
VERBOSE_LOGF("[connect #%d] Embedded daemon disabled — external daemon at %s:%s not responding\n",
|
||
attempt, config.host.c_str(), config.port.c_str());
|
||
} else {
|
||
VERBOSE_LOGF("[connect #%d] Embedded daemon is running but RPC failed — daemon may be initializing\n", attempt);
|
||
}
|
||
}
|
||
}
|
||
connection_in_progress_ = false;
|
||
};
|
||
});
|
||
}
|
||
|
||
void App::onConnected()
|
||
{
|
||
state_.connected = true;
|
||
const bool completedSwitch = wallet_switch_pending_confirm_.exchange(false); // this connect confirms a switch
|
||
// A successful connect completes a switch — close/clear the progress modal (kept open through the
|
||
// stop → start → reconnect sequence, or already hidden via "continue in background").
|
||
if (completedSwitch || wallet_switch_dialog_open_.load()) {
|
||
wallet_switch_dialog_open_.store(false);
|
||
wallet_switch_phase_.store(static_cast<int>(WalletSwitchPhase::None));
|
||
}
|
||
state_.daemon_initializing = false; // RPC is answering now; clear the "initializing" overlay
|
||
daemon_wait_attempts_ = 0; // re-arm the port-busy / start-failure notifications
|
||
daemon_start_error_shown_ = false;
|
||
daemon_last_seen_crashes_ = 0; // (onConnected resets the daemon's crash count too)
|
||
connection_status_ = TR("connected");
|
||
|
||
// Stamp the active wallet as opened in the index (last-opened + size + synced-here). Balance +
|
||
// address count fill in on the first address refresh (addresses aren't loaded yet here).
|
||
updateWalletIndexForActiveWallet(/*markOpened=*/true);
|
||
|
||
// Reset crash counter on successful connection
|
||
if (daemon_controller_) {
|
||
daemon_controller_->resetCrashCount();
|
||
}
|
||
|
||
// Get daemon info + wallet encryption state on the worker thread.
|
||
// Fetching getwalletinfo here (before refreshData) ensures the lock
|
||
// screen appears immediately instead of after 6+ queued RPC calls.
|
||
bool initialPrefetchQueued = false;
|
||
if (worker_ && rpc_) {
|
||
auto prefetchedInfo = NetworkRefreshService::parseConnectionInfoResult(rpc_->getLastConnectInfo());
|
||
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::ConnectionInit, *worker_, [this, prefetchedInfo]() -> rpc::RPCWorker::MainCb {
|
||
AppRefreshRpcGateway refreshRpc(*rpc_, "Startup / Connection init");
|
||
auto result = NetworkRefreshService::collectConnectionInitResult(refreshRpc, prefetchedInfo);
|
||
return [this, result]() {
|
||
NetworkRefreshService::applyConnectionInitResult(state_, result);
|
||
if (state_.isLocked()) {
|
||
resetTransactionHistoryCacheSession();
|
||
} else if (state_.transactions.empty()) {
|
||
loadTransactionHistoryCacheIfAvailable();
|
||
} else {
|
||
storeTransactionHistoryCacheIfAvailable();
|
||
}
|
||
};
|
||
}, 3);
|
||
initialPrefetchQueued = enqueued.enqueued;
|
||
}
|
||
|
||
// onConnected already fetched getwalletinfo — tell refreshData to skip
|
||
// the duplicate call on the very first cycle.
|
||
encryption_state_prefetched_ = initialPrefetchQueued;
|
||
|
||
// Addresses are unknown on fresh connect — force a fetch
|
||
addresses_dirty_ = true;
|
||
|
||
// Start the fast-lane RPC connection (dedicated to 1-second mining polls).
|
||
// Uses its own curl handle + worker thread so getlocalsolps never blocks
|
||
// behind the main refresh batch.
|
||
if (!fast_rpc_) {
|
||
fast_rpc_ = std::make_unique<rpc::RPCClient>();
|
||
}
|
||
if (!fast_worker_) {
|
||
fast_worker_ = std::make_unique<rpc::RPCWorker>();
|
||
fast_worker_->start();
|
||
}
|
||
// Connect on the fast worker's own thread (non-blocking to main)
|
||
fast_worker_->post([this]() -> rpc::RPCWorker::MainCb {
|
||
bool ok = fast_rpc_->connect(saved_config_.host, saved_config_.port,
|
||
saved_config_.rpcuser, saved_config_.rpcpassword,
|
||
saved_config_.use_tls);
|
||
return [ok]() {
|
||
if (!ok) {
|
||
DEBUG_LOGF("[FastLane] Failed to connect secondary RPC client\\n");
|
||
} else {
|
||
DEBUG_LOGF("[FastLane] Secondary RPC client connected\\n");
|
||
}
|
||
};
|
||
});
|
||
|
||
// Initial data refresh
|
||
refreshData();
|
||
refreshMarketData();
|
||
|
||
// Apply compiled-in default ban list
|
||
applyDefaultBanlist();
|
||
}
|
||
|
||
void App::onDisconnected(const std::string& reason)
|
||
{
|
||
state_.connected = false;
|
||
state_.warming_up = false;
|
||
state_.warmup_status.clear();
|
||
state_.clear();
|
||
connection_status_ = reason;
|
||
|
||
// Re-classify the wallet's mnemonic status on the next connect (the active wallet may have
|
||
// changed — a switch or a post-migration adopt both disconnect here).
|
||
wallet_seed_status_ = WalletSeedStatus::Unknown;
|
||
wallet_seed_status_attempts_ = 0;
|
||
|
||
// Clear RPC result caches
|
||
viewtx_cache_.clear();
|
||
confirmed_tx_cache_.clear();
|
||
confirmed_tx_ids_.clear();
|
||
confirmed_cache_block_ = -1;
|
||
last_tx_block_height_ = -1;
|
||
pending_opids_.clear();
|
||
pending_send_info_.clear();
|
||
// Resolve any deferred send callbacks so their UI doesn't spin forever on disconnect.
|
||
for (auto& entry : pending_send_callbacks_) {
|
||
if (entry.second) entry.second(false, reason);
|
||
}
|
||
pending_send_callbacks_.clear();
|
||
consecutive_core_failures_ = 0;
|
||
send_progress_active_ = false;
|
||
send_submissions_in_flight_ = 0;
|
||
network_refresh_.resetJobs();
|
||
rescan_status_poll_in_progress_ = false;
|
||
opid_poll_in_progress_ = false;
|
||
address_validation_cache_dirty_ = true;
|
||
resetTransactionHistoryCacheSession();
|
||
|
||
// Tear down the fast-lane connection. Signal abort first so a fast-lane call blocked in
|
||
// curl_easy_perform unblocks and stop()'s join() returns promptly (no UI freeze).
|
||
if (fast_rpc_) fast_rpc_->requestAbort();
|
||
if (fast_worker_) {
|
||
fast_worker_->stop();
|
||
// Drop the stopped worker so onConnected recreates and starts a fresh one. Keeping a
|
||
// stopped-but-present worker would defeat onConnected's `if (!fast_worker_)` guard, leaving
|
||
// the fast lane dead for the rest of the session — which silently stalls the opid poll
|
||
// (its post() never runs, so a completed send spins on "Waiting for operation").
|
||
fast_worker_.reset();
|
||
}
|
||
if (fast_rpc_) {
|
||
fast_rpc_->disconnect();
|
||
}
|
||
}
|
||
|
||
std::string App::applyDaemonInitStatus(bool reachableButBusy)
|
||
{
|
||
state_.daemon_initializing = true;
|
||
|
||
// Find the most recent console line that names an init phase, so we can tell the user exactly
|
||
// what the node is doing (loading the block index, verifying, activating best chain, …).
|
||
std::string phaseLine;
|
||
if (daemon_controller_) {
|
||
const auto lines = daemon_controller_->recentLines(40);
|
||
for (auto it = lines.rbegin(); it != lines.rend() && phaseLine.empty(); ++it) {
|
||
for (const char* phase : kDaemonInitPhases) {
|
||
if (it->find(phase) != std::string::npos) { phaseLine = *it; break; }
|
||
}
|
||
}
|
||
}
|
||
|
||
WarmupText wt;
|
||
if (!phaseLine.empty()) {
|
||
wt = translateWarmup(phaseLine);
|
||
} else if (reachableButBusy) {
|
||
// The probe connected but got no RPC reply within the timeout: the node is up but busy
|
||
// initializing (it isn't printing a recognizable phase, or we didn't launch it).
|
||
wt = {"Starting DragonX node…",
|
||
"The node is reachable but still initializing and isn't answering yet. "
|
||
"This is normal after an update or on first launch — it can take a few minutes."};
|
||
} else {
|
||
// The daemon is launching but hasn't bound its RPC port yet.
|
||
wt = {"Starting DragonX node…",
|
||
"Launching dragonxd and waiting for it to come online…"};
|
||
}
|
||
|
||
std::string title = wt.title;
|
||
const int h = daemon_controller_ ? daemon_controller_->lastBlockHeight() : -1;
|
||
if (h > 0) title += " (Block " + std::to_string(h) + ")";
|
||
|
||
state_.warmup_status = title;
|
||
state_.warmup_description = wt.description ? wt.description : "";
|
||
connection_status_ = title;
|
||
return title;
|
||
}
|
||
|
||
void App::handleLostConnection(const std::string& reason)
|
||
{
|
||
DEBUG_LOGF("[Connection] %s — tearing down for reconnect\n", reason.c_str());
|
||
// Flip the main client's connected_ flag so update()'s else-branch re-enters
|
||
// tryConnect(). onDisconnected() alone only tears down the fast lane.
|
||
if (rpc_) rpc_->disconnect();
|
||
onDisconnected(reason);
|
||
}
|
||
|
||
// ============================================================================
|
||
// Data Refresh — Tab-Aware Prioritized System
|
||
//
|
||
// Data is split into independent categories, each with its own refresh
|
||
// function, timer, and in-progress guard. The orchestrator (refreshData)
|
||
// dispatches all categories, but each can also be called independently
|
||
// (e.g. on tab switch for immediate refresh).
|
||
//
|
||
// Categories:
|
||
// Core — z_gettotalbalance + getblockchaininfo (balance, sync)
|
||
// Addresses — z_listaddresses + listunspent (address list, per-addr balances)
|
||
// Transactions — listtransactions + z_listreceivedbyaddress + z_viewtransaction
|
||
// Peers — getpeerinfo + listbanned (already standalone)
|
||
// Encryption — getwalletinfo (one-shot on connect)
|
||
//
|
||
// Intervals are adjusted by applyRefreshPolicy() based on the active tab,
|
||
// so the user sees faster updates for the data they're interacting with.
|
||
// ============================================================================
|
||
|
||
App::RefreshIntervals App::getIntervalsForPage(ui::NavPage page)
|
||
{
|
||
return services::NetworkRefreshService::intervalsForPage(page);
|
||
}
|
||
|
||
void App::applyRefreshPolicy(ui::NavPage page)
|
||
{
|
||
// While the daemon is syncing, override the per-tab cadence with the low-impact sync profile so
|
||
// the wallet stops contending for the daemon's cs_main lock (frequent getpeerinfo / per-block
|
||
// transaction scans / balance polls slow block connection). This makes every tab sync as fast
|
||
// as the Console tab does today. Reverts to the per-tab profile once sync finishes.
|
||
refresh_policy_syncing_ = state_.sync.syncing;
|
||
network_refresh_.setIntervals(refresh_policy_syncing_
|
||
? services::RefreshScheduler::kSyncProfile
|
||
: getIntervalsForPage(page));
|
||
}
|
||
|
||
bool App::currentPageNeedsWalletDataRefresh() const
|
||
{
|
||
using NP = ui::NavPage;
|
||
return current_page_ == NP::Overview ||
|
||
current_page_ == NP::Send ||
|
||
current_page_ == NP::Receive ||
|
||
current_page_ == NP::History;
|
||
}
|
||
|
||
bool App::shouldRunWalletTransactionRefresh() const
|
||
{
|
||
if (currentPageNeedsWalletDataRefresh()) return true;
|
||
if (hasTransactionSendProgress() || !send_txids_.empty()) return true;
|
||
return transactions_dirty_ && !shielded_history_scan_pending_;
|
||
}
|
||
|
||
void App::setCurrentPage(ui::NavPage page)
|
||
{
|
||
if (!isPageEnabledForBuild(page)) {
|
||
page = ui::NavPage::Overview;
|
||
}
|
||
if (page == current_page_) return;
|
||
current_page_ = page;
|
||
applyRefreshPolicy(page);
|
||
using RefreshTimer = services::NetworkRefreshService::Timer;
|
||
|
||
// Immediate refresh for the incoming tab's priority data. Gate on ACTUAL RPC connectivity
|
||
// (not state_.connected, which is the lite "online" proxy) — lite has no RPC daemon and the
|
||
// lite controller refreshes wallet data itself, so these full-node RPC polls must not fire.
|
||
if (rpc_ && rpc_->isConnected() && !state_.isLocked()) {
|
||
using NP = ui::NavPage;
|
||
switch (page) {
|
||
case NP::Overview:
|
||
refreshCoreData();
|
||
network_refresh_.reset(RefreshTimer::Core);
|
||
break;
|
||
case NP::History:
|
||
transactions_dirty_ = true;
|
||
refreshTransactionData();
|
||
network_refresh_.reset(RefreshTimer::Transactions);
|
||
break;
|
||
case NP::Send:
|
||
case NP::Receive:
|
||
addresses_dirty_ = true;
|
||
refreshAddressData();
|
||
network_refresh_.reset(RefreshTimer::Addresses);
|
||
break;
|
||
case NP::Peers:
|
||
refreshPeerInfo();
|
||
network_refresh_.reset(RefreshTimer::Peers);
|
||
break;
|
||
case NP::Mining:
|
||
refreshMiningInfo();
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Screenshot sweep (tab-only + full-UI) + its demo data live in src/app_sweep.cpp.
|
||
|
||
bool App::shouldRefreshTransactions() const
|
||
{
|
||
// NOTE: this is block-height / dirty driven, NOT interval-gated. It returns true only when a new
|
||
// block arrived (currentBlocks != last_tx_block_height_), the history was never fetched, or
|
||
// something marked it dirty (tab entry, a send, a reorg, etc.). The Transactions timer only
|
||
// controls how often this CHECK runs; between blocks the lightweight recent-poll
|
||
// (shouldRefreshRecentTransactions / TxAge) handles mempool + unconfirmed deltas instead.
|
||
const int currentBlocks = state_.sync.blocks;
|
||
return network_refresh_.shouldRefreshTransactions(last_tx_block_height_,
|
||
currentBlocks,
|
||
transactions_dirty_);
|
||
}
|
||
|
||
bool App::shouldRefreshRecentTransactions() const
|
||
{
|
||
using RefreshTimer = services::NetworkRefreshService::Timer;
|
||
return network_refresh_.isDue(RefreshTimer::TxAge)
|
||
&& last_tx_block_height_ >= 0
|
||
&& state_.sync.blocks == last_tx_block_height_
|
||
&& !state_.transactions.empty()
|
||
&& !transactions_dirty_
|
||
&& !addresses_dirty_;
|
||
}
|
||
|
||
void App::upsertPendingSendTransaction(const std::string& opid,
|
||
const std::string& from,
|
||
const std::string& to,
|
||
double amount,
|
||
const std::string& memo,
|
||
double fee)
|
||
{
|
||
if (opid.empty()) return;
|
||
|
||
bool newPending = pending_send_info_.find(opid) == pending_send_info_.end();
|
||
auto& pendingInfo = pending_send_info_[opid];
|
||
if (pendingInfo.timestamp == 0) pendingInfo.timestamp = static_cast<std::int64_t>(std::time(nullptr));
|
||
pendingInfo.from = from;
|
||
pendingInfo.to = to;
|
||
pendingInfo.amount = std::abs(amount);
|
||
pendingInfo.memo = memo;
|
||
pendingInfo.fee = fee;
|
||
|
||
TransactionInfo pending;
|
||
pending.txid = opid;
|
||
pending.type = "send";
|
||
pending.amount = -pendingInfo.amount;
|
||
pending.timestamp = pendingInfo.timestamp;
|
||
pending.confirmations = 0;
|
||
pending.address = pendingInfo.to;
|
||
pending.from_address = pendingInfo.from;
|
||
pending.memo = pendingInfo.memo;
|
||
|
||
auto existing = std::find_if(state_.transactions.begin(), state_.transactions.end(),
|
||
[&](const TransactionInfo& transaction) { return transaction.txid == opid; });
|
||
if (existing != state_.transactions.end()) {
|
||
*existing = std::move(pending);
|
||
} else {
|
||
state_.transactions.insert(state_.transactions.begin(), std::move(pending));
|
||
}
|
||
if (newPending) {
|
||
applyPendingSendDelta(pendingInfo.from, -pendingInfo.amount, /*includeAggregates*/ true);
|
||
}
|
||
state_.last_tx_update = std::time(nullptr);
|
||
}
|
||
|
||
void App::markPendingSendTransactionSucceeded(const std::string& opid,
|
||
const std::string& txid)
|
||
{
|
||
if (opid.empty() || txid.empty()) return;
|
||
|
||
auto pending = std::find_if(state_.transactions.begin(), state_.transactions.end(),
|
||
[&](const TransactionInfo& transaction) { return transaction.txid == opid; });
|
||
if (pending == state_.transactions.end()) return;
|
||
|
||
bool duplicateRealTx = std::any_of(state_.transactions.begin(), state_.transactions.end(),
|
||
[&](const TransactionInfo& transaction) { return transaction.txid == txid; });
|
||
if (duplicateRealTx) {
|
||
state_.transactions.erase(pending);
|
||
} else {
|
||
pending->txid = txid;
|
||
pending->confirmations = 0;
|
||
if (pending->timestamp == 0) pending->timestamp = static_cast<std::int64_t>(std::time(nullptr));
|
||
}
|
||
state_.last_tx_update = std::time(nullptr);
|
||
}
|
||
|
||
void App::removePendingSendTransactions(const std::vector<std::string>& opids,
|
||
bool restoreBalances)
|
||
{
|
||
if (opids.empty()) return;
|
||
std::unordered_set<std::string> opidSet(opids.begin(), opids.end());
|
||
if (restoreBalances) {
|
||
for (const auto& opid : opidSet) {
|
||
auto pending = pending_send_info_.find(opid);
|
||
if (pending == pending_send_info_.end()) continue;
|
||
applyPendingSendDelta(pending->second.from, pending->second.amount,
|
||
/*includeAggregates*/ true);
|
||
}
|
||
}
|
||
state_.transactions.erase(
|
||
std::remove_if(state_.transactions.begin(), state_.transactions.end(),
|
||
[&](const TransactionInfo& transaction) {
|
||
return opidSet.find(transaction.txid) != opidSet.end();
|
||
}),
|
||
state_.transactions.end());
|
||
for (const auto& opid : opidSet) pending_send_info_.erase(opid);
|
||
state_.last_tx_update = std::time(nullptr);
|
||
}
|
||
|
||
void App::applyPendingSendDelta(const std::string& fromAddress, double signedAmount,
|
||
bool includeAggregates)
|
||
{
|
||
// For a debit (signedAmount < 0) this clamps at >=0 exactly as the debit sites did. For a
|
||
// restore (signedAmount > 0) the clamp is a no-op — max(0, bal+amt) == bal+amt when bal,amt>=0 —
|
||
// so the single clamped form reproduces the original restore's unclamped bal += amt.
|
||
auto applyToAddress = [&](std::vector<AddressInfo>& addresses) {
|
||
for (auto& address : addresses) {
|
||
if (address.address == fromAddress) {
|
||
address.balance = std::max(0.0, address.balance + signedAmount);
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
};
|
||
if (!applyToAddress(state_.z_addresses)) applyToAddress(state_.t_addresses);
|
||
if (includeAggregates) {
|
||
if (!fromAddress.empty() && fromAddress[0] == 'z') {
|
||
state_.privateBalance = std::max(0.0, state_.privateBalance + signedAmount);
|
||
} else {
|
||
state_.transparentBalance = std::max(0.0, state_.transparentBalance + signedAmount);
|
||
}
|
||
state_.totalBalance = std::max(0.0, state_.totalBalance + signedAmount);
|
||
}
|
||
}
|
||
|
||
void App::trackOperation(const std::string& opid)
|
||
{
|
||
if (opid.empty()) return;
|
||
// Touched only from the main thread (sendTransaction's MainCb and the opid poller's
|
||
// MainCb both run via drainResults()), so no locking is needed.
|
||
if (std::find(pending_opids_.begin(), pending_opids_.end(), opid) != pending_opids_.end())
|
||
return;
|
||
pending_opids_.push_back(opid);
|
||
}
|
||
|
||
bool App::invokeSendResultCallback(const std::string& opid, bool ok,
|
||
const std::string& result)
|
||
{
|
||
auto it = pending_send_callbacks_.find(opid);
|
||
if (it == pending_send_callbacks_.end()) return false;
|
||
auto cb = std::move(it->second);
|
||
pending_send_callbacks_.erase(it);
|
||
if (cb) cb(ok, result);
|
||
return true;
|
||
}
|
||
|
||
void App::applyPendingSendBalanceDeltas(bool includeAggregateBalances)
|
||
{
|
||
for (const auto& [opid, pending] : pending_send_info_) {
|
||
(void)opid;
|
||
applyPendingSendDelta(pending.from, -pending.amount, includeAggregateBalances);
|
||
}
|
||
}
|
||
|
||
void App::resetWitnessRescanProgress()
|
||
{
|
||
// Common core of the four rescan-completion sites. Deliberately does NOT set
|
||
// rescan_progress (callers set it to 1.0 only on success) or runtime_rescan_active_
|
||
// (only the manual-rescan path uses it), and is distinct from the witness phase-transition
|
||
// reset in App::update() which sets witness_phase to the new phase rather than clearing it.
|
||
rescan_confirmed_active_ = false;
|
||
state_.sync.rescanning = false;
|
||
state_.sync.rescan_status.clear();
|
||
state_.sync.building_witnesses = false;
|
||
state_.sync.witness_phase = 0;
|
||
state_.sync.witness_progress = 0.0f;
|
||
state_.sync.witness_remaining = 0;
|
||
witness_rebuild_total_blocks_ = 0;
|
||
witness_seen_txids_.clear();
|
||
witness_total_txs_ = 0;
|
||
}
|
||
|
||
std::string App::transactionHistoryCacheWalletIdentity() const
|
||
{
|
||
std::vector<std::string> shieldedAddresses;
|
||
std::vector<std::string> transparentAddresses;
|
||
shieldedAddresses.reserve(state_.z_addresses.size());
|
||
transparentAddresses.reserve(state_.t_addresses.size());
|
||
for (const auto& address : state_.z_addresses) {
|
||
if (!address.address.empty()) shieldedAddresses.push_back(address.address);
|
||
}
|
||
for (const auto& address : state_.t_addresses) {
|
||
if (!address.address.empty()) transparentAddresses.push_back(address.address);
|
||
}
|
||
return data::TransactionHistoryCache::walletIdentityFromAddresses(
|
||
shieldedAddresses, transparentAddresses);
|
||
}
|
||
|
||
std::string App::activeWalletIdentityHash() const
|
||
{
|
||
const std::string identity = transactionHistoryCacheWalletIdentity();
|
||
if (identity.empty()) return std::string();
|
||
return data::TransactionHistoryCache::walletIdentityHash(identity);
|
||
}
|
||
|
||
std::string App::activeWalletScopeId()
|
||
{
|
||
if (!settings_) return std::string();
|
||
const std::string file = settings_->getActiveWalletFile();
|
||
if (file.empty()) return std::string();
|
||
|
||
// Reuse the persisted id if we have one for this wallet file.
|
||
if (const auto* existing = wallet_index_.find(file)) {
|
||
if (!existing->scopeId.empty()) return existing->scopeId;
|
||
}
|
||
|
||
// Establish one now: 16 random bytes, "w:"-prefixed so it's distinguishable from the legacy
|
||
// 64-hex address-hash scopes we migrate away from.
|
||
unsigned char rnd[16];
|
||
randombytes_buf(rnd, sizeof(rnd));
|
||
static const char* kHex = "0123456789abcdef";
|
||
std::string id = "w:";
|
||
for (unsigned char b : rnd) { id.push_back(kHex[b >> 4]); id.push_back(kHex[b & 0x0F]); }
|
||
|
||
data::WalletIndexEntry e;
|
||
if (const auto* existing = wallet_index_.find(file)) e = *existing;
|
||
e.fileName = file;
|
||
if (e.displayName.empty()) e.displayName = file;
|
||
e.scopeId = id;
|
||
if (wallet_index_.upsert(e)) wallet_index_.save();
|
||
return id;
|
||
}
|
||
|
||
void App::updateWalletIndexForActiveWallet(bool markOpened)
|
||
{
|
||
if (!settings_) return;
|
||
const std::string file = settings_->getActiveWalletFile();
|
||
if (file.empty()) return;
|
||
|
||
// Start from the existing entry so we preserve fields we're not updating this call.
|
||
data::WalletIndexEntry e;
|
||
if (const auto* existing = wallet_index_.find(file)) e = *existing;
|
||
e.fileName = file;
|
||
if (e.displayName.empty()) e.displayName = file;
|
||
|
||
// Balance + address count are only meaningful once the wallet's addresses are known (identity
|
||
// non-empty); at connect time they're still 0, so don't cache bogus zeros then.
|
||
const std::string idHash = state_.connected ? activeWalletIdentityHash() : std::string();
|
||
if (!idHash.empty()) {
|
||
e.walletIdentityHash = idHash;
|
||
e.cachedBalance = state_.totalBalance;
|
||
e.cachedAddressCount = static_cast<long long>(state_.z_addresses.size() + state_.t_addresses.size());
|
||
}
|
||
|
||
// File size is always readable from disk.
|
||
std::error_code ec;
|
||
const std::string path = util::Platform::getDragonXDataDir() + "/" + file;
|
||
if (std::filesystem::exists(path, ec)) {
|
||
auto sz = std::filesystem::file_size(path, ec);
|
||
if (!ec) e.sizeBytesAtLastOpen = static_cast<long long>(sz);
|
||
}
|
||
|
||
if (markOpened) {
|
||
e.lastOpenedEpoch = static_cast<long long>(std::time(nullptr));
|
||
e.syncedHere = true; // we've loaded it in this datadir -> catch-up (no full rescan) on switch
|
||
}
|
||
|
||
if (wallet_index_.upsert(e)) wallet_index_.save();
|
||
}
|
||
|
||
void App::switchToWallet(const std::string& walletFile, bool stopDaemonConfirmed, bool salvage)
|
||
{
|
||
if (!supportsFullNodeLifecycleActions()) {
|
||
ui::Notifications::instance().warning("Full-node lifecycle actions are unavailable in lite build");
|
||
return;
|
||
}
|
||
if (walletFile.empty() || !settings_) return;
|
||
if (settings_->getActiveWalletFile() == walletFile) {
|
||
ui::Notifications::instance().info("That wallet is already open.");
|
||
return;
|
||
}
|
||
if (daemon_restarting_) {
|
||
ui::Notifications::instance().warning("The node is busy restarting — try again in a moment.");
|
||
return;
|
||
}
|
||
// Don't race the other daemon-lifecycle operations. rescan/repair set state_.sync.rescanning; the
|
||
// seed-migration flow drives its own daemon stop/start; an encryption restart sets daemon_restarting_.
|
||
if (state_.sync.rescanning) {
|
||
ui::Notifications::instance().warning("A rescan or repair is in progress — try again once it finishes.");
|
||
return;
|
||
}
|
||
if (show_seed_migration_) {
|
||
ui::Notifications::instance().warning("Finish or cancel the seed migration before switching wallets.");
|
||
return;
|
||
}
|
||
if (!isUsingEmbeddedDaemon()) {
|
||
ui::Notifications::instance().warning("Switching wallets needs the embedded daemon — stop any external dragonxd first.");
|
||
return;
|
||
}
|
||
if (hasTransactionSendProgress()) {
|
||
ui::Notifications::instance().warning("Finish or cancel the pending send before switching wallets.");
|
||
return;
|
||
}
|
||
// If we're connected to a node this session did NOT spawn (no live process handle — it was left
|
||
// running by "keep node running", started by the user, or we just direct-connected to a config-provided
|
||
// one), confirm before stopping it: switching must stop+restart it on the new wallet, but the user may
|
||
// have kept it running on purpose. A node we started ourselves (handle held) restarts silently.
|
||
if (!stopDaemonConfirmed && state_.connected && !isEmbeddedDaemonRunning()) {
|
||
pending_switch_wallet_file_ = walletFile;
|
||
show_switch_stop_daemon_confirm_ = true;
|
||
return;
|
||
}
|
||
|
||
// Rescan only if this wallet was never synced in this datadir (freshly imported/new). One we've
|
||
// loaded here before just catches up from its recorded block on start — fast.
|
||
bool needRescan = true;
|
||
if (const auto* e = wallet_index_.find(walletFile)) needRescan = !e->syncedHere;
|
||
|
||
const std::string prevWallet = settings_->getActiveWalletFile(); // for revert if the new one fails
|
||
wallet_switch_prev_file_ = prevWallet;
|
||
wallet_switch_target_file_ = walletFile; // remembered for a "salvage" retry from the failure modal
|
||
wallet_switch_failed_.store(false);
|
||
switch_stop_failed_.store(false); // reason latch for the revert message; a stale one must not leak
|
||
switch_wallet_corrupt_.store(false);
|
||
wallet_switch_pending_confirm_.store(true); // cleared on a successful connect (onConnected)
|
||
settings_->setActiveWalletFile(walletFile);
|
||
settings_->save();
|
||
// A prior crash-wedge shouldn't block reconnecting to the freshly-launched wallet daemon.
|
||
if (daemon_controller_) daemon_controller_->resetCrashCount();
|
||
|
||
// Re-scope the PIN vault to the new wallet (its own vault-<scope>.dat), and clear any carried-over
|
||
// lock-screen attempt/lockout state + wipe the passphrase/PIN entry buffers — the previous wallet's
|
||
// failed-unlock counter and typed secrets must not apply to the new wallet.
|
||
if (vault_) vault_->setWalletScope(walletFile);
|
||
lock_attempts_ = 0;
|
||
lock_lockout_timer_ = 0.0f;
|
||
lock_error_msg_.clear();
|
||
lock_error_timer_ = 0.0f;
|
||
lock_unlock_in_progress_ = false;
|
||
util::SecureVault::secureZero(lock_passphrase_buf_, sizeof(lock_passphrase_buf_));
|
||
util::SecureVault::secureZero(lock_pin_buf_, sizeof(lock_pin_buf_));
|
||
|
||
// Same restart coordination as the seed-adopt flow: gate the main-loop reconnect, disconnect,
|
||
// then stop + restart on a background thread. syncSettings() re-reads active_wallet_file on
|
||
// start, so the daemon comes up on -wallet=<name>. WalletState clears on disconnect and the
|
||
// per-wallet caches re-key off the new identity (P1), so no previous-wallet data leaks through.
|
||
daemon_restarting_ = true;
|
||
if (rpc_ && rpc_->isConnected()) rpc_->disconnect();
|
||
onDisconnected("Switching wallet");
|
||
// The loaded wallet is changing — drop the old wallet's chat identity + decrypted store so it
|
||
// can't surface (or sign outgoing chat) under the new wallet. It re-provisions from the new
|
||
// wallet's seed once it connects. onDisconnected alone doesn't cover chat.
|
||
resetChatSession();
|
||
// Open the switch progress modal — it stays up through stop → wait-for-exit → start → reconnect and
|
||
// auto-closes when the new node connects (onConnected). The worker advances the phase from here.
|
||
wallet_switch_error_.clear();
|
||
wallet_switch_started_time_ = 0.0; // re-captured on the first progress frame → elapsed starts fresh
|
||
wallet_switch_phase_.store(static_cast<int>(WalletSwitchPhase::Stopping));
|
||
wallet_switch_dialog_open_.store(true);
|
||
|
||
async_tasks_.submit("Switch wallet", [this, needRescan, salvage](const util::AsyncTaskManager::Token&) {
|
||
bool ok = false;
|
||
// Watermark the node's captured console output so we scan only THIS start's lines for a
|
||
// corruption signature (below), not stale output from a previous daemon.
|
||
std::size_t out_off = 0;
|
||
if (daemon_controller_) daemon_controller_->outputSince(out_off);
|
||
try {
|
||
// Stop the node so it releases the datadir .lock + RPC port before we relaunch. For an adopted
|
||
// (external) daemon this sends a graceful RPC "stop" — stopEmbeddedDaemon()'s policy would leave
|
||
// it running — and then waits for the RPC PORT to actually free, the correct readiness signal
|
||
// (isEmbeddedDaemonRunning() is process-handle-only and reads false immediately for an adopted
|
||
// daemon). The wait breaks promptly on shutdown so beginShutdown's join() doesn't freeze the UI.
|
||
const bool port_free = stopDaemonForWalletSwitch();
|
||
if (!port_free) {
|
||
// The old node wouldn't release the port in time — don't start() into a busy port (that
|
||
// fast-fails on the isPortInUse check and gets misread as a bad wallet). Revert with an
|
||
// accurate, distinct reason instead.
|
||
switch_stop_failed_.store(true);
|
||
ok = false;
|
||
} else {
|
||
// The relaunch is ours — clear the adopted-external latch so the fresh process is treated
|
||
// as owned (stop/isRunning/exit behave normally for the next switch and app exit).
|
||
wallet_switch_phase_.store(static_cast<int>(WalletSwitchPhase::Starting));
|
||
if (daemon_controller_) daemon_controller_->clearExternalDaemonDetected();
|
||
if (salvage && daemon_controller_) daemon_controller_->setSalvageOnNextStart(true); // repair a corrupt wallet
|
||
else if (needRescan && daemon_controller_) daemon_controller_->setRescanOnNextStart(true); // (salvage implies rescan)
|
||
// Start ONCE. Do NOT retry-spawn: a second start while the first is still shutting down leaves
|
||
// two dragonxd holding wallet.dat against each other (BDB "Failed to rename … Error"). The
|
||
// stopDaemonForWalletSwitch() wait already ensured the old node's process is gone, so a valid
|
||
// wallet opens cleanly; a bad/corrupt wallet exits during init and we revert.
|
||
ok = shutting_down_ ? true : startEmbeddedDaemon();
|
||
// A missing/corrupt wallet makes dragonxd exit during init — confirm the process survived a
|
||
// moment; if not, the wallet is bad. (A valid launch keeps the process alive while it syncs.)
|
||
if (ok && !shutting_down_) {
|
||
std::this_thread::sleep_for(std::chrono::milliseconds(1500));
|
||
if (!isEmbeddedDaemonRunning()) ok = false;
|
||
}
|
||
// If it died in init, scan this start's captured output for a corrupt-wallet signature so the
|
||
// failure modal can offer a repair. Give the dying node a moment to flush its final lines.
|
||
if (!ok && !shutting_down_ && daemon_controller_) {
|
||
for (int i = 0; i < 12 && !shutting_down_; ++i) std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||
if (walletOutputLooksCorrupt(daemon_controller_->outputSince(out_off)))
|
||
switch_wallet_corrupt_.store(true);
|
||
}
|
||
// Process is up and staying up — now waiting for RPC to answer (onConnected closes the modal).
|
||
if (ok) wallet_switch_phase_.store(static_cast<int>(WalletSwitchPhase::Reconnecting));
|
||
}
|
||
} catch (...) {
|
||
ok = false; // a throw during stop/start is a failed switch — revert rather than wedge
|
||
}
|
||
if (ok) {
|
||
daemon_restarting_ = false; // re-arm reconnect once the new daemon is up
|
||
} else {
|
||
// Leave the reconnect GATED (daemon_restarting_ stays true) and hand the revert to the main
|
||
// thread — settings writes + vault re-scope must not run on this worker thread. The revert
|
||
// re-arms the gate, so it can never get stuck true.
|
||
wallet_switch_failed_.store(true);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Main-thread continuation for a failed wallet switch: restore the previous wallet file so a broken
|
||
// active_wallet_file isn't persisted, re-scope the vault back, and re-arm the reconnect so the connect
|
||
// loop brings the (good) previous wallet's daemon back up. Called each tick from update().
|
||
void App::processWalletSwitchRevert()
|
||
{
|
||
if (!wallet_switch_failed_.exchange(false)) return;
|
||
wallet_switch_pending_confirm_.store(false);
|
||
if (settings_ && !wallet_switch_prev_file_.empty() &&
|
||
settings_->getActiveWalletFile() != wallet_switch_prev_file_) {
|
||
settings_->setActiveWalletFile(wallet_switch_prev_file_);
|
||
settings_->save();
|
||
if (vault_) vault_->setWalletScope(wallet_switch_prev_file_);
|
||
}
|
||
// Clear the crash-wedge so the (good) previous wallet's daemon is allowed to start again.
|
||
if (daemon_controller_) daemon_controller_->resetCrashCount();
|
||
// Accurate reason: "port wouldn't free" (the running node kept the connection); "wallet is corrupt"
|
||
// (the node's recovery couldn't open it); else a generic bad-wallet failure.
|
||
const std::string reason =
|
||
switch_stop_failed_.exchange(false)
|
||
? std::string("Couldn't switch wallets — the running node didn't release its connection in time. "
|
||
"It's still on the previous wallet.")
|
||
: switch_wallet_corrupt_.load()
|
||
? std::string(TR("switch_corrupt_body"))
|
||
: std::string("Couldn't open that wallet — reverted to the previous one.");
|
||
// Surface it in the progress modal if it's still up (keep it open on Failed until the user closes);
|
||
// otherwise (the user chose "continue in background") fall back to a toast.
|
||
if (wallet_switch_dialog_open_.load()) {
|
||
wallet_switch_error_ = reason;
|
||
wallet_switch_phase_.store(static_cast<int>(WalletSwitchPhase::Failed));
|
||
} else {
|
||
ui::Notifications::instance().error(reason);
|
||
}
|
||
daemon_restarting_ = false; // the connect loop now restarts the previous wallet's daemon
|
||
}
|
||
|
||
int App::chatUnreadCount() const
|
||
{
|
||
if (!chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return 0;
|
||
int unread = 0;
|
||
const auto& store = chat_service_.store();
|
||
for (const auto& cid : store.conversationIds()) {
|
||
if (settings_ && settings_->isChatMuted(cid)) continue; // muted conversations don't badge (Q10)
|
||
if (settings_ && settings_->isChatHidden(cid)) continue; // hidden conversations don't badge
|
||
std::int64_t seen = 0;
|
||
const auto it = chat_seen_watermark_.find(cid);
|
||
if (it != chat_seen_watermark_.end()) seen = it->second;
|
||
for (const auto& m : store.conversation(cid))
|
||
if (m.direction == chat::ChatDirection::Incoming && m.timestamp > seen) ++unread;
|
||
}
|
||
return unread;
|
||
}
|
||
|
||
void App::markChatConversationSeen(const std::string& cid, std::int64_t latestTs)
|
||
{
|
||
if (latestTs > 0) chat_seen_watermark_[cid] = latestTs;
|
||
}
|
||
|
||
void App::wipePendingTransactionHistoryCachePassphrase()
|
||
{
|
||
if (!pending_transaction_history_cache_passphrase_.empty()) {
|
||
util::SecureVault::secureZero(pending_transaction_history_cache_passphrase_.data(),
|
||
pending_transaction_history_cache_passphrase_.size());
|
||
pending_transaction_history_cache_passphrase_.clear();
|
||
}
|
||
}
|
||
|
||
void App::resetTransactionHistoryCacheSession()
|
||
{
|
||
transaction_history_cache_.lockKey();
|
||
wipePendingTransactionHistoryCachePassphrase();
|
||
transaction_history_cache_loaded_ = false;
|
||
invalidateShieldedHistoryScanProgress(false);
|
||
}
|
||
|
||
void App::pruneShieldedHistoryScanProgress()
|
||
{
|
||
std::unordered_set<std::string> currentShieldedAddresses;
|
||
currentShieldedAddresses.reserve(state_.z_addresses.size());
|
||
for (const auto& address : state_.z_addresses) {
|
||
if (!address.address.empty()) currentShieldedAddresses.insert(address.address);
|
||
}
|
||
|
||
for (auto it = shielded_history_scan_heights_.begin(); it != shielded_history_scan_heights_.end();) {
|
||
if (currentShieldedAddresses.find(it->first) == currentShieldedAddresses.end()) {
|
||
it = shielded_history_scan_heights_.erase(it);
|
||
} else {
|
||
++it;
|
||
}
|
||
}
|
||
}
|
||
|
||
void App::invalidateShieldedHistoryScanProgress(bool persistCache)
|
||
{
|
||
shielded_history_scan_cursor_ = 0;
|
||
shielded_history_scan_pending_ = false;
|
||
initial_history_scan_complete_ = false; // a full re-scan is coming — show load progress again
|
||
shielded_history_scan_heights_.clear();
|
||
if (persistCache) storeTransactionHistoryCacheIfAvailable();
|
||
}
|
||
|
||
bool App::ensureTransactionHistoryCacheUnlockedFor(const std::string& walletIdentity)
|
||
{
|
||
if (walletIdentity.empty()) return false;
|
||
if (transaction_history_cache_.isUnlockedFor(walletIdentity)) return true;
|
||
|
||
if (!pending_transaction_history_cache_passphrase_.empty()) {
|
||
std::string passphrase = pending_transaction_history_cache_passphrase_;
|
||
bool unlocked = transaction_history_cache_.unlockWithPassphrase(walletIdentity, passphrase);
|
||
if (unlocked) wipePendingTransactionHistoryCachePassphrase();
|
||
util::SecureVault::secureZero(passphrase.data(), passphrase.size());
|
||
if (unlocked) return true;
|
||
}
|
||
|
||
if (state_.encryption_state_known && !state_.encrypted) {
|
||
std::string cacheKey = unencryptedTransactionHistoryCacheKey(walletIdentity);
|
||
bool unlocked = transaction_history_cache_.unlockWithPassphrase(walletIdentity, cacheKey);
|
||
util::SecureVault::secureZero(cacheKey.data(), cacheKey.size());
|
||
return unlocked;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
void App::unlockTransactionHistoryCacheWithPassphrase(const std::string& passphrase)
|
||
{
|
||
if (passphrase.empty()) return;
|
||
|
||
std::string walletIdentity = transactionHistoryCacheWalletIdentity();
|
||
if (walletIdentity.empty()) {
|
||
wipePendingTransactionHistoryCachePassphrase();
|
||
pending_transaction_history_cache_passphrase_ = passphrase;
|
||
return;
|
||
}
|
||
|
||
if (transaction_history_cache_.unlockWithPassphrase(walletIdentity, passphrase)) {
|
||
wipePendingTransactionHistoryCachePassphrase();
|
||
if (state_.transactions.empty()) loadTransactionHistoryCacheIfAvailable();
|
||
else storeTransactionHistoryCacheIfAvailable();
|
||
}
|
||
}
|
||
|
||
void App::loadTransactionHistoryCacheIfAvailable()
|
||
{
|
||
if (transaction_history_cache_loaded_ || !state_.transactions.empty()) return;
|
||
|
||
std::string walletIdentity = transactionHistoryCacheWalletIdentity();
|
||
if (walletIdentity.empty()) return;
|
||
|
||
if (!ensureTransactionHistoryCacheUnlockedFor(walletIdentity)) return;
|
||
|
||
auto loaded = transaction_history_cache_.load(walletIdentity,
|
||
state_.sync.blocks,
|
||
state_.sync.best_blockhash);
|
||
if (!loaded.loaded) return;
|
||
|
||
state_.transactions = std::move(loaded.transactions);
|
||
shielded_history_scan_heights_ = std::move(loaded.shieldedScanHeights);
|
||
pruneShieldedHistoryScanProgress();
|
||
state_.last_tx_update = loaded.updatedAt;
|
||
last_tx_block_height_ = loaded.tipHeight;
|
||
confirmed_tx_cache_.clear();
|
||
confirmed_tx_ids_.clear();
|
||
for (const auto& transaction : state_.transactions) {
|
||
if (transaction.confirmations >= 10 && transaction.timestamp != 0) {
|
||
confirmed_tx_ids_.insert(transaction.txid);
|
||
confirmed_tx_cache_.push_back(transaction);
|
||
}
|
||
}
|
||
confirmed_cache_block_ = loaded.tipHeight;
|
||
transaction_history_cache_loaded_ = true;
|
||
transactions_dirty_ = true;
|
||
network_refresh_.markDue(services::NetworkRefreshService::Timer::Transactions);
|
||
}
|
||
|
||
void App::storeTransactionHistoryCacheIfAvailable()
|
||
{
|
||
if (state_.transactions.empty()) return;
|
||
|
||
std::string walletIdentity = transactionHistoryCacheWalletIdentity();
|
||
if (walletIdentity.empty()) return;
|
||
|
||
if (!ensureTransactionHistoryCacheUnlockedFor(walletIdentity)) return;
|
||
pruneShieldedHistoryScanProgress();
|
||
|
||
std::unordered_set<std::string> pendingOpids(pending_opids_.begin(), pending_opids_.end());
|
||
std::vector<TransactionInfo> cacheTransactions;
|
||
cacheTransactions.reserve(state_.transactions.size());
|
||
for (const auto& transaction : state_.transactions) {
|
||
if (pendingOpids.find(transaction.txid) != pendingOpids.end()) continue;
|
||
cacheTransactions.push_back(transaction);
|
||
}
|
||
if (cacheTransactions.empty()) return;
|
||
|
||
std::time_t updatedAt = state_.last_tx_update != 0
|
||
? static_cast<std::time_t>(state_.last_tx_update)
|
||
: std::time(nullptr);
|
||
transaction_history_cache_.replace(walletIdentity,
|
||
state_.sync.blocks,
|
||
state_.sync.best_blockhash,
|
||
cacheTransactions,
|
||
updatedAt,
|
||
shielded_history_scan_heights_);
|
||
}
|
||
|
||
void App::refreshData()
|
||
{
|
||
if (!state_.connected || !rpc_ || !worker_) return;
|
||
|
||
// During warmup, only poll for warmup completion via refreshCoreData.
|
||
// Other RPC calls (balance, addresses, transactions) will fail with -28.
|
||
if (state_.warming_up) {
|
||
refreshCoreData();
|
||
return;
|
||
}
|
||
|
||
// Dispatch each category independently — results trickle into the UI
|
||
// as each completes, rather than waiting for the slowest phase.
|
||
refreshCoreData();
|
||
|
||
bool addressRefreshNeeded = addresses_dirty_;
|
||
bool walletDataPage = currentPageNeedsWalletDataRefresh();
|
||
if (addressRefreshNeeded)
|
||
refreshAddressData();
|
||
|
||
if (!addressRefreshNeeded && shouldRunWalletTransactionRefresh() && shouldRefreshTransactions())
|
||
refreshTransactionData();
|
||
else if (!addressRefreshNeeded && walletDataPage && shouldRefreshRecentTransactions())
|
||
refreshRecentTransactionData();
|
||
|
||
// Always refresh peers here — refreshData() only runs on one-shot transitions (connect,
|
||
// warmup-complete, unlock), so this populates the status-bar peer count immediately on open
|
||
// regardless of the active tab. Ongoing updates come from the periodic Peers timer.
|
||
refreshPeerInfo();
|
||
|
||
if (!state_.encryption_state_known &&
|
||
!network_refresh_.jobInProgress(services::NetworkRefreshService::Job::ConnectionInit)) {
|
||
encryption_state_prefetched_ = refreshEncryptionState();
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// Core Data: balance + blockchain info (~50-100ms, 2 RPC calls)
|
||
// Uses fast_worker_ when on Overview tab for lower latency.
|
||
// ============================================================================
|
||
|
||
void App::refreshCoreData()
|
||
{
|
||
if (!state_.connected) return;
|
||
|
||
// During warmup, poll getinfo to detect when warmup ends.
|
||
// Most RPC calls (balance, blockchain info) will fail with -28 during warmup.
|
||
if (state_.warming_up) {
|
||
if (!worker_) return;
|
||
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Core, *worker_, [this]() -> rpc::RPCWorker::MainCb {
|
||
AppRefreshRpcGateway refreshRpc(*rpc_, "Startup / Warmup poll");
|
||
auto result = NetworkRefreshService::collectWarmupPollResult(refreshRpc);
|
||
return [this, result = std::move(result)]() {
|
||
if (result.ready) {
|
||
// Warmup finished — daemon is fully ready
|
||
state_.warming_up = false;
|
||
state_.warmup_status.clear();
|
||
state_.warmup_description.clear();
|
||
connection_status_ = TR("connected");
|
||
VERBOSE_LOGF("[warmup] Daemon ready, warmup complete\n");
|
||
|
||
// A -rescan runs entirely INSIDE daemon warmup (every RPC returns -28 until the
|
||
// scan finishes), so warmup completing IS the rescan completing. This is the
|
||
// reliable completion signal: some daemons lack getrescaninfo (it returns
|
||
// "Method not found") or never print a "Done rescanning"/bench line, which left
|
||
// the older detectors stuck at 99% — the user would then kill it prematurely.
|
||
// rescan_confirmed_active_ ensures we actually observed this rescan running (set
|
||
// by the getrescaninfo / daemon-log pollers) before declaring it done.
|
||
if (state_.sync.rescanning && rescan_confirmed_active_) {
|
||
resetWitnessRescanProgress();
|
||
state_.sync.rescan_progress = 1.0f;
|
||
// Notes/witnesses were rebuilt — force a fresh history + balance pull.
|
||
transactions_dirty_ = true;
|
||
last_tx_block_height_ = -1;
|
||
invalidateShieldedHistoryScanProgress(true);
|
||
ui::Notifications::instance().success("Blockchain rescan complete");
|
||
}
|
||
|
||
NetworkRefreshService::applyConnectionInfoResult(state_, result.info);
|
||
// Trigger full data refresh now that daemon is ready
|
||
refreshData();
|
||
} else {
|
||
// Still warming up — update status
|
||
auto wt = translateWarmup(result.errorMessage);
|
||
state_.warmup_status = wt.title;
|
||
state_.warmup_description = wt.description;
|
||
if (daemon_controller_) {
|
||
int h = daemon_controller_->lastBlockHeight();
|
||
if (h > 0)
|
||
state_.warmup_status += " (Block " + std::to_string(h) + ")";
|
||
}
|
||
connection_status_ = state_.warmup_status;
|
||
VERBOSE_LOGF("[warmup] Still warming up: %s\n", result.errorMessage.c_str());
|
||
}
|
||
};
|
||
}, 3);
|
||
if (!enqueued.enqueued) return;
|
||
return;
|
||
}
|
||
|
||
// Use fast-lane on Overview for snappier balance updates
|
||
bool useFast = (current_page_ == ui::NavPage::Overview);
|
||
auto* w = useFast && fast_worker_ && fast_worker_->isRunning()
|
||
? fast_worker_.get() : worker_.get();
|
||
auto* rpc = useFast && fast_rpc_ && fast_rpc_->isConnected()
|
||
? fast_rpc_.get() : rpc_.get();
|
||
if (!w || !rpc) return;
|
||
ui::NavPage tracePage = current_page_;
|
||
// Skip the balance call while syncing (it's incomplete anyway and takes the wallet lock +
|
||
// cs_main). Captured on the main thread to avoid reading state_ off the worker thread.
|
||
const bool includeBalance = !state_.sync.syncing;
|
||
|
||
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Core, *w, [this, rpc, tracePage, includeBalance]() -> rpc::RPCWorker::MainCb {
|
||
AppRefreshRpcGateway refreshRpc(*rpc, traceSource(tracePage, "Core refresh"));
|
||
auto result = NetworkRefreshService::collectCoreRefreshResult(refreshRpc, includeBalance);
|
||
return [this, result]() {
|
||
try {
|
||
NetworkRefreshService::applyCoreRefreshResult(state_, result, std::time(nullptr));
|
||
applyPendingSendBalanceDeltas(true);
|
||
|
||
// Mid-session connection-loss detection. During normal operation, both core
|
||
// RPCs failing together means the daemon connection is dead (a busy daemon
|
||
// fails them individually, not both at once). Warmup is excluded — both fail
|
||
// with -28 there legitimately, and counting it would cause a reconnect loop.
|
||
constexpr int kCoreFailuresBeforeDisconnect = 3;
|
||
if (!state_.warming_up) {
|
||
if (!result.balanceOk && !result.blockchainOk) {
|
||
if (++consecutive_core_failures_ >= kCoreFailuresBeforeDisconnect &&
|
||
state_.connected) {
|
||
consecutive_core_failures_ = 0;
|
||
handleLostConnection("Lost connection to daemon");
|
||
return; // state torn down — skip the rest of this callback
|
||
}
|
||
} else {
|
||
consecutive_core_failures_ = 0;
|
||
}
|
||
}
|
||
|
||
// Auto-shield transparent funds if enabled
|
||
if (result.balanceOk && settings_ && settings_->getAutoShield() &&
|
||
state_.transparent_balance > 0.0001 && !state_.sync.syncing &&
|
||
!auto_shield_pending_.exchange(true)) {
|
||
std::string targetZAddr;
|
||
for (const auto& addr : state_.addresses) {
|
||
if (addr.isShielded()) {
|
||
targetZAddr = addr.address;
|
||
break;
|
||
}
|
||
}
|
||
if (!targetZAddr.empty() && worker_) {
|
||
DEBUG_LOGF("[AutoShield] Shielding %.8f DRGX to %s\n",
|
||
state_.transparent_balance, targetZAddr.c_str());
|
||
// Use the user-configured fee, formatted fixed-decimal so the daemon's
|
||
// ParseFixedPoint accepts it (a small double would serialize to "5e-05").
|
||
const std::string feeStr =
|
||
util::formatAmountFixed(settings_ ? settings_->getDefaultFee() : 0.0001);
|
||
// This callback runs on the UI thread (drainResults). Build/broadcast
|
||
// on the worker thread — never block the UI with synchronous RPC.
|
||
worker_->post([this, targetZAddr, feeStr]() -> rpc::RPCWorker::MainCb {
|
||
std::string opid;
|
||
std::string err;
|
||
try {
|
||
rpc::RPCClient::TraceScope trace("Auto-shield / z_shieldcoinbase");
|
||
auto result = rpc_->call("z_shieldcoinbase",
|
||
{std::string("*"), targetZAddr, feeStr, 50});
|
||
opid = result.value("opid", "");
|
||
} catch (const std::exception& e) {
|
||
err = e.what();
|
||
}
|
||
return [this, opid, err]() {
|
||
auto_shield_pending_ = false;
|
||
if (!err.empty()) {
|
||
DEBUG_LOGF("[AutoShield] Error: %s\n", err.c_str());
|
||
return;
|
||
}
|
||
if (!opid.empty()) {
|
||
DEBUG_LOGF("[AutoShield] Started: %s\n", opid.c_str());
|
||
// Surface the async result + refresh balances on completion.
|
||
trackOperation(opid);
|
||
}
|
||
};
|
||
});
|
||
} else {
|
||
auto_shield_pending_ = false;
|
||
}
|
||
}
|
||
} catch (const std::exception& e) {
|
||
DEBUG_LOGF("[refreshCoreData] callback error: %s\n", e.what());
|
||
}
|
||
};
|
||
}, 3);
|
||
if (!enqueued.enqueued) return;
|
||
}
|
||
|
||
// ============================================================================
|
||
// Address Data: z/t address lists + per-address balances
|
||
// ============================================================================
|
||
|
||
void App::refreshAddressData()
|
||
{
|
||
if (!worker_ || !rpc_ || !state_.connected) return;
|
||
const std::size_t previousAddressCount = state_.z_addresses.size() + state_.t_addresses.size();
|
||
const std::string previousWalletIdentity = transactionHistoryCacheWalletIdentity();
|
||
auto addressSnapshot = address_validation_cache_dirty_
|
||
? NetworkRefreshService::AddressRefreshSnapshot{}
|
||
: NetworkRefreshService::buildAddressRefreshSnapshot(state_);
|
||
ui::NavPage tracePage = current_page_;
|
||
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Addresses, *worker_, [this, previousAddressCount, previousWalletIdentity, addressSnapshot = std::move(addressSnapshot), tracePage]() -> rpc::RPCWorker::MainCb {
|
||
AppRefreshRpcGateway refreshRpc(*rpc_, traceSource(tracePage, "Address refresh"));
|
||
auto result = NetworkRefreshService::collectAddressRefreshResult(refreshRpc, addressSnapshot);
|
||
|
||
return [this, previousAddressCount, previousWalletIdentity, result = std::move(result)]() mutable {
|
||
NetworkRefreshService::applyAddressRefreshResult(state_, std::move(result));
|
||
applyPendingSendBalanceDeltas(false);
|
||
address_validation_cache_dirty_ = false;
|
||
address_list_dirty_ = true;
|
||
addresses_dirty_ = false;
|
||
const std::size_t currentAddressCount = state_.z_addresses.size() + state_.t_addresses.size();
|
||
const bool addressSetChanged = currentAddressCount != previousAddressCount ||
|
||
transactionHistoryCacheWalletIdentity() != previousWalletIdentity;
|
||
if (state_.transactions.empty() || addressSetChanged) {
|
||
if (addressSetChanged) {
|
||
invalidateShieldedHistoryScanProgress(false);
|
||
}
|
||
transactions_dirty_ = true;
|
||
last_tx_block_height_ = -1;
|
||
network_refresh_.markDue(services::NetworkRefreshService::Timer::Transactions);
|
||
}
|
||
if (state_.transactions.empty()) loadTransactionHistoryCacheIfAvailable();
|
||
else storeTransactionHistoryCacheIfAvailable();
|
||
maybeFinishTransactionSendProgress();
|
||
// Addresses (hence identity + count) are now known — refresh the wallet index's cached
|
||
// metadata for the active wallet. Throttled: upsert() only writes on a real change.
|
||
updateWalletIndexForActiveWallet(/*markOpened=*/false);
|
||
};
|
||
}, 3);
|
||
if (!enqueued.enqueued) return;
|
||
}
|
||
|
||
// ============================================================================
|
||
// Transaction Data: transparent + shielded receives + z_viewtransaction enrichment
|
||
// ============================================================================
|
||
|
||
void App::refreshTransactionData()
|
||
{
|
||
if (!worker_ || !rpc_ || !state_.connected) return;
|
||
if (addresses_dirty_) {
|
||
refreshAddressData();
|
||
network_refresh_.markDue(services::NetworkRefreshService::Timer::Transactions);
|
||
return;
|
||
}
|
||
|
||
const int currentBlocks = state_.sync.blocks;
|
||
if (last_tx_block_height_ < 0 || currentBlocks != last_tx_block_height_ ||
|
||
!shielded_history_scan_pending_) {
|
||
shielded_history_scan_cursor_ = 0;
|
||
shielded_history_scan_pending_ = false;
|
||
}
|
||
auto transactionSnapshot = NetworkRefreshService::buildTransactionRefreshSnapshot(
|
||
state_, viewtx_cache_, send_txids_);
|
||
transactionSnapshot.pendingOpids.insert(pending_opids_.begin(), pending_opids_.end());
|
||
if (settings_) transactionSnapshot.miningAddresses = settings_->getMiningAddresses();
|
||
transactionSnapshot.shieldedScanHeights = shielded_history_scan_heights_;
|
||
transactionSnapshot.shieldedScanStartIndex = shielded_history_scan_cursor_;
|
||
transactionSnapshot.maxShieldedReceiveScans = shieldedReceiveScanBudget(current_page_);
|
||
transactionSnapshot.shieldedScanTipTolerance =
|
||
shieldedScanTipTolerance(transactionSnapshot.shieldedAddresses.size());
|
||
ui::NavPage tracePage = current_page_;
|
||
|
||
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Transactions, *worker_, [this, currentBlocks,
|
||
transactionSnapshot = std::move(transactionSnapshot), tracePage]() -> rpc::RPCWorker::MainCb {
|
||
AppRefreshRpcGateway refreshRpc(*rpc_, traceSource(tracePage, "Transaction refresh"));
|
||
auto result = NetworkRefreshService::collectTransactionRefreshResult(
|
||
refreshRpc, transactionSnapshot, currentBlocks, MAX_VIEWTX_PER_CYCLE);
|
||
|
||
return [this, result = std::move(result)]() mutable {
|
||
bool shieldedScanComplete = result.shieldedScanComplete;
|
||
std::size_t nextShieldedScanStartIndex = result.nextShieldedScanStartIndex;
|
||
auto shieldedScanHeights = std::move(result.shieldedScanHeights);
|
||
NetworkRefreshService::TransactionCacheUpdate cacheUpdate{
|
||
viewtx_cache_,
|
||
send_txids_,
|
||
confirmed_tx_cache_,
|
||
confirmed_tx_ids_,
|
||
confirmed_cache_block_,
|
||
last_tx_block_height_
|
||
};
|
||
// HushChat: decrypt & thread any harvested chat memos BEFORE `result` is moved into
|
||
// applyTransactionRefreshResult. No-op when the feature is off or no identity is set;
|
||
// the store dedups (txid+position) so the full + recent refresh paths ingest safely.
|
||
if (chat::hushChatFeatureEnabledAtBuild() && chat_service_.hasIdentity() &&
|
||
!result.hushChatMetadata.empty()) {
|
||
std::unordered_map<std::string, std::int64_t> chatTxTimes;
|
||
for (const auto& tx : result.transactions) chatTxTimes[tx.txid] = tx.timestamp;
|
||
std::vector<std::string> newChatCids;
|
||
chat_service_.ingest(result.hushChatMetadata, chatTxTimes, std::time(nullptr), &newChatCids);
|
||
// A new message un-hides a hidden conversation (you can't un-receive), so nothing is lost.
|
||
if (settings_) {
|
||
bool unhid = false;
|
||
for (const auto& cid : newChatCids)
|
||
if (settings_->isChatHidden(cid)) { settings_->setChatHidden(cid, false); unhid = true; }
|
||
if (unhid) settings_->save();
|
||
}
|
||
// Toast when a NON-muted conversation received a new message and we're off the Chat tab.
|
||
// Gate on the ingest-reported cids, not a seen-watermark delta — the latter can be swallowed
|
||
// by block-time vs wall-clock (echo) skew (Q10 + Q4).
|
||
if (current_page_ != ui::NavPage::Chat &&
|
||
std::any_of(newChatCids.begin(), newChatCids.end(),
|
||
[this](const std::string& cid){ return !(settings_ && settings_->isChatMuted(cid)); }))
|
||
ui::Notifications::instance().info(TR("chat_new_message_toast"));
|
||
}
|
||
NetworkRefreshService::applyTransactionRefreshResult(
|
||
state_, cacheUpdate, std::move(result), std::time(nullptr));
|
||
shielded_history_scan_heights_ = std::move(shieldedScanHeights);
|
||
storeTransactionHistoryCacheIfAvailable();
|
||
shielded_history_scan_cursor_ = nextShieldedScanStartIndex;
|
||
shielded_history_scan_pending_ = !shieldedScanComplete;
|
||
if (shieldedScanComplete) initial_history_scan_complete_ = true;
|
||
transactions_dirty_ = !shieldedScanComplete;
|
||
maybeFinishTransactionSendProgress();
|
||
};
|
||
}, 3);
|
||
if (!enqueued.enqueued) return;
|
||
|
||
network_refresh_.resetTxAge();
|
||
}
|
||
|
||
void App::refreshRecentTransactionData()
|
||
{
|
||
if (!worker_ || !rpc_ || !state_.connected) return;
|
||
if (!shouldRefreshRecentTransactions()) return;
|
||
|
||
const int currentBlocks = state_.sync.blocks;
|
||
auto transactionSnapshot = NetworkRefreshService::buildTransactionRefreshSnapshot(
|
||
state_, viewtx_cache_, send_txids_);
|
||
transactionSnapshot.pendingOpids.insert(pending_opids_.begin(), pending_opids_.end());
|
||
if (settings_) transactionSnapshot.miningAddresses = settings_->getMiningAddresses();
|
||
transactionSnapshot.shieldedScanHeights = shielded_history_scan_heights_;
|
||
transactionSnapshot.shieldedScanStartIndex = shielded_history_scan_cursor_;
|
||
transactionSnapshot.maxShieldedReceiveScans = 1;
|
||
transactionSnapshot.shieldedScanTipTolerance =
|
||
shieldedScanTipTolerance(transactionSnapshot.shieldedAddresses.size());
|
||
ui::NavPage tracePage = current_page_;
|
||
|
||
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Transactions, *worker_, [this, currentBlocks,
|
||
transactionSnapshot = std::move(transactionSnapshot), tracePage]() -> rpc::RPCWorker::MainCb {
|
||
AppRefreshRpcGateway refreshRpc(*rpc_, traceSource(tracePage, "Recent transaction poll"));
|
||
auto result = NetworkRefreshService::collectRecentTransactionRefreshResult(
|
||
refreshRpc, transactionSnapshot, currentBlocks);
|
||
|
||
return [this, result = std::move(result)]() mutable {
|
||
std::size_t nextShieldedScanStartIndex = result.nextShieldedScanStartIndex;
|
||
auto shieldedScanHeights = std::move(result.shieldedScanHeights);
|
||
NetworkRefreshService::TransactionCacheUpdate cacheUpdate{
|
||
viewtx_cache_,
|
||
send_txids_,
|
||
confirmed_tx_cache_,
|
||
confirmed_tx_ids_,
|
||
confirmed_cache_block_,
|
||
last_tx_block_height_
|
||
};
|
||
// HushChat: decrypt & thread any harvested chat memos BEFORE `result` is moved (see the
|
||
// full-refresh path above for rationale; the store dedups across both paths).
|
||
if (chat::hushChatFeatureEnabledAtBuild() && chat_service_.hasIdentity() &&
|
||
!result.hushChatMetadata.empty()) {
|
||
std::unordered_map<std::string, std::int64_t> chatTxTimes;
|
||
for (const auto& tx : result.transactions) chatTxTimes[tx.txid] = tx.timestamp;
|
||
std::vector<std::string> newChatCids;
|
||
chat_service_.ingest(result.hushChatMetadata, chatTxTimes, std::time(nullptr), &newChatCids);
|
||
// A new message un-hides a hidden conversation (you can't un-receive), so nothing is lost.
|
||
if (settings_) {
|
||
bool unhid = false;
|
||
for (const auto& cid : newChatCids)
|
||
if (settings_->isChatHidden(cid)) { settings_->setChatHidden(cid, false); unhid = true; }
|
||
if (unhid) settings_->save();
|
||
}
|
||
// Toast when a NON-muted conversation received a new message and we're off the Chat tab.
|
||
// Gate on the ingest-reported cids, not a seen-watermark delta — the latter can be swallowed
|
||
// by block-time vs wall-clock (echo) skew (Q10 + Q4).
|
||
if (current_page_ != ui::NavPage::Chat &&
|
||
std::any_of(newChatCids.begin(), newChatCids.end(),
|
||
[this](const std::string& cid){ return !(settings_ && settings_->isChatMuted(cid)); }))
|
||
ui::Notifications::instance().info(TR("chat_new_message_toast"));
|
||
}
|
||
NetworkRefreshService::applyTransactionRefreshResult(
|
||
state_, cacheUpdate, std::move(result), std::time(nullptr));
|
||
shielded_history_scan_heights_ = std::move(shieldedScanHeights);
|
||
shielded_history_scan_cursor_ = nextShieldedScanStartIndex;
|
||
storeTransactionHistoryCacheIfAvailable();
|
||
maybeFinishTransactionSendProgress();
|
||
};
|
||
}, 3);
|
||
if (!enqueued.enqueued) return;
|
||
|
||
network_refresh_.resetTxAge();
|
||
}
|
||
|
||
// ============================================================================
|
||
// Encryption State: wallet info (one-shot on connect, lightweight)
|
||
// ============================================================================
|
||
|
||
bool App::refreshEncryptionState()
|
||
{
|
||
if (!worker_ || !rpc_ || !state_.connected) return false;
|
||
|
||
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Encryption, *worker_, [this]() -> rpc::RPCWorker::MainCb {
|
||
json walletInfo;
|
||
bool ok = false;
|
||
try {
|
||
rpc::RPCClient::TraceScope trace("Startup / Wallet encryption state");
|
||
walletInfo = rpc_->call("getwalletinfo");
|
||
ok = true;
|
||
} catch (...) {}
|
||
|
||
if (!ok) return nullptr;
|
||
|
||
auto result = NetworkRefreshService::parseWalletEncryptionResult(walletInfo);
|
||
return [this, result]() {
|
||
NetworkRefreshService::applyWalletEncryptionResult(state_, result);
|
||
if (state_.isLocked()) {
|
||
resetTransactionHistoryCacheSession();
|
||
} else if (state_.transactions.empty()) {
|
||
loadTransactionHistoryCacheIfAvailable();
|
||
} else {
|
||
storeTransactionHistoryCacheIfAvailable();
|
||
}
|
||
};
|
||
}, 3);
|
||
return enqueued.enqueued;
|
||
}
|
||
|
||
void App::refreshBalance()
|
||
{
|
||
refreshCoreData();
|
||
}
|
||
|
||
void App::refreshAddresses()
|
||
{
|
||
addresses_dirty_ = true;
|
||
refreshAddressData();
|
||
}
|
||
|
||
void App::refreshMiningInfo()
|
||
{
|
||
// Use the dedicated fast-lane worker + connection so mining polls
|
||
// never block behind the main refresh batch. Falls back to the main
|
||
// worker if the fast lane isn't ready yet (e.g. during initial connect).
|
||
auto* w = (fast_worker_ && fast_worker_->isRunning()) ? fast_worker_.get() : worker_.get();
|
||
auto* rpc = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get();
|
||
if (!w || !rpc) return;
|
||
|
||
// Capture daemon memory outside (may be accessed on main thread)
|
||
double daemonMemMb = 0.0;
|
||
if (daemon_controller_) {
|
||
daemonMemMb = daemon_controller_->memoryUsageMB();
|
||
}
|
||
|
||
// Slow-tick counter: run full getmininginfo every ~5 seconds
|
||
// to reduce RPC overhead. getlocalsolps is only needed while solo mining
|
||
// or while the Mining tab is actively showing live local hashrate.
|
||
// NOTE: getinfo is NOT called here — longestchain/notarized are updated by
|
||
// refreshBalance (via getblockchaininfo), and daemon_version/protocol_version/
|
||
// p2p_port are static for the lifetime of a connection (set in onConnected).
|
||
bool doSlowRefresh = (mining_slow_counter_++ % 5 == 0);
|
||
bool includeLocalHashrate = state_.mining.generate || current_page_ == ui::NavPage::Mining;
|
||
if (!includeLocalHashrate && !doSlowRefresh) return;
|
||
|
||
// While syncing, don't poll getmininginfo (another cs_main contender) unless the user is
|
||
// actually on the Mining tab or mining — mining stats are irrelevant mid-sync, and this keeps
|
||
// the sync throttle complete (the mining poll runs off the separate 1s Fast timer, so the
|
||
// sync-profile intervals don't otherwise cover it).
|
||
if (state_.sync.syncing && !includeLocalHashrate) return;
|
||
ui::NavPage tracePage = current_page_;
|
||
|
||
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Mining, *w,
|
||
[this, rpc, daemonMemMb, doSlowRefresh, includeLocalHashrate, tracePage]() -> rpc::RPCWorker::MainCb {
|
||
AppRefreshRpcGateway refreshRpc(*rpc, traceSource(tracePage, "Mining refresh"));
|
||
auto result = NetworkRefreshService::collectMiningRefreshResult(
|
||
refreshRpc, daemonMemMb, doSlowRefresh, includeLocalHashrate);
|
||
return [this, result]() {
|
||
try {
|
||
NetworkRefreshService::applyMiningRefreshResult(state_, result, std::time(nullptr));
|
||
} catch (const std::exception& e) {
|
||
DEBUG_LOGF("[refreshMiningInfo] callback error: %s\n", e.what());
|
||
}
|
||
};
|
||
}, 2);
|
||
if (!enqueued.enqueued) return;
|
||
}
|
||
|
||
void App::refreshPeerInfo()
|
||
{
|
||
if (!rpc_) return;
|
||
|
||
// Use fast-lane worker to bypass head-of-line blocking behind refreshData.
|
||
auto* w = (fast_worker_ && fast_worker_->isRunning()) ? fast_worker_.get() : worker_.get();
|
||
auto* r = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get();
|
||
if (!w) return;
|
||
ui::NavPage tracePage = current_page_;
|
||
|
||
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Peers, *w, [this, r, tracePage]() -> rpc::RPCWorker::MainCb {
|
||
AppRefreshRpcGateway refreshRpc(*r, traceSource(tracePage, "Peer refresh"));
|
||
auto result = NetworkRefreshService::collectPeerRefreshResult(refreshRpc);
|
||
return [this, result = std::move(result)]() mutable {
|
||
NetworkRefreshService::applyPeerRefreshResult(state_, std::move(result), std::time(nullptr));
|
||
};
|
||
}, 2);
|
||
if (!enqueued.enqueued) return;
|
||
}
|
||
|
||
void App::refreshPrice()
|
||
{
|
||
// Skip if price fetching is disabled
|
||
if (!settings_->getFetchPrices()) return;
|
||
if (!worker_) return;
|
||
|
||
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Price, *worker_, [this]() -> rpc::RPCWorker::MainCb {
|
||
// --- Worker thread: blocking HTTP GET to CoinGecko ---
|
||
NetworkRefreshService::PriceHttpResult result;
|
||
|
||
try {
|
||
CURL* curl = curl_easy_init();
|
||
if (!curl) {
|
||
DEBUG_LOGF("Failed to initialize curl for price fetch\n");
|
||
result.errorMessage = "Price fetch failed: failed to initialize curl";
|
||
} else {
|
||
std::string response_data;
|
||
const char* url = "https://api.coingecko.com/api/v3/simple/price?ids=dragonx-2&vs_currencies=usd,btc&include_24hr_change=true&include_24hr_vol=true&include_market_cap=true";
|
||
|
||
auto write_callback = [](void* contents, size_t size, size_t nmemb, std::string* userp) -> size_t {
|
||
size_t totalSize = size * nmemb;
|
||
userp->append((char*)contents, totalSize);
|
||
return totalSize;
|
||
};
|
||
|
||
curl_easy_setopt(curl, CURLOPT_URL, url);
|
||
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, +write_callback);
|
||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response_data);
|
||
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 10L);
|
||
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 5L);
|
||
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
||
curl_easy_setopt(curl, CURLOPT_USERAGENT, "DragonX-Wallet/1.0");
|
||
|
||
CURLcode res = curl_easy_perform(curl);
|
||
long http_code = 0;
|
||
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
|
||
curl_easy_cleanup(curl);
|
||
|
||
NetworkRefreshService::PriceHttpResponse response{
|
||
res == CURLE_OK,
|
||
http_code,
|
||
std::move(response_data),
|
||
res == CURLE_OK ? std::string() : std::string(curl_easy_strerror(res))
|
||
};
|
||
result = NetworkRefreshService::parsePriceHttpResponse(response, std::time(nullptr));
|
||
}
|
||
} catch (const std::exception& e) {
|
||
DEBUG_LOGF("Price fetch error: %s\n", e.what());
|
||
result.errorMessage = std::string("Price fetch error: ") + e.what();
|
||
} catch (...) {
|
||
DEBUG_LOGF("Price fetch error: unknown exception\n");
|
||
result.errorMessage = "Price fetch error: unknown exception";
|
||
}
|
||
|
||
if (result.price) {
|
||
DEBUG_LOGF("Price updated: $%.6f USD\n", result.price->market.price_usd);
|
||
} else {
|
||
DEBUG_LOGF("%s\n", result.errorMessage.c_str());
|
||
}
|
||
|
||
return [this, result = std::move(result)]() mutable {
|
||
if (result.price) {
|
||
NetworkRefreshService::applyPriceRefreshResult(state_, *result.price, std::chrono::steady_clock::now());
|
||
} else {
|
||
NetworkRefreshService::applyPriceRefreshFailure(state_, result.errorMessage);
|
||
}
|
||
};
|
||
}, 0);
|
||
if (!enqueued.enqueued) return;
|
||
NetworkRefreshService::markPriceRefreshStarted(state_);
|
||
}
|
||
|
||
void App::refreshMarketData()
|
||
{
|
||
refreshPrice();
|
||
refreshExchanges();
|
||
refreshMarketChart();
|
||
refreshExchangeChart();
|
||
}
|
||
|
||
void App::refreshExchangeChart()
|
||
{
|
||
if (!settings_ || !settings_->getFetchPrices() || !worker_) return;
|
||
if (exchange_chart_fetch_in_flight_) return;
|
||
|
||
// Resolve the selected pair from the same registry the Market tab renders (live tickers, else the
|
||
// static fallback), matching exchange name THEN pair to disambiguate the same pair across venues.
|
||
const auto& reg = state_.market.exchanges.empty()
|
||
? data::getExchangeRegistry() : state_.market.exchanges;
|
||
const std::string selEx = settings_->getSelectedExchange();
|
||
const std::string selPair = settings_->getSelectedPair();
|
||
const data::ExchangePair* pair = nullptr;
|
||
for (const auto& ex : reg) {
|
||
if (!selEx.empty() && ex.name != selEx) continue;
|
||
for (const auto& p : ex.pairs)
|
||
if (p.displayName == selPair) { pair = &p; break; }
|
||
if (pair) break;
|
||
}
|
||
if (!pair && !reg.empty() && !reg[0].pairs.empty()) pair = ®[0].pairs[0]; // default like the UI
|
||
if (!pair) { state_.market.exchange_chart_active = false; exchange_chart_key_.clear(); return; }
|
||
|
||
const std::string key = pair->identifier + ":" + pair->base + "/" + pair->quote;
|
||
|
||
// No candle adapter for this venue -> draw the CoinGecko aggregate instead.
|
||
if (pair->identifier.empty() || !data::hasExchangeCandleAdapter(pair->identifier)) {
|
||
state_.market.exchange_chart_active = false;
|
||
exchange_chart_key_.clear();
|
||
return;
|
||
}
|
||
// Already loaded for this pair and still fresh (candles move slowly).
|
||
if (key == exchange_chart_key_ && state_.market.exchange_chart_active &&
|
||
std::chrono::steady_clock::now() - exchange_chart_last_fetch_ < std::chrono::minutes(30))
|
||
return;
|
||
// Cached from a recent view of this pair -> load instantly, no re-fetch (switching back is snappy).
|
||
if (auto it = exchange_chart_cache_.find(key);
|
||
it != exchange_chart_cache_.end() &&
|
||
std::chrono::steady_clock::now() - it->second.fetchedAt < std::chrono::minutes(30)) {
|
||
state_.market.exchange_chart_intraday = it->second.closeIntraday;
|
||
state_.market.exchange_chart_daily = it->second.closeDaily;
|
||
state_.market.exchange_ohlc_intraday = it->second.ohlcIntraday;
|
||
state_.market.exchange_ohlc_daily = it->second.ohlcDaily;
|
||
state_.market.exchange_chart_active = true;
|
||
exchange_chart_key_ = key;
|
||
exchange_chart_last_fetch_ = it->second.fetchedAt;
|
||
return;
|
||
}
|
||
// Switching pairs: fall back to the aggregate until the new venue's candles arrive.
|
||
if (key != exchange_chart_key_) state_.market.exchange_chart_active = false;
|
||
|
||
exchange_chart_fetch_in_flight_ = true;
|
||
const std::string identifier = pair->identifier, base = pair->base, quote = pair->quote;
|
||
const std::time_t now = std::time(nullptr);
|
||
const std::string urlIntra = data::buildExchangeCandleUrl(identifier, base, quote, data::CandleRange::Intraday, now);
|
||
const std::string urlDaily = data::buildExchangeCandleUrl(identifier, base, quote, data::CandleRange::Daily, now);
|
||
|
||
worker_->post([this, identifier, key, urlIntra, urlDaily]() -> rpc::RPCWorker::MainCb {
|
||
// httpGetString verifies TLS and returns "" on any failure (parses to an empty series).
|
||
std::string bIntra = util::httpGetString(urlIntra, "[exch-chart]");
|
||
std::string bDaily = util::httpGetString(urlDaily, "[exch-chart]");
|
||
auto ohlcIntra = data::parseExchangeOHLC(identifier, bIntra);
|
||
auto ohlcDaily = data::parseExchangeOHLC(identifier, bDaily);
|
||
return [this, key, ohlcIntra = std::move(ohlcIntra), ohlcDaily = std::move(ohlcDaily)]() mutable {
|
||
exchange_chart_fetch_in_flight_ = false;
|
||
if (!ohlcDaily.empty() || !ohlcIntra.empty()) {
|
||
// Build a cache entry (OHLC + derived close series for the line/change%), publish it to
|
||
// the active buffer, and keep it so switching back to this pair later is instant.
|
||
ExchangeChartCache e;
|
||
e.ohlcIntraday = std::move(ohlcIntra);
|
||
e.ohlcDaily = std::move(ohlcDaily);
|
||
e.closeIntraday.reserve(e.ohlcIntraday.size());
|
||
e.closeDaily.reserve(e.ohlcDaily.size());
|
||
for (const auto& c : e.ohlcIntraday) e.closeIntraday.emplace_back(c.time, c.close);
|
||
for (const auto& c : e.ohlcDaily) e.closeDaily.emplace_back(c.time, c.close);
|
||
e.fetchedAt = std::chrono::steady_clock::now();
|
||
state_.market.exchange_chart_intraday = e.closeIntraday;
|
||
state_.market.exchange_chart_daily = e.closeDaily;
|
||
state_.market.exchange_ohlc_intraday = e.ohlcIntraday;
|
||
state_.market.exchange_ohlc_daily = e.ohlcDaily;
|
||
state_.market.exchange_chart_active = true;
|
||
exchange_chart_key_ = key;
|
||
exchange_chart_last_fetch_ = e.fetchedAt;
|
||
if (exchange_chart_cache_.size() >= 16) exchange_chart_cache_.clear(); // bound it (many venues)
|
||
exchange_chart_cache_[key] = std::move(e);
|
||
} else {
|
||
// Both ranges empty -> the venue's API failed/changed; keep the aggregate.
|
||
state_.market.exchange_chart_active = false;
|
||
exchange_chart_key_.clear();
|
||
}
|
||
};
|
||
});
|
||
}
|
||
|
||
void App::refreshMarketChart()
|
||
{
|
||
if (!settings_ || !settings_->getFetchPrices()) return;
|
||
if (!worker_) return;
|
||
if (chart_fetch_in_flight_) return;
|
||
// Historical data moves slowly — refresh at most every 30 minutes after the first fetch.
|
||
if (state_.market.chart_loaded &&
|
||
std::chrono::steady_clock::now() - state_.market.chart_last_fetch_time < std::chrono::minutes(30))
|
||
return;
|
||
chart_fetch_in_flight_ = true;
|
||
|
||
worker_->post([this]() -> rpc::RPCWorker::MainCb {
|
||
// Two ranges give real granularity across every sparkline interval:
|
||
// days=1 -> 5-minute samples (backs the "hour" interval)
|
||
// days=365 -> daily samples (backs "day"/"week"/"month")
|
||
// httpGetString verifies TLS and returns "" on any failure (parses to an empty series).
|
||
std::string intradayBody = util::httpGetString(
|
||
"https://api.coingecko.com/api/v3/coins/dragonx-2/market_chart?vs_currency=usd&days=1",
|
||
"[market-chart]");
|
||
std::string dailyBody = util::httpGetString(
|
||
"https://api.coingecko.com/api/v3/coins/dragonx-2/market_chart?vs_currency=usd&days=365",
|
||
"[market-chart]");
|
||
auto intraday = NetworkRefreshService::parseCoinGeckoMarketChart(intradayBody);
|
||
auto daily = NetworkRefreshService::parseCoinGeckoMarketChart(dailyBody);
|
||
|
||
return [this, intraday = std::move(intraday), daily = std::move(daily)]() mutable {
|
||
chart_fetch_in_flight_ = false;
|
||
const bool any = !intraday.empty() || !daily.empty();
|
||
if (!intraday.empty()) state_.market.price_chart_intraday = std::move(intraday);
|
||
if (!daily.empty()) state_.market.price_chart_daily = std::move(daily);
|
||
if (any) {
|
||
state_.market.chart_loaded = true;
|
||
state_.market.chart_last_fetch_time = std::chrono::steady_clock::now();
|
||
}
|
||
// On total failure, leave chart_loaded=false so the next tick retries.
|
||
};
|
||
});
|
||
}
|
||
|
||
void App::refreshExchanges()
|
||
{
|
||
if (!settings_ || !settings_->getFetchPrices()) return;
|
||
if (!worker_) return;
|
||
if (exchanges_fetch_started_) return; // once per session (venues are near-static)
|
||
exchanges_fetch_started_ = true;
|
||
|
||
worker_->post([this]() -> rpc::RPCWorker::MainCb {
|
||
std::vector<data::ExchangeInfo> exchanges;
|
||
try {
|
||
CURL* curl = curl_easy_init();
|
||
if (curl) {
|
||
std::string body;
|
||
const char* url = "https://api.coingecko.com/api/v3/coins/dragonx-2/tickers";
|
||
auto write_callback = [](void* contents, size_t size, size_t nmemb, std::string* userp) -> size_t {
|
||
size_t total = size * nmemb;
|
||
userp->append((char*)contents, total);
|
||
return total;
|
||
};
|
||
curl_easy_setopt(curl, CURLOPT_URL, url);
|
||
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, +write_callback);
|
||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &body);
|
||
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 12L);
|
||
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 5L);
|
||
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
||
curl_easy_setopt(curl, CURLOPT_USERAGENT, "DragonX-Wallet/1.0");
|
||
CURLcode res = curl_easy_perform(curl);
|
||
long http_code = 0;
|
||
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
|
||
curl_easy_cleanup(curl);
|
||
if (res == CURLE_OK && http_code >= 200 && http_code < 300)
|
||
exchanges = data::parseCoinGeckoTickers(body);
|
||
}
|
||
} catch (...) {}
|
||
|
||
return [this, exchanges = std::move(exchanges)]() mutable {
|
||
if (!exchanges.empty())
|
||
state_.market.exchanges = std::move(exchanges);
|
||
else
|
||
exchanges_fetch_started_ = false; // allow a retry if it failed/empty
|
||
};
|
||
});
|
||
}
|
||
|
||
// ============================================================================
|
||
// Mining Operations
|
||
// ============================================================================
|
||
|
||
void App::startMining(int threads)
|
||
{
|
||
if (!supportsSoloMining()) {
|
||
(void)threads;
|
||
ui::Notifications::instance().warning("Solo mining is unavailable in lite build");
|
||
return;
|
||
}
|
||
if (!state_.connected || !rpc_ || !worker_) return;
|
||
if (mining_toggle_in_progress_.exchange(true)) return; // already in progress
|
||
|
||
worker_->post([this, threads]() -> rpc::RPCWorker::MainCb {
|
||
bool ok = false;
|
||
std::string errMsg;
|
||
try {
|
||
rpc::RPCClient::TraceScope trace("Mining tab / Start mining");
|
||
rpc_->call("setgenerate", {true, threads});
|
||
ok = true;
|
||
} catch (const std::exception& e) {
|
||
errMsg = e.what();
|
||
}
|
||
return [this, threads, ok, errMsg]() {
|
||
mining_toggle_in_progress_.store(false);
|
||
if (ok) {
|
||
state_.mining.generate = true;
|
||
state_.mining.genproclimit = threads;
|
||
DEBUG_LOGF("Mining started with %d threads\n", threads);
|
||
} else {
|
||
DEBUG_LOGF("Failed to start mining: %s\n", errMsg.c_str());
|
||
ui::Notifications::instance().error("Mining failed: " + errMsg);
|
||
}
|
||
};
|
||
});
|
||
}
|
||
|
||
void App::stopMining()
|
||
{
|
||
if (!supportsSoloMining()) return;
|
||
if (!state_.connected || !rpc_ || !worker_) return;
|
||
if (mining_toggle_in_progress_.exchange(true)) return; // already in progress
|
||
|
||
worker_->post([this]() -> rpc::RPCWorker::MainCb {
|
||
bool ok = false;
|
||
try {
|
||
rpc::RPCClient::TraceScope trace("Mining tab / Stop mining");
|
||
rpc_->call("setgenerate", {false, 0});
|
||
ok = true;
|
||
} catch (const std::exception& e) {
|
||
DEBUG_LOGF("Failed to stop mining: %s\n", e.what());
|
||
}
|
||
return [this, ok]() {
|
||
mining_toggle_in_progress_.store(false);
|
||
if (ok) {
|
||
state_.mining.generate = false;
|
||
state_.mining.localHashrate = 0.0;
|
||
DEBUG_LOGF("Mining stopped\n");
|
||
}
|
||
};
|
||
});
|
||
}
|
||
|
||
void App::startPoolMining(int threads)
|
||
{
|
||
if (!supportsPoolMining()) {
|
||
(void)threads;
|
||
ui::Notifications::instance().warning("Pool mining is unavailable in this build");
|
||
return;
|
||
}
|
||
|
||
if (!xmrig_manager_)
|
||
xmrig_manager_ = std::make_unique<daemon::XmrigManager>();
|
||
|
||
// If already running, stop first (e.g. thread count change)
|
||
if (xmrig_manager_->isRunning()) {
|
||
xmrig_manager_->stop();
|
||
}
|
||
|
||
// Stop solo mining first if active
|
||
if (state_.mining.generate) stopMining();
|
||
|
||
daemon::XmrigManager::Config cfg;
|
||
cfg.pool_url = settings_->getPoolUrl();
|
||
cfg.worker_name = settings_->getPoolWorker();
|
||
// The algo follows the pool: official pools use their own algo (pool.dragonx.cc
|
||
// needs rx/dragonx, pool.dragonx.is rx/hush); custom hosts keep the setting.
|
||
cfg.algo = util::resolvePoolAlgo(cfg.pool_url, settings_->getPoolAlgo());
|
||
cfg.threads = threads; // Use the same thread selection as solo mining
|
||
cfg.tls = settings_->getPoolTls();
|
||
cfg.hugepages = settings_->getPoolHugepages();
|
||
|
||
// Use first shielded address as the mining wallet address, fall back to transparent
|
||
for (const auto& addr : state_.z_addresses) {
|
||
if (!addr.address.empty()) {
|
||
cfg.wallet_address = addr.address;
|
||
break;
|
||
}
|
||
}
|
||
if (cfg.wallet_address.empty()) {
|
||
for (const auto& addr : state_.addresses) {
|
||
if (addr.type == "transparent" && !addr.address.empty()) {
|
||
cfg.wallet_address = addr.address;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Fallback: use pool worker address from settings (available even before
|
||
// the daemon is connected or the blockchain is synced).
|
||
if (cfg.wallet_address.empty() && !cfg.worker_name.empty()) {
|
||
cfg.wallet_address = cfg.worker_name;
|
||
}
|
||
|
||
if (cfg.wallet_address.empty()) {
|
||
DEBUG_LOGF("[ERROR] Pool mining: No wallet address available\n");
|
||
ui::Notifications::instance().error("No wallet address available — generate a Z address in the Receive tab");
|
||
return;
|
||
}
|
||
|
||
if (!xmrig_manager_->start(cfg)) {
|
||
std::string err = xmrig_manager_->getLastError();
|
||
DEBUG_LOGF("[ERROR] Pool mining: %s\n", err.c_str());
|
||
|
||
// Check for Windows Defender blocking (error 225 = ERROR_VIRUS_INFECTED)
|
||
if (err.find("error 225") != std::string::npos ||
|
||
err.find("virus") != std::string::npos) {
|
||
ui::Notifications::instance().error(
|
||
"Windows Defender blocked xmrig. Add exclusion for %APPDATA%\\ObsidianDragon");
|
||
#ifdef _WIN32
|
||
// Offer to open Windows Security settings
|
||
pending_antivirus_dialog_ = true;
|
||
#endif
|
||
} else {
|
||
ui::Notifications::instance().error("Failed to start pool miner: " + err);
|
||
}
|
||
} else {
|
||
// Miner spawned — it still needs a few seconds to connect to the pool and start hashing.
|
||
pool_starting_.store(true, std::memory_order_relaxed);
|
||
ui::Notifications::instance().info("Starting pool miner — connecting to the pool…");
|
||
}
|
||
}
|
||
|
||
void App::stopPoolMining()
|
||
{
|
||
if (xmrig_manager_ && xmrig_manager_->isRunning()) {
|
||
xmrig_manager_->stop(3000);
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// Peer Operations
|
||
// ============================================================================
|
||
|
||
void App::banPeer(const std::string& ip, int duration_seconds)
|
||
{
|
||
if (!state_.connected || !rpc_ || !worker_) return;
|
||
// Run on the worker thread — these are called straight from the Peers tab's ImGui
|
||
// handlers, and rpc_->call() blocks on synchronous curl under curl_mutex_.
|
||
worker_->post([this, ip, duration_seconds]() -> rpc::RPCWorker::MainCb {
|
||
std::string err;
|
||
try {
|
||
rpc::RPCClient::TraceScope trace("Peers / Ban");
|
||
rpc_->call("setban", {ip, "add", duration_seconds});
|
||
} catch (const std::exception& e) {
|
||
err = e.what();
|
||
}
|
||
return [this, err]() {
|
||
if (!err.empty()) ui::Notifications::instance().error("Ban failed: " + err);
|
||
else refreshPeerInfo();
|
||
};
|
||
});
|
||
}
|
||
|
||
void App::unbanPeer(const std::string& ip)
|
||
{
|
||
if (!state_.connected || !rpc_ || !worker_) return;
|
||
worker_->post([this, ip]() -> rpc::RPCWorker::MainCb {
|
||
std::string err;
|
||
try {
|
||
rpc::RPCClient::TraceScope trace("Peers / Unban");
|
||
rpc_->call("setban", {ip, "remove"});
|
||
} catch (const std::exception& e) {
|
||
err = e.what();
|
||
}
|
||
return [this, err]() {
|
||
if (!err.empty()) ui::Notifications::instance().error("Unban failed: " + err);
|
||
else refreshPeerInfo();
|
||
};
|
||
});
|
||
}
|
||
|
||
void App::clearBans()
|
||
{
|
||
if (!state_.connected || !rpc_ || !worker_) return;
|
||
worker_->post([this]() -> rpc::RPCWorker::MainCb {
|
||
std::string err;
|
||
try {
|
||
rpc::RPCClient::TraceScope trace("Peers / Clear bans");
|
||
rpc_->call("clearbanned", nlohmann::json::array());
|
||
} catch (const std::exception& e) {
|
||
err = e.what();
|
||
}
|
||
return [this, err]() {
|
||
if (!err.empty()) { ui::Notifications::instance().error("Clear bans failed: " + err); return; }
|
||
state_.banned_peers.clear();
|
||
refreshPeerInfo();
|
||
};
|
||
});
|
||
}
|
||
|
||
void App::applyDefaultBanlist()
|
||
{
|
||
if (!state_.connected || !rpc_ || !worker_) return;
|
||
|
||
// Parse the embedded default_banlist.txt (compiled from res/default_banlist.txt)
|
||
std::string data(reinterpret_cast<const char*>(embedded::default_banlist_data),
|
||
embedded::default_banlist_size);
|
||
|
||
std::vector<std::string> ips;
|
||
size_t pos = 0;
|
||
while (pos < data.size()) {
|
||
size_t eol = data.find('\n', pos);
|
||
if (eol == std::string::npos) eol = data.size();
|
||
std::string line = data.substr(pos, eol - pos);
|
||
pos = eol + 1;
|
||
|
||
// Strip carriage return (Windows line endings)
|
||
if (!line.empty() && line.back() == '\r') line.pop_back();
|
||
// Strip leading/trailing whitespace
|
||
size_t start = line.find_first_not_of(" \t");
|
||
if (start == std::string::npos) continue;
|
||
line = line.substr(start, line.find_last_not_of(" \t") - start + 1);
|
||
// Skip empty lines and comments
|
||
if (line.empty() || line[0] == '#') continue;
|
||
|
||
ips.push_back(line);
|
||
}
|
||
|
||
if (ips.empty()) return;
|
||
|
||
// Apply bans on the worker thread to avoid blocking the UI
|
||
worker_->post([this, ips]() -> rpc::RPCWorker::MainCb {
|
||
int applied = 0;
|
||
for (const auto& ip : ips) {
|
||
try {
|
||
rpc::RPCClient::TraceScope trace("Startup / Default banlist");
|
||
// 0 = permanent ban (until node restart or manual unban)
|
||
// Using a very long duration (10 years) for effectively permanent bans
|
||
rpc_->call("setban", {ip, "add", 315360000});
|
||
applied++;
|
||
} catch (...) {
|
||
// Already banned or invalid — skip silently
|
||
}
|
||
}
|
||
return [applied]() {
|
||
if (applied > 0) {
|
||
DEBUG_LOGF("[Banlist] Applied %d default bans\n", applied);
|
||
}
|
||
};
|
||
});
|
||
}
|
||
|
||
// ============================================================================
|
||
// Address Operations
|
||
// ============================================================================
|
||
|
||
void App::createNewZAddress(std::function<void(const std::string&)> callback)
|
||
{
|
||
createNewAddress(/*shielded*/ true, std::move(callback));
|
||
}
|
||
|
||
void App::createNewTAddress(std::function<void(const std::string&)> callback)
|
||
{
|
||
createNewAddress(/*shielded*/ false, std::move(callback));
|
||
}
|
||
|
||
void App::createNewAddress(bool shielded, std::function<void(const std::string&)> callback)
|
||
{
|
||
const char* typeStr = shielded ? "shielded" : "transparent";
|
||
auto& targetList = shielded ? state_.z_addresses : state_.t_addresses;
|
||
|
||
// Lite build: derive locally via the controller (fast, no network). The backend auto-saves
|
||
// new addresses; the next lite refresh lists it with a balance.
|
||
if (lite_wallet_) {
|
||
const auto result = lite_wallet_->newAddress(shielded);
|
||
if (result.ok) {
|
||
AddressInfo info;
|
||
info.address = result.address;
|
||
info.type = typeStr;
|
||
info.balance = 0.0;
|
||
targetList.push_back(info);
|
||
state_.addresses.push_back(info);
|
||
address_list_dirty_ = true;
|
||
}
|
||
if (callback) callback(result.ok ? result.address : std::string());
|
||
return;
|
||
}
|
||
|
||
if (!state_.connected || !rpc_ || !worker_) return;
|
||
|
||
const char* rpcMethod = shielded ? "z_getnewaddress" : "getnewaddress";
|
||
const char* traceLabel = shielded ? "Receive tab / New shielded address"
|
||
: "Receive tab / New transparent address";
|
||
worker_->post([this, callback, shielded, typeStr, rpcMethod, traceLabel]() -> rpc::RPCWorker::MainCb {
|
||
std::string addr;
|
||
try {
|
||
rpc::RPCClient::TraceScope trace(traceLabel);
|
||
json result = rpc_->call(rpcMethod);
|
||
addr = result.get<std::string>();
|
||
} catch (const std::exception& e) {
|
||
DEBUG_LOGF("%s error: %s\n", rpcMethod, e.what());
|
||
}
|
||
return [this, callback, addr, shielded, typeStr]() {
|
||
if (!addr.empty()) {
|
||
// Inject immediately so UI can select the address next frame
|
||
AddressInfo info;
|
||
info.address = addr;
|
||
info.type = typeStr;
|
||
info.balance = 0.0;
|
||
(shielded ? state_.z_addresses : state_.t_addresses).push_back(info);
|
||
state_.addresses.push_back(info); // keep combined view in sync immediately
|
||
address_list_dirty_ = true;
|
||
// Also trigger full refresh to get proper balances
|
||
addresses_dirty_ = true;
|
||
refreshAddresses();
|
||
}
|
||
if (callback) callback(addr);
|
||
};
|
||
});
|
||
}
|
||
|
||
void App::hideAddress(const std::string& addr)
|
||
{
|
||
if (settings_) {
|
||
settings_->hideAddress(addr);
|
||
settings_->save();
|
||
}
|
||
}
|
||
|
||
void App::unhideAddress(const std::string& addr)
|
||
{
|
||
if (settings_) {
|
||
settings_->unhideAddress(addr);
|
||
settings_->save();
|
||
}
|
||
}
|
||
|
||
bool App::isAddressHidden(const std::string& addr) const
|
||
{
|
||
return settings_ && settings_->isAddressHidden(addr);
|
||
}
|
||
|
||
int App::getHiddenAddressCount() const
|
||
{
|
||
return settings_ ? settings_->getHiddenAddressCount() : 0;
|
||
}
|
||
|
||
void App::favoriteAddress(const std::string& addr)
|
||
{
|
||
if (settings_) {
|
||
settings_->favoriteAddress(addr);
|
||
settings_->save();
|
||
}
|
||
}
|
||
|
||
void App::unfavoriteAddress(const std::string& addr)
|
||
{
|
||
if (settings_) {
|
||
settings_->unfavoriteAddress(addr);
|
||
settings_->save();
|
||
}
|
||
}
|
||
|
||
bool App::isAddressFavorite(const std::string& addr) const
|
||
{
|
||
return settings_ && settings_->isAddressFavorite(addr);
|
||
}
|
||
|
||
void App::setAddressLabel(const std::string& addr, const std::string& label)
|
||
{
|
||
if (settings_) {
|
||
settings_->setAddressLabel(addr, label);
|
||
settings_->save();
|
||
}
|
||
}
|
||
|
||
void App::setAddressIcon(const std::string& addr, const std::string& icon)
|
||
{
|
||
if (settings_) {
|
||
settings_->setAddressIcon(addr, icon);
|
||
settings_->save();
|
||
}
|
||
}
|
||
|
||
std::string App::getAddressLabel(const std::string& addr) const
|
||
{
|
||
if (!settings_) return "";
|
||
return settings_->getAddressMeta(addr).label;
|
||
}
|
||
|
||
std::string App::getAddressIcon(const std::string& addr) const
|
||
{
|
||
if (!settings_) return "";
|
||
return settings_->getAddressMeta(addr).icon;
|
||
}
|
||
|
||
int App::getAddressSortOrder(const std::string& addr) const
|
||
{
|
||
if (!settings_) return -1;
|
||
return settings_->getAddressMeta(addr).sortOrder;
|
||
}
|
||
|
||
void App::setAddressSortOrder(const std::string& addr, int order)
|
||
{
|
||
if (settings_) {
|
||
settings_->setAddressSortOrder(addr, order);
|
||
settings_->save();
|
||
}
|
||
}
|
||
|
||
int App::getNextSortOrder() const
|
||
{
|
||
return settings_ ? settings_->getNextSortOrder() : 0;
|
||
}
|
||
|
||
void App::swapAddressOrder(const std::string& a, const std::string& b)
|
||
{
|
||
if (settings_) {
|
||
settings_->swapAddressOrder(a, b);
|
||
settings_->save();
|
||
}
|
||
}
|
||
|
||
void App::reorderAddresses(const std::vector<std::string>& orderedAddrs)
|
||
{
|
||
if (!settings_) return;
|
||
for (size_t i = 0; i < orderedAddrs.size(); ++i)
|
||
settings_->setAddressSortOrder(orderedAddrs[i], static_cast<int>(i));
|
||
settings_->save();
|
||
}
|
||
|
||
bool App::isMiningAddress(const std::string& addr) const
|
||
{
|
||
return settings_ && settings_->isMiningAddress(addr);
|
||
}
|
||
|
||
void App::setMiningAddress(const std::string& addr, bool mining)
|
||
{
|
||
if (!settings_) return;
|
||
settings_->setMiningAddress(addr, mining);
|
||
settings_->save();
|
||
|
||
// "mined" vs "receive" is a pure function of the LOCAL mining-address set — the daemon knows
|
||
// nothing about it, so there is NO need to re-scan the chain. Relabel the affected rows in the
|
||
// in-memory history directly and persist them to the (SQLite) history cache. This is instant,
|
||
// with no daemon round-trip; the History tab's display cache rebuilds on the type change.
|
||
const auto miningAddrs = settings_->getMiningAddresses();
|
||
bool changed = false;
|
||
for (auto& tx : state_.transactions) {
|
||
if (tx.address.empty() || (tx.type != "receive" && tx.type != "mined")) continue;
|
||
std::string newType = miningAddrs.count(tx.address) ? "mined" : "receive";
|
||
if (tx.type != newType) {
|
||
tx.type = std::move(newType);
|
||
changed = true;
|
||
}
|
||
}
|
||
if (changed) storeTransactionHistoryCacheIfAvailable();
|
||
}
|
||
|
||
void App::invalidateAddressValidationCache()
|
||
{
|
||
address_validation_cache_dirty_ = true;
|
||
addresses_dirty_ = true;
|
||
invalidateShieldedHistoryScanProgress(true);
|
||
}
|
||
|
||
// ============================================================================
|
||
// Key Export/Import Operations
|
||
// ============================================================================
|
||
|
||
void App::exportPrivateKey(const std::string& address, std::function<void(const std::string&)> callback)
|
||
{
|
||
if (!state_.connected || !rpc_ || !worker_) {
|
||
if (callback) callback("");
|
||
return;
|
||
}
|
||
|
||
const bool shielded = services::WalletSecurityController::classifyAddress(address)
|
||
== services::WalletSecurityController::KeyKind::Shielded;
|
||
const char* method = shielded ? "z_exportkey" : "dumpprivkey";
|
||
// Run on the worker thread — z_exportkey/dumpprivkey block on synchronous curl and
|
||
// are invoked straight from the export dialog (UI thread).
|
||
worker_->post([this, method, address, callback]() -> rpc::RPCWorker::MainCb {
|
||
std::string key;
|
||
std::string err;
|
||
try {
|
||
rpc::RPCClient::TraceScope trace("Settings / Export private key");
|
||
key = rpc_->callSecretString(method, {address}); // scrubs raw body + json node (B7)
|
||
} catch (const std::exception& e) {
|
||
err = e.what();
|
||
}
|
||
return [callback, key, err]() {
|
||
if (!err.empty()) {
|
||
DEBUG_LOGF("Export key error: %s\n", err.c_str());
|
||
ui::Notifications::instance().error("Key export failed: " + err);
|
||
if (callback) callback("");
|
||
} else if (callback) {
|
||
callback(key);
|
||
}
|
||
};
|
||
});
|
||
}
|
||
|
||
// ============================================================================
|
||
// HushChat identity provisioning (experimental; gated by DRAGONX_ENABLE_CHAT)
|
||
// ============================================================================
|
||
|
||
// Derive the chat identity from the wallet's own seed-phrase secret and hand it to the chat
|
||
// service. `secret` is taken by value so we own a copy to wipe; the caller must wipe its own.
|
||
void App::provisionChatIdentityFromSecret(std::string secret)
|
||
{
|
||
// Defensive: strip surrounding whitespace so a stray newline can't change the identity — the
|
||
// same wallet must derive the SAME identity on full-node and lite (both return the canonical
|
||
// single-space phrase today, so this is belt-and-suspenders). Trim into a SEPARATE buffer so
|
||
// both the original (kept full-size) and the trimmed copy (size == content) are fully wiped —
|
||
// an in-place erase/pop_back would shrink size() and leave un-scrubbed seed bytes past it.
|
||
auto isws = [](char c) { return std::isspace(static_cast<unsigned char>(c)) != 0; };
|
||
std::size_t begin = 0, end = secret.size();
|
||
while (begin < end && isws(secret[begin])) ++begin;
|
||
while (end > begin && isws(secret[end - 1])) --end;
|
||
std::string trimmed = secret.substr(begin, end - begin);
|
||
|
||
chat::ChatKeyPair keys;
|
||
const auto result = chat::deriveChatIdentityFromSecret(trimmed, keys);
|
||
|
||
if (result.status == chat::ChatIdentityStatus::Ready) {
|
||
chat_service_.setIdentity(keys); // copies the keypair
|
||
chat_identity_provisioned_ = true;
|
||
// Persistence: unlock the seed-derived chat DB with the SAME secret and rehydrate the store
|
||
// with prior messages (decrypted at rest under a key only this seed can derive).
|
||
chat_service_.setPersistence(&chat_db_);
|
||
if (chat_db_.unlockWithSecret(trimmed)) {
|
||
chat_service_.loadFromDatabase();
|
||
// Baseline unread: treat everything already in the store at load as read, so only messages
|
||
// that arrive while the app is open badge as unread (Q1).
|
||
const auto& store = chat_service_.store();
|
||
for (const auto& cid : store.conversationIds()) {
|
||
const auto msgs = store.conversation(cid);
|
||
if (!msgs.empty()) chat_seen_watermark_[cid] = msgs.back().timestamp;
|
||
}
|
||
}
|
||
} else {
|
||
chat_identity_unavailable_ = true;
|
||
}
|
||
if (!trimmed.empty()) sodium_memzero(&trimmed[0], trimmed.size());
|
||
if (!secret.empty()) sodium_memzero(&secret[0], secret.size());
|
||
chat::wipeChatKeyPair(keys);
|
||
}
|
||
|
||
// Provision the chat identity once the wallet's seed phrase is reachable. Called every update()
|
||
// tick; cheap early-outs keep it idle until it can act. Both variants derive the SAME identity
|
||
// because they feed the SAME SDXLite-compatible mnemonic into the KDF (identity derivation is
|
||
// local — peers only ever exchange public keys).
|
||
void App::resetChatSession()
|
||
{
|
||
if (!chat::hushChatFeatureEnabledAtBuild()) return; // constexpr — folds away in OFF builds
|
||
// Drop identity + decrypted plaintext in RAM, lock the seed-encrypted DB, re-arm provisioning.
|
||
chat_service_.clearIdentity();
|
||
chat_service_.store().clear();
|
||
chat_seen_watermark_.clear(); // unread state is per-wallet — don't leak it across a switch
|
||
ui::ResetChatTab(); // wipe the chat UI's typed plaintext + selection so it can't leak into the next wallet
|
||
chat_db_.lock();
|
||
chat_identity_provisioned_ = false;
|
||
chat_identity_fetch_in_flight_ = false;
|
||
chat_fast_scan_in_flight_ = false; // a stale in-flight fast-scan is dropped by its session guard
|
||
chat_identity_unavailable_ = false;
|
||
// Invalidate any in-flight identity fetch that captured the PREVIOUS wallet's secret, so its
|
||
// completion callback can't provision that secret under the new wallet.
|
||
++chat_session_generation_;
|
||
}
|
||
|
||
void App::maybeProvisionChatIdentity()
|
||
{
|
||
if (!chat::hushChatFeatureEnabledAtBuild()) return; // constexpr — folds away in OFF builds
|
||
|
||
// Locked (encrypted && locked, mirrored for both variants) → the seed is unreadable. Drop any
|
||
// in-memory identity and re-arm so it re-derives on the next unlock. Unencrypted wallets report
|
||
// isLocked()==false and fall through.
|
||
if (state_.isLocked()) {
|
||
if (chat_identity_provisioned_ || chat_service_.hasIdentity()) resetChatSession();
|
||
return;
|
||
}
|
||
|
||
if (chat_service_.hasIdentity() || chat_identity_provisioned_ ||
|
||
chat_identity_fetch_in_flight_ || chat_identity_unavailable_) return;
|
||
|
||
if (supportsLiteBackend()) {
|
||
// Lite: the backend's `seed` command returns the 24-word mnemonic synchronously.
|
||
if (!lite_wallet_ || !lite_wallet_->walletOpen()) return;
|
||
wallet::LiteSeedResult seed = lite_wallet_->exportSeed();
|
||
if (!seed.ok || seed.seedPhrase.empty()) {
|
||
wallet::secureWipeLiteSecret(seed.seedPhrase);
|
||
chat_identity_unavailable_ = true;
|
||
return;
|
||
}
|
||
provisionChatIdentityFromSecret(seed.seedPhrase); // copies internally
|
||
wallet::secureWipeLiteSecret(seed.seedPhrase);
|
||
} else if (supportsFullNodeLifecycleActions()) {
|
||
// Full-node: fetch the identity secret off the UI thread, provision on the main thread.
|
||
// Prefer the wallet's mnemonic (z_exportmnemonic) for a portable, SDXLite-compatible identity;
|
||
// if the wallet has no mnemonic (a legacy random-seed wallet, or a daemon without that RPC),
|
||
// fall back to a stable z-address's spending key (z_exportkey) — a functional, wallet-local
|
||
// identity that works on any daemon. Both secrets are wiped after deriving.
|
||
if (!state_.connected || !rpc_ || !worker_) return;
|
||
if (!state_.encryption_state_known) return; // don't fetch before we know the lock state
|
||
const std::string fallbackZaddr = chatReplyZaddr(); // main thread: stable identity source
|
||
const int fetchGen = chat_session_generation_; // wallet epoch this secret belongs to
|
||
chat_identity_fetch_in_flight_ = true;
|
||
worker_->post([this, fallbackZaddr, fetchGen]() -> rpc::RPCWorker::MainCb {
|
||
std::string secret; // the mnemonic (portable) OR a spending key (legacy fallback)
|
||
bool transientFail = false; // connection blip — allow a later retry
|
||
bool unavailable = false; // no usable secret — stop retrying this session
|
||
rpc::RPCClient::TraceScope trace("HushChat / identity");
|
||
try {
|
||
auto response = rpc_->callSecret("z_exportmnemonic"); // zero the raw body too (B7)
|
||
if (response.contains("mnemonic") && response["mnemonic"].is_string()) {
|
||
// Scrub the json's own copy of the seed after taking ours (the rest of the RPC
|
||
// response chain is unmanaged — a fuller fix belongs in the RPC layer).
|
||
auto& phrase = response["mnemonic"].get_ref<std::string&>();
|
||
secret = phrase;
|
||
if (!phrase.empty()) sodium_memzero(&phrase[0], phrase.size());
|
||
}
|
||
} catch (const rpc::RpcError&) {
|
||
// No mnemonic → derive from a spending key instead (legacy wallet / old daemon).
|
||
if (fallbackZaddr.empty()) {
|
||
unavailable = true;
|
||
} else {
|
||
try {
|
||
// Mirror the mnemonic path: take our copy, then scrub the json's own copy of the
|
||
// spending key so it isn't left in freed heap (B4). z_exportkey returns a bare string.
|
||
auto keyResp = rpc_->callSecret("z_exportkey", {fallbackZaddr}); // zero the raw body too (B7)
|
||
if (keyResp.is_string()) {
|
||
auto& key = keyResp.get_ref<std::string&>();
|
||
secret = key;
|
||
if (!key.empty()) sodium_memzero(&key[0], key.size());
|
||
}
|
||
} catch (const rpc::RpcError&) {
|
||
unavailable = true; // no spending key either (view-only?) — give up
|
||
} catch (const std::exception&) {
|
||
transientFail = true;
|
||
}
|
||
}
|
||
} catch (const std::exception&) {
|
||
transientFail = true;
|
||
}
|
||
return [this, secret = std::move(secret), transientFail, unavailable, fetchGen]() mutable {
|
||
// The wallet changed while this fetch ran (resetChatSession bumped the generation): this
|
||
// secret belongs to the PREVIOUS wallet — wipe it and do nothing so it can't provision an
|
||
// identity under the new wallet. Don't touch the flags either (they belong to the new epoch).
|
||
if (fetchGen != chat_session_generation_) {
|
||
if (!secret.empty()) sodium_memzero(&secret[0], secret.size());
|
||
return;
|
||
}
|
||
chat_identity_fetch_in_flight_ = false;
|
||
if (unavailable) chat_identity_unavailable_ = true;
|
||
// Provision only on success AND while still unlocked: a lock (e.g. auto-lock) can
|
||
// land during the blocking fetch, and provisioning then would unlock the chat DB +
|
||
// load decrypted history while the wallet is locked. Leaving the flag cleared here
|
||
// re-arms a fresh fetch on the next unlock.
|
||
if (!transientFail && !unavailable && !secret.empty() && !state_.isLocked()) {
|
||
provisionChatIdentityFromSecret(secret); // copies internally
|
||
}
|
||
if (!secret.empty()) sodium_memzero(&secret[0], secret.size());
|
||
};
|
||
});
|
||
}
|
||
}
|
||
|
||
// A STABLE wallet z-address for chat: the "z" reply field + the send-from address, and (for
|
||
// legacy/non-mnemonic wallets) the source of the seed-derived chat identity. Persisted on first use
|
||
// so the identity + reply address don't shift when new addresses are generated. Prefers a spendable
|
||
// one so replies land somewhere we control and fees can be paid.
|
||
std::string App::chatReplyZaddr()
|
||
{
|
||
// Reuse the previously-chosen address as long as we still own it spendably.
|
||
if (settings_) {
|
||
const std::string saved = settings_->getChatReplyZaddr();
|
||
if (!saved.empty()) {
|
||
for (const auto& addr : state_.z_addresses)
|
||
if (addr.address == saved && addr.has_spending_key) return saved;
|
||
}
|
||
}
|
||
// Otherwise pick the lexicographically-smallest spendable z-address (deterministic) and persist.
|
||
std::string best;
|
||
for (const auto& addr : state_.z_addresses)
|
||
if (addr.has_spending_key && !addr.address.empty() && (best.empty() || addr.address < best))
|
||
best = addr.address;
|
||
if (!best.empty()) {
|
||
if (settings_ && settings_->getChatReplyZaddr() != best) {
|
||
settings_->setChatReplyZaddr(best);
|
||
settings_->save();
|
||
}
|
||
return best;
|
||
}
|
||
// No spendable z-address yet — fall back to the smallest we have (don't persist a view-only one).
|
||
for (const auto& addr : state_.z_addresses)
|
||
if (!addr.address.empty() && (best.empty() || addr.address < best)) best = addr.address;
|
||
return best;
|
||
}
|
||
|
||
std::string App::chatPayFromZaddr(double fee) const
|
||
{
|
||
// z_sendmany spends from ONE z-address, so the "from" must itself hold >= fee. Prefer the identity
|
||
// reply address (keeps from == reply-to, the simplest case); otherwise pick the highest-balance
|
||
// spendable z-address that can cover the fee. The memo still advertises the identity address as
|
||
// reply-to, so paying from a different note doesn't change who the peer replies to.
|
||
std::string reply;
|
||
if (settings_) reply = settings_->getChatReplyZaddr();
|
||
for (const auto& a : state_.z_addresses)
|
||
if (a.address == reply && a.has_spending_key && a.balance >= fee) return reply;
|
||
|
||
std::string best;
|
||
double bestBal = -1.0;
|
||
for (const auto& a : state_.z_addresses)
|
||
if (a.has_spending_key && !a.address.empty() && a.balance >= fee && a.balance > bestBal) {
|
||
best = a.address;
|
||
bestBal = a.balance;
|
||
}
|
||
return best; // empty → no z-address can cover the fee
|
||
}
|
||
|
||
// A unique opaque id (hex), used for the local echo id (we never harvest our own sends) and for
|
||
// new conversation ids. Random — collisions are astronomically unlikely.
|
||
std::string App::generateChatLocalId(const char* prefix, int numBytes) const
|
||
{
|
||
if (numBytes < 1) numBytes = 8;
|
||
std::vector<unsigned char> buf(static_cast<std::size_t>(numBytes));
|
||
randombytes_buf(buf.data(), buf.size());
|
||
static const char* kHex = "0123456789abcdef";
|
||
std::string id = prefix ? prefix : "";
|
||
for (unsigned char b : buf) { id.push_back(kHex[b >> 4]); id.push_back(kHex[b & 0x0F]); }
|
||
return id;
|
||
}
|
||
|
||
// Broadcast the header + payload as two 0-value memo outputs to memos.recipientZaddr (header first,
|
||
// the lower memo position). Routes to the lite controller in lite builds, else full-node z_sendmany.
|
||
//
|
||
// LIVE-VERIFY (cannot be proven from source): that dragonxd returns the memo under `memoStr` verbatim
|
||
// on receive, that recipient-array order maps to note position (the receive pairing needs the header
|
||
// at a lower position), that a 0-value memo-only tx relays, and full SDXL<->DragonX interop.
|
||
// A chat send moves 0 value, so the fee is the ONLY thing that forces a real shielded input — a
|
||
// 0-value + 0-fee tx builds a degenerate, unrelayable tx (see z_sendmany targetAmount). Never let the
|
||
// global default-fee setting drop chat below a working minimum (well above the node's min relay fee).
|
||
static constexpr double kChatMinFeeDrgx = 0.0001;
|
||
|
||
bool App::broadcastChatMemos(const chat::OutgoingChatMemos& memos, const std::string& echoLocalId)
|
||
{
|
||
if (memos.recipientZaddr.empty()) return false;
|
||
|
||
// Guard the async resolve against a wallet lock/switch between submit and callback: if the chat
|
||
// session was reset in between, this echo belongs to a stale session — don't touch the new store.
|
||
const auto sessionGen = chat_session_generation_;
|
||
|
||
if (lite_wallet_) {
|
||
const bool ok = broadcastChatMemosLite(memos);
|
||
// The lite backend has no per-send completion callback here; resolve optimistically on a
|
||
// successful queue (its own broadcast log surfaces a later failure).
|
||
if (ok && sessionGen == chat_session_generation_)
|
||
chat_service_.resolveOutgoing(echoLocalId, chat::ChatDelivery::Sent);
|
||
return ok;
|
||
}
|
||
|
||
if (!state_.connected || !rpc_ || !worker_) {
|
||
ui::Notifications::instance().error(TR("chat_toast_not_connected"));
|
||
return false;
|
||
}
|
||
|
||
// Chat moves 0 value, and dragonxd REJECTS a 0-value tx whose fee exceeds the default miners fee
|
||
// (0.0001) — so pin chat to exactly kChatMinFeeDrgx, independent of the user's default-fee setting
|
||
// (which may be higher). It's also well above the node's min relay fee.
|
||
const double fee = kChatMinFeeDrgx;
|
||
|
||
// Pay from a z-address that can actually cover the fee. The memo still advertises the identity
|
||
// reply address, so the peer replies to the right place regardless of which note paid.
|
||
const std::string from = chatPayFromZaddr(fee);
|
||
if (from.empty()) {
|
||
ui::Notifications::instance().error(TR("chat_toast_need_funds"));
|
||
return false;
|
||
}
|
||
|
||
// Full-node: each memo needs the daemon's "utf8:" prefix (raw JSON/hex is otherwise rejected).
|
||
const auto outputs = chat::chatSendOutputs(memos, /*utf8Prefix=*/true);
|
||
nlohmann::json recipients = nlohmann::json::array();
|
||
for (const auto& out : outputs) {
|
||
nlohmann::json recipient;
|
||
recipient["address"] = out.address;
|
||
recipient["amount"] = util::formatAmountFixed(0.0); // 0-value memo output
|
||
recipient["memo"] = out.memo;
|
||
recipients.push_back(std::move(recipient));
|
||
}
|
||
|
||
// markFeeGapRetry=true is deliberate: it suppresses the fee-gap auto-retry, which rebuilds a
|
||
// SINGLE-recipient tx from the scalar to/amount/memo and would drop the second (payload) output.
|
||
// The callback flips the echo to Sent/Failed once the async op resolves — real delivery status.
|
||
submitZSendMany(from, memos.recipientZaddr, 0.0, fee, /*memo*/"", recipients,
|
||
"HushChat / broadcast", /*markFeeGapRetry*/ true,
|
||
[this, echoLocalId, sessionGen](bool ok, const std::string& /*result*/) {
|
||
if (sessionGen != chat_session_generation_) return; // wallet locked/switched — stale
|
||
chat_service_.resolveOutgoing(echoLocalId,
|
||
ok ? chat::ChatDelivery::Sent : chat::ChatDelivery::Failed);
|
||
});
|
||
return true; // submitted (async build/broadcast; the callback resolves the final status)
|
||
}
|
||
|
||
// Lite variant: two 0-value recipients to the same z-address, RAW memos (the backend does
|
||
// Memo::from_str directly — no "utf8:" prefix). The backend accepts duplicate addresses + 0-value
|
||
// outputs for exactly this HushChat pattern. Fire-and-forget: the result surfaces via the lite
|
||
// broadcast log; the message is already echoed locally.
|
||
bool App::broadcastChatMemosLite(const chat::OutgoingChatMemos& memos)
|
||
{
|
||
if (!lite_wallet_) return false;
|
||
const auto outputs = chat::chatSendOutputs(memos, /*utf8Prefix=*/false);
|
||
wallet::LiteSendRequest req;
|
||
for (const auto& out : outputs) {
|
||
wallet::LiteSendRecipient recipient;
|
||
recipient.address = out.address;
|
||
recipient.amountZatoshis = 0;
|
||
recipient.memo = out.memo;
|
||
req.recipients.push_back(std::move(recipient));
|
||
}
|
||
if (!lite_wallet_->sendTransaction(req)) {
|
||
ui::Notifications::instance().error(TR("chat_toast_lite_busy"));
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
void App::sendChatMessage(const std::string& conversationId, const std::string& text)
|
||
{
|
||
if (!chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return;
|
||
if (text.empty() || conversationId.empty()) return;
|
||
|
||
// The peer's z-address + public key come from a message we already have in this conversation. Pin to
|
||
// the EARLIEST (establishing) values, not the latest: the memo header's `z`/`cid` ride outside the
|
||
// AEAD, so trusting the newest would let a later message redirect our replies to an attacker-chosen
|
||
// address / splice threads (B2). A full fix binds z+cid into the secretstream additional-data, but
|
||
// that's a coordinated HushChat/SDXLite wire-format change; pinning hardens the reply target without it.
|
||
std::string peerZaddr;
|
||
std::string peerPubKey;
|
||
for (const auto& m : chat_service_.store().conversation(conversationId)) {
|
||
if (peerZaddr.empty() && !m.peer_zaddr.empty()) peerZaddr = m.peer_zaddr;
|
||
if (peerPubKey.empty() && !m.peer_public_key_hex.empty()) peerPubKey = m.peer_public_key_hex;
|
||
if (!peerZaddr.empty() && !peerPubKey.empty()) break;
|
||
}
|
||
if (peerPubKey.empty()) {
|
||
ui::Notifications::instance().info(TR("chat_toast_waiting_reply"));
|
||
return;
|
||
}
|
||
const std::string myReply = chatReplyZaddr();
|
||
if (myReply.empty()) {
|
||
ui::Notifications::instance().error(TR("chat_toast_no_zaddr"));
|
||
return;
|
||
}
|
||
|
||
chat::OutgoingChatMemos memos;
|
||
if (chat_service_.composeMessage(myReply, peerPubKey, peerZaddr, conversationId, text, memos)
|
||
!= chat::ChatComposeStatus::Ok) {
|
||
ui::Notifications::instance().error(TR("chat_toast_compose_failed"));
|
||
return;
|
||
}
|
||
chat::ChatMessage echo;
|
||
echo.direction = chat::ChatDirection::Outgoing;
|
||
echo.kind = chat::ChatMessageKind::Message;
|
||
echo.conversation_id = conversationId;
|
||
echo.peer_zaddr = peerZaddr;
|
||
echo.peer_public_key_hex = peerPubKey;
|
||
echo.body = text;
|
||
echo.timestamp = std::time(nullptr);
|
||
echo.txid = generateChatLocalId("out:", 8);
|
||
echo.payload_position = 0;
|
||
echo.delivery = chat::ChatDelivery::Sending; // shows immediately; the broadcast callback resolves it
|
||
chat_service_.recordOutgoingPending(echo); // in-memory now; persisted with the final status
|
||
// A synchronous refusal (not connected / no funds) resolves to Failed right away so the Retry
|
||
// affordance appears; otherwise the async callback flips it to Sent/Failed.
|
||
if (!broadcastChatMemos(memos, echo.txid))
|
||
chat_service_.resolveOutgoing(echo.txid, chat::ChatDelivery::Failed);
|
||
}
|
||
|
||
void App::startChatConversation(const std::string& peerZaddr, const std::string& text)
|
||
{
|
||
if (!chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return;
|
||
if (peerZaddr.empty() || text.empty()) return;
|
||
sendContactRequestForCid(generateChatLocalId("", 16), peerZaddr, text); // new opaque cid
|
||
}
|
||
|
||
// Compose + broadcast a contact request into a SPECIFIC conversation. startChatConversation() mints a
|
||
// fresh cid; a Retry of a failed request reuses its existing cid so it stays in the same thread.
|
||
void App::sendContactRequestForCid(const std::string& cid, const std::string& peerZaddr,
|
||
const std::string& text)
|
||
{
|
||
if (!chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return;
|
||
if (cid.empty() || peerZaddr.empty() || text.empty()) return;
|
||
const std::string myReply = chatReplyZaddr();
|
||
if (myReply.empty()) {
|
||
ui::Notifications::instance().error(TR("chat_toast_no_zaddr"));
|
||
return;
|
||
}
|
||
|
||
chat::OutgoingChatMemos memos;
|
||
if (chat_service_.composeContactRequest(myReply, peerZaddr, cid, text, memos)
|
||
!= chat::ChatComposeStatus::Ok) {
|
||
ui::Notifications::instance().error(TR("chat_toast_request_compose_failed"));
|
||
return;
|
||
}
|
||
chat::ChatMessage echo;
|
||
echo.direction = chat::ChatDirection::Outgoing;
|
||
echo.kind = chat::ChatMessageKind::ContactRequest;
|
||
echo.conversation_id = cid;
|
||
echo.peer_zaddr = peerZaddr;
|
||
echo.body = text;
|
||
echo.timestamp = std::time(nullptr);
|
||
echo.txid = generateChatLocalId("out:", 8);
|
||
echo.payload_position = 0;
|
||
echo.delivery = chat::ChatDelivery::Sending;
|
||
chat_service_.recordOutgoingPending(echo);
|
||
if (broadcastChatMemos(memos, echo.txid))
|
||
ui::Notifications::instance().success(TR("chat_toast_request_queued"));
|
||
else
|
||
chat_service_.resolveOutgoing(echo.txid, chat::ChatDelivery::Failed);
|
||
}
|
||
|
||
// HushChat (lite variant): the full-node harvest works off z_viewtransaction, but the lite wallet's
|
||
// transactions come from the backend. The backend lists one entry per received (non-change) note —
|
||
// same txid, distinct position + decoded-UTF-8 memo — so a chat tx yields two entries (header +
|
||
// payload). Group the received notes by txid, run them through the SAME parser, and thread the
|
||
// result. The store dedups (txid+position), so re-listing every refresh is harmless.
|
||
void App::ingestLiteChatMemos(const wallet::LiteWalletAppRefreshModel& model)
|
||
{
|
||
if (!chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return;
|
||
|
||
std::unordered_map<std::string, chat::HushChatTransactionInput> byTxid;
|
||
std::unordered_map<std::string, std::int64_t> txTimestamps;
|
||
for (const auto& tx : model.transactions) {
|
||
if (tx.kind != wallet::LiteWalletAppTransactionKind::Receive) continue; // only incoming carry chat
|
||
if (tx.memo.empty() || !tx.position) continue;
|
||
auto& input = byTxid[tx.txid];
|
||
input.txid = tx.txid;
|
||
input.outputs.push_back({static_cast<std::size_t>(*tx.position), tx.memo});
|
||
txTimestamps[tx.txid] = tx.timestamp;
|
||
}
|
||
if (byTxid.empty()) return;
|
||
|
||
std::vector<chat::HushChatTransactionMetadata> metadata;
|
||
for (auto& entry : byTxid) {
|
||
auto extracted = chat::extractHushChatTransactionMetadata(entry.second, true);
|
||
for (auto& meta : extracted.metadata) metadata.push_back(std::move(meta));
|
||
}
|
||
if (!metadata.empty()) {
|
||
std::vector<std::string> newChatCids;
|
||
chat_service_.ingest(metadata, txTimestamps, std::time(nullptr), &newChatCids);
|
||
// Mirror the full-node paths: a new incoming message un-hides a hidden conversation (you can't
|
||
// un-receive), and a non-muted new message toasts when we're off the Chat tab.
|
||
if (settings_) {
|
||
bool unhid = false;
|
||
for (const auto& cid : newChatCids)
|
||
if (settings_->isChatHidden(cid)) { settings_->setChatHidden(cid, false); unhid = true; }
|
||
if (unhid) settings_->save();
|
||
}
|
||
if (current_page_ != ui::NavPage::Chat &&
|
||
std::any_of(newChatCids.begin(), newChatCids.end(),
|
||
[this](const std::string& cid){ return !(settings_ && settings_->isChatMuted(cid)); }))
|
||
ui::Notifications::instance().info(TR("chat_new_message_toast"));
|
||
}
|
||
}
|
||
|
||
void App::fastScanChatMemos()
|
||
{
|
||
// Full-node only (lite has its own harvest). Re-scan JUST the chat reply address at minconf=0 every
|
||
// cycle so peers' messages surface at mempool speed. The normal, block-tip-gated harvest still
|
||
// ingests everything on confirmation (the store dedups on txid+position, so no double-insert).
|
||
if (lite_wallet_) return;
|
||
if (!chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return;
|
||
if (!state_.connected || !rpc_ || !worker_) return;
|
||
if (chat_fast_scan_in_flight_) return; // don't stack RPCs if a previous scan is still running
|
||
const std::string addr = chatReplyZaddr();
|
||
if (addr.empty()) return;
|
||
|
||
chat_fast_scan_in_flight_ = true;
|
||
const int scanGen = chat_session_generation_; // guard: drop the result if the wallet switches/locks
|
||
worker_->post([this, addr, scanGen]() -> rpc::RPCWorker::MainCb {
|
||
std::vector<chat::HushChatTransactionMetadata> metadata;
|
||
try {
|
||
rpc::RPCClient::TraceScope trace("HushChat / 0-conf fast scan");
|
||
nlohmann::json received = rpc_->call("z_listreceivedbyaddress", {addr, 0}); // 0 = include mempool
|
||
if (received.is_array()) {
|
||
std::unordered_map<std::string, chat::HushChatTransactionInput> byTxid;
|
||
std::size_t noteIndex = 0;
|
||
for (const auto& note : received) {
|
||
const std::size_t fallbackPos = noteIndex++;
|
||
if (!note.is_object()) continue;
|
||
const std::string txid = note.value("txid", std::string());
|
||
const std::string memo = note.value("memoStr", std::string());
|
||
if (txid.empty() || memo.empty()) continue;
|
||
std::size_t pos = fallbackPos;
|
||
for (const char* key : {"position", "outputIndex", "outindex"})
|
||
if (note.contains(key) && note[key].is_number_integer() && note[key].get<int>() >= 0) {
|
||
pos = static_cast<std::size_t>(note[key].get<int>());
|
||
break;
|
||
}
|
||
auto& in = byTxid[txid];
|
||
in.txid = txid;
|
||
in.outputs.push_back(chat::HushChatMemoOutput{pos, memo});
|
||
}
|
||
for (auto& entry : byTxid) {
|
||
auto extracted = chat::extractHushChatTransactionMetadata(entry.second);
|
||
for (auto& m : extracted.metadata) metadata.push_back(std::move(m));
|
||
}
|
||
}
|
||
} catch (const std::exception&) {}
|
||
return [this, scanGen, metadata = std::move(metadata)]() mutable {
|
||
// The wallet was switched/locked between post and now — this metadata belongs to the previous
|
||
// session. resetChatSession already reset the in-flight flag, so just drop; clearing it here
|
||
// would clobber a new session's own in-flight scan (mirrors the broadcast/identity guards).
|
||
if (scanGen != chat_session_generation_) return;
|
||
chat_fast_scan_in_flight_ = false;
|
||
if (metadata.empty()) return;
|
||
// Skip HIDDEN conversations — the mempool fast path deliberately doesn't surface them; they
|
||
// still come back through the normal confirmed harvest (which un-hides on a new message).
|
||
std::vector<chat::HushChatTransactionMetadata> visible;
|
||
visible.reserve(metadata.size());
|
||
for (auto& m : metadata)
|
||
if (!(settings_ && settings_->isChatHidden(m.conversation_id)))
|
||
visible.push_back(std::move(m));
|
||
if (visible.empty()) return;
|
||
|
||
std::unordered_map<std::string, std::int64_t> noTimes; // mempool: no block time → ingest uses now
|
||
std::vector<std::string> newChatCids;
|
||
chat_service_.ingest(visible, noTimes, std::time(nullptr), &newChatCids);
|
||
if (current_page_ != ui::NavPage::Chat &&
|
||
std::any_of(newChatCids.begin(), newChatCids.end(),
|
||
[this](const std::string& cid){ return !(settings_ && settings_->isChatMuted(cid)); }))
|
||
ui::Notifications::instance().info(TR("chat_new_message_toast"));
|
||
};
|
||
});
|
||
}
|
||
|
||
bool App::lockLiteWallet()
|
||
{
|
||
if (!lite_wallet_) return false;
|
||
const bool ok = lite_wallet_->lockWallet();
|
||
if (ok) {
|
||
// Full-node App::lockWallet() sets state_.locked synchronously; the lite backend `lock`
|
||
// doesn't, and state_.locked only refreshes on the next ~2s poll. Mirror the full-node
|
||
// behavior here and tear down the chat session NOW so no decrypted store / unlocked DB key
|
||
// lingers past an explicit lock. (These chat calls are safe no-ops when chat is off/idle.)
|
||
state_.locked = true;
|
||
chat_service_.clearIdentity();
|
||
chat_service_.store().clear();
|
||
chat_db_.lock();
|
||
chat_identity_provisioned_ = false;
|
||
chat_identity_unavailable_ = false;
|
||
}
|
||
return ok;
|
||
}
|
||
|
||
void App::seedChatDemoData()
|
||
{
|
||
if (!chat::hushChatFeatureEnabledAtBuild()) return;
|
||
|
||
// Demo identity so the tab renders the populated UI (hasIdentity()==true). Injected messages go
|
||
// straight into the in-memory store (no DB attached here → not persisted, gone on restart).
|
||
//
|
||
// Two hard rules, both learned the hard way:
|
||
// 1. NEVER clobber a real provisioned identity. Overwriting the wallet's seed-derived identity with
|
||
// a demo one made two different-seed wallets share an identity — so their own sends looped back
|
||
// and DECRYPTED as incoming. Only fabricate an identity when there is no real one.
|
||
// 2. Use a RANDOM secret, never a fixed string. A constant demo secret is the SAME public key on
|
||
// every install/wallet, i.e. everyone who ran the demo shared one cryptographic identity.
|
||
if (!chat_service_.hasIdentity()) {
|
||
chat::ChatKeyPair keys;
|
||
const std::string demoSecret = generateChatLocalId("demo-chat:", 24); // random, per-run
|
||
if (chat::deriveChatIdentityFromSecret(demoSecret, keys).status
|
||
== chat::ChatIdentityStatus::Ready) {
|
||
chat_service_.setIdentity(keys);
|
||
}
|
||
chat::wipeChatKeyPair(keys);
|
||
}
|
||
|
||
auto& store = chat_service_.store();
|
||
const std::string zAlice = "zs1demoalice6xh2n8fchrz23thcgqqd2353v8ev2pr7p7lq4p3elsyrfkuenq";
|
||
const std::string zBob = "zs1demobob9k4p3elsyrfkuenq4kl79j2pg7l3h2juz4t6q9wqxkha2n8fchrz2";
|
||
auto add = [&](const std::string& cid, const std::string& z, const std::string& pk,
|
||
chat::ChatDirection dir, chat::ChatMessageKind kind, const std::string& body,
|
||
std::int64_t ts, const std::string& id) {
|
||
chat::ChatMessage m;
|
||
m.direction = dir; m.kind = kind; m.conversation_id = cid; m.peer_zaddr = z;
|
||
m.peer_public_key_hex = pk; m.body = body; m.timestamp = ts; m.txid = id;
|
||
m.payload_position = 0;
|
||
store.append(m);
|
||
};
|
||
// A full thread (peer key known → composer enabled).
|
||
add("demo-1", zAlice, std::string(64, 'a'), chat::ChatDirection::Incoming, chat::ChatMessageKind::Message,
|
||
"hey! got the DragonX wallet running, chat works great", 1751284800, "demo:a1");
|
||
add("demo-1", zAlice, std::string(64, 'a'), chat::ChatDirection::Outgoing, chat::ChatMessageKind::Message,
|
||
"nice - this is end-to-end encrypted over shielded memos", 1751285400, "demo:a2");
|
||
add("demo-1", zAlice, std::string(64, 'a'), chat::ChatDirection::Incoming, chat::ChatMessageKind::Message,
|
||
"and it survives the daemon's output shuffle now", 1751286000, "demo:a3");
|
||
// An incoming contact request (peer key known → can reply).
|
||
add("demo-2", zBob, std::string(64, 'b'), chat::ChatDirection::Incoming, chat::ChatMessageKind::ContactRequest,
|
||
"hi, add me? - bob", 1751200000, "demo:b1");
|
||
// An outgoing contact request awaiting a reply (no peer key → "waiting" composer state).
|
||
add("demo-3", "zs1demopeerawaitingtheirfirstreplybeforewecanmessagethemyet00", "",
|
||
chat::ChatDirection::Outgoing, chat::ChatMessageKind::ContactRequest, "hey, let's chat", 1751100000, "demo:c1");
|
||
}
|
||
|
||
void App::exportAllKeys(std::function<void(const std::string&, int, int)> callback)
|
||
{
|
||
if (!state_.connected || !rpc_) {
|
||
if (callback) callback("", 0, 0);
|
||
return;
|
||
}
|
||
|
||
// Collect all keys into a string
|
||
auto keys_result = std::make_shared<std::string>();
|
||
auto pending = std::make_shared<int>(0);
|
||
auto total = std::make_shared<int>(0);
|
||
auto exported = std::make_shared<int>(0); // keys actually retrieved (vs. failed/locked)
|
||
|
||
// First get all addresses
|
||
auto all_addresses = std::make_shared<std::vector<std::string>>();
|
||
|
||
// Add t-addresses
|
||
for (const auto& addr : state_.t_addresses) {
|
||
all_addresses->push_back(addr.address);
|
||
}
|
||
// Add z-addresses
|
||
for (const auto& addr : state_.z_addresses) {
|
||
all_addresses->push_back(addr.address);
|
||
}
|
||
|
||
*total = all_addresses->size();
|
||
*pending = *total;
|
||
|
||
if (*total == 0) {
|
||
if (callback) callback("# No addresses to export\n", 0, 0);
|
||
return;
|
||
}
|
||
|
||
*keys_result = "# DragonX Wallet Private Keys Export\n";
|
||
*keys_result += "# WARNING: Keep this file secure! Anyone with these keys can spend your coins!\n\n";
|
||
|
||
for (const auto& addr : *all_addresses) {
|
||
exportPrivateKey(addr, [keys_result, pending, total, exported, callback, addr](const std::string& key) {
|
||
if (!key.empty()) {
|
||
*keys_result += "# " + addr + "\n";
|
||
*keys_result += key + "\n\n";
|
||
(*exported)++;
|
||
}
|
||
(*pending)--;
|
||
if (*pending == 0 && callback) {
|
||
callback(*keys_result, *exported, *total);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
void App::importPrivateKey(const std::string& rawKey, int startHeight,
|
||
std::function<void(bool, const std::string&, const std::string&)> callback)
|
||
{
|
||
if (!state_.connected || !rpc_ || !worker_) {
|
||
if (callback) callback(false, "Not connected", "");
|
||
return;
|
||
}
|
||
|
||
// Trim whitespace/newlines — manual entry doesn't go through the dialog's Paste trimmer, and a
|
||
// stray character makes the daemon reject the key with a cryptic error.
|
||
std::string key(rawKey);
|
||
while (!key.empty() && (key.front()==' '||key.front()=='\t'||key.front()=='\n'||key.front()=='\r')) key.erase(key.begin());
|
||
while (!key.empty() && (key.back()==' '||key.back()=='\t'||key.back()=='\n'||key.back()=='\r')) key.pop_back();
|
||
|
||
// Reject anything that isn't a recognized Z/T private key or shielded viewing key before handing
|
||
// it to the daemon (the dialog's indicator and this guard share isRecognizedImportKey).
|
||
if (!services::WalletSecurityController::isRecognizedImportKey(key)) {
|
||
if (callback) callback(false, "Unrecognized key format.", "");
|
||
return;
|
||
}
|
||
|
||
const bool viewing = services::WalletSecurityController::isViewingKey(key);
|
||
const bool shielded = services::WalletSecurityController::classifyPrivateKey(key)
|
||
== services::WalletSecurityController::KeyKind::Shielded;
|
||
// Run on the worker thread — import requests a full rescan (rescan=true), so the
|
||
// synchronous curl call can take many seconds; never block the UI thread on it.
|
||
worker_->post([this, key, viewing, shielded, startHeight, callback]() -> rpc::RPCWorker::MainCb {
|
||
std::string err, addr;
|
||
try {
|
||
rpc::RPCClient::TraceScope trace("Settings / Import key");
|
||
std::string method;
|
||
nlohmann::json params;
|
||
if (viewing) { method = "z_importviewingkey"; params = {key, "yes"}; } // watch-only
|
||
else if (shielded) { method = "z_importkey"; params = {key, "yes"}; }
|
||
else { method = "importprivkey"; params = {key, "", true}; }
|
||
// A start height (shielded RPCs only) rescans from that block instead of genesis.
|
||
if (startHeight > 0 && (viewing || shielded)) params.push_back(startHeight);
|
||
nlohmann::json r = rpc_->call(method, params);
|
||
// z_import* return {type,address}; importprivkey returns the t-address string.
|
||
if (r.is_object() && r.contains("address") && r["address"].is_string())
|
||
addr = r["address"].get<std::string>();
|
||
else if (r.is_string())
|
||
addr = r.get<std::string>();
|
||
} catch (const std::exception& e) {
|
||
err = e.what();
|
||
} catch (...) {
|
||
// Non-std throw must not escape into the worker — the main-thread callback
|
||
// below would never run, leaving a stuck "Importing…" spinner.
|
||
err = "Import failed (unknown error)";
|
||
}
|
||
return [this, err, addr, callback]() {
|
||
if (!err.empty()) {
|
||
if (callback) callback(false, err, "");
|
||
return;
|
||
}
|
||
invalidateAddressValidationCache();
|
||
refreshAddresses();
|
||
if (callback) callback(true, "", addr);
|
||
};
|
||
});
|
||
}
|
||
|
||
// Sweep a spending key: import it (a full rescan populates its UTXOs/notes — the stock node has no
|
||
// address index, so there is no way to enumerate them without importing) then z_sendmany ALL of the
|
||
// key's funds (balance − fee) to a destination the user already controls. The imported key is left in
|
||
// the wallet with an empty balance (there is no remove-key RPC) — the point is that the funds now sit
|
||
// on the user's own key. z_sendmany (not z_mergetoaddress): it moves a single-UTXO transparent source
|
||
// and fails loudly rather than silently leaving a remainder. Fund-moving — see the reviewed design.
|
||
void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMode,
|
||
const std::string& destExisting)
|
||
{
|
||
if (!state_.connected || !rpc_ || !worker_) {
|
||
sweep_status_ = "Not connected to the daemon.";
|
||
sweep_step_ = SweepStep::Error;
|
||
return;
|
||
}
|
||
// Trim (manual entry doesn't go through the dialog's Paste trimmer).
|
||
std::string key(rawKey);
|
||
while (!key.empty() && (key.front()==' '||key.front()=='\t'||key.front()=='\n'||key.front()=='\r')) key.erase(key.begin());
|
||
while (!key.empty() && (key.back()==' '||key.back()=='\t'||key.back()=='\n'||key.back()=='\r')) key.pop_back();
|
||
|
||
// Sweeping requires a SPENDING key (transparent WIF or shielded z-spending key) — a viewing key
|
||
// can't sign, and this must never accidentally route to z_importviewingkey.
|
||
if (!services::WalletSecurityController::isRecognizedPrivateKey(key)) {
|
||
sweep_status_ = "Enter a spending key (a viewing key can't move funds).";
|
||
sweep_step_ = SweepStep::Error;
|
||
return;
|
||
}
|
||
|
||
sweep_step_ = SweepStep::Running;
|
||
sweep_status_ = "Importing key & rescanning…";
|
||
sweep_txid_.clear();
|
||
sweep_dest_shown_.clear();
|
||
|
||
const bool shielded = services::WalletSecurityController::classifyPrivateKey(key)
|
||
== services::WalletSecurityController::KeyKind::Shielded;
|
||
const double fee = DRAGONX_DEFAULT_FEE;
|
||
worker_->post([this, key, startHeight, destMode, destExisting, shielded, fee]() -> rpc::RPCWorker::MainCb {
|
||
std::string err, dest, sourceAddr, amountStr;
|
||
double amount = 0.0;
|
||
try {
|
||
rpc::RPCClient::TraceScope trace("Settings / Sweep key");
|
||
|
||
// For a shielded key this daemon's z_importkey returns null (no address), so snapshot the
|
||
// z-address set before the import and diff it after to find the newly-controlled address.
|
||
std::vector<std::string> preZ;
|
||
bool preZok = false;
|
||
auto listZ = [&](std::vector<std::string>& out) {
|
||
out.clear();
|
||
auto la = rpc_->call("z_listaddresses");
|
||
if (la.is_array()) for (auto& a : la) if (a.is_string()) out.push_back(a.get<std::string>());
|
||
};
|
||
if (shielded) { try { listZ(preZ); preZok = true; } catch (...) { preZok = false; } }
|
||
|
||
// 1. Import the key (blocks until the rescan completes → its funds become spendable).
|
||
std::string method;
|
||
nlohmann::json params;
|
||
if (shielded) { method = "z_importkey"; params = {key, "yes"}; }
|
||
else { method = "importprivkey"; params = {key, "", true}; }
|
||
if (startHeight > 0 && shielded) params.push_back(startHeight);
|
||
nlohmann::json r = rpc_->call(method, params);
|
||
|
||
// 2. Determine the swept address. importprivkey returns the t-address string; z_importkey
|
||
// returns null, so diff the z-address list to find the one the key just added.
|
||
if (shielded) {
|
||
// A failed pre-snapshot would make the diff treat pre-existing addresses as "new" and
|
||
// could pick the WRONG source — refuse rather than risk moving another address's funds.
|
||
if (!preZok) throw std::runtime_error("Couldn't read the wallet's addresses to identify the swept key. Try again.");
|
||
std::vector<std::string> postZ;
|
||
listZ(postZ);
|
||
std::vector<std::string> added;
|
||
for (const auto& s : postZ)
|
||
if (std::find(preZ.begin(), preZ.end(), s) == preZ.end()) added.push_back(s);
|
||
if (added.size() == 1) sourceAddr = added[0];
|
||
else if (added.empty()) throw std::runtime_error("__ALREADY__"); // key already in wallet
|
||
else throw std::runtime_error("Could not identify the key's address after import.");
|
||
} else {
|
||
if (r.is_string()) sourceAddr = r.get<std::string>();
|
||
else if (r.is_object() && r.contains("address") && r["address"].is_string())
|
||
sourceAddr = r["address"].get<std::string>();
|
||
}
|
||
if (sourceAddr.empty()) throw std::runtime_error("Could not determine the key's address.");
|
||
|
||
// 3. Confirm the key holds spendable (confirmed) funds. z_getbalance covers t- and z-addrs.
|
||
auto balAt = [&](int minconf) -> double {
|
||
try {
|
||
nlohmann::json b = rpc_->call("z_getbalance", {sourceAddr, minconf});
|
||
return b.is_number() ? b.get<double>()
|
||
: b.is_string() ? std::stod(b.get<std::string>()) : 0.0;
|
||
} catch (...) { return 0.0; }
|
||
};
|
||
double bal = balAt(1);
|
||
if (bal <= 0.0) {
|
||
throw std::runtime_error(balAt(0) > 0.0 ? "__UNCONFIRMED__" : "__NOFUNDS__");
|
||
}
|
||
if (bal <= fee) throw std::runtime_error("__DUST__");
|
||
|
||
// 4. Resolve the destination — a fresh shielded address by default, else the picked one.
|
||
if (destMode == 0) dest = rpc_->call("z_getnewaddress").get<std::string>();
|
||
else dest = destExisting;
|
||
if (dest.empty()) throw std::runtime_error("No destination address for the sweep.");
|
||
|
||
// Send everything; the fee consumes the remainder (no change/residue). Compute in integer
|
||
// satoshis so the fixed-decimal amount is exact for all realistic balances (double bal-fee
|
||
// is exact below 2^52 sat ≈ 45M DRGX; above that the JSON-parsed balance is itself lossy —
|
||
// a swept key that large is implausible and would fail safe with funds left in place).
|
||
const long long feeSats = (long long)std::llround(fee * 100000000.0);
|
||
const long long amtSats = (long long)std::llround(bal * 100000000.0) - feeSats;
|
||
if (amtSats <= 0) throw std::runtime_error("__DUST__");
|
||
char amtBuf[32];
|
||
snprintf(amtBuf, sizeof(amtBuf), "%lld.%08lld",
|
||
amtSats / 100000000LL, amtSats % 100000000LL);
|
||
amountStr = amtBuf;
|
||
amount = (double)amtSats / 100000000.0; // for send bookkeeping / display only
|
||
} catch (const std::exception& e) {
|
||
err = e.what();
|
||
} catch (...) {
|
||
err = "Sweep failed (unknown error)";
|
||
}
|
||
return [this, err, sourceAddr, dest, amount, amountStr, fee]() {
|
||
invalidateAddressValidationCache();
|
||
refreshAddresses();
|
||
if (!err.empty()) {
|
||
if (err == "__NOFUNDS__") sweep_status_ = "This key holds no funds to sweep.";
|
||
else if (err == "__UNCONFIRMED__") sweep_status_ = "This key's funds are still unconfirmed — try again shortly.";
|
||
else if (err == "__ALREADY__") sweep_status_ = "This key is already in your wallet — use Send to move its funds.";
|
||
else if (err == "__DUST__") sweep_status_ = "This key's balance is too small to cover the network fee.";
|
||
else sweep_status_ = err;
|
||
sweep_step_ = SweepStep::Error;
|
||
return;
|
||
}
|
||
sweep_dest_shown_ = dest;
|
||
sweep_status_ = "Sweeping funds to your address…";
|
||
// Send all funds from the swept address to the destination through the tested z_sendmany
|
||
// wrapper (fixed-decimal amount + async-op tracking). z_sendmany needs every input in one
|
||
// transaction to cover the full balance, so it fails loudly rather than partial-sweeping.
|
||
nlohmann::json recipients = nlohmann::json::array();
|
||
nlohmann::json rcp;
|
||
rcp["address"] = dest;
|
||
rcp["amount"] = amountStr; // exact integer-satoshi fixed-decimal string
|
||
recipients.push_back(rcp);
|
||
submitZSendMany(sourceAddr, dest, amount, fee, "", recipients, "Settings / Sweep key",
|
||
/*markFeeGapRetry*/ false, [this](bool ok, const std::string& result) {
|
||
if (ok) {
|
||
sweep_txid_ = result;
|
||
sweep_status_.clear();
|
||
sweep_step_ = SweepStep::Done;
|
||
} else {
|
||
sweep_status_ = result.empty() ? "The sweep transaction failed." : result;
|
||
sweep_step_ = SweepStep::Error;
|
||
}
|
||
refreshBalance();
|
||
});
|
||
};
|
||
});
|
||
}
|
||
|
||
void App::exportSeedPhrase(std::function<void(bool, bool, const std::string&, const std::string&)> callback)
|
||
{
|
||
if (!state_.connected || !rpc_ || !worker_) {
|
||
if (callback) callback(false, false, std::string(), "Not connected to the daemon.");
|
||
return;
|
||
}
|
||
worker_->post([this, callback]() -> rpc::RPCWorker::MainCb {
|
||
std::string phrase; // SECRET — wiped after the callback runs
|
||
std::string err;
|
||
bool ok = false;
|
||
bool noMnemonic = false;
|
||
rpc::RPCClient::TraceScope trace("Settings / Export seed phrase");
|
||
try {
|
||
auto response = rpc_->callSecret("z_exportmnemonic"); // zero the raw body too (B7)
|
||
if (response.contains("mnemonic") && response["mnemonic"].is_string()) {
|
||
auto& m = response["mnemonic"].get_ref<std::string&>();
|
||
phrase = m;
|
||
if (!m.empty()) sodium_memzero(&m[0], m.size()); // scrub the json's own copy
|
||
ok = !phrase.empty();
|
||
} else {
|
||
err = "The daemon returned no seed phrase.";
|
||
}
|
||
} catch (const rpc::RpcError& e) {
|
||
err = e.what();
|
||
// The daemon errors with this specific message when the wallet's seed is not
|
||
// mnemonic-derived (a legacy wallet). Any other error (e.g. locked) stays generic.
|
||
if (err.find("not derived from a mnemonic") != std::string::npos)
|
||
noMnemonic = true;
|
||
} catch (const std::exception& e) {
|
||
err = e.what();
|
||
}
|
||
return [callback, ok, noMnemonic, phrase = std::move(phrase), err]() mutable {
|
||
if (callback) callback(ok, noMnemonic, phrase, err);
|
||
if (!phrase.empty()) sodium_memzero(&phrase[0], phrase.size());
|
||
};
|
||
});
|
||
}
|
||
|
||
// One-time nudge to back up the seed phrase on a mnemonic-backed full-node wallet. Fires at
|
||
// most one z_exportmnemonic probe per install; the phrase is not retained (the probe only
|
||
// learns whether a mnemonic exists). Legacy (non-mnemonic) wallets are not nagged.
|
||
void App::maybeRemindSeedBackup()
|
||
{
|
||
if (capture_mode_) return; // no live ops during a UI sweep
|
||
if (lite_wallet_) return; // lite has its own seed UX
|
||
if (!settings_ || settings_->getSeedBackupReminded()) return;
|
||
if (!state_.connected || !state_.encryption_state_known) return;
|
||
if (state_.isLocked()) return; // wait until unlocked to read it
|
||
if (seed_backup_reminder_in_flight_) return;
|
||
seed_backup_reminder_in_flight_ = true;
|
||
exportSeedPhrase([this](bool ok, bool noMnemonic, const std::string& /*phrase*/,
|
||
const std::string& /*error*/) {
|
||
seed_backup_reminder_in_flight_ = false;
|
||
// Only mark "reminded" once we have a definitive answer, so a transient failure retries
|
||
// next session rather than silently suppressing the reminder forever.
|
||
if (ok || noMnemonic) {
|
||
settings_->setSeedBackupReminded(true);
|
||
settings_->save();
|
||
}
|
||
if (ok)
|
||
ui::Notifications::instance().info(TR("seed_backup_reminder"), 12.0f);
|
||
});
|
||
}
|
||
|
||
// One-shot (per connect) probe of the current wallet's mnemonic status, so the Settings
|
||
// Migrate-to-seed button can glow for a legacy wallet without opening the migration dialog. Same
|
||
// classification as the migration Intro pre-flight, but proactive and cached. Reads no secret past
|
||
// the exportSeedPhrase callback (which wipes the phrase). Retries next tick on a transient failure.
|
||
void App::probeWalletSeedStatus()
|
||
{
|
||
if (capture_mode_) return; // no live ops during a UI sweep
|
||
if (lite_wallet_) return; // lite has its own seed UX
|
||
if (wallet_seed_status_ != WalletSeedStatus::Unknown) return; // already classified
|
||
if (!state_.connected || !state_.encryption_state_known) return;
|
||
if (state_.isLocked()) return; // needs an unlocked wallet to read it
|
||
if (wallet_seed_status_in_flight_) return;
|
||
wallet_seed_status_in_flight_ = true;
|
||
exportSeedPhrase([this](bool ok, bool noMnemonic, const std::string& /*phrase*/,
|
||
const std::string& error) {
|
||
wallet_seed_status_in_flight_ = false;
|
||
if (ok)
|
||
wallet_seed_status_ = WalletSeedStatus::HasMnemonic;
|
||
else if (noMnemonic)
|
||
wallet_seed_status_ = WalletSeedStatus::NoMnemonic; // legacy → migratable → glow
|
||
else if (error.find("Method not found") != std::string::npos ||
|
||
error.find("-32601") != std::string::npos)
|
||
wallet_seed_status_ = WalletSeedStatus::Incapable; // old daemon: can't migrate
|
||
else if (++wallet_seed_status_attempts_ >= 3)
|
||
wallet_seed_status_ = WalletSeedStatus::Incapable; // give up after a few transient errors
|
||
// else: transient error → stay Unknown, retry next tick
|
||
});
|
||
}
|
||
|
||
void App::beginCreateSeedWallet()
|
||
{
|
||
if (seed_migration_in_flight_) return;
|
||
seed_migration_in_flight_ = true;
|
||
seed_migration_step_ = SeedMigrationStep::Working;
|
||
{
|
||
std::lock_guard<std::mutex> lk(seed_migration_mutex_);
|
||
seed_migration_done_ = false;
|
||
seed_migration_progress_ = "Starting…";
|
||
}
|
||
// Runs the isolated create on a background thread; the main thread picks up progress + the
|
||
// result in pumpSeedMigration(). No funds move and the real wallet is never touched.
|
||
async_tasks_.submit("Create seed wallet", [this](const util::AsyncTaskManager::Token&) {
|
||
auto res = daemon::SeedWalletCreator::create(/*keepDatadir=*/true,
|
||
[this](const std::string& msg) {
|
||
std::lock_guard<std::mutex> lk(seed_migration_mutex_);
|
||
seed_migration_progress_ = msg;
|
||
});
|
||
std::lock_guard<std::mutex> lk(seed_migration_mutex_);
|
||
seed_migration_ok_ = res.ok;
|
||
seed_migration_r_seed_ = res.seedPhrase;
|
||
seed_migration_r_dest_ = res.destAddress;
|
||
seed_migration_r_tmp_ = res.tempDatadir;
|
||
seed_migration_r_err_ = res.error;
|
||
seed_migration_done_ = true;
|
||
if (!res.seedPhrase.empty()) sodium_memzero(&res.seedPhrase[0], res.seedPhrase.size());
|
||
});
|
||
}
|
||
|
||
void App::showSeedMigrationDialog()
|
||
{
|
||
show_seed_migration_ = true;
|
||
// Resume a pending migration. If a sweep was already submitted (txid persisted), resume at the
|
||
// confirm/adopt stage — re-derived from the chain — rather than sweeping again; otherwise start
|
||
// at the Sweep step. With no pending migration, start fresh at the intro.
|
||
if (settings_ && settings_->getSeedMigrationPending() && !settings_->getSeedMigrationDest().empty()) {
|
||
seed_migration_dest_ = settings_->getSeedMigrationDest();
|
||
seed_migration_temp_dir_ = settings_->getSeedMigrationTempDir();
|
||
seed_migration_sweep_txid_ = settings_->getSeedMigrationSweepTxid();
|
||
if (!seed_migration_sweep_txid_.empty()) {
|
||
seed_migration_sweep_confs_ = 0;
|
||
seed_migration_legacy_remaining_ = -1.0;
|
||
seed_migration_poll_timer_ = 0.0f; // poll immediately
|
||
seed_migration_step_ = SeedMigrationStep::Confirming;
|
||
} else {
|
||
seed_migration_step_ = SeedMigrationStep::Sweep;
|
||
seed_migration_balance_loaded_ = false;
|
||
seed_migration_nofunds_confirmed_ = false;
|
||
refreshSeedMigrationBalance();
|
||
}
|
||
} else {
|
||
seed_migration_step_ = SeedMigrationStep::Intro;
|
||
// Fresh start: the Intro step will pre-flight the wallet (legacy vs already-seeded vs old
|
||
// daemon) before offering to create anything.
|
||
seed_migration_precheck_ = SeedMigrationPrecheck::Pending;
|
||
seed_migration_precheck_started_ = false;
|
||
}
|
||
}
|
||
|
||
// Intro pre-flight: probe z_exportmnemonic on the CURRENT wallet so we can branch the Intro:
|
||
// ok → the wallet is already mnemonic-backed (offer backup, not a pointless migration)
|
||
// "not derived" → a legacy wallet (proceed with create)
|
||
// "Method not found" → the daemon is too old to create a seed wallet (explain + bail)
|
||
// The probe needs an unlocked, connected wallet; the Intro gates on that before calling this.
|
||
void App::beginSeedMigrationPrecheck()
|
||
{
|
||
seed_migration_precheck_started_ = true;
|
||
seed_migration_precheck_ = SeedMigrationPrecheck::Pending;
|
||
exportSeedPhrase([this](bool ok, bool noMnemonic, const std::string& /*phrase*/,
|
||
const std::string& error) {
|
||
// exportSeedPhrase wipes the phrase after this callback — we only need the classification.
|
||
if (ok)
|
||
seed_migration_precheck_ = SeedMigrationPrecheck::AlreadyMnemonic;
|
||
else if (noMnemonic)
|
||
seed_migration_precheck_ = SeedMigrationPrecheck::Legacy;
|
||
else if (error.find("Method not found") != std::string::npos ||
|
||
error.find("-32601") != std::string::npos)
|
||
seed_migration_precheck_ = SeedMigrationPrecheck::DaemonTooOld;
|
||
else
|
||
seed_migration_precheck_ = SeedMigrationPrecheck::CheckFailed; // transient — allow re-check
|
||
});
|
||
}
|
||
|
||
void App::refreshSeedMigrationBalance()
|
||
{
|
||
if (!rpc_ || !worker_ || !state_.connected) return;
|
||
worker_->post([this]() -> rpc::RPCWorker::MainCb {
|
||
double total = 0.0; bool ok = false;
|
||
rpc::RPCClient::TraceScope trace("Migrate / balance");
|
||
try {
|
||
auto res = rpc_->call("z_gettotalbalance");
|
||
if (res.contains("total") && res["total"].is_string())
|
||
total = std::stod(res["total"].get<std::string>());
|
||
ok = true;
|
||
} catch (...) {}
|
||
return [this, total, ok]() { if (ok) { seed_migration_balance_ = total; seed_migration_balance_loaded_ = true; } };
|
||
});
|
||
}
|
||
|
||
// Phase 2 step 1: sweep ALL legacy funds (transparent + shielded) into the new seed wallet's
|
||
// address. z_mergetoaddress with 0,0 limits merges as many inputs as fit in one transaction; a
|
||
// huge-input wallet may leave a small remainder in the (preserved) legacy backup.
|
||
void App::beginSweepToSeedWallet()
|
||
{
|
||
if (!rpc_ || !worker_ || !state_.connected) {
|
||
seed_migration_status_ = "Not connected to the daemon.";
|
||
seed_migration_step_ = SeedMigrationStep::Error; return;
|
||
}
|
||
if (seed_migration_dest_.empty()) {
|
||
seed_migration_status_ = "No destination address for the sweep.";
|
||
seed_migration_step_ = SeedMigrationStep::Error; return;
|
||
}
|
||
seed_migration_step_ = SeedMigrationStep::Sweeping;
|
||
seed_migration_status_ = "Building the sweep transaction…";
|
||
const std::string dest = seed_migration_dest_;
|
||
worker_->post([this, dest]() -> rpc::RPCWorker::MainCb {
|
||
std::string opid, err;
|
||
rpc::RPCClient::TraceScope trace("Migrate / sweep");
|
||
try {
|
||
json fromAddrs = json::array({"ANY_TADDR", "ANY_ZADDR"});
|
||
auto res = rpc_->call("z_mergetoaddress", {fromAddrs, dest, DRAGONX_DEFAULT_FEE, 0, 0});
|
||
opid = res.value("opid", std::string());
|
||
if (opid.empty()) err = "The daemon returned no operation id for the sweep.";
|
||
} catch (const std::exception& e) { err = e.what(); }
|
||
return [this, opid, err]() {
|
||
if (!err.empty() || opid.empty()) {
|
||
seed_migration_status_ = err.empty() ? "The sweep failed to start." : err;
|
||
seed_migration_step_ = SeedMigrationStep::Error;
|
||
return;
|
||
}
|
||
pending_send_callbacks_[opid] = [this](bool ok, const std::string& result) {
|
||
if (ok) {
|
||
seed_migration_sweep_txid_ = result;
|
||
// Persist the txid so a restart resumes at the confirm/adopt stage and never
|
||
// re-sweeps from scratch. The Confirming step gates adopt on this tx being mined
|
||
// (>= 1 confirmation) AND the legacy balance dropping to ~0.
|
||
if (settings_) { settings_->setSeedMigrationSweepTxid(result); settings_->save(); }
|
||
seed_migration_sweep_confs_ = 0;
|
||
seed_migration_legacy_remaining_ = -1.0;
|
||
seed_migration_poll_timer_ = 0.0f;
|
||
seed_migration_status_.clear();
|
||
seed_migration_step_ = SeedMigrationStep::Confirming;
|
||
} else {
|
||
seed_migration_status_ = result.empty() ? "The sweep transaction failed." : result;
|
||
seed_migration_step_ = SeedMigrationStep::Error;
|
||
}
|
||
};
|
||
trackOperation(opid);
|
||
seed_migration_status_ = "Waiting for the sweep transaction to be accepted…";
|
||
};
|
||
});
|
||
}
|
||
|
||
// Confirming step: poll the sweep tx's confirmations + the legacy wallet's remaining balance. The
|
||
// adopt step is gated on the tx being mined (confs >= 1) AND the legacy balance being ~0, so we
|
||
// never swap wallet.dat while the funds could still bounce back (dropped/reorged tx) or while a
|
||
// remainder is left behind (a sweep too big for one transaction).
|
||
void App::pollSweepStatus()
|
||
{
|
||
if (capture_mode_) return; // no live ops during a UI sweep
|
||
if (!rpc_ || !worker_ || !state_.connected) return;
|
||
if (seed_migration_confirm_in_flight_ || seed_migration_sweep_txid_.empty()) return;
|
||
seed_migration_confirm_in_flight_ = true;
|
||
const std::string txid = seed_migration_sweep_txid_;
|
||
worker_->post([this, txid]() -> rpc::RPCWorker::MainCb {
|
||
int confs = 0; double remaining = -1.0; bool ok = false;
|
||
rpc::RPCClient::TraceScope trace("Migrate / confirm");
|
||
try {
|
||
auto tx = rpc_->call("gettransaction", {txid});
|
||
if (tx.contains("confirmations") && tx["confirmations"].is_number())
|
||
confs = tx["confirmations"].get<int>();
|
||
auto bal = rpc_->call("z_gettotalbalance");
|
||
if (bal.contains("total") && bal["total"].is_string())
|
||
remaining = std::stod(bal["total"].get<std::string>());
|
||
ok = true;
|
||
} catch (...) {}
|
||
return [this, confs, remaining, ok]() {
|
||
seed_migration_confirm_in_flight_ = false;
|
||
if (ok) {
|
||
seed_migration_sweep_confs_ = confs;
|
||
seed_migration_legacy_remaining_ = remaining;
|
||
}
|
||
};
|
||
});
|
||
}
|
||
|
||
// Phase 2 step 2: adopt the new seed wallet as the primary wallet. Stop the daemon, move the
|
||
// legacy wallet.dat aside to a timestamped backup (NEVER delete it), copy the new wallet in, and
|
||
// restart with -rescan so the new keys pick up the swept funds. Runs on a background thread.
|
||
void App::beginAdoptSeedWallet()
|
||
{
|
||
seed_migration_step_ = SeedMigrationStep::Adopting;
|
||
seed_migration_status_ = "Restarting with your new wallet — this rescan can take a while…";
|
||
{ std::lock_guard<std::mutex> lk(seed_migration_mutex_); seed_migration_adopt_done_ = false; }
|
||
// Gate the main-loop reconnect so tryConnect can't start the daemon while we swap wallet.dat.
|
||
daemon_restarting_ = true;
|
||
if (rpc_ && rpc_->isConnected()) rpc_->disconnect();
|
||
onDisconnected("Adopting seed wallet");
|
||
// Adopting swaps in a brand-new seed wallet — drop the legacy wallet's chat identity + store so
|
||
// it re-derives from the new seed (the file name is unchanged, so nothing else detects the swap).
|
||
resetChatSession();
|
||
// The PIN vault is name-scoped, and the file name is unchanged, so the legacy wallet's stored
|
||
// passphrase would otherwise stay associated with the new seed wallet — remove it (the new wallet
|
||
// has its own passphrase; the user can re-enable PIN quick-unlock for it).
|
||
if (vault_) vault_->removeVault();
|
||
const std::string base = seed_migration_temp_dir_;
|
||
async_tasks_.submit("Adopt seed wallet", [this, base](const util::AsyncTaskManager::Token&) {
|
||
namespace fs = std::filesystem;
|
||
std::string err; // fatal (swap did not happen; migration incomplete)
|
||
std::string warn; // non-fatal (swap done but the daemon did not restart)
|
||
bool swapDone = false;
|
||
try {
|
||
std::error_code ec;
|
||
// 1. Stop the main daemon and wait for it to FULLY release wallet.dat + the RPC port before we
|
||
// swap the file. Use the wallet-switch stop path: for an ADOPTED (external) daemon it sends a
|
||
// graceful RPC "stop" and gates on the port actually freeing — isEmbeddedDaemonRunning() is
|
||
// process-handle-only and would falsely read "stopped" for an adopted daemon, letting the swap
|
||
// run against a live daemon still holding wallet.dat. (For an owned daemon stopEmbeddedDaemon()
|
||
// already blocks for full process exit, so the file is closed before the swap.)
|
||
const bool port_free = stopDaemonForWalletSwitch();
|
||
if (!port_free) {
|
||
err = "The daemon did not stop in time; your wallet was not changed.";
|
||
} else {
|
||
// 2. Swap wallet.dat. Move the legacy one aside to a timestamped backup (NEVER
|
||
// delete), then copy the new seed wallet in. On any failure, restore the legacy.
|
||
const std::string datadir = util::Platform::getDragonXDataDir();
|
||
const std::string legacy = datadir + "/wallet.dat";
|
||
const std::string newWallet = base + "/DRAGONX/wallet.dat";
|
||
std::time_t t = std::time(nullptr);
|
||
std::tm tmv{}; // thread-safe local time (the UI thread also uses localtime)
|
||
#ifdef _WIN32
|
||
localtime_s(&tmv, &t);
|
||
#else
|
||
localtime_r(&t, &tmv);
|
||
#endif
|
||
char ts[32]; std::strftime(ts, sizeof(ts), "%Y%m%d-%H%M%S", &tmv);
|
||
const std::string backup = legacy + ".legacy-migrated-" + ts + ".bak";
|
||
if (!fs::exists(newWallet)) {
|
||
err = "The new wallet file is missing; your wallet was not changed.";
|
||
} else {
|
||
bool movedLegacy = false;
|
||
if (fs::exists(legacy)) {
|
||
fs::rename(legacy, backup, ec);
|
||
if (ec) err = "Could not back up your current wallet; nothing was changed.";
|
||
else movedLegacy = true;
|
||
}
|
||
if (err.empty()) {
|
||
fs::copy_file(newWallet, legacy, fs::copy_options::overwrite_existing, ec);
|
||
if (ec) {
|
||
if (movedLegacy) { std::error_code ec2; fs::rename(backup, legacy, ec2); }
|
||
err = "Could not install the new wallet; your original wallet was restored.";
|
||
} else {
|
||
swapDone = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 3. Rescan on next start (only if the swap happened) and bring the daemon back up —
|
||
// unless we're quitting, in which case don't resurrect it.
|
||
if (swapDone && daemon_controller_) daemon_controller_->setRescanOnNextStart(true);
|
||
if (!shutting_down_) {
|
||
// We stopped the daemon ourselves (port_free) — clear the adopted-external latch so the
|
||
// relaunched process is treated as owned (stop/isRunning/exit behave normally afterward).
|
||
// Skip when the stop failed: the old adopted daemon is still up, and we must not mark a
|
||
// process we can't control as owned.
|
||
if (port_free && daemon_controller_) daemon_controller_->clearExternalDaemonDetected();
|
||
if (!startEmbeddedDaemon() && swapDone)
|
||
warn = "Your wallet was swapped, but the daemon did not restart — start it from Settings.";
|
||
}
|
||
} catch (const std::exception& e) {
|
||
err = std::string("Migration failed: ") + e.what();
|
||
} catch (...) {
|
||
err = "Migration failed due to an unexpected error.";
|
||
}
|
||
daemon_restarting_ = false; // ALWAYS re-arm the reconnect gate, even on a throw
|
||
|
||
std::lock_guard<std::mutex> lk(seed_migration_mutex_);
|
||
// Migration counts as done once the swap succeeded (funds already moved + wallet installed);
|
||
// a restart hiccup is only a warning. If the swap never happened, it's a full error and the
|
||
// pending state is kept so the user can retry.
|
||
seed_migration_adopt_ok_ = swapDone;
|
||
seed_migration_adopt_err_ = swapDone ? warn : err;
|
||
seed_migration_adopt_done_ = true;
|
||
});
|
||
}
|
||
|
||
void App::pumpSeedMigration()
|
||
{
|
||
if (capture_mode_) return; // no live ops during a UI sweep (steps are set directly)
|
||
// --- Adopt handoff (Phase 2, background daemon swap) ---
|
||
{
|
||
bool done = false, ok = false; std::string err;
|
||
{
|
||
std::lock_guard<std::mutex> lk(seed_migration_mutex_);
|
||
if (seed_migration_adopt_done_) {
|
||
done = true; ok = seed_migration_adopt_ok_; err = seed_migration_adopt_err_;
|
||
seed_migration_adopt_done_ = false;
|
||
}
|
||
}
|
||
if (done) {
|
||
if (ok) {
|
||
if (!seed_migration_temp_dir_.empty()) {
|
||
std::error_code ec; std::filesystem::remove_all(seed_migration_temp_dir_, ec);
|
||
}
|
||
if (settings_) {
|
||
settings_->setSeedMigrationPending(false);
|
||
settings_->setSeedMigrationDest("");
|
||
settings_->setSeedMigrationTempDir("");
|
||
settings_->setSeedMigrationSweepTxid("");
|
||
settings_->save();
|
||
}
|
||
seed_migration_status_ = err; // a non-empty warning here (e.g. restart hiccup) is shown on Done
|
||
seed_migration_step_ = SeedMigrationStep::Done;
|
||
} else {
|
||
seed_migration_status_ = err.empty() ? "Adopting the new wallet failed." : err;
|
||
seed_migration_step_ = SeedMigrationStep::Error;
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- Create handoff (Phase 1, background isolated create) ---
|
||
if (!seed_migration_in_flight_) return;
|
||
bool done = false, ok = false;
|
||
std::string seed, dest, tmp, err;
|
||
{
|
||
std::lock_guard<std::mutex> lk(seed_migration_mutex_);
|
||
seed_migration_status_ = seed_migration_progress_; // live progress for the Working step
|
||
done = seed_migration_done_;
|
||
if (done) {
|
||
ok = seed_migration_ok_;
|
||
seed = std::move(seed_migration_r_seed_);
|
||
dest = std::move(seed_migration_r_dest_);
|
||
tmp = std::move(seed_migration_r_tmp_);
|
||
err = std::move(seed_migration_r_err_);
|
||
seed_migration_done_ = false;
|
||
seed_migration_r_seed_.clear();
|
||
}
|
||
}
|
||
if (!done) return;
|
||
seed_migration_in_flight_ = false;
|
||
if (ok) {
|
||
seed_migration_seed_ = std::move(seed);
|
||
seed_migration_dest_ = std::move(dest);
|
||
seed_migration_temp_dir_ = std::move(tmp);
|
||
seed_migration_backed_up_ = false;
|
||
seed_migration_step_ = SeedMigrationStep::ShowSeed;
|
||
// Persist the pending migration so a later sweep/adopt step can find the new wallet.
|
||
if (settings_) {
|
||
settings_->setSeedMigrationPending(true);
|
||
settings_->setSeedMigrationDest(seed_migration_dest_);
|
||
settings_->setSeedMigrationTempDir(seed_migration_temp_dir_);
|
||
settings_->save();
|
||
}
|
||
} else {
|
||
seed_migration_status_ = err.empty() ? std::string("Could not create the seed wallet.") : err;
|
||
seed_migration_step_ = SeedMigrationStep::Error;
|
||
}
|
||
}
|
||
|
||
void App::backupWallet(const std::string& destination, std::function<void(bool, const std::string&)> callback)
|
||
{
|
||
if (!state_.connected || !rpc_) {
|
||
if (callback) callback(false, "Not connected");
|
||
return;
|
||
}
|
||
|
||
// Export all keys and save to file.
|
||
exportAllKeys([destination, callback](const std::string& keys, int exported, int total) {
|
||
// NEVER write (or report success for) a keyless backup — the usual cause is an encrypted+locked
|
||
// wallet, and a "success" here would leave the user believing they have a recovery file when the
|
||
// file contains only the header comments and ZERO keys.
|
||
if (exported == 0) {
|
||
if (callback) callback(false, total == 0
|
||
? "No addresses to back up."
|
||
: "Backup failed: 0 of " + std::to_string(total) + " keys could be exported. "
|
||
"Unlock the wallet (if encrypted) and try again.");
|
||
return;
|
||
}
|
||
|
||
std::ofstream file(destination);
|
||
if (!file.is_open()) {
|
||
if (callback) callback(false, "Could not open file: " + destination);
|
||
return;
|
||
}
|
||
file << keys;
|
||
file.close();
|
||
|
||
std::string msg = "Wallet backup saved to " + destination + " — "
|
||
+ std::to_string(exported) + " of " + std::to_string(total) + " keys.";
|
||
if (exported < total)
|
||
msg += " NOTE: some addresses had no spending key or the wallet is locked — this backup is INCOMPLETE.";
|
||
// A partial backup is a real risk for recovery, so surface it as a warning (not a green success).
|
||
if (callback) callback(exported == total, msg);
|
||
});
|
||
}
|
||
|
||
// ============================================================================
|
||
// Transaction Operations
|
||
// ============================================================================
|
||
|
||
void App::sendTransaction(const std::string& from, const std::string& to,
|
||
double amount, double fee, const std::string& memo,
|
||
std::function<void(bool success, const std::string& result)> callback)
|
||
{
|
||
// Lite build: route to the controller's async broadcast. `from`/`fee` are ignored — the
|
||
// backend selects inputs and adds the network fee itself. The result (txid/error) is
|
||
// delivered to `callback` from update() once takeBroadcastResult() yields it.
|
||
if (lite_wallet_) {
|
||
wallet::LiteSendRequest req;
|
||
wallet::LiteSendRecipient recipient;
|
||
recipient.address = to;
|
||
recipient.amountZatoshis = static_cast<std::uint64_t>(std::llround(amount * 100000000.0));
|
||
recipient.memo = memo;
|
||
req.recipients.push_back(std::move(recipient));
|
||
if (!lite_wallet_->sendTransaction(req)) {
|
||
if (callback) callback(false, "A send is already in progress, or no wallet is open");
|
||
return;
|
||
}
|
||
lite_send_callback_ = std::move(callback); // delivered from update()
|
||
return;
|
||
}
|
||
|
||
if (!state_.connected || !rpc_) {
|
||
if (callback) callback(false, "Not connected");
|
||
return;
|
||
}
|
||
|
||
// Single-flight guard: a rapid double-click (or any second caller) must not issue two
|
||
// z_sendmany calls before the first returns its opid. The send form already guards this in the
|
||
// UI, but the controller entry point must not depend on that. (send_submissions_in_flight_ is
|
||
// main-thread only: ++ here, -- in the worker's main-thread result callback.)
|
||
if (send_submissions_in_flight_ > 0) {
|
||
if (callback) callback(false, "A transaction is already being submitted — please wait.");
|
||
return;
|
||
}
|
||
|
||
// Check that we have the spending key for the from address
|
||
if (!from.empty() && from[0] == 'z') {
|
||
bool spendable = false;
|
||
for (const auto& addr : state_.z_addresses) {
|
||
if (addr.address == from) {
|
||
spendable = addr.has_spending_key;
|
||
break;
|
||
}
|
||
}
|
||
if (!spendable) {
|
||
if (callback) callback(false, "This is a view-only address (no spending key). Import the spending key to send from this address.");
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Build recipients array
|
||
nlohmann::json recipients = nlohmann::json::array();
|
||
nlohmann::json recipient;
|
||
recipient["address"] = to;
|
||
recipient["amount"] = util::formatAmountFixed(amount);
|
||
if (!memo.empty()) {
|
||
recipient["memo"] = memo;
|
||
}
|
||
recipients.push_back(recipient);
|
||
|
||
// Run z_sendmany on worker thread to avoid blocking UI
|
||
if (!worker_) {
|
||
send_progress_active_ = false;
|
||
if (callback) callback(false, "RPC worker unavailable");
|
||
return;
|
||
}
|
||
|
||
// z_sendmany signature is (fromaddress, amounts, minconf, fee). The fee MUST be a JSON number:
|
||
// the daemon reads it with get_real(), so a string is rejected ("JSON value is not a number as
|
||
// expected"). get_real() parses the double directly and accepts any number notation (incl. the
|
||
// "5e-05" form of a small fee), so passing the raw double is correct for every fee value.
|
||
// (The recipient "amount" above is intentionally a fixed-decimal STRING — that field is parsed
|
||
// with ParseFixedPoint, which a scientific-notation double would break.)
|
||
submitZSendMany(from, to, amount, fee, memo, recipients, "Send tab / Submit transaction",
|
||
/*markFeeGapRetry*/ false, std::move(callback));
|
||
}
|
||
|
||
void App::submitZSendMany(const std::string& from, const std::string& to, double amount, double fee,
|
||
const std::string& memo, const nlohmann::json& recipients,
|
||
const char* traceLabel, bool markFeeGapRetry,
|
||
std::function<void(bool, const std::string&)> callback)
|
||
{
|
||
send_progress_active_ = true;
|
||
++send_submissions_in_flight_;
|
||
worker_->post([this, from, to, amount, fee, memo, recipients, callback, traceLabel,
|
||
markFeeGapRetry]() -> rpc::RPCWorker::MainCb {
|
||
bool ok = false;
|
||
std::string result_str;
|
||
try {
|
||
rpc::RPCClient::TraceScope trace(traceLabel);
|
||
auto result = rpc_->call("z_sendmany", {from, recipients, 1, fee});
|
||
result_str = result.get<std::string>();
|
||
ok = true;
|
||
} catch (const std::exception& e) {
|
||
result_str = e.what();
|
||
} catch (...) {
|
||
// Non-std throw must not escape into the worker — the main-thread callback
|
||
// below would never run, leaving a stuck "Sending…" spinner.
|
||
result_str = "Send failed (unknown error)";
|
||
}
|
||
return [this, callback, ok, result_str, from, to, amount, fee, memo, markFeeGapRetry]() {
|
||
if (send_submissions_in_flight_ > 0) --send_submissions_in_flight_;
|
||
if (ok) {
|
||
// A send changes address balances — refresh on next cycle
|
||
addresses_dirty_ = true;
|
||
// Force transaction list refresh so the sent tx appears immediately
|
||
transactions_dirty_ = true;
|
||
last_tx_block_height_ = -1;
|
||
network_refresh_.markWalletMutationRefresh();
|
||
// z_sendmany only returned an opid: the transaction is built/signed/
|
||
// broadcast asynchronously by the daemon. Defer the user-facing
|
||
// success/failure to the opid poller (app.cpp) so we don't report
|
||
// "sent successfully" for an operation that may still fail.
|
||
if (!result_str.empty()) {
|
||
pending_opids_.push_back(result_str);
|
||
if (markFeeGapRetry)
|
||
send_feegap_retried_opids_.insert(result_str); // a retry of a retry is a real error
|
||
upsertPendingSendTransaction(result_str, from, to, amount, memo, fee);
|
||
if (callback) pending_send_callbacks_[result_str] = callback;
|
||
} else if (callback) {
|
||
callback(true, result_str); // no opid to track — report as-is
|
||
}
|
||
} else {
|
||
send_progress_active_ = false;
|
||
if (callback) callback(false, result_str);
|
||
}
|
||
};
|
||
});
|
||
}
|
||
|
||
// Parse the daemon's "Insufficient shielded funds, have <H>, need <N>" message. std::stod stops at
|
||
// the trailing comma/text, so it extracts each amount cleanly. Returns false if it isn't that error.
|
||
static bool parseInsufficientShielded(const std::string& msg, double& have, double& need)
|
||
{
|
||
if (msg.find("Insufficient shielded funds") == std::string::npos) return false;
|
||
auto hp = msg.find("have ");
|
||
auto np = msg.find("need ");
|
||
if (hp == std::string::npos || np == std::string::npos) return false;
|
||
try {
|
||
have = std::stod(msg.substr(hp + 5));
|
||
need = std::stod(msg.substr(np + 5));
|
||
} catch (...) {
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
bool App::maybeRetrySendForFeeGap(const std::string& opid, const std::string& rawMsg)
|
||
{
|
||
double have = 0.0, need = 0.0;
|
||
if (!parseInsufficientShielded(rawMsg, have, need)) return false;
|
||
|
||
auto it = pending_send_info_.find(opid);
|
||
if (it == pending_send_info_.end()) return false;
|
||
const PendingSendInfo info = it->second; // copy before any cleanup
|
||
|
||
// Only shielded sends hit the bug; a transparent "from" uses a different (correct) selector.
|
||
if (info.from.empty() || info.from[0] != 'z') return false;
|
||
// Discriminator: the daemon stopped once it covered the amount but short of the fee, so the
|
||
// selected total (have) is >= the amount. A genuine shortfall reports have < amount (it grabbed
|
||
// every note and still couldn't reach the amount) — don't "retry" those.
|
||
if (have + 1e-9 < info.amount) return false;
|
||
// Never retry a retry (the self-output already widened the target; a second failure is real).
|
||
if (send_feegap_retried_opids_.count(opid)) return false;
|
||
|
||
const double fee = info.fee > 0.0 ? info.fee : 0.0001;
|
||
|
||
// Hand the waiting UI callback to the retry so the user sees the final outcome, not the
|
||
// intermediate "insufficient" we're working around.
|
||
std::function<void(bool, const std::string&)> cb;
|
||
auto cbIt = pending_send_callbacks_.find(opid);
|
||
if (cbIt != pending_send_callbacks_.end()) {
|
||
cb = cbIt->second;
|
||
pending_send_callbacks_.erase(cbIt);
|
||
}
|
||
|
||
DEBUG_LOGF("[send] fee-gap workaround: retrying %s with self-output (have=%.8f need=%.8f amount=%.8f fee=%.8f)\n",
|
||
opid.c_str(), have, need, info.amount, fee);
|
||
resendWithFeeGapWorkaround(info.from, info.to, info.amount, fee, info.memo, std::move(cb));
|
||
return true;
|
||
}
|
||
|
||
void App::resendWithFeeGapWorkaround(const std::string& from, const std::string& to,
|
||
double amount, double fee, const std::string& memo,
|
||
std::function<void(bool, const std::string&)> callback)
|
||
{
|
||
if (!state_.connected || !rpc_ || !worker_) {
|
||
if (callback) callback(false, "Not connected");
|
||
return;
|
||
}
|
||
|
||
// recipients = the real recipient + a tiny self-output (= fee) back to `from`. The extra output
|
||
// raises the daemon's note-selection target (nTotalOut) above the single largest note, so its
|
||
// greedy picker grabs another note and can therefore also cover the miner fee. The recipient
|
||
// still receives EXACTLY `amount`; the self-output and any change return to `from`.
|
||
nlohmann::json recipients = nlohmann::json::array();
|
||
nlohmann::json primary;
|
||
primary["address"] = to;
|
||
primary["amount"] = util::formatAmountFixed(amount);
|
||
if (!memo.empty()) primary["memo"] = memo;
|
||
recipients.push_back(primary);
|
||
nlohmann::json selfOut;
|
||
selfOut["address"] = from;
|
||
selfOut["amount"] = util::formatAmountFixed(fee);
|
||
recipients.push_back(selfOut);
|
||
|
||
submitZSendMany(from, to, amount, fee, memo, recipients, "Send tab / Fee-gap retry",
|
||
/*markFeeGapRetry*/ true, std::move(callback));
|
||
}
|
||
|
||
// --------------------------------------------------------------------------------------------
|
||
// Bootstrapped/pruned-node rescan support
|
||
//
|
||
// A node restored from a bootstrap snapshot only has block data from the snapshot base upward;
|
||
// blocks below it are absent on disk. The startup -rescan flag rescans from genesis and fails on
|
||
// such a node ("error in HDD data"), and rescanblockchain(0) hits the same missing blocks. The
|
||
// fix is to rescan from a height the snapshot includes — found here by binary-searching for the
|
||
// lowest height whose block data the node can actually read.
|
||
// --------------------------------------------------------------------------------------------
|
||
void App::detectLowestAvailableBlockHeight(std::function<void(bool, int, bool)> cb)
|
||
{
|
||
if (!rpc_ || !rpc_->isConnected()) {
|
||
if (cb) cb(false, 0, false);
|
||
return;
|
||
}
|
||
const int tip = state_.sync.blocks;
|
||
if (tip <= 1) {
|
||
// No usable tip yet (or a brand-new chain) — treat as full history, nothing to probe.
|
||
if (cb) cb(false, 0, true);
|
||
return;
|
||
}
|
||
|
||
// Shared search window; each probe halves it. Invariant maintained: block at `hi` is readable.
|
||
struct SearchState { int lo; int hi; std::function<void(bool, int, bool)> cb; };
|
||
auto st = std::make_shared<SearchState>(SearchState{0, tip, std::move(cb)});
|
||
auto step = std::make_shared<std::function<void()>>();
|
||
*step = [this, st, step]() {
|
||
if (st->lo >= st->hi) {
|
||
const bool fullHistory = (st->lo <= 1); // genesis/early block present → not bootstrapped
|
||
if (st->cb) st->cb(true, st->lo, fullHistory);
|
||
return;
|
||
}
|
||
const int mid = st->lo + (st->hi - st->lo) / 2;
|
||
rpc_->getBlock(mid, [st, step, mid](const nlohmann::json& result, const std::string& error) {
|
||
if (error.empty() && !result.is_null()) {
|
||
st->hi = mid; // block data present → lowest available is <= mid
|
||
} else {
|
||
st->lo = mid + 1; // block data missing → lowest available is > mid
|
||
}
|
||
(*step)();
|
||
});
|
||
};
|
||
(*step)();
|
||
}
|
||
|
||
void App::runtimeRescan(int startHeight)
|
||
{
|
||
if (!supportsFullNodeLifecycleActions()) {
|
||
ui::Notifications::instance().warning("Full-node lifecycle actions are unavailable in lite build");
|
||
return;
|
||
}
|
||
if (!state_.connected || !rpc_ || !rpc_->isConnected() || !worker_) {
|
||
ui::Notifications::instance().warning("Not connected to daemon");
|
||
return;
|
||
}
|
||
if (runtime_rescan_active_) return;
|
||
if (startHeight < 0) startHeight = 0;
|
||
|
||
DEBUG_LOGF("[App] Starting runtime rescanblockchain from height %d\n", startHeight);
|
||
|
||
// The rescan runs inside the daemon (holds cs_main/cs_wallet) — it does not restart. We own
|
||
// completion via this RPC's callback; rescan_confirmed_active_ keeps the -rescan-style pollers
|
||
// from misreading state, and runtime_rescan_active_ suppresses the per-second pollers that
|
||
// would otherwise pile up behind the held lock.
|
||
runtime_rescan_active_ = true;
|
||
state_.sync.rescanning = true;
|
||
rescan_confirmed_active_ = true;
|
||
state_.sync.rescan_progress = 0.0f;
|
||
state_.sync.rescan_status = "Rescanning from block " + std::to_string(startHeight) + "...";
|
||
transactions_dirty_ = true;
|
||
last_tx_block_height_ = -1;
|
||
invalidateShieldedHistoryScanProgress(true);
|
||
|
||
ui::Notifications::instance().info("Rescanning blockchain from block " + std::to_string(startHeight) +
|
||
" — this can take a while.");
|
||
|
||
worker_->post([this, startHeight]() -> rpc::RPCWorker::MainCb {
|
||
bool ok = false;
|
||
std::string err;
|
||
try {
|
||
rpc::RPCClient::TraceScope trace("Settings / Runtime rescan");
|
||
rpc_->call("rescan", {startHeight}); // hush "rescan <height>"; blocks until the scan finishes
|
||
ok = true;
|
||
} catch (const std::exception& e) {
|
||
err = e.what();
|
||
}
|
||
return [this, ok, err]() {
|
||
runtime_rescan_active_ = false;
|
||
resetWitnessRescanProgress();
|
||
if (ok) {
|
||
state_.sync.rescan_progress = 1.0f;
|
||
transactions_dirty_ = true;
|
||
last_tx_block_height_ = -1;
|
||
invalidateShieldedHistoryScanProgress(true);
|
||
ui::Notifications::instance().success("Blockchain rescan complete");
|
||
refreshData();
|
||
} else {
|
||
state_.sync.rescan_progress = 0.0f;
|
||
ui::Notifications::instance().error("Rescan failed: " + err);
|
||
}
|
||
};
|
||
});
|
||
}
|
||
|
||
} // namespace dragonx
|