// 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 "ui/windows/shield_dialog.h"  // ui::ShieldDialog — Merge to Address shortcut from the bloat nudge
#include "ui/windows/mining_pool_panel.h"  // ui::resolveMiningUserAddress
#include <sodium.h>              // sodium_memzero for wiping the fetched mnemonic
#include <cctype>
#include "config/settings.h"
#include "wallet/lite_wallet_controller.h"  // lite send/new-address routing
#include "wallet/lite_wallet_state_mapper.h"  // LiteWalletAppRefreshModel for lite chat harvest
#include "wallet/lite_diagnostics.h"        // liteLog — chat note-buffer coordinator diagnostics
#include "config/version.h"
#include "daemon/daemon_controller.h"
#include "daemon/daemon_startup_diagnosis.h"
#include "daemon/embedded_daemon.h"
#include "daemon/seed_wallet_creator.h"
#include "daemon/xmrig_manager.h"
#include "util/pool_registry.h"
#include "ui/notifications.h"
#include "ui/schema/skin_manager.h"
#include "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 "data/seed_migration_resume.h"
#include "util/platform.h"
#include "util/wallet_file_probe.h"   // verify a salvage-backup is a real BDB before restoring it
#include "resources/embedded_resources.h"   // getDaemonDirectory() — locate the wallet-rebuild helper
#include <cstdio>                            // popen the rebuild helper
#include "util/perf_log.h"
#include "util/i18n.h"
#include "util/address_validation.h"   // isValidRecipientAddress — payout validation at every start path (M-01)
#include "util/secure_vault.h"

#include <nlohmann/json.hpp>
#include <curl/curl.h>
#include <algorithm>
#include <cmath>
#include <ctime>
#include <filesystem>
#include <thread>
#include <chrono>
#include <fstream>
#include <utility>

namespace dragonx {

using json = nlohmann::json;
using NetworkRefreshService = services::NetworkRefreshService;

namespace {

bool isPageEnabledForBuild(ui::NavPage page)
{
    return wallet::isUiSurfaceAvailable(
        wallet::currentWalletCapabilities(), ui::NavPageSurface(page));
}

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)
{
    // W1-2: the generic "Error loading wallet" fallback is ALSO printed for DB_TOO_NEW
    // ("...requires ... newer version..."), which -salvagewallet cannot fix — so don't misclassify a
    // version mismatch as salvageable corruption and offer a repair that can't help.
    const bool versionMismatch = out.find("newer version") != std::string::npos;
    return out.find("Failed to rename")     != std::string::npos
        || out.find("salvage failed")       != std::string::npos
        || out.find("wallet.dat corrupt")   != std::string::npos
        || (out.find("Error loading wallet") != std::string::npos && !versionMismatch);
}

// Phrases dragonxd prints to its console while initializing, in the order translateWarmup()
// understands them. The most recent matching console line tells us which stage the node is in
// even when the RPC probe just times out (no -28 reply to read).
static const char* const kDaemonInitPhases[] = {
    "Rescanning", "Rewinding", "Activating", "Verifying", "Loading", "Pruning",
};

// How many consecutive "RPC port busy but no config" connect attempts to wait through before
// warning the user that whatever owns the port isn't a usable DragonX node. The core retry runs
// roughly every few seconds, so this is on the order of ~20s — long enough for a real daemon to
// write its config, short enough not to leave the user guessing.
static constexpr int kDaemonWaitWarnAttempts = 4;

// ============================================================================
// Connection Management
// ============================================================================

// dragonxd moves wallet.dat to wallet.<ts>.bak and loads a salvaged copy whenever BDB verify fails —
// no flag, and often a false positive (stale/cross-platform env) or an inconsistent-but-readable file.
// The salvage prints to the node's captured output at STARTUP, but the node may then fail to connect
// (block-index abort, long sync, crash) so we must NOT wait for onConnected — scan the output on every
// tryConnect tick, early enough that the line hasn't been trimmed from the rolling buffer. Fires once
// per session; the dialog offers Rebuild (fix the DB) / Restore (swap the .bak back).
void App::detectWalletAutoRecovery()
{
    if (wallet_auto_recovered_warned_) return;
    if (!isUsingEmbeddedDaemon() || !daemon_controller_ || !daemon_controller_->daemon()) return;
    if (!daemon::walletAutoRecovered(daemon_controller_->daemon()->getOutput())) return;
    wallet_auto_recovered_ = true;
    wallet_auto_recovered_warned_ = true;
    show_wallet_recovered_dialog_ = true;
    ui::Notifications::instance().info(TR("wallet_recovered_notify"), 30.0f);   // calm, not red — coins are safe
    VERBOSE_LOGF("[recovery] Daemon auto-recovered/salvaged wallet.dat — surfacing the recovery dialog\n");
}

// v1.3.0+ opens a wallet that lost its hdchain in DEGRADED mode (funds spendable) instead of aborting,
// but can't derive NEW HD keys — z_getnewaddress / z_shieldcoinbase / t->z z_sendmany fail with "HD seed
// not found". Only a startup log line signals it, so scan the captured output and warn once. Pre-1.3.0
// daemons never emit it, so this is a no-op there (backwards compatible).
void App::detectWalletDegraded()
{
    if (wallet_degraded_warned_) return;
    if (!isUsingEmbeddedDaemon() || !daemon_controller_ || !daemon_controller_->daemon()) return;
    if (!daemon::walletOpenedDegraded(daemon_controller_->daemon()->getOutput())) return;
    wallet_degraded_ = true;
    wallet_degraded_warned_ = true;
    ui::Notifications::instance().warning(TR("wallet_degraded_notify"), 30.0f);
    VERBOSE_LOGF("[recovery] Daemon opened wallet in DEGRADED mode — new-key derivation disabled\n");
}

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;

    // Catch a startup wallet salvage as soon as it appears in the node's output — independent of whether
    // the node ever finishes starting or connects (skip only while an orchestrated swap is mid-flight).
    if (!daemon_restarting_) detectWalletAutoRecovery();
    if (!daemon_restarting_) detectWalletDegraded();

    if (connection_in_progress_) return;

    // Don't fight an in-progress restart/adopt orchestration: while it stops the daemon, swaps
    // wallet.dat (seed migration), and restarts, tryConnect must NOT start the daemon underneath it.
    if (daemon_restarting_) return;

    static int connect_attempt = 0;
    ++connect_attempt;

    connection_in_progress_ = true;
    connection_status_ = TR("sb_loading_config");
    
    // Auto-detect configuration (file I/O — fast, safe on main thread)
    auto config = rpc::Connection::autoDetectConfig();

    if (!config.dir_error.empty()) {
        // The data directory could not be created (read-only home, permission denied,
        // disk full). Retrying won't fix it, so surface it in the status line instead of
        // mislabelling it as "waiting for config" below.
        connection_in_progress_ = false;
        connection_status_ = config.dir_error;
        VERBOSE_LOGF("[connect #%d] data dir error: %s\n", connect_attempt, config.dir_error.c_str());
        return;
    }
    
    if (config.rpcuser.empty() || config.rpcpassword.empty()) {
        connection_in_progress_ = false;
        std::string confPath = rpc::Connection::getDefaultConfPath();
        VERBOSE_LOGF("[connect #%d] No valid config — DRAGONX.conf missing or no rpcuser/rpcpassword (looked at: %s)\n",
                   connect_attempt, confPath.c_str());
        
        // Re-evaluate the RPC port LIVE rather than trusting a latched "external daemon detected"
        // flag: EmbeddedDaemon::start() sets that latch whenever the port was busy at a prior
        // attempt and then never re-checks it, so a stale socket (or a transient squatter that has
        // since died) would strand us forever "waiting for config". If the port is genuinely busy,
        // a real daemon writes its config shortly and we keep waiting; if it's free, we must start
        // our own.
        const bool portInUse = daemon::EmbeddedDaemon::isRpcPortInUse();
        if (portInUse) {
            connection_status_ = TR("sb_waiting_config");
            VERBOSE_LOGF("[connect #%d] RPC port in use but no config yet — waiting for the daemon to write it\n",
                       connect_attempt);
            // After a bounded wait with no config appearing, whatever owns the port is not a usable
            // DragonX node (a foreign process, or a stuck/half-dead daemon). Say so once, with the
            // action, instead of leaving the user on a silent "waiting" spinner forever.
            if (++daemon_wait_attempts_ == kDaemonWaitWarnAttempts) {
                ui::Notifications::instance().warning(TR("daemon_port_busy_warn"), 20.0f);
            }
            network_refresh_.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) &&
        !rpc::Connection::allowsPlaintextRemote(config)) {
        // Refuse to send Basic-auth credentials in cleartext to a remote host — a local-network
        // MITM would otherwise capture rpcuser:rpcpassword. This is a deliberate behaviour change
        // from the old warn-and-proceed: opt in explicitly with rpcallowplaintext=1 in
        // DRAGONX.conf (or enable TLS with rpctls=1) if the plaintext link is intended.
        connection_in_progress_ = false;
        connection_status_ = TR("sb_plaintext_remote_blocked");
        if (!remote_rpc_plaintext_warning_shown_) {
            remote_rpc_plaintext_warning_shown_ = true;
            ui::Notifications::instance().warning(TR("sb_plaintext_remote_blocked"), 20.0f);
        }
        VERBOSE_LOGF("[connect #%d] refusing plaintext-remote RPC to %s:%s (set rpcallowplaintext=1 to override)\n",
                   connect_attempt, config.host.c_str(), config.port.c_str());
        return;
    }
    
    // Run the blocking rpc_->connect() on the worker thread so the UI
    // stays responsive (curl connect timeout can be up to 10 seconds).
    if (!worker_) {
        connection_in_progress_ = false;
        VERBOSE_LOGF("[connect #%d] No worker thread available!\n", connect_attempt);
        return;
    }
    
    // Capture daemon state before posting to worker
    bool daemonStarting = daemon_controller_ &&
        (daemon_controller_->state() == daemon::EmbeddedDaemon::State::Starting ||
         daemon_controller_->state() == daemon::EmbeddedDaemon::State::Running);
    bool externalDetected = daemon_controller_ && daemon_controller_->externalDaemonDetected();
    int attempt = connect_attempt;
    
    // Log detailed daemon state for diagnostics
    if (daemon_controller_) {
        const char* stateStr = "unknown";
        switch (daemon_controller_->state()) {
            case daemon::EmbeddedDaemon::State::Stopped:  stateStr = "Stopped";  break;
            case daemon::EmbeddedDaemon::State::Starting: stateStr = "Starting"; break;
            case daemon::EmbeddedDaemon::State::Running:  stateStr = "Running";  break;
            case daemon::EmbeddedDaemon::State::Stopping: stateStr = "Stopping"; break;
            case daemon::EmbeddedDaemon::State::Error:    stateStr = "Error";    break;
        }
        VERBOSE_LOGF("[connect #%d] Daemon state: %s, running: %s, external: %s, crashes: %d, lastErr: %s\n",
                   attempt, stateStr,
                   daemon_controller_->isRunning() ? "yes" : "no",
                   externalDetected ? "yes" : "no",
                   daemon_controller_->crashCount(),
                   daemon_controller_->lastError().empty() ? "(none)" : daemon_controller_->lastError().c_str());

        // The embedded daemon can spawn successfully and then exit immediately (a missing runtime
        // DLL, wrong architecture, corrupt binary, datadir lock, …). The crash monitor records a
        // detailed reason (translated exit code + launch command + debug.log tail) in lastError(),
        // but it runs on a background thread and was never shown — so the wallet looked like it was
        // "stuck connecting" while the node silently died-and-respawned. Surface each new crash once.
        const int crashes = daemon_controller_->crashCount();
        if (crashes > daemon_last_seen_crashes_) {
            daemon_last_seen_crashes_ = crashes;   // consume it either way, so it can't toast later
            const std::string detail = daemon_controller_->lastError();
            // Suppress the scary "dragonxd exited unexpectedly" toast during recovery: the stop after a
            // salvage, and the intentional restart-with-rescan after a repair, are both EXPECTED here and
            // owned by the recovery UI (dialog / calm rescan overlay).
            if (!detail.empty() && !wallet_auto_recovered_ && !post_recovery_rescan_) {
                connection_status_ = TR("sb_daemon_start_failed");
                ui::Notifications::instance().error(detail, 30.0f);
            }
        }
    } else {
        VERBOSE_LOGF("[connect #%d] No embedded daemon object (use_embedded=%s)\n",
                   attempt, isUsingEmbeddedDaemon() ? "yes" : "no");
    }
    
    worker_->post([this, config, daemonStarting, externalDetected, attempt]() -> rpc::RPCWorker::MainCb {
        bool connected = rpc_->connect(config.host, config.port, config.rpcuser, config.rpcpassword, config.use_tls);
        std::string connectErr = rpc_->getLastConnectError();
        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;
                    if (connect_stall_since_ <= 0.0) connect_stall_since_ = ImGui::GetTime();  // start the "taking too long" clock
                    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()) {
                        // A repair completed and we restarted with a full rescan, but the fresh daemon has
                        // now EXITED — a fault distinct from the wallet file (corrupt block index, disk full,
                        // OOM). Drop out of the calm "finishing repair" state so this surfaces as a normal
                        // daemon failure (reindex offer / crash toast / restart) instead of silently freezing
                        // the reconnect loop on a reassuring "don't restart" screen with no way forward.
                        if (post_recovery_rescan_) {
                            post_recovery_rescan_ = false;
                            wallet_auto_recovered_ = false;   // repair done; the original-salvage hold is over
                        }
                        // If the node aborted because its BLOCK DATABASE is unreadable (a daemon-vs-chaindata
                        // format mismatch after an update, or a corrupt index), crash-restarting just repeats
                        // the same abort — and each attempt reloads the whole index (wasteful). Detect it once
                        // and offer a one-click reindex instead of silently looping into a zero-balance node.
                        if (!block_db_reindex_available_ && daemon_controller_ && daemon_controller_->daemon() &&
                            daemon::blockDbOutputLooksBroken(daemon_controller_->daemon()->getOutput())) {
                            block_db_reindex_available_ = true;
                            show_block_db_reindex_confirm_ = true;
                            ui::Notifications::instance().error(TR("block_db_reindex_notify"), 20.0f);
                            VERBOSE_LOGF("[connect #%d] Block database unreadable — offering a one-click reindex\n", attempt);
                        }
                        // Prevent infinite crash-restart loop
                        if (block_db_reindex_available_) {
                            connection_status_ = TR("sb_block_db_unreadable");   // hold; awaiting the rebuild choice
                        } else if (wallet_auto_recovered_) {
                            // A salvage is happening — DON'T restart into another one (each round can shrink
                            // the wallet further). Hold while the recovery dialog (Rebuild/Restore) is up.
                            connection_status_ = TR("sb_wallet_needs_recovery");
                        } else if (daemon_controller_ && daemon_controller_->crashCount() >= 3) {
                            if (wallet_switch_pending_confirm_.load()) {
                                // The just-switched-to wallet's daemon keeps crashing (e.g. a wallet that
                                // fails LATE in init, past the fast start grace) — revert to the previous
                                // wallet rather than wedging on a broken one. The main thread does the
                                // settings revert (processWalletSwitchRevert) + resets the crash count.
                                wallet_switch_pending_confirm_.store(false);
                                wallet_switch_failed_.store(true);
                            }
                            { char buf[128]; snprintf(buf, sizeof(buf), TR("sb_daemon_crashed"), daemon_controller_->crashCount());
                            connection_status_ = buf; }
                            VERBOSE_LOGF("[connect #%d] Daemon crashed %d times — not restarting (use Settings > Restart Daemon to retry)\n",
                                   attempt, daemon_controller_->crashCount());
                        } else {
                            connection_status_ = TR("sb_starting_daemon");
                            if (startEmbeddedDaemon()) {
                                VERBOSE_LOGF("[connect #%d] Embedded daemon starting, will retry connection...\n", attempt);
                            } else if (daemon_controller_ && daemon_controller_->externalDaemonDetected()) {
                                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
    connect_stall_since_ = 0.0;          // connected — clear the "taking too long" clock
    // A repair's rescan finished and connected — retire the recovery session so its crash-toast/error-card
    // suppression and status-chip hold can't persist forever. (Only when we were mid-post-repair-rescan;
    // a pre-repair salvaged-daemon connect keeps the flag so the recovery dialog/chip stay available.)
    if (post_recovery_rescan_) wallet_auto_recovered_ = false;
    post_recovery_rescan_ = false;       // repair's post-restart rescan is past the RPC-less phase now
    daemon_start_error_shown_ = false;
    daemon_last_seen_crashes_ = 0;       // (onConnected resets the daemon's crash count too)
    connection_status_ = TR("connected");

    detectWalletAutoRecovery();   // also runs every tryConnect tick — catches a salvage even if we never connect

    // Re-arm the empty-wallet-with-funded-sibling check for this (possibly switched) wallet: re-evaluate the
    // on-disk state once it finishes loading + syncing. Deliberately DON'T touch empty_wallet_scan_in_flight_
    // here — a scan from a prior connect self-clears it when it posts back, and resetting it while that scan
    // is still running would let the next submit() block the UI thread on join() (async_tasks_ is only ever
    // cancelled at shutdown, so the flag can't wedge during normal runtime).
    empty_wallet_warn_checked_ = false;

    // Stamp the active wallet as opened in the index (last-opened + size + synced-here). Balance +
    // address count fill in on the first address refresh (addresses aren't loaded yet here).
    updateWalletIndexForActiveWallet(/*markOpened=*/true);

    // Reset crash counter on successful connection
    if (daemon_controller_) {
        daemon_controller_->resetCrashCount();
    }
    
    // Get daemon info + wallet encryption state on the worker thread.
    // Fetching getwalletinfo here (before refreshData) ensures the lock
    // screen appears immediately instead of after 6+ queued RPC calls.
    bool initialPrefetchQueued = false;
    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();
    connect_stall_since_ = 0.0;  // reset the "taking too long" clock (App member, untouched by state_.clear())
    state_.clear();
    connection_status_ = reason;

    // Re-classify the wallet's mnemonic status on the next connect (the active wallet may have
    // changed — a switch or a post-migration adopt both disconnect here).
    wallet_seed_status_ = WalletSeedStatus::Unknown;
    wallet_seed_status_attempts_ = 0;

    // Re-probe whether the daemon auto-shields coinbase on the next connect — it may have been
    // upgraded/swapped (e.g. v1.0.3 which has no z_autoshieldstatus -> v1.3.0 which auto-shields).
    daemon_autoshield_probed_ = false;
    daemon_autoshield_active_ = false;

    // Clear RPC result caches
    viewtx_cache_.clear();
    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;
    // Release the chat note-buffer's single-split guard on a plain disconnect (which doesn't run
    // resetChatSession) so a re-split isn't suppressed until the 1200s watchdog after reconnect.
    chat_split_outstanding_ = false;
    chat_split_submitted_at_ = 0.0;
    inflight_op_ = LiteInflightOp{};   // the in-flight chat send's callback was just fired above (Failed)
    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;
    if (connect_stall_since_ <= 0.0) connect_stall_since_ = ImGui::GetTime();  // start the "taking too long" clock

    // Find the most recent console line that names an init phase, so we can tell the user exactly
    // what the node is doing (loading the block index, verifying, activating best chain, …).
    std::string phaseLine;
    if (daemon_controller_) {
        const auto lines = daemon_controller_->recentLines(40);
        for (auto it = lines.rbegin(); it != lines.rend() && phaseLine.empty(); ++it) {
            for (const char* phase : kDaemonInitPhases) {
                if (it->find(phase) != std::string::npos) { phaseLine = *it; break; }
            }
        }
    }

    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. effectivelySyncing() keeps this profile on briefly after catching
    // up (hysteresis) so a large-wallet scan can't immediately re-starve connection and bounce the
    // node back into "syncing". Reverts to the per-tab profile once the settle window passes.
    refresh_policy_syncing_ = effectivelySyncing();
    network_refresh_.setIntervals(refresh_policy_syncing_
        ? services::RefreshScheduler::kSyncProfile
        : getIntervalsForPage(page));
}

// True while the node is behind, and for a short settle window after it first catches up. The settle
// window is armed only on the syncing→caught-up edge (see the Core refresh callback), so a wallet that
// was synced from the start is never throttled at connect — only a node that just finished catching up.
bool App::effectivelySyncing() const
{
    if (state_.sync.syncing) return true;
    if (sync_settle_until_ == 0) return false;      // no pending settle → genuinely caught up
    return std::time(nullptr) < sync_settle_until_;
}

// Adaptive throttle: the next balance poll must wait at least (lastScanCost / kBalanceDutyCycle) since
// the last one, so balance scanning can never occupy more than ~kBalanceDutyCycle of wall-clock. A
// cheap wallet (sub-cadence cost) is unaffected — the tab's Core timer stays the real cadence; a ~20s
// scan on a large wallet backs off to roughly every ~200s instead of every 2s, freeing cs_main for
// block connection. A wallet mutation bypasses this via force_balance_refresh_.
bool App::scanRefreshDue(std::int64_t lastUpdate, double lastScanMs) const
{
    constexpr double kScanDutyCycle = 0.10;
    if (lastUpdate == 0) return true;        // never fetched
    if (lastScanMs <= 0.0) return true;      // no cost measured yet
    const double minInterval = (lastScanMs / 1000.0) / kScanDutyCycle;
    return std::difftime(std::time(nullptr), static_cast<std::time_t>(lastUpdate)) >= minInterval;
}

bool App::balanceRefreshDue() const
{
    return scanRefreshDue(state_.last_balance_update, last_balance_scan_ms_);
}

// The address scan (z_listunspent) and history scan (z_listreceivedbyaddress) are just as O(mapWallet)
// and cs_main-bound as the balance scan, so they get the same adaptive back-off. This is what keeps a
// large synced wallet from re-scanning them every tab cadence — the near-tip starvation the balance-only
// throttle missed (only the periodic poll is gated; explicit refreshes call the refresh directly).
bool App::addressRefreshDue() const
{
    return scanRefreshDue(state_.last_address_update, last_address_scan_ms_);
}

bool App::txRefreshDue() const
{
    return scanRefreshDue(state_.last_tx_update, last_tx_scan_ms_);
}

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.
    // Debit/restore the SPENDABLE view ONLY. The DISPLAY balances now come from an honest minconf=0 RPC
    // that already reflects the outgoing spend AND the incoming 0-conf change, so debiting them here too
    // would re-crater the display. This delta only lowers what can be re-spent before the change confirms.
    auto applyToAddress = [&](std::vector<AddressInfo>& addresses) {
        for (auto& address : addresses) {
            if (address.address == fromAddress) {
                address.spendableBalance = std::max(0.0, address.spendableBalance + signedAmount);
                return true;
            }
        }
        return false;
    };
    if (!applyToAddress(state_.z_addresses)) applyToAddress(state_.t_addresses);
    if (includeAggregates) {
        if (!fromAddress.empty() && fromAddress[0] == 'z') {
            state_.spendablePrivateBalance = std::max(0.0, state_.spendablePrivateBalance + signedAmount);
        } else {
            state_.spendableTransparentBalance = std::max(0.0, state_.spendableTransparentBalance + signedAmount);
        }
        state_.spendableTotalBalance = std::max(0.0, state_.spendableTotalBalance + signedAmount);
        state_.unconfirmedBalance = std::max(0.0, state_.totalBalance - state_.spendableTotalBalance);
    }
}

void App::trackOperation(const std::string& opid)
{
    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);
    }

    // W1-3: record "synced here" only once the wallet's identity is actually verified (its addresses are
    // known -> idHash non-empty). Stamping it on the bare connect (before any address readback) would let
    // a freshly-restored wallet skip its needed rescan. It's idempotent, so the post-refresh update
    // (updateWalletIndexForActiveWallet after addresses load) sets it once; lastOpenedEpoch is still
    // recorded at open time here.
    if (!idHash.empty()) {
        e.syncedHere = true;  // loaded + identity-verified in this datadir -> catch-up (no full rescan)
    }
    if (markOpened) {
        e.lastOpenedEpoch = static_cast<long long>(std::time(nullptr));
    }

    if (wallet_index_.upsert(e)) wallet_index_.save();
}

void App::switchToWallet(const std::string& walletFile, bool stopDaemonConfirmed, bool salvage)
{
    if (!supportsFullNodeLifecycleActions()) {
        ui::Notifications::instance().warning("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;
    }
    // W3-4: block switching while a migration is PENDING, not only while its dialog is open — closing
    // the dialog via "Later" mid-migration leaves the pending state but previously dropped this guard.
    if (show_seed_migration_ || (settings_ && settings_->getSeedMigrationPending())) {
        ui::Notifications::instance().warning("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;
    }
    // W1-1: verify the target wallet file actually exists before switching. dragonxd auto-CREATES a
    // fresh empty wallet for a missing -wallet=<name>, so without this a moved/deleted wallet file would
    // silently "open" as a brand-new empty wallet with a zero balance — looking exactly like fund loss.
    // (Also closes the W1-4 stale-switcher-row race: the check runs no matter how switchToWallet is called.)
    {
        std::error_code existEc;
        const std::string walletPath = util::Platform::getDragonXDataDir() + "/" + walletFile;
        if (!std::filesystem::exists(walletPath, existEc)) {
            ui::Notifications::instance().warning(
                "Wallet file not found (moved or deleted?): " + walletFile + " — it was not opened.", 15.0f);
            return;
        }
    }
    // If we're connected to a node this session did NOT spawn (no live process handle — it was left
    // running by "keep node running", started by the user, or we just direct-connected to a config-provided
    // one), confirm before stopping it: switching must stop+restart it on the new wallet, but the user may
    // have kept it running on purpose. A node we started ourselves (handle held) restarts silently.
    if (!stopDaemonConfirmed && state_.connected && !isEmbeddedDaemonRunning()) {
        pending_switch_wallet_file_ = walletFile;
        show_switch_stop_daemon_confirm_ = true;
        return;
    }

    // Rescan only if this wallet was never synced in this datadir (freshly imported/new). One we've
    // loaded here before just catches up from its recorded block on start — fast.
    bool needRescan = true;
    if (const auto* e = wallet_index_.find(walletFile)) needRescan = !e->syncedHere;

    const std::string prevWallet = settings_->getActiveWalletFile();   // for revert if the new one fails
    wallet_switch_prev_file_ = prevWallet;
    wallet_switch_target_file_ = walletFile;   // remembered for a "salvage" retry from the failure modal
    wallet_switch_failed_.store(false);
    switch_stop_failed_.store(false);   // reason latch for the revert message; a stale one must not leak
    switch_wallet_corrupt_.store(false);
    wallet_switch_pending_confirm_.store(true);   // cleared on a successful connect (onConnected)
    settings_->setActiveWalletFile(walletFile);
    settings_->save();
    // A prior crash-wedge shouldn't block reconnecting to the freshly-launched wallet daemon.
    if (daemon_controller_) daemon_controller_->resetCrashCount();

    // Re-scope the PIN vault to the new wallet (its own vault-<scope>.dat), and clear any carried-over
    // lock-screen attempt/lockout state + wipe the passphrase/PIN entry buffers — the previous wallet's
    // failed-unlock counter and typed secrets must not apply to the new wallet.
    if (vault_) vault_->setWalletScope(walletFile);
    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)
                if (salvage || needRescan) user_initiated_rescan_ = true;   // wallet-triggered repair/rescan → completion should toast
                // Start ONCE. Do NOT retry-spawn: a second start while the first is still shutting down leaves
                // two dragonxd holding wallet.dat against each other (BDB "Failed to rename … Error"). The
                // stopDaemonForWalletSwitch() wait already ensured the old node's process is gone, so a valid
                // wallet opens cleanly; a bad/corrupt wallet exits during init and we revert.
                ok = shutting_down_ ? true : startEmbeddedDaemon();
                // 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;
    // This is called every frame from render() to size the Chat nav badge. Recompute only when the store
    // actually changed (new/removed messages bump revision()) or after a short interval (to pick up
    // mute/hide/seen changes, which don't bump the store). Previously it ran a full O(conversations x
    // messages) scan that COPIED and stable_sorted every conversation's messages, every frame.
    const auto& store = chat_service_.store();
    const std::uint64_t rev = store.revision();
    const double now = ImGui::GetTime();
    if (rev != chat_unread_rev_ || now - chat_unread_computed_at_ > 0.25) {
        chat_unread_cached_ = store.countUnread(
            [this](const std::string& cid) {   // excluded from the badge
                return settings_ && (settings_->isChatMuted(cid) || settings_->isChatHidden(cid));
            },
            [this](const std::string& cid) -> std::int64_t {   // seen watermark
                const auto it = chat_seen_watermark_.find(cid);
                return it != chat_seen_watermark_.end() ? it->second : 0;
            });
        chat_unread_rev_ = rev;
        chat_unread_computed_at_ = now;
    }
    return chat_unread_cached_;
}

void App::markChatConversationSeen(const std::string& cid, std::int64_t latestTs)
{
    if (latestTs > 0) chat_seen_watermark_[cid] = latestTs;
}

void App::forgetChatConversationSeen(const std::string& cid)
{
    chat_seen_watermark_.erase(cid);
}

void App::wipePendingTransactionHistoryCachePassphrase()
{
    if (!pending_transaction_history_cache_passphrase_.empty()) {
        util::SecureVault::secureZero(pending_transaction_history_cache_passphrase_.data(),
                                      pending_transaction_history_cache_passphrase_.size());
        pending_transaction_history_cache_passphrase_.clear();
    }
}

void App::resetTransactionHistoryCacheSession()
{
    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();
                    connect_stall_since_ = 0.0;  // warmup finished — clear the "taking too long" clock
                    connection_status_ = TR("connected");
                    VERBOSE_LOGF("[warmup] Daemon ready, warmup complete\n");

                    // A -rescan runs entirely INSIDE daemon warmup (every RPC returns -28 until the
                    // scan finishes), so warmup completing IS the rescan completing. This is the
                    // reliable completion signal: some daemons lack getrescaninfo (it returns
                    // "Method not found") or never print a "Done rescanning"/bench line, which left
                    // the older detectors stuck at 99% — the user would then kill it prematurely.
                    // rescan_confirmed_active_ ensures we actually observed this rescan running (set
                    // by the getrescaninfo / daemon-log pollers) before declaring it done.
                    if (state_.sync.rescanning && rescan_confirmed_active_) {
                        resetWitnessRescanProgress();
                        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);
                        if (user_initiated_rescan_) {
                            ui::Notifications::instance().success("Blockchain rescan complete");
                            user_initiated_rescan_ = false;   // surfaced once; not for background rebuilds
                        }
                    }

                    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_;
    // Decide whether to include the balance call (z_gettotalbalance — O(mapWallet), holds cs_main).
    // Suppress it (a) while syncing or within the post-sync settle window, so it can't starve block
    // connection, and (b) unless enough time has elapsed given the LAST scan's measured cost, so a
    // large shielded wallet backs off automatically instead of re-scanning every couple of seconds.
    // A wallet mutation (send/shield) forces the next poll through so the user's own action updates the
    // balance immediately. Captured on the main thread to avoid reading state_ off the worker thread.
    const bool includeBalance = !effectivelySyncing() &&
                                (force_balance_refresh_ || balanceRefreshDue());
    if (includeBalance) force_balance_refresh_ = false;

    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);

                // Feed the adaptive balance throttle + sync-settle hysteresis. Record the last scan's
                // cost (0 when balance was skipped), and arm the settle window only on the
                // syncing→caught-up edge so a wallet synced from the start is never throttled at connect.
                if (result.balanceScanMs > 0.0) last_balance_scan_ms_ = result.balanceScanMs;
                // Sticky-behind hysteresis: hold the low-impact profile for kSyncSettleSeconds after the
                // LAST time we read "behind" — not only on the caught-up edge. While actually syncing we
                // re-arm every Core tick, so the profile stays engaged continuously; once we truly reach
                // the tip (within the 2-block tolerance in applyCoreRefreshResult) we stop re-arming and it
                // lapses after the window. This is what breaks the near-tip feedback loop: a single stale or
                // optimistic "caught up" reading can no longer immediately unleash the heavy O(mapWallet)
                // scans and re-starve the tail (which dropped us behind again → oscillation). With the
                // scans suppressed, kSyncProfile keeps sync detection itself cheap, so we keep getting fresh
                // "behind" readings and stay latched until genuinely caught up. Window must exceed the
                // worst-case Core-refresh interval so it can't lapse between two behind readings mid-catchup.
                const bool nowSyncing = state_.sync.syncing;
                constexpr double kSyncSettleSeconds = 30.0;
                if (nowSyncing || was_core_syncing_) {
                    sync_settle_until_ = std::time(nullptr) + static_cast<std::time_t>(kSyncSettleSeconds);
                }
                was_core_syncing_ = nowSyncing;

                // Mid-session connection-loss detection. During normal operation, both core
                // RPCs failing together means the daemon connection is dead (a busy daemon
                // fails them individually, not both at once). Warmup is excluded — both fail
                // with -28 there legitimately, and counting it would cause a reconnect loop.
                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;
                    }
                }

                // Probe the daemon's auto-shield status once per connection — independent of our own
                // toggle/balance — so both the defer-gate below and the Settings UI (O1) can read it.
                // Only when synced (daemon past warmup). Fail-closed: a pre-1.3.0 daemon lacks the RPC,
                // so the probe leaves active=false and the wallet keeps shielding client-side.
                if (result.balanceOk && !state_.sync.syncing && !daemon_autoshield_probed_ && worker_ &&
                    !daemon_autoshield_probe_inflight_.exchange(true)) {
                    worker_->post([this]() -> rpc::RPCWorker::MainCb {
                        bool active = false, seedRecoverable = false;
                        std::string addr, reason;
                        try {
                            auto st = rpc_->call("z_autoshieldstatus", json::array());
                            if (st.is_object()) {
                                if (st.contains("autoshield"))        active = st["autoshield"].get<bool>();
                                if (st.contains("autoshieldaddress")) addr = st["autoshieldaddress"].get<std::string>();
                                if (st.contains("disabled_reason"))   reason = st["disabled_reason"].get<std::string>();
                                if (st.contains("seed_recoverable"))  seedRecoverable = st["seed_recoverable"].get<bool>();
                            }
                        } catch (...) {}   // pre-1.3.0 daemon: no such method — leave defaults (inactive)
                        return [this, active, addr, reason, seedRecoverable]() {
                            daemon_autoshield_active_ = active;
                            daemon_autoshield_address_ = addr;
                            daemon_autoshield_disabled_reason_ = reason;
                            daemon_autoshield_seed_recoverable_ = seedRecoverable;
                            daemon_autoshield_probed_ = true;
                            daemon_autoshield_probe_inflight_ = false;
                        };
                    });
                }

                // Auto-shield transparent funds — but defer to the daemon's own coinbase auto-shielder
                // (v1.3.0+) when it's active, so we don't double-shield and split funds across z-addrs.
                const bool autoShieldEligible = result.balanceOk && settings_ && settings_->getAutoShield() &&
                    state_.spendableTransparentBalance > 0.0001 && !state_.sync.syncing;
                if (autoShieldEligible && daemon_autoshield_probed_ && !daemon_autoshield_active_ &&
                    !auto_shield_pending_.exchange(true)) {
                    std::string targetZAddr;
                    for (const auto& addr : state_.addresses) {
                        if (addr.isShielded()) {
                            targetZAddr = addr.address;
                            break;
                        }
                    }
                    if (!targetZAddr.empty() && worker_) {
                        DEBUG_LOGF("[AutoShield] Shielding %.8f DRGX to %s\n",
                               state_.spendableTransparentBalance, targetZAddr.c_str());
                        // Use the user-configured fee, formatted fixed-decimal so the daemon's
                        // ParseFixedPoint accepts it (a small double would serialize to "5e-05").
                        const std::string feeStr =
                            util::formatAmountFixed(settings_ ? settings_->getDefaultFee() : 0.0001);
                        // This callback runs on the UI thread (drainResults). Build/broadcast
                        // on the worker thread — never block the UI with synchronous RPC.
                        worker_->post([this, targetZAddr, feeStr]() -> rpc::RPCWorker::MainCb {
                            std::string opid;
                            std::string err;
                            try {
                                rpc::RPCClient::TraceScope trace("Auto-shield / z_shieldcoinbase");
                                auto result = rpc_->call("z_shieldcoinbase",
                                                         {std::string("*"), targetZAddr, feeStr, 50});
                                opid = result.value("opid", "");
                            } catch (const std::exception& e) {
                                err = e.what();
                            }
                            return [this, opid, err]() {
                                auto_shield_pending_ = false;
                                if (!err.empty()) {
                                    DEBUG_LOGF("[AutoShield] Error: %s\n", err.c_str());
                                    return;
                                }
                                if (!opid.empty()) {
                                    DEBUG_LOGF("[AutoShield] Started: %s\n", opid.c_str());
                                    // Surface the async result + refresh balances on completion.
                                    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 {
            const bool addrListOk = result.addressListOk;   // capture before the move
            const double scanMs = result.scanMs;            // feed the adaptive throttle (addressRefreshDue)
            if (scanMs > 0.0) last_address_scan_ms_ = scanMs;
            // #3: this scan already listed the wallet's unspent notes — feed the chat send-budget from it
            // (dedups the dedicated chat z_listunspent). Grab it before `result` is moved below.
            auto unspentNotes = std::move(result.unspentNotes);
            NetworkRefreshService::applyAddressRefreshResult(state_, std::move(result));
            updateChatNoteBudgetFromUnspent(unspentNotes);
            // Mark the address list as loaded ONLY if enumeration actually succeeded — a swallowed
            // z_listaddresses/getaddressesbyaccount failure returns a falsely-short list, and stamping it
            // would let the empty-wallet warning trust a spurious 0 count (see maybeWarnEmptyWallet…).
            if (addrListOk) state_.last_address_update = std::time(nullptr);
            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());
    // When fully synced, widen the re-scan tolerance so the O(mapWallet) per-address history scan runs
    // every several blocks instead of ~every 2 — the heaviest residual synced-state cs_main cost. The
    // balance poll still surfaces incoming funds on its own cadence; only the detailed history list lags a
    // few minutes. (During catch-up the tx refresh is suppressed by kSyncProfile, so this is synced-only.)
    if (!effectivelySyncing())
        transactionSnapshot.shieldedScanTipTolerance =
            std::max(transactionSnapshot.shieldedScanTipTolerance, 8);
    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;
            if (result.scanMs > 0.0) last_tx_scan_ms_ = result.scanMs;   // feed the adaptive throttle (txRefreshDue)
            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());
    if (!effectivelySyncing())   // synced: widen the re-scan tolerance (see refreshTransactionData)
        transactionSnapshot.shieldedScanTipTolerance =
            std::max(transactionSnapshot.shieldedScanTipTolerance, 8);
    ui::NavPage tracePage = current_page_;

    auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Transactions, *worker_, [this, currentBlocks,
                   transactionSnapshot = std::move(transactionSnapshot), tracePage]() -> rpc::RPCWorker::MainCb {
        AppRefreshRpcGateway refreshRpc(*rpc_, traceSource(tracePage, "Recent transaction poll"));
        auto result = NetworkRefreshService::collectRecentTransactionRefreshResult(
            refreshRpc, transactionSnapshot, currentBlocks);

        return [this, result = std::move(result)]() mutable {
            std::size_t nextShieldedScanStartIndex = result.nextShieldedScanStartIndex;
            auto shieldedScanHeights = std::move(result.shieldedScanHeights);
            NetworkRefreshService::TransactionCacheUpdate cacheUpdate{
                viewtx_cache_,
                send_txids_,
                confirmed_tx_cache_,
                confirmed_tx_ids_,
                confirmed_cache_block_,
                last_tx_block_height_
            };
            // HushChat: decrypt & thread any harvested chat memos BEFORE `result` is moved (see the
            // full-refresh path above for rationale; the store dedups across both paths).
            if (chat::hushChatFeatureEnabledAtBuild() && chat_service_.hasIdentity() &&
                !result.hushChatMetadata.empty()) {
                std::unordered_map<std::string, std::int64_t> chatTxTimes;
                for (const auto& tx : result.transactions) chatTxTimes[tx.txid] = tx.timestamp;
                std::vector<std::string> newChatCids;
                chat_service_.ingest(result.hushChatMetadata, chatTxTimes, std::time(nullptr), &newChatCids);
                // A new message un-hides a hidden conversation (you can't un-receive), so nothing is lost.
                if (settings_) {
                    bool unhid = false;
                    for (const auto& cid : newChatCids)
                        if (settings_->isChatHidden(cid)) { settings_->setChatHidden(cid, false); unhid = true; }
                    if (unhid) settings_->save();
                }
                // Toast when a NON-muted conversation received a new message and we're off the Chat tab.
                // Gate on the ingest-reported cids, not a seen-watermark delta — the latter can be swallowed
                // by block-time vs wall-clock (echo) skew (Q10 + Q4).
                if (current_page_ != ui::NavPage::Chat &&
                    std::any_of(newChatCids.begin(), newChatCids.end(),
                                [this](const std::string& cid){ return !(settings_ && settings_->isChatMuted(cid)); }))
                    ui::Notifications::instance().info(TR("chat_new_message_toast"));
            }
            NetworkRefreshService::applyTransactionRefreshResult(
                state_, cacheUpdate, std::move(result), std::time(nullptr));
            shielded_history_scan_heights_ = std::move(shieldedScanHeights);
            shielded_history_scan_cursor_ = nextShieldedScanStartIndex;
            storeTransactionHistoryCacheIfAvailable();
            maybeFinishTransactionSendProgress();
        };
    }, 3);
    if (!enqueued.enqueued) return;

    network_refresh_.resetTxAge();
}

// ============================================================================
// Encryption State: wallet info (one-shot on connect, lightweight)
// ============================================================================

bool App::refreshEncryptionState()
{
    if (!worker_ || !rpc_ || !state_.connected) return false;

    auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Encryption, *worker_, [this]() -> rpc::RPCWorker::MainCb {
        json walletInfo;
        bool ok = false;
        try {
            rpc::RPCClient::TraceScope trace("Startup / Wallet encryption state");
            walletInfo = rpc_->call("getwalletinfo");
            ok = true;
        } catch (...) {}

        if (!ok) return nullptr;

        auto result = NetworkRefreshService::parseWalletEncryptionResult(walletInfo);
        return [this, result]() {
            NetworkRefreshService::applyWalletEncryptionResult(state_, result);
            if (state_.isLocked()) {
                resetTransactionHistoryCacheSession();
            } else if (state_.transactions.empty()) {
                loadTransactionHistoryCacheIfAvailable();
            } else {
                storeTransactionHistoryCacheIfAvailable();
            }
        };
    }, 3);
    return enqueued.enqueued;
}

void App::refreshBalance()
{
    refreshCoreData();
}

void App::refreshAddresses()
{
    addresses_dirty_ = true;
    refreshAddressData();
}

void App::refreshMiningInfo()
{
    // Use the dedicated fast-lane worker + connection so mining polls
    // never block behind the main refresh batch.  Falls back to the main
    // worker if the fast lane isn't ready yet (e.g. during initial connect).
    auto* w   = (fast_worker_ && fast_worker_->isRunning()) ? fast_worker_.get() : worker_.get();
    auto* rpc = (fast_rpc_ && fast_rpc_->isConnected())     ? fast_rpc_.get()    : rpc_.get();
    if (!w || !rpc) return;

    // Capture daemon memory outside (may be accessed on main thread)
    double daemonMemMb = 0.0;
    if (daemon_controller_) {
        daemonMemMb = daemon_controller_->memoryUsageMB();
    }
    
    // Slow-tick counter: run full getmininginfo every ~5 seconds
    // to reduce RPC overhead. getlocalsolps is only needed while solo mining
    // or while the Mining tab is actively showing live local hashrate.
    // NOTE: getinfo is NOT called here — longestchain/notarized are updated by
    // refreshBalance (via getblockchaininfo), and daemon_version/protocol_version/
    // p2p_port are static for the lifetime of a connection (set in onConnected).
    bool doSlowRefresh = (mining_slow_counter_++ % 5 == 0);
    bool includeLocalHashrate = state_.mining.generate || current_page_ == ui::NavPage::Mining;
    if (!includeLocalHashrate && !doSlowRefresh) return;

    // While syncing, don't poll getmininginfo (another cs_main contender) unless the user is
    // actually on the Mining tab or mining — mining stats are irrelevant mid-sync, and this keeps
    // the sync throttle complete (the mining poll runs off the separate 1s Fast timer, so the
    // sync-profile intervals don't otherwise cover it).
    if (state_.sync.syncing && !includeLocalHashrate) return;
    ui::NavPage tracePage = current_page_;
    
    auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Mining, *w,
        [this, rpc, daemonMemMb, doSlowRefresh, includeLocalHashrate, tracePage]() -> rpc::RPCWorker::MainCb {
            AppRefreshRpcGateway refreshRpc(*rpc, traceSource(tracePage, "Mining refresh"));
            auto result = NetworkRefreshService::collectMiningRefreshResult(
                refreshRpc, daemonMemMb, doSlowRefresh, includeLocalHashrate);
            return [this, result]() {
                try {
                    NetworkRefreshService::applyMiningRefreshResult(state_, result, std::time(nullptr));
                } catch (const std::exception& e) {
                    DEBUG_LOGF("[refreshMiningInfo] callback error: %s\n", e.what());
                }
            };
        }, 2);
    if (!enqueued.enqueued) return;
}

void App::refreshPeerInfo()
{
    if (!rpc_) return;

    // Use fast-lane worker to bypass head-of-line blocking behind refreshData.
    auto* w = (fast_worker_ && fast_worker_->isRunning()) ? fast_worker_.get() : worker_.get();
    auto* r = (fast_rpc_ && fast_rpc_->isConnected())     ? fast_rpc_.get()    : rpc_.get();
    if (!w) return;
    ui::NavPage tracePage = current_page_;

    auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Peers, *w, [this, r, tracePage]() -> rpc::RPCWorker::MainCb {
        AppRefreshRpcGateway refreshRpc(*r, traceSource(tracePage, "Peer refresh"));
        auto result = NetworkRefreshService::collectPeerRefreshResult(refreshRpc);
        return [this, result = std::move(result)]() mutable {
            NetworkRefreshService::applyPeerRefreshResult(state_, std::move(result), std::time(nullptr));
        };
    }, 2);
    if (!enqueued.enqueued) return;
}

void App::refreshPrice()
{
    // Skip if price fetching is disabled
    if (!settings_->getFetchPrices()) return;
    if (!worker_) return;
    
    auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Price, *worker_, [this]() -> rpc::RPCWorker::MainCb {
        // --- Worker thread: blocking HTTP GET to CoinGecko ---
        NetworkRefreshService::PriceHttpResult result;
        
        try {
            CURL* curl = curl_easy_init();
            if (!curl) {
                DEBUG_LOGF("Failed to initialize curl for price fetch\n");
                result.errorMessage = "Price fetch failed: failed to initialize curl";
            } else {
                std::string response_data;
                const char* url = "https://api.coingecko.com/api/v3/simple/price?ids=dragonx-2&vs_currencies=usd,btc&include_24hr_change=true&include_24hr_vol=true&include_market_cap=true";

                auto write_callback = [](void* contents, size_t size, size_t nmemb, std::string* userp) -> size_t {
                    size_t totalSize = size * nmemb;
                    userp->append((char*)contents, totalSize);
                    return totalSize;
                };

                curl_easy_setopt(curl, CURLOPT_URL, url);
                curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, +write_callback);
                curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response_data);
                curl_easy_setopt(curl, CURLOPT_TIMEOUT, 10L);
                curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 5L);
                curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
                curl_easy_setopt(curl, CURLOPT_USERAGENT, "DragonX-Wallet/1.0");

                CURLcode res = curl_easy_perform(curl);
                long http_code = 0;
                curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
                curl_easy_cleanup(curl);

                NetworkRefreshService::PriceHttpResponse response{
                    res == CURLE_OK,
                    http_code,
                    std::move(response_data),
                    res == CURLE_OK ? std::string() : std::string(curl_easy_strerror(res))
                };
                result = NetworkRefreshService::parsePriceHttpResponse(response, std::time(nullptr));
            }
        } catch (const std::exception& e) {
            DEBUG_LOGF("Price fetch error: %s\n", e.what());
            result.errorMessage = std::string("Price fetch error: ") + e.what();
        } catch (...) {
            DEBUG_LOGF("Price fetch error: unknown exception\n");
            result.errorMessage = "Price fetch error: unknown exception";
        }

        if (result.price) {
            DEBUG_LOGF("Price updated: $%.6f USD\n", result.price->market.price_usd);
        } else {
            DEBUG_LOGF("%s\n", result.errorMessage.c_str());
        }

        return [this, result = std::move(result)]() mutable {
            if (result.price) {
                NetworkRefreshService::applyPriceRefreshResult(state_, *result.price, std::chrono::steady_clock::now());
            } else {
                NetworkRefreshService::applyPriceRefreshFailure(state_, result.errorMessage);
            }
        };
    }, 0);
    if (!enqueued.enqueued) return;
    NetworkRefreshService::markPriceRefreshStarted(state_);
}

void App::refreshMarketData()
{
    refreshPrice();
    refreshExchanges();
    refreshMarketChart();
    refreshExchangeChart();
}

void App::refreshExchangeChart()
{
    if (!settings_ || !settings_->getFetchPrices() || !worker_) return;
    if (exchange_chart_fetch_in_flight_) return;

    // Resolve the selected pair from the same registry the Market tab renders (live tickers, else the
    // static fallback), matching exchange name THEN pair to disambiguate the same pair across venues.
    const auto& reg = state_.market.exchanges.empty()
                          ? data::getExchangeRegistry() : state_.market.exchanges;
    const std::string selEx   = settings_->getSelectedExchange();
    const std::string selPair = settings_->getSelectedPair();
    const data::ExchangePair* pair = nullptr;
    for (const auto& ex : reg) {
        if (!selEx.empty() && ex.name != selEx) continue;
        for (const auto& p : ex.pairs)
            if (p.displayName == selPair) { pair = &p; break; }
        if (pair) break;
    }
    if (!pair && !reg.empty() && !reg[0].pairs.empty()) pair = &reg[0].pairs[0];  // default like the UI
    if (!pair) { state_.market.exchange_chart_active = false; exchange_chart_key_.clear(); return; }

    const std::string key = pair->identifier + ":" + pair->base + "/" + pair->quote;

    // No candle adapter for this venue -> draw the CoinGecko aggregate instead.
    if (pair->identifier.empty() || !data::hasExchangeCandleAdapter(pair->identifier)) {
        state_.market.exchange_chart_active = false;
        exchange_chart_key_.clear();
        return;
    }
    // Already loaded for this pair and still fresh (candles move slowly).
    if (key == exchange_chart_key_ && state_.market.exchange_chart_active &&
        std::chrono::steady_clock::now() - exchange_chart_last_fetch_ < std::chrono::minutes(30))
        return;
    // Cached from a recent view of this pair -> load instantly, no re-fetch (switching back is snappy).
    if (auto it = exchange_chart_cache_.find(key);
        it != exchange_chart_cache_.end() &&
        std::chrono::steady_clock::now() - it->second.fetchedAt < std::chrono::minutes(30)) {
        state_.market.exchange_chart_intraday = it->second.closeIntraday;
        state_.market.exchange_chart_daily    = it->second.closeDaily;
        state_.market.exchange_ohlc_intraday  = it->second.ohlcIntraday;
        state_.market.exchange_ohlc_daily     = it->second.ohlcDaily;
        state_.market.exchange_chart_active   = true;
        exchange_chart_key_        = key;
        exchange_chart_last_fetch_ = it->second.fetchedAt;
        return;
    }
    // Switching pairs: fall back to the aggregate until the new venue's candles arrive.
    if (key != exchange_chart_key_) state_.market.exchange_chart_active = false;

    exchange_chart_fetch_in_flight_ = true;
    const std::string identifier = pair->identifier, base = pair->base, quote = pair->quote;
    const std::time_t now = std::time(nullptr);
    const std::string urlIntra = data::buildExchangeCandleUrl(identifier, base, quote, data::CandleRange::Intraday, now);
    const std::string urlDaily = data::buildExchangeCandleUrl(identifier, base, quote, data::CandleRange::Daily, now);

    worker_->post([this, identifier, key, urlIntra, urlDaily]() -> rpc::RPCWorker::MainCb {
        // httpGetString verifies TLS and returns "" on any failure (parses to an empty series).
        std::string bIntra = util::httpGetString(urlIntra, "[exch-chart]");
        std::string bDaily = util::httpGetString(urlDaily, "[exch-chart]");
        auto ohlcIntra = data::parseExchangeOHLC(identifier, bIntra);
        auto ohlcDaily = data::parseExchangeOHLC(identifier, bDaily);
        return [this, key, ohlcIntra = std::move(ohlcIntra), ohlcDaily = std::move(ohlcDaily)]() mutable {
            exchange_chart_fetch_in_flight_ = false;
            if (!ohlcDaily.empty() || !ohlcIntra.empty()) {
                // Build a cache entry (OHLC + derived close series for the line/change%), publish it to
                // the active buffer, and keep it so switching back to this pair later is instant.
                ExchangeChartCache e;
                e.ohlcIntraday = std::move(ohlcIntra);
                e.ohlcDaily    = std::move(ohlcDaily);
                e.closeIntraday.reserve(e.ohlcIntraday.size());
                e.closeDaily.reserve(e.ohlcDaily.size());
                for (const auto& c : e.ohlcIntraday) e.closeIntraday.emplace_back(c.time, c.close);
                for (const auto& c : e.ohlcDaily)    e.closeDaily.emplace_back(c.time, c.close);
                e.fetchedAt = std::chrono::steady_clock::now();
                state_.market.exchange_chart_intraday = e.closeIntraday;
                state_.market.exchange_chart_daily    = e.closeDaily;
                state_.market.exchange_ohlc_intraday  = e.ohlcIntraday;
                state_.market.exchange_ohlc_daily     = e.ohlcDaily;
                state_.market.exchange_chart_active   = true;
                exchange_chart_key_        = key;
                exchange_chart_last_fetch_ = e.fetchedAt;
                if (exchange_chart_cache_.size() >= 16) exchange_chart_cache_.clear();  // bound it (many venues)
                exchange_chart_cache_[key] = std::move(e);
            } else {
                // Both ranges empty -> the venue's API failed/changed; keep the aggregate.
                state_.market.exchange_chart_active = false;
                exchange_chart_key_.clear();
            }
        };
    });
}

void App::refreshMarketChart()
{
    if (!settings_ || !settings_->getFetchPrices()) return;
    if (!worker_) return;
    if (chart_fetch_in_flight_) return;
    // Historical data moves slowly — refresh at most every 30 minutes after the first fetch.
    if (state_.market.chart_loaded &&
        std::chrono::steady_clock::now() - state_.market.chart_last_fetch_time < std::chrono::minutes(30))
        return;
    chart_fetch_in_flight_ = true;

    worker_->post([this]() -> rpc::RPCWorker::MainCb {
        // Two ranges give real granularity across every sparkline interval:
        //   days=1   -> 5-minute samples (backs the "hour" interval)
        //   days=365 -> daily samples    (backs "day"/"week"/"month")
        // httpGetString verifies TLS and returns "" on any failure (parses to an empty series).
        std::string intradayBody = util::httpGetString(
            "https://api.coingecko.com/api/v3/coins/dragonx-2/market_chart?vs_currency=usd&days=1",
            "[market-chart]");
        std::string dailyBody = util::httpGetString(
            "https://api.coingecko.com/api/v3/coins/dragonx-2/market_chart?vs_currency=usd&days=365",
            "[market-chart]");
        auto intraday = NetworkRefreshService::parseCoinGeckoMarketChart(intradayBody);
        auto daily    = NetworkRefreshService::parseCoinGeckoMarketChart(dailyBody);

        return [this, intraday = std::move(intraday), daily = std::move(daily)]() mutable {
            chart_fetch_in_flight_ = false;
            const bool any = !intraday.empty() || !daily.empty();
            if (!intraday.empty()) state_.market.price_chart_intraday = std::move(intraday);
            if (!daily.empty())    state_.market.price_chart_daily = std::move(daily);
            if (any) {
                state_.market.chart_loaded = true;
                state_.market.chart_last_fetch_time = std::chrono::steady_clock::now();
            }
            // On total failure, leave chart_loaded=false so the next tick retries.
        };
    });
}

void App::refreshExchanges()
{
    if (!settings_ || !settings_->getFetchPrices()) return;
    if (!worker_) return;
    if (exchanges_fetch_started_) return;  // once per session (venues are near-static)
    exchanges_fetch_started_ = true;

    worker_->post([this]() -> rpc::RPCWorker::MainCb {
        std::vector<data::ExchangeInfo> exchanges;
        try {
            CURL* curl = curl_easy_init();
            if (curl) {
                std::string body;
                const char* url = "https://api.coingecko.com/api/v3/coins/dragonx-2/tickers";
                auto write_callback = [](void* contents, size_t size, size_t nmemb, std::string* userp) -> size_t {
                    size_t total = size * nmemb;
                    userp->append((char*)contents, total);
                    return total;
                };
                curl_easy_setopt(curl, CURLOPT_URL, url);
                curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, +write_callback);
                curl_easy_setopt(curl, CURLOPT_WRITEDATA, &body);
                curl_easy_setopt(curl, CURLOPT_TIMEOUT, 12L);
                curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 5L);
                curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
                curl_easy_setopt(curl, CURLOPT_USERAGENT, "DragonX-Wallet/1.0");
                CURLcode res = curl_easy_perform(curl);
                long http_code = 0;
                curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
                curl_easy_cleanup(curl);
                if (res == CURLE_OK && http_code >= 200 && http_code < 300)
                    exchanges = data::parseCoinGeckoTickers(body);
            }
        } catch (...) {}

        return [this, exchanges = std::move(exchanges)]() mutable {
            if (!exchanges.empty())
                state_.market.exchanges = std::move(exchanges);
            else
                exchanges_fetch_started_ = false;  // allow a retry if it failed/empty
        };
    });
}

// ============================================================================
// Mining Operations
// ============================================================================

void App::startMining(int threads)
{
    if (!supportsSoloMining()) {
        (void)threads;
        ui::Notifications::instance().warning("Solo mining is unavailable in lite build");
        return;
    }
    if (!state_.connected || !rpc_ || !worker_) return;
    // Clamp the requested thread count to [1, logical cores] before setgenerate — an unclamped value
    // (from a settings field or idle-scaling) would ask the daemon to spawn arbitrarily many threads. (M-08)
    const int maxThreads = std::max(1, (int)std::thread::hardware_concurrency());
    threads = std::clamp(threads, 1, maxThreads);
    if (mining_toggle_in_progress_.exchange(true)) return; // already in progress

    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;
        std::string errMsg;
        try {
            rpc::RPCClient::TraceScope trace("Mining tab / Stop mining");
            rpc_->call("setgenerate", {false, 0});
            ok = true;
        } catch (const std::exception& e) {
            errMsg = e.what();
            DEBUG_LOGF("Failed to stop mining: %s\n", errMsg.c_str());
        }
        return [this, ok, errMsg]() {
            mining_toggle_in_progress_.store(false);
            if (ok) {
                state_.mining.generate = false;
                state_.mining.localHashrate = 0.0;
                DEBUG_LOGF("Mining stopped\n");
            } else {
                // Don't silently leave generate=true as if it worked: tell the user and let the next
                // getmininginfo refresh reconcile the true daemon state. (M-07)
                ui::Notifications::instance().error("Failed to stop mining: " + errMsg);
            }
        };
    });
}

void App::startPoolMining(int threads)
{
    if (!supportsPoolMining()) {
        (void)threads;
        ui::Notifications::instance().warning("Pool mining is unavailable in this build");
        return;
    }
    // Clamp to [1, logical cores] before the count reaches xmrig (M-06/M-08 pool path).
    threads = std::clamp(threads, 1, std::max(1, (int)std::thread::hardware_concurrency()));

    if (!xmrig_manager_)
        xmrig_manager_ = std::make_unique<daemon::XmrigManager>();

    // Stop solo mining first if active (async via the RPC worker).
    if (state_.mining.generate) stopMining();
    // (the "stop the already-running miner first" step is done inside the control job below, in FIFO order)

    daemon::XmrigManager::Config cfg;
    cfg.pool_url       = settings_->getPoolUrl();
    cfg.worker_name    = settings_->getPoolWorker();
    // Validate the payout address at EVERY start entry point (manual Start button, idle auto-start, thread
    // scaling) — not just the UI gate — since a stale/hand-edited/wrong-chain address here silently loses
    // mining rewards. (M-01) worker_name IS the pool login the rewards are credited to (see below).
    if (!cfg.worker_name.empty() && cfg.worker_name != "x" &&
        !util::isValidRecipientAddress(cfg.worker_name)) {
        ui::Notifications::instance().error(
            "Pool payout address is not a valid DragonX address — mining not started.");
        return;
    }
    // The algo follows the pool: official pools use their own algo (pool.dragonx.cc
    // needs rx/dragonx, pool.dragonx.is rx/hush); custom hosts keep the setting.
    cfg.algo           = util::resolvePoolAlgo(cfg.pool_url, settings_->getPoolAlgo());
    cfg.threads        = threads;  // Use the same thread selection as solo mining
    cfg.tls            = settings_->getPoolTls();
    cfg.hugepages      = settings_->getPoolHugepages();

    // xmrig "user" is the pool login the block rewards are credited to. The user's
    // "Payout Address" field (cfg.worker_name = getPoolWorker) is exactly that, so it
    // takes priority — otherwise a payout address that differs from the wallet's own
    // first z-address is silently ignored and rewards go to the wrong address. Only when
    // no payout address is set do we fall back to the wallet's own first shielded, then
    // transparent, address (available even before the daemon is connected/synced).
    std::string firstShielded, firstTransparent;
    for (const auto& addr : state_.z_addresses) {
        if (!addr.address.empty()) { firstShielded = addr.address; break; }
    }
    for (const auto& addr : state_.addresses) {
        if (addr.type == "transparent" && !addr.address.empty()) {
            firstTransparent = addr.address;
            break;
        }
    }
    cfg.wallet_address = ui::resolveMiningUserAddress(cfg.worker_name, firstShielded, firstTransparent);

    if (cfg.wallet_address.empty()) {
        DEBUG_LOGF("[ERROR] Pool mining: No wallet address available\n");
        ui::Notifications::instance().error("No wallet address available — generate a Z address in the Receive tab");
        return;
    }

    // Run the blocking stop(if running)+start on the serialized mining-control thread so the render thread
    // never blocks on stop()'s SIGTERM->SIGKILL->join; marshal the spawn result back to the UI. (M-03/L-06/
    // L-08/L-09/L-13). cfg was fully built above on this (main) thread.
    daemon::XmrigManager::Config cfgCopy = cfg;
    postMiningControl([this, cfgCopy]() {
        if (xmrig_manager_->isRunning()) xmrig_manager_->stop(3000);
        const bool ok = xmrig_manager_->start(cfgCopy);
        const std::string err = ok ? std::string() : xmrig_manager_->getLastError();
        if (!worker_) return;
        worker_->post([this, ok, err]() -> rpc::RPCWorker::MainCb {
            return [this, ok, err]() {
                if (ok) {
                    // Miner spawned — it still needs a few seconds to connect to the pool and start hashing.
                    pool_starting_.store(true, std::memory_order_relaxed);
                    ui::Notifications::instance().info("Starting pool miner — connecting to the pool…");
                } else {
                    DEBUG_LOGF("[ERROR] Pool mining: %s\n", err.c_str());
                    // Windows Defender blocking (error 225 = ERROR_VIRUS_INFECTED)
                    if (err.find("error 225") != std::string::npos || err.find("virus") != std::string::npos) {
                        ui::Notifications::instance().error(
                            "Windows Defender blocked xmrig. Add exclusion for %APPDATA%\\ObsidianDragon");
#ifdef _WIN32
                        pending_antivirus_dialog_ = true;
#endif
                    } else {
                        ui::Notifications::instance().error("Failed to start pool miner: " + err);
                    }
                }
            };
        });
    });
}

void App::stopPoolMining()
{
    if (!xmrig_manager_) return;
    // Off the render thread — stop()'s SIGTERM->SIGKILL->join can block up to ~3s. (M-03/L-06/L-08/L-09)
    postMiningControl([this]() {
        if (xmrig_manager_->isRunning()) xmrig_manager_->stop(3000);
    });
}

// ============================================================================
// Peer Operations
// ============================================================================

void App::banPeer(const std::string& ip, int duration_seconds)
{
    if (!state_.connected || !rpc_ || !worker_) return;
    // Run on the worker thread — these are called straight from the Peers tab's ImGui
    // handlers, and rpc_->call() blocks on synchronous curl under curl_mutex_.
    worker_->post([this, ip, duration_seconds]() -> rpc::RPCWorker::MainCb {
        std::string err;
        try {
            rpc::RPCClient::TraceScope trace("Peers / Ban");
            rpc_->call("setban", {ip, "add", duration_seconds});
        } catch (const std::exception& e) {
            err = e.what();
        }
        return [this, err]() {
            if (!err.empty()) ui::Notifications::instance().error("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_);
        // A blocked conversation's messages are dropped at ingest (old + future) until it is unblocked.
        chat_service_.setBlockedPredicate([this](const std::string& cid) {
            return settings_ && settings_->isChatBlocked(cid);
        });
        if (chat_db_.unlockWithSecret(trimmed)) {
            chat_service_.loadFromDatabase();
            // Baseline unread: treat everything already in the store at load as read, so only messages
            // that arrive while the app is open badge as unread (Q1).
            const auto& store = chat_service_.store();
            for (const auto& cid : 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()
{
    // Lite send channel — reset UNCONDITIONALLY (independent of the chat feature flag; user sends set
    // inflight_op_/lite_send_callback_ even with chat off). Clear the note-buffer coordinator so wallet A's
    // queued sends can't drain under wallet B, and DON'T orphan a user Send callback across a controller
    // rebuild: rebuildLiteWallet(force) destroys the controller holding the in-flight send, so its result
    // slot dies with it and the callback would hang the Send dialog forever. Fire the orphaned callback on a
    // moved-out copy AFTER the channel state is reset, so a re-entrant sendTransaction stays clean.
    auto orphanedSend = std::move(lite_send_callback_);
    lite_send_callback_ = nullptr;
    chat_send_queue_.clear();
    inflight_op_ = LiteInflightOp{};
    chat_verified_note_budget_ = 0;
    chat_pipeline_note_count_ = 0;
    chat_verified_shielded_zat_ = 0;
    chat_note_model_seen_ = false;
    chat_split_outstanding_ = false;
    chat_split_submitted_at_ = 0.0;
    chat_note_scan_in_flight_ = false;   // a stale in-flight note scan is dropped by its scanGen guard
    if (orphanedSend) orphanedSend(false, "Wallet/server changed — send aborted");

    if (!chat::hushChatFeatureEnabledAtBuild()) return;  // constexpr — folds away in OFF builds
    // Drop identity + decrypted plaintext in RAM, lock the seed-encrypted DB, re-arm provisioning.
    chat_service_.clearIdentity();
    chat_service_.store().clear();
    chat_seen_watermark_.clear();  // unread state is per-wallet — don't leak it across a switch
    ui::ResetChatTab();  // wipe the chat UI's typed plaintext + selection so it can't leak into the next wallet
    chat_db_.lock();
    chat_identity_provisioned_ = false;
    chat_identity_fetch_in_flight_ = false;
    chat_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.spendableBalance >= 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.spendableBalance >= fee && a.spendableBalance > bestBal) {
            best = a.address;
            bestBal = a.spendableBalance;
        }
    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_) {
        // Lite chat is owned by the note-buffer coordinator (enqueue + drain + resolve from the real
        // broadcast result). Route any caller here through the queue rather than resolving optimistically
        // (the old code marked a merely-queued send "Sent", masking real failures). Kept for safety —
        // the send/Retry paths call enqueueChatSend directly and don't hit this branch.
        (void)sessionGen;
        enqueueChatSend(LiteOpKind::ChatSend, memos, echoLocalId);
        return true;
    }

    // Coordinator-owned primitive (pumpChatNoteBuffer): the pump already gates on connected/unlocked and a
    // verified-note budget, so these refusals are belt-and-suspenders. Return false SILENTLY (no per-frame
    // toast) — the pump leaves the message queued ("Sending") and retries when the guard clears.
    if (!state_.connected || !rpc_ || !worker_) return false;

    // Chat moves 0 value, and dragonxd REJECTS a 0-value tx whose fee exceeds the default miners fee
    // (0.0001) — so pin chat to exactly kChatMinFeeDrgx, independent of the user's default-fee setting
    // (which may be higher). It's also well above the node's min relay fee.
    const double fee = kChatMinFeeDrgx;

    // Pay from a z-address that can actually cover the fee. The memo still advertises the identity
    // reply address, so the peer replies to the right place regardless of which note paid.
    const std::string from = chatPayFromZaddr(fee);
    if (from.empty()) return false;   // no fundable note yet — pump keeps it queued, retries after a scan

    // Full-node: each memo needs the daemon's "utf8:" prefix (raw JSON/hex is otherwise rejected).
    const auto outputs = chat::chatSendOutputs(memos, /*utf8Prefix=*/true);
    nlohmann::json recipients = nlohmann::json::array();
    for (const auto& out : outputs) {
        nlohmann::json recipient;
        recipient["address"] = out.address;
        recipient["amount"] = util::formatAmountFixed(0.0);  // 0-value memo output
        recipient["memo"] = out.memo;
        recipients.push_back(std::move(recipient));
    }

    // markFeeGapRetry=true is deliberate: it suppresses the fee-gap auto-retry, which rebuilds a
    // SINGLE-recipient tx from the scalar to/amount/memo and would drop the second (payload) output.
    // This is now the note-buffer coordinator's full-node submit primitive (called only from the pump).
    // The terminal callback (fired once by the opid poller) releases the send channel and routes the
    // result through onChatBroadcastResult, so a transient "Insufficient shielded funds" requeues instead
    // of hard-failing. The sessionGen guard drops a stale wallet's echo AND protects the inflight_op_
    // clear from a callback landing after a wallet switch (which would otherwise clear a NEW op's token).
    submitZSendMany(from, memos.recipientZaddr, 0.0, fee, /*memo*/"", recipients,
                    "HushChat / broadcast", /*markFeeGapRetry*/ true,
                    [this, echoLocalId, sessionGen](bool ok, const std::string& result) {
                        if (sessionGen != chat_session_generation_) return;  // stale wallet — don't touch new session
                        inflight_op_ = LiteInflightOp{};                     // release the channel for this session
                        LiteInflightOp op; op.echoLocalId = echoLocalId; op.sessionGen = sessionGen;
                        onChatBroadcastResult(op, ok, result);
                    },
                    /*background*/ true);   // status is shown on the chat message, not the global send UI
    return true;  // submitted (async build/broadcast; the callback resolves the final status)
}

// Lite variant: two 0-value recipients to the same z-address, RAW memos (the backend does
// Memo::from_str directly — no "utf8:" prefix). The backend accepts duplicate addresses + 0-value
// outputs for exactly this HushChat pattern. Fire-and-forget: the result surfaces via the lite
// broadcast log; the message is already echoed locally.
bool App::broadcastChatMemosLite(const chat::OutgoingChatMemos& memos)
{
    if (!lite_wallet_) return false;
    const auto outputs = chat::chatSendOutputs(memos, /*utf8Prefix=*/false);
    wallet::LiteSendRequest req;
    for (const auto& out : outputs) {
        wallet::LiteSendRecipient recipient;
        recipient.address = out.address;
        recipient.amountZatoshis = 0;
        recipient.memo = out.memo;
        req.recipients.push_back(std::move(recipient));
    }
    // No busy toast here: the coordinator (pumpChatNoteBuffer) owns this call, retries next frame when the
    // single broadcast channel frees, and surfaces status on the message itself.
    return lite_wallet_->sendTransaction(req);
}

void App::sendChatMessage(const std::string& conversationId, const std::string& text)
{
    if (!chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return;
    if (text.empty() || conversationId.empty()) return;

    // The peer's z-address + public key come from a message we already have in this conversation. Pin to
    // the EARLIEST (establishing) values, not the latest: the memo header's `z`/`cid` ride outside the
    // AEAD, so trusting the newest would let a later message redirect our replies to an attacker-chosen
    // address / splice threads (B2). A full fix binds z+cid into the secretstream additional-data, but
    // that's a coordinated HushChat/SDXLite wire-format change; pinning hardens the reply target without it.
    std::string peerZaddr;
    std::string peerPubKey;
    for (const auto& m : chat_service_.store().conversation(conversationId)) {
        if (peerZaddr.empty() && !m.peer_zaddr.empty()) peerZaddr = m.peer_zaddr;
        if (peerPubKey.empty() && !m.peer_public_key_hex.empty()) peerPubKey = m.peer_public_key_hex;
        if (!peerZaddr.empty() && !peerPubKey.empty()) break;
    }
    if (peerPubKey.empty()) {
        ui::Notifications::instance().info(TR("chat_toast_waiting_reply"));
        return;
    }
    const std::string myReply = chatReplyZaddr();
    if (myReply.empty()) {
        ui::Notifications::instance().error(TR("chat_toast_no_zaddr"));
        return;
    }

    chat::OutgoingChatMemos memos;
    if (chat_service_.composeMessage(myReply, peerPubKey, peerZaddr, conversationId, text, memos)
        != chat::ChatComposeStatus::Ok) {
        ui::Notifications::instance().error(TR("chat_toast_compose_failed"));
        return;
    }
    chat::ChatMessage echo;
    echo.direction = chat::ChatDirection::Outgoing;
    echo.kind = chat::ChatMessageKind::Message;
    echo.conversation_id = conversationId;
    echo.peer_zaddr = peerZaddr;
    echo.peer_public_key_hex = peerPubKey;
    echo.body = text;
    echo.timestamp = std::time(nullptr);
    echo.txid = generateChatLocalId("out:", 8);
    echo.payload_position = 0;
    echo.delivery = chat::ChatDelivery::Sending;   // shows immediately; the broadcast callback resolves it
    chat_service_.recordOutgoingPending(echo);     // in-memory now; persisted with the final status
    // Both variants: hand to the note-buffer coordinator, which drains the queue against verified notes
    // (building/refilling the buffer as needed) and resolves the echo Sent/Failed from the REAL result.
    enqueueChatSend(LiteOpKind::ChatSend, memos, echo.txid);
}

void App::startChatConversation(const std::string& peerZaddr, const std::string& text)
{
    if (!chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return;
    if (peerZaddr.empty() || text.empty()) return;
    sendContactRequestForCid(generateChatLocalId("", 16), peerZaddr, text);  // new opaque cid
}

// Compose + broadcast a contact request into a SPECIFIC conversation. startChatConversation() mints a
// fresh cid; a Retry of a failed request reuses its existing cid so it stays in the same thread.
void App::sendContactRequestForCid(const std::string& cid, const std::string& peerZaddr,
                                   const std::string& text)
{
    if (!chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return;
    if (cid.empty() || peerZaddr.empty() || text.empty()) return;
    const std::string myReply = chatReplyZaddr();
    if (myReply.empty()) {
        ui::Notifications::instance().error(TR("chat_toast_no_zaddr"));
        return;
    }

    chat::OutgoingChatMemos memos;
    if (chat_service_.composeContactRequest(myReply, peerZaddr, cid, text, memos)
        != chat::ChatComposeStatus::Ok) {
        ui::Notifications::instance().error(TR("chat_toast_request_compose_failed"));
        return;
    }
    chat::ChatMessage echo;
    echo.direction = chat::ChatDirection::Outgoing;
    echo.kind = chat::ChatMessageKind::ContactRequest;
    echo.conversation_id = cid;
    echo.peer_zaddr = peerZaddr;
    echo.body = text;
    echo.timestamp = std::time(nullptr);
    echo.txid = generateChatLocalId("out:", 8);
    echo.payload_position = 0;
    echo.delivery = chat::ChatDelivery::Sending;
    chat_service_.recordOutgoingPending(echo);
    // Both variants: enqueue through the coordinator (resolves the echo from the real result).
    enqueueChatSend(LiteOpKind::ContactRequest, memos, echo.txid);
    ui::Notifications::instance().success(TR("chat_toast_request_queued"));
}

// ── Chat note-buffer coordinator (lite only) ─────────────────────────────────────────────────────
// Design in app.h. Keeps ~kChatBufferTarget verified self-notes so a burst of messages each spends a
// separate note; refilled from change + background self-splits. Shares the controller's single broadcast
// channel with user sends (inflight_op_ tracks ownership). Pumped from App::update().
namespace {
constexpr int    kChatBufferTarget      = 10;      // verified self-notes we aim to keep
constexpr int    kChatRefillTrigger     = 4;       // (re)build only when the maturing pipeline drops below this
// A note is spendable when its witness count > ANCHOR_OFFSET, i.e. block depth >= ANCHOR_OFFSET+1 = 5
// (backend data.rs:543 needs anchor_offset+1 witnesses; ANCHOR_OFFSET=4 in lib.rs:25). Depth 4 is NOT yet
// spendable — counting it there wastes a proving round-trip on a guaranteed "insufficient verified funds".
constexpr int    kChatConfsRequired     = 5;
constexpr double kChatNoteSizeDrgx      = 0.001;   // each buffer note (change after a send stays >= fee)
constexpr double kChatSplitWatchdogSecs = 1200.0;  // 20 min: clear a stuck split flag if it never mines (expiry/reorg)
constexpr int    kMaxChatSendRetries    = 20;      // transient-funds requeues before giving up
inline std::uint64_t chatDrgxToZat(double drgx) {
    return static_cast<std::uint64_t>(std::llround(drgx * 100000000.0));
}
}  // namespace

// Confirmations a self-note needs before it's spendable: lite requires ANCHOR_OFFSET+1 witnesses (=5,
// data.rs:543); the full node's chat z_sendmany uses minconf=1 (app_network.cpp z_sendmany call).
int App::chatConfsRequired() const { return lite_wallet_ ? kChatConfsRequired : 1; }

// Status-bar summary of the chat note buffer (shown while the Chat tab is active). Empty unless chat has
// an identity. Surfaces the buffer state so the user can see it filling / draining, and it doubles as a
// diagnostic: "…" = no note data yet; "0/10 ready" = counted but no verified notes; "N/10 ready" = armed.
std::string App::chatBufferStatusText() {
    if (!chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return "";
    const int queued = static_cast<int>(chat_send_queue_.size());
    const int verified = std::max(0, std::min(chat_verified_note_budget_, kChatBufferTarget));
    const int target = static_cast<int>(kChatBufferTarget);
    char buf[128];
    if (queued > 0) {
        snprintf(buf, sizeof(buf), TR(queued == 1 ? "chat_buffer_sending_one" : "chat_buffer_sending"), queued);
        return buf;
    }
    if (chat_split_outstanding_) {
        snprintf(buf, sizeof(buf), TR("chat_buffer_preparing"), verified, target);
        return buf;
    }
    if (!chat_note_model_seen_)
        return TR("chat_buffer_loading");
    snprintf(buf, sizeof(buf), TR("chat_buffer_ready"), verified, target);
    return buf;
}

bool App::isTransientVerifiedFundsError(const std::string& error) {
    // Both variants' "only unconfirmed change is short" phrasing, treated as transient (retry after the
    // buffer matures): lite = "Insufficient verified funds" (lightwallet.rs:2402); full node =
    // "Insufficient shielded funds" (asyncrpcoperation_sendmany, parsed at parseInsufficientShielded).
    return error.find("Insufficient verified funds") != std::string::npos
        || error.find("Insufficient shielded funds") != std::string::npos;
}

// Verified notes = ANY spendable shielded note (>= a fee) with >= kChatConfsRequired confirmations — the
// ones a chat send can spend RIGHT NOW. We count notes at ALL addresses, not just the reply address,
// because a chat send pays via chatPayFromZaddr (any funded z-addr) — gating on reply-address-only notes
// would queue a sendable message whenever funds sit elsewhere. NB: the model's per-note `spendable` flag
// means "unspent", NOT verified (lite_result_parsers.cpp recomputes it), so we derive it from depth here.
int App::verifiedSelfNoteCount(const wallet::LiteWalletAppRefreshModel& model) {
    if (!model.hasSpendableOutputs) return 0;
    // Use the cached tip (refreshChatNoteBudget updates it), not this model's — a notes-carrying model may
    // not carry sync status that cycle, which would otherwise zero the whole count.
    const std::int64_t tip = chat_last_chain_height_;
    if (tip <= 0) return 0;
    const std::uint64_t minVal = chatDrgxToZat(kChatMinFeeDrgx);
    int n = 0;
    for (const auto& o : model.spendableOutputs) {
        if (o.kind != wallet::LiteSpendableOutputKind::UnspentNote) continue;   // shielded notes only
        if (o.spent || o.unconfirmedSpent || o.pending) continue;
        if (o.valueZatoshis < minVal) continue;                                  // skip dust below a fee
        if (!o.createdInBlock.has_value()) continue;                             // unknown depth -> not verified
        if (tip - *o.createdInBlock + 1 >= chatConfsRequired()) ++n;
    }
    return n;
}

// Notes that are verified OR still maturing (any unspent shielded note of usable size) — i.e. notes that
// will become spendable WITHOUT another split. Drives the refill trigger so we don't over-split while
// change is confirming or when the wallet already holds enough notes anywhere.
int App::pipelineSelfNoteCount(const wallet::LiteWalletAppRefreshModel& model) {
    if (!model.hasSpendableOutputs) return 0;
    const std::uint64_t minVal = chatDrgxToZat(kChatMinFeeDrgx);
    int n = 0;
    for (const auto& o : model.spendableOutputs) {
        if (o.kind != wallet::LiteSpendableOutputKind::UnspentNote) continue;
        if (o.spent || o.unconfirmedSpent) continue;                             // already consumed
        if (o.valueZatoshis < minVal) continue;
        ++n;                                                                     // verified or maturing (any addr)
    }
    return n;
}

// Recompute the cached note estimates from a fresh refresh model (called from update() when one arrives).
void App::refreshChatNoteBudget(const wallet::LiteWalletAppRefreshModel& model) {
    if (!lite_wallet_ || !chat::hushChatFeatureEnabledAtBuild()) return;
    // Cache the chain tip from whatever height source THIS model carries (sync status or chain info) — a
    // model with notes may lack sync that cycle, so verifiedSelfNoteCount reads the cache below.
    if (model.hasSyncStatus && model.sync.chainHeight > 0)
        chat_last_chain_height_ = static_cast<std::int64_t>(model.sync.chainHeight);
    else if (model.hasChainInfo && model.chain.latestBlockHeight.has_value() &&
             *model.chain.latestBlockHeight > 0)
        chat_last_chain_height_ = static_cast<std::int64_t>(*model.chain.latestBlockHeight);
    if (model.hasSpendableOutputs) {
        chat_note_model_seen_ = true;
        chat_verified_note_budget_ = verifiedSelfNoteCount(model);
        chat_pipeline_note_count_  = pipelineSelfNoteCount(model);
    }
    if (model.hasBalance)
        chat_verified_shielded_zat_ = model.balance.verifiedShieldedZatoshis;
    // Release the single-split guard once the outstanding split's outputs are mined+visible (pipeline
    // recovered) — or once a watchdog window elapses, so a split that never mines can't wedge refill.
    if (chat_split_outstanding_ &&
        (chat_pipeline_note_count_ >= kChatRefillTrigger ||
         ImGui::GetTime() - chat_split_submitted_at_ > kChatSplitWatchdogSecs)) {
        chat_split_outstanding_ = false;
    }
}

// Full-node twin of refreshChatNoteBudget: the shared balance poll discards per-note data, so run our own
// rate-limited z_listunspent worker scan (mirrors fastScanChatMemos) to count verified/maturing spendable
// notes. z_listunspent returns only the wallet's OWN notes, and a chat send pays from any of them
// (chatPayFromZaddr), so we count ALL addresses — gating on the reply address only would queue a sendable
// message when funds sit elsewhere. minconf=0 exposes maturing change so the pipeline count doesn't over-
// split. On an older daemon that rejects z_listunspent it leaves chat_note_model_seen_ false → the pump
// won't drain a phantom budget or split (graceful degradation to naive one-at-a-time sends).
void App::refreshChatNoteBudgetNode() {
    if (lite_wallet_ || !chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return;
    if (!state_.connected || !rpc_ || !worker_ || state_.isLocked()) return;
    // Never contend the daemon's cs_main while the node is catching up. z_listunspent is O(mapWallet)
    // and holds cs_main for its whole duration — seconds on a large wallet — so an ungated per-few-second
    // chat scan here starves block connection and keeps the node from ever pinning to the tip. Chat
    // send-readiness can wait until we're synced; this budget is only advisory (the send path re-checks).
    if (effectivelySyncing()) return;
    // Only pre-warm the send-budget while the user is actually in chat. Off the Chat tab this heavy
    // z_listunspent is unnecessary — and on a wallet-data tab, refreshAddressData's own z_listunspent
    // already feeds the budget for free (updateChatNoteBudgetFromUnspent), so this dedicated scan is a
    // fallback for the Chat tab (where the address refresh doesn't run).
    if (current_page_ != ui::NavPage::Chat) return;
    if (chat_note_scan_in_flight_) return;                 // one scan at a time
    const double now = ImGui::GetTime();
    // Adaptive back-off keyed on the last scan's own cost: on a large wallet where the scan takes many
    // seconds, this stretches the interval so the scan can't occupy more than ~10% of wall-clock (same
    // duty-cycle discipline as the balance/address/tx polls). A cheap wallet stays at the 4s floor.
    double minInterval = 4.0;
    if (chat_note_scan_ms_ > 0.0)
        minInterval = std::max(4.0, (chat_note_scan_ms_ / 1000.0) / 0.10);
    if (now - chat_note_scan_last_ < minInterval) return;

    chat_note_scan_last_ = now;
    chat_note_scan_in_flight_ = true;
    const int scanGen = chat_session_generation_;
    const int confsNeeded = chatConfsRequired();           // == 1 for full node
    const std::uint64_t minVal = chatDrgxToZat(kChatMinFeeDrgx);
    worker_->post([this, scanGen, confsNeeded, minVal]() -> rpc::RPCWorker::MainCb {
        int verified = 0, pipeline = 0;
        std::uint64_t verifiedZat = 0;
        bool ok = false;
        const auto scanStart = std::chrono::steady_clock::now();
        try {
            rpc::RPCClient::TraceScope trace("HushChat / note-buffer scan");
            nlohmann::json notes = rpc_->call("z_listunspent", nlohmann::json::array({0}));  // 0 = include maturing
            if (notes.is_array()) {
                ok = true;
                for (const auto& nz : notes) {
                    if (!nz.is_object()) continue;
                    if (nz.value("locked", false)) continue;                     // tied up by an in-flight send
                    const std::uint64_t amtZat = chatDrgxToZat(nz.value("amount", 0.0));
                    if (amtZat < minVal) continue;                               // skip dust below a fee
                    // rawconfirmations is the TRUE depth; `confirmations` is dPoW-clamped to 1 and understates it.
                    const int confs = (nz.contains("rawconfirmations") && nz["rawconfirmations"].is_number_integer())
                                      ? nz["rawconfirmations"].get<int>()
                                      : nz.value("confirmations", 0);
                    ++pipeline;                                                  // unspent self-note (verified or maturing)
                    if (confs >= confsNeeded) { ++verified; verifiedZat += amtZat; }
                }
            }
        } catch (const std::exception&) {}
        const double scanMs = std::chrono::duration<double, std::milli>(
            std::chrono::steady_clock::now() - scanStart).count();
        return [this, scanGen, ok, verified, pipeline, verifiedZat, scanMs]() {
            if (scanGen != chat_session_generation_) return;   // wallet switched — drop (reset cleared the flag)
            chat_note_scan_in_flight_ = false;
            if (scanMs > 0.0) chat_note_scan_ms_ = scanMs;      // feed the adaptive back-off
            if (!ok) return;                                    // z_listunspent unavailable — leave caches as-is
            chat_note_model_seen_ = true;
            chat_verified_note_budget_  = verified;
            chat_pipeline_note_count_   = pipeline;
            chat_verified_shielded_zat_ = verifiedZat;
            if (chat_split_outstanding_ &&
                (chat_pipeline_note_count_ >= kChatRefillTrigger ||
                 ImGui::GetTime() - chat_split_submitted_at_ > kChatSplitWatchdogSecs)) {
                chat_split_outstanding_ = false;
            }
        };
    });
}

// #3: derive the chat send-budget from an already-collected z_listunspent (refreshAddressData's scan),
// so we don't issue a duplicate z_listunspent. Runs on the main thread; the notes are pre-parsed to the
// fields we need (amount / locked / TRUE confirmations), so there's no RPC and no worker hop. Mirrors the
// apply in refreshChatNoteBudgetNode and stamps chat_note_scan_last_ so the dedicated scan backs off.
void App::updateChatNoteBudgetFromUnspent(
    const std::vector<services::NetworkRefreshService::UnspentNoteLite>& unspentNotes) {
    if (lite_wallet_ || !chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return;
    const int confsNeeded = chatConfsRequired();
    const std::uint64_t minVal = chatDrgxToZat(kChatMinFeeDrgx);
    int verified = 0, pipeline = 0;
    std::uint64_t verifiedZat = 0;
    for (const auto& nz : unspentNotes) {
        if (nz.locked) continue;                                 // tied up by an in-flight send
        const std::uint64_t amtZat = chatDrgxToZat(nz.amount);
        if (amtZat < minVal) continue;                           // skip dust below a fee
        ++pipeline;                                              // unspent self-note (verified or maturing)
        if (nz.confirmations >= confsNeeded) { ++verified; verifiedZat += amtZat; }
    }
    chat_note_model_seen_ = true;
    chat_verified_note_budget_  = verified;
    chat_pipeline_note_count_   = pipeline;
    chat_verified_shielded_zat_ = verifiedZat;
    chat_note_scan_last_ = ImGui::GetTime();   // counts as a fresh scan → dedicated scan backs off
    if (chat_split_outstanding_ &&
        (chat_pipeline_note_count_ >= kChatRefillTrigger ||
         ImGui::GetTime() - chat_split_submitted_at_ > kChatSplitWatchdogSecs)) {
        chat_split_outstanding_ = false;
    }
}

void App::enqueueChatSend(LiteOpKind kind, const chat::OutgoingChatMemos& memos, const std::string& echoLocalId) {
    QueuedChatOp op;
    op.kind = kind;
    op.memos = memos;                     // kept so a transient-funds retry re-broadcasts, never recomposes
    op.echoLocalId = echoLocalId;
    op.sessionGen = chat_session_generation_;
    chat_send_queue_.push_back(std::move(op));
    wallet::liteLog("chat: queued outgoing " +
                    std::string(kind == LiteOpKind::ContactRequest ? "contact request" : "message") +
                    " (queue depth " + std::to_string(chat_send_queue_.size()) +
                    ", verified notes ~" + std::to_string(chat_verified_note_budget_) + ")");
    // pumpChatNoteBuffer() (next update tick) submits it when the channel is free + a verified note exists.
}

// Self-send that mints `noteCount` reply-address notes (funds stay in the wallet), building/refilling the
// buffer. Returns false if the single broadcast channel is busy (the pump retries next frame).
bool App::broadcastSelfSplitLite(int noteCount) {
    if (!lite_wallet_ || noteCount < 1) return false;
    const std::string myZ = chatReplyZaddr();
    if (myZ.empty()) return false;
    wallet::LiteSendRequest req;
    const std::uint64_t noteZat = chatDrgxToZat(kChatNoteSizeDrgx);
    for (int i = 0; i < noteCount; ++i) {
        wallet::LiteSendRecipient r;
        r.address = myZ;
        r.amountZatoshis = noteZat;
        req.recipients.push_back(std::move(r));
    }
    if (!lite_wallet_->sendTransaction(req)) return false;   // channel busy — retry next frame
    wallet::liteLog("Chat buffer: splitting funds into " + std::to_string(noteCount) +
                    " notes (self-send) so messages can be sent back-to-back");
    return true;
}

// Full-node twin of broadcastSelfSplitLite: a z_sendmany self-send with `noteCount` outputs to our reply
// address (funds stay in the wallet), building/refilling the buffer. Caps outputs to stay well under the
// per-tx size limit after Sietch decoy padding. markFeeGapRetry=true is load-bearing — the fee-gap retry
// rebuilds a SINGLE-recipient tx and would collapse the multi-output split. The terminal callback releases
// the send channel; chat_split_outstanding_ stays set until a scan sees the outputs.
bool App::broadcastSelfSplitNode(int noteCount) {
    if (lite_wallet_ || noteCount < 1) return false;
    if (!state_.connected || !rpc_ || !worker_) return false;
    const std::string myZ = chatReplyZaddr();
    if (myZ.empty()) return false;
    const double fee = kChatMinFeeDrgx;
    // Pay from any funded z-address; the split's OUTPUTS still go to the reply address (building the buffer
    // there). The note count now includes notes at every address and is sized against the total verified
    // balance, so paying from wherever the funds actually sit is consistent — and it can't stall when funds
    // aren't at the reply address (which paying strictly from myZ would).
    const std::string from = chatPayFromZaddr(fee);
    if (from.empty()) return false;   // no fundable note — pump retries after a scan
    const int n = std::min(noteCount, 40);   // bounded so the split tx stays under max_tx_size after decoys
    nlohmann::json recipients = nlohmann::json::array();
    for (int i = 0; i < n; ++i) {
        nlohmann::json r;
        r["address"] = myZ;
        r["amount"] = util::formatAmountFixed(kChatNoteSizeDrgx);
        recipients.push_back(std::move(r));
    }
    const auto sessionGen = chat_session_generation_;
    submitZSendMany(from, myZ, 0.0, fee, /*memo*/"", recipients,
                    "HushChat / note-buffer split", /*markFeeGapRetry*/ true,
                    [this, sessionGen](bool ok, const std::string& /*result*/) {
                        if (sessionGen != chat_session_generation_) return;   // stale wallet
                        inflight_op_ = LiteInflightOp{};                      // release the channel
                        // On success the outputs aren't mined yet — chat_split_outstanding_ clears on the
                        // next scan (pipeline recovered) or the watchdog. On FAILURE (e.g. a stale-scan
                        // insufficient-funds throw) clear it now so the pump can retry after the next scan
                        // instead of waiting out the 1200s watchdog. (No notes were created, so it's safe.)
                        if (!ok) chat_split_outstanding_ = false;
                    },
                    /*background*/ true);   // autonomous buffer maintenance — no global send UI
    wallet::liteLog("Chat buffer: splitting funds into " + std::to_string(n) +
                    " notes (self-send) so messages can be sent back-to-back");
    return true;
}

bool App::shouldSplitChatBuffer() {
    // Only for engaged chat users (has a conversation or a queued send) — never split for someone who
    // never chats. Connected, unlocked, channel free, no split already outstanding, pipeline genuinely
    // depleted, funded. NB: a wall-clock cooldown is deliberately NOT used — a split's outputs stay
    // invisible until mined (~1 block, far longer than any UI cooldown), so we gate on a single-split-
    // in-flight flag (cleared when the pipeline recovers or the watchdog fires) to prevent runaway splits.
    if (chat_service_.store().conversationIds().empty() && chat_send_queue_.empty()) return false;
    if (!state_.connected || state_.isLocked()) return false;
    // Channel busy = our own op inflight, OR a user send holds the shared channel. Lite user sends set
    // lite_send_callback_; full-node user sends don't touch inflight_op_, so they're detected via the
    // shared single-flight counter + any outstanding opid (a chat/user/sweep z_sendmany not yet resolved).
    if (inflight_op_.kind != LiteOpKind::None) return false;
    if (lite_wallet_ ? (bool)lite_send_callback_
                     : (send_submissions_in_flight_ > 0 || !pending_opids_.empty())) return false;
    if (chat_split_outstanding_) return false;
    if (!chat_note_model_seen_) return false;
    if (chat_pipeline_note_count_ >= kChatRefillTrigger) return false;   // change refill covers it
    if (chat_verified_shielded_zat_ < chatDrgxToZat(kChatNoteSizeDrgx + kChatMinFeeDrgx)) return false;
    return true;
}

// Resolve the just-completed chat/contact broadcast onto its echo. The in-flight op is the queue FRONT
// (the pump submits it without popping) so we can requeue it in place on a transient-funds failure.
void App::onChatBroadcastResult(const LiteInflightOp& op, bool ok, const std::string& error) {
    if (op.sessionGen != chat_session_generation_) return;   // wallet switched since submit — echo is gone
    const bool frontMatches = !chat_send_queue_.empty()
                              && chat_send_queue_.front().echoLocalId == op.echoLocalId;
    if (ok) {
        chat_service_.resolveOutgoing(op.echoLocalId, chat::ChatDelivery::Sent);
        if (frontMatches) chat_send_queue_.pop_front();
        wallet::liteLog("chat: message broadcast (Sent); " + std::to_string(chat_send_queue_.size()) +
                        " still queued");
        return;
    }
    if (frontMatches && isTransientVerifiedFundsError(error)
        && chat_send_queue_.front().retries < kMaxChatSendRetries) {
        // The optimistic budget was wrong (change not confirmed yet). Stop draining until the next refresh
        // restores the true verified-note count; keep the message queued ("Sending") and retry then.
        chat_verified_note_budget_ = 0;
        ++chat_send_queue_.front().retries;
        wallet::liteLog("chat: send deferred — funds still confirming (retry " +
                        std::to_string(chat_send_queue_.front().retries) + "); message stays 'Sending'");
        return;                                              // leave at front
    }
    chat_service_.resolveOutgoing(op.echoLocalId, chat::ChatDelivery::Failed);  // hard failure -> Retry affordance
    if (frontMatches) chat_send_queue_.pop_front();
    wallet::liteLog("chat: send FAILED: " + error);
}

// Per-frame pump: drain the chat queue against verified notes (priority 1); otherwise build/refill the
// buffer during idle (priority 2). One op per frame — the single broadcast channel serializes the rest.
void App::pumpChatNoteBuffer() {
    if (!chat::hushChatFeatureEnabledAtBuild()) return;
    if (!chat_service_.hasIdentity() || !state_.connected) return;
    // A locked wallet can't spend: submitting would return a non-transient lock error and flap the queued
    // message to Failed. Hold everything (send + split) until unlock — messages stay "Sending".
    if (state_.isLocked()) return;
    // Channel busy — exactly one send must be outstanding. Lite serializes via inflight_op_ + the send
    // callback; the full node has no single-broadcast channel, so we also honour the shared single-flight
    // counter and any outstanding opid (a chat/user/sweep z_sendmany not yet terminally resolved). Without
    // the latter two a chat send could overlap a user Send-tab send (which never sets inflight_op_).
    if (inflight_op_.kind != LiteOpKind::None) return;
    if (lite_wallet_ ? (bool)lite_send_callback_
                     : (send_submissions_in_flight_ > 0 || !pending_opids_.empty())) return;

    // Priority 1: drain a queued chat send while we (optimistically) still have a verified note.
    if (!chat_send_queue_.empty()) {
        QueuedChatOp& front = chat_send_queue_.front();
        if (front.sessionGen != chat_session_generation_) { chat_send_queue_.pop_front(); return; }
        if (chat_verified_note_budget_ >= 1) {
            // Submit onto the wallet's send channel; NOT popped until the async result resolves (so a
            // transient-funds failure can requeue it in place). Full-node broadcastChatMemos registers a
            // terminal opid callback that clears inflight_op_ + routes onChatBroadcastResult.
            const bool submitted = lite_wallet_ ? broadcastChatMemosLite(front.memos)
                                                : broadcastChatMemos(front.memos, front.echoLocalId);
            if (submitted) {
                inflight_op_ = LiteInflightOp{ front.kind, front.echoLocalId, front.sessionGen, ImGui::GetTime() };
                --chat_verified_note_budget_;            // this note is now (optimistically) spent
            }
            return;                                      // one op per frame; the front waits until resolved
        }
        // No verified note available: leave it queued ("Sending"); fall through to maybe build the buffer.
    }

    // Priority 2: build/refill the buffer during idle.
    if (shouldSplitChatBuffer()) {
        const int need = kChatBufferTarget - chat_pipeline_note_count_;
        const std::uint64_t noteZat = chatDrgxToZat(kChatNoteSizeDrgx);
        const std::uint64_t feeZat  = chatDrgxToZat(kChatMinFeeDrgx);
        int affordable = 0;
        if (chat_verified_shielded_zat_ > feeZat && noteZat > 0)
            affordable = static_cast<int>((chat_verified_shielded_zat_ - feeZat) / noteZat);
        const int n = std::min(need, affordable);
        const bool submitted = n >= 1 && (lite_wallet_ ? broadcastSelfSplitLite(n)
                                                       : broadcastSelfSplitNode(n));
        if (submitted) {
            inflight_op_ = LiteInflightOp{ LiteOpKind::Split, {}, chat_session_generation_, ImGui::GetTime() };
            // Block further splits until this one's outputs mine in (or the watchdog fires): the outputs are
            // invisible to the note count for ~1 block, so without this we'd fire a fresh split every frame.
            chat_split_outstanding_ = true;
            chat_split_submitted_at_ = ImGui::GetTime();
        }
    }
}

// HushChat (lite variant): the full-node harvest works off z_viewtransaction, but the lite wallet's
// transactions come from the backend. The backend lists one entry per received (non-change) note —
// same txid, distinct position + decoded-UTF-8 memo — so a chat tx yields two entries (header +
// payload). Group the received notes by txid, run them through the SAME parser, and thread the
// result. The store dedups (txid+position), so re-listing every refresh is harmless.
void App::ingestLiteChatMemos(const wallet::LiteWalletAppRefreshModel& model)
{
    if (!chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return;

    std::unordered_map<std::string, chat::HushChatTransactionInput> byTxid;
    std::unordered_map<std::string, std::int64_t> txTimestamps;
    bool anyUnconfirmedChatTx = false;   // did a chat-carrying tx arrive still-unconfirmed (0-conf mempool)?
    for (const auto& tx : model.transactions) {
        if (tx.kind != wallet::LiteWalletAppTransactionKind::Receive) continue;  // only incoming carry chat
        if (tx.memo.empty() || !tx.position) continue;
        auto& input = byTxid[tx.txid];
        input.txid = tx.txid;
        input.outputs.push_back({static_cast<std::size_t>(*tx.position), tx.memo});
        txTimestamps[tx.txid] = tx.timestamp;
        if (tx.unconfirmed) anyUnconfirmedChatTx = true;
    }
    if (byTxid.empty()) return;

    std::vector<chat::HushChatTransactionMetadata> metadata;
    for (auto& entry : byTxid) {
        auto extracted = chat::extractHushChatTransactionMetadata(entry.second, true);
        for (auto& meta : extracted.metadata) metadata.push_back(std::move(meta));
    }
    if (!metadata.empty()) {
        std::vector<std::string> newChatCids;
        chat_service_.ingest(metadata, txTimestamps, std::time(nullptr), &newChatCids);
        if (!newChatCids.empty())
            wallet::liteLog("chat: harvested " + std::to_string(newChatCids.size()) + " new message(s) " +
                            (anyUnconfirmedChatTx ? "(0-conf / mempool — monitor working)"
                                                  : "(confirmed only — not seen at mempool speed)"));
        // Mirror the full-node paths: a new incoming message un-hides a hidden conversation (you can't
        // un-receive), and a non-muted new message toasts when we're off the Chat tab.
        if (settings_) {
            bool unhid = false;
            for (const auto& cid : newChatCids)
                if (settings_->isChatHidden(cid)) { settings_->setChatHidden(cid, false); unhid = true; }
            if (unhid) settings_->save();
        }
        if (current_page_ != ui::NavPage::Chat &&
            std::any_of(newChatCids.begin(), newChatCids.end(),
                        [this](const std::string& cid){ return !(settings_ && settings_->isChatMuted(cid)); }))
            ui::Notifications::instance().info(TR("chat_new_message_toast"));
    }
}

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
    // z_listreceivedbyaddress is O(mapWallet) (the daemon iterates every wtx even when filtered to one
    // address) — seconds on a large wallet — so this 0-conf poll must NOT run while the node is catching
    // up, and even when synced it backs off to ~10% wall-clock keyed on its own cost. The block-tip
    // harvest still ingests every message on confirmation; this only trades away mempool-speed delivery.
    if (effectivelySyncing()) return;
    // Mempool-speed 0-conf delivery is only worth its heavy cost while the user is watching chat. Off the
    // Chat tab, incoming messages still arrive via the confirmed block-tip harvest (+ toast) within ~1 block.
    if (current_page_ != ui::NavPage::Chat) return;
    const double nowT = ImGui::GetTime();
    if (chat_fast_scan_ms_ > 0.0 &&
        nowT - chat_fast_scan_last_ < (chat_fast_scan_ms_ / 1000.0) / 0.10) return;
    const std::string addr = chatReplyZaddr();
    if (addr.empty()) return;

    chat_fast_scan_last_ = nowT;
    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;
        int rawMemoCount = 0;   // received notes carrying a memo at the reply addr (0-conf visibility signal)
        std::string scanError;
        const auto scanStart = std::chrono::steady_clock::now();
        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;
                    ++rawMemoCount;
                    std::size_t pos = fallbackPos;
                    for (const char* key : {"position", "outputIndex", "outindex"})
                        if (note.contains(key) && note[key].is_number_integer() && note[key].get<int>() >= 0) {
                            pos = static_cast<std::size_t>(note[key].get<int>());
                            break;
                        }
                    auto& in = byTxid[txid];
                    in.txid = txid;
                    in.outputs.push_back(chat::HushChatMemoOutput{pos, memo});
                }
                for (auto& entry : byTxid) {
                    auto extracted = chat::extractHushChatTransactionMetadata(entry.second);
                    for (auto& m : extracted.metadata) metadata.push_back(std::move(m));
                }
            }
        } catch (const std::exception& e) { scanError = e.what(); }
        const int parsedCount = static_cast<int>(metadata.size());
        const double scanMs = std::chrono::duration<double, std::milli>(
            std::chrono::steady_clock::now() - scanStart).count();
        return [this, scanGen, metadata = std::move(metadata), rawMemoCount, parsedCount, scanError, scanMs]() 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 (scanMs > 0.0) chat_fast_scan_ms_ = scanMs;   // feed the adaptive back-off
            // Diagnostic (console App/chat channel): log when the memo-note count at the reply address
            // CHANGES, so a stable mempool doesn't spam every 2.5s. Reveals whether inbound 0-conf messages
            // are reaching us at all, and how many parse as chat vs are unrelated memos.
            if (!scanError.empty()) {
                wallet::liteLog("chat[0conf]: reply-addr scan failed: " + scanError);
            } else if (rawMemoCount != chat_fast_scan_last_seen_) {
                chat_fast_scan_last_seen_ = rawMemoCount;
                if (rawMemoCount > 0)
                    wallet::liteLog("chat[0conf]: reply-addr scan sees " + std::to_string(rawMemoCount) +
                                    " memo note(s), " + std::to_string(parsedCount) + " parse as chat");
            }
            if (metadata.empty()) return;
            // Skip HIDDEN conversations — the mempool fast path deliberately doesn't surface them; they
            // still come back through the normal confirmed harvest (which un-hides on a new message).
            std::vector<chat::HushChatTransactionMetadata> visible;
            visible.reserve(metadata.size());
            for (auto& m : metadata)
                if (!(settings_ && settings_->isChatHidden(m.conversation_id)))
                    visible.push_back(std::move(m));
            if (visible.empty()) return;

            std::unordered_map<std::string, std::int64_t> noTimes;  // mempool: no block time → ingest uses now
            std::vector<std::string> newChatCids;
            chat_service_.ingest(visible, noTimes, std::time(nullptr), &newChatCids);
            if (!newChatCids.empty())
                wallet::liteLog("chat[0conf]: harvested " + std::to_string(newChatCids.size()) +
                                " new message(s) from the mempool");
            if (current_page_ != ui::NavPage::Chat &&
                std::any_of(newChatCids.begin(), newChatCids.end(),
                            [this](const std::string& cid){ return !(settings_ && settings_->isChatMuted(cid)); }))
                ui::Notifications::instance().info(TR("chat_new_message_toast"));
        };
    });
}

bool App::lockLiteWallet()
{
    if (!lite_wallet_) return false;
    const bool ok = lite_wallet_->lockWallet();
    if (ok) {
        // 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);
                // Scrub the concatenated all-keys buffer once the consumer (backup writer) has used it.
                if (!keys_result->empty()) sodium_memzero(&(*keys_result)[0], keys_result->size());
            }
        });
    }
}

void App::importPrivateKey(const std::string& rawKey, int startHeight,
                           std::function<void(bool, const std::string&, const std::string&)> callback)
{
    if (!state_.connected || !rpc_ || !worker_) {
        if (callback) callback(false, "Not connected", "");
        return;
    }

    // Trim whitespace/newlines — manual entry doesn't go through the dialog's Paste trimmer, and a
    // stray character makes the daemon reject the key with a cryptic error.
    std::string key(rawKey);
    while (!key.empty() && (key.front()==' '||key.front()=='\t'||key.front()=='\n'||key.front()=='\r')) key.erase(key.begin());
    while (!key.empty() && (key.back()==' '||key.back()=='\t'||key.back()=='\n'||key.back()=='\r')) key.pop_back();

    // Reject anything that isn't a recognized Z/T private key or shielded viewing key before handing
    // it to the daemon (the dialog's indicator and this guard share isRecognizedImportKey).
    if (!services::WalletSecurityController::isRecognizedImportKey(key)) {
        if (callback) callback(false,
            "Not a recognized DragonX private key or viewing key. Check for missing or "
            "mistyped characters, and that this is a DragonX key (not another coin).", "");
        return;
    }

    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]() mutable -> 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);
            // Scrub the key out of the request params (the json holds its own copy of it).
            if (params.is_array() && !params.empty() && params[0].is_string()) {
                std::string& pk = params[0].get_ref<std::string&>();
                if (!pk.empty()) sodium_memzero(&pk[0], pk.size());
            }
            // z_import* return {type,address}; importprivkey returns the t-address string.
            if (r.is_object() && r.contains("address") && r["address"].is_string())
                addr = r["address"].get<std::string>();
            else if (r.is_string())
                addr = r.get<std::string>();
        } catch (const std::exception& e) {
            err = e.what();
        } catch (...) {
            // Non-std throw must not escape into the worker — the main-thread callback
            // below would never run, leaving a stuck "Importing…" spinner.
            err = "Import failed (unknown error)";
        }
        // Scrub the worker's copy of the key now that the request has been sent (all paths).
        if (!key.empty()) sodium_memzero(&key[0], key.size());
        // A checksum-valid key the daemon still rejects is almost always the right *format* but the
        // wrong network/coin (Komodo-family chains share version bytes) or a corrupted paste — say so,
        // since the bare "Invalid …" text reads like a wallet bug (F5).
        if (!err.empty() && err.find("Invalid") != std::string::npos &&
            err.find("DragonX") == std::string::npos) {
            err += " — check the key is for DragonX (not another coin or network) and has no missing "
                   "or altered characters.";
        }
        return [this, err, addr, callback]() {
            if (!err.empty()) {
                if (callback) callback(false, err, "");
                return;
            }
            invalidateAddressValidationCache();
            refreshAddresses();
            if (callback) callback(true, "", addr);
        };
    });
    if (!key.empty()) sodium_memzero(&key[0], key.size());  // scrub the calling-frame copy
}

// Sweep a spending key: import it (a full rescan populates its UTXOs/notes — the stock node has no
// address index, so there is no way to enumerate them without importing) then z_sendmany ALL of the
// key's funds (balance − fee) to a destination the user already controls. The imported key is left in
// the wallet with an empty balance (there is no remove-key RPC) — the point is that the funds now sit
// on the user's own key. z_sendmany (not z_mergetoaddress): it moves a single-UTXO transparent source
// and fails loudly rather than silently leaving a remainder. Fund-moving — see the reviewed design.
void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMode,
                          const std::string& destExisting)
{
    if (!state_.connected || !rpc_ || !worker_) {
        sweep_status_ = "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]() mutable -> rpc::RPCWorker::MainCb {
        std::string err, dest, sourceAddr, amountStr;
        double amount = 0.0;
        try {
            rpc::RPCClient::TraceScope trace("Settings / Sweep key");

            // For a shielded key this daemon's z_importkey returns null (no address), so snapshot the
            // z-address set before the import and diff it after to find the newly-controlled address.
            std::vector<std::string> preZ;
            bool preZok = false;
            auto listZ = [&](std::vector<std::string>& out) {
                out.clear();
                auto la = 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);
            // Scrub the key out of the request params (the json holds its own copy of it).
            if (params.is_array() && !params.empty() && params[0].is_string()) {
                std::string& pk = params[0].get_ref<std::string&>();
                if (!pk.empty()) sodium_memzero(&pk[0], pk.size());
            }

            // 2. Determine the swept address. importprivkey returns the t-address string; z_importkey
            //    returns null, so diff the z-address list to find the one the key just added.
            if (shielded) {
                // A failed pre-snapshot would make the diff treat pre-existing addresses as "new" and
                // could pick the WRONG source — refuse rather than risk moving another address's funds.
                if (!preZok) throw std::runtime_error("Couldn't read the wallet's addresses to identify the swept key. Try again.");
                std::vector<std::string> postZ;
                listZ(postZ);
                std::vector<std::string> added;
                for (const auto& s : postZ)
                    if (std::find(preZ.begin(), preZ.end(), s) == preZ.end()) added.push_back(s);
                if (added.size() == 1) sourceAddr = added[0];
                else if (added.empty()) throw std::runtime_error("__ALREADY__");   // key already in wallet
                else throw std::runtime_error("Could not identify the key's address after import.");
            } else {
                if (r.is_string()) sourceAddr = r.get<std::string>();
                else if (r.is_object() && r.contains("address") && r["address"].is_string())
                    sourceAddr = r["address"].get<std::string>();
            }
            if (sourceAddr.empty()) throw std::runtime_error("Could not determine the key's address.");

            // 3. Confirm the key holds spendable (confirmed) funds. z_getbalance covers t- and z-addrs.
            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)";
        }
        // Scrub the worker's copy of the spending key now that the request has been sent (all paths).
        if (!key.empty()) sodium_memzero(&key[0], key.size());
        return [this, err, sourceAddr, dest, amount, amountStr, fee]() {
            invalidateAddressValidationCache();
            refreshAddresses();
            if (!err.empty()) {
                if (err == "__NOFUNDS__")          sweep_status_ = "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();
            });
        };
    });
    if (!key.empty()) sodium_memzero(&key[0], key.size());  // scrub the calling-frame copy
}

void App::exportSeedPhrase(std::function<void(bool, bool, const std::string&, const std::string&)> callback)
{
    if (!state_.connected || !rpc_ || !worker_) {
        if (callback) callback(false, false, std::string(), "Not connected to the daemon.");
        return;
    }
    worker_->post([this, callback]() -> rpc::RPCWorker::MainCb {
        std::string phrase;   // SECRET — wiped after the callback runs
        std::string err;
        bool ok = false;
        bool noMnemonic = false;
        rpc::RPCClient::TraceScope trace("Settings / Export seed phrase");
        try {
            auto response = rpc_->callSecret("z_exportmnemonic");   // zero the raw body too (B7)
            if (response.contains("mnemonic") && response["mnemonic"].is_string()) {
                auto& m = response["mnemonic"].get_ref<std::string&>();
                phrase = m;
                if (!m.empty()) sodium_memzero(&m[0], m.size());  // scrub the json's own copy
                ok = !phrase.empty();
            } else {
                err = "The daemon returned no seed phrase.";
            }
        } catch (const rpc::RpcError& e) {
            err = e.what();
            // The daemon errors with this specific message when the wallet's seed is not
            // mnemonic-derived (a legacy wallet). Any other error (e.g. locked) stays generic.
            if (err.find("not derived from a mnemonic") != std::string::npos)
                noMnemonic = true;
        } catch (const std::exception& e) {
            err = e.what();
        }
        return [callback, ok, noMnemonic, phrase = std::move(phrase), err]() mutable {
            if (callback) callback(ok, noMnemonic, phrase, err);
            if (!phrase.empty()) sodium_memzero(&phrase[0], phrase.size());
        };
    });
}

// One-time nudge to back up the seed phrase on a mnemonic-backed full-node wallet. Fires at
// most one z_exportmnemonic probe per install; the phrase is not retained (the probe only
// learns whether a mnemonic exists). Legacy (non-mnemonic) wallets are not nagged.
void App::maybeRemindSeedBackup()
{
    if (capture_mode_) return;                                 // no live ops during a UI sweep
    if (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-time nudge (full-node) when the BDB wallet.dat has bloated past the threshold. Berkeley DB never
// shrinks in place and shielded-note witness data accumulates, so a mining/shielded wallet can grow
// unbounded. Fires ONCE (persisted flag) a warning toast + a clickable "Consolidate notes…" entry in the
// bell/alert panel that opens Merge to Address; re-arms if the file later drops back under the threshold.
void App::maybeWarnLargeWallet()
{
    if (capture_mode_ || lite_wallet_) return;                 // no live nags during a UI sweep; lite has no wallet.dat
    if (!supportsFullNodeLifecycleActions() || !settings_) return;
    if (!state_.connected || !state_.encryption_state_known) return;
    if (state_.warming_up || state_.daemon_initializing || !state_.sync.isSynced()) return;
    if (large_wallet_checked_) return;                         // stat wallet.dat at most once per launch
    large_wallet_checked_ = true;

    static constexpr uint64_t kWalletBloatWarnBytes = 500ull * 1024 * 1024;   // 500 MB (matches the Settings banner)
    const std::string walletPath = util::Platform::getDragonXDataDir() + "/wallet.dat";
    const uint64_t sz = util::Platform::getFileSize(walletPath);
    if (sz <= kWalletBloatWarnBytes) {
        // Re-arm the one-time warning if the file shrank back under the threshold (e.g. after a fresh seed wallet).
        if (settings_->getLargeWalletWarned()) { settings_->setLargeWalletWarned(false); settings_->save(); }
        return;
    }
    if (settings_->getLargeWalletWarned()) return;             // already warned once for this bloat episode
    settings_->setLargeWalletWarned(true);
    settings_->save();
    ui::Notifications::instance().action(
        TR("wallet_size_warn"), ui::NotificationType::Warning,
        []() { ui::ShieldDialog::showConsolidate(); },
        TR("wallet_size_consolidate"), 12.0f);
}

// Complementary on-disk safety net (full-node) for a wallet salvage we did NOT witness this launch — it
// happened on a prior run, or under an external daemon whose startup output we never captured, so
// detectWalletAutoRecovery() never fired. If the active wallet loads EMPTY while a sibling wallet file in
// the datadir still holds keys, warn once: the user's funds were likely moved into a wallet.<ts>.bak by a
// prior BDB salvage and are not lost, just in another file. Fires at most once per wallet-open and once per
// unacknowledged wallet filename; the probe runs off the UI thread (scanFundedSiblingsAsync).
void App::maybeWarnEmptyWalletWithFundedSiblings()
{
    if (capture_mode_) return;                                        // no live ops during a UI sweep
    if (lite_wallet_ || !supportsFullNodeLifecycleActions()) return;  // full-node only (lite = single-file dir)
    if (!settings_) return;
    if (empty_wallet_warn_checked_ || empty_wallet_scan_in_flight_) return;   // at most once per wallet-open
    if (show_empty_wallet_warning_) return;                          // already surfaced
    // The console-driven recovery flow owns the salvage-this-launch case — don't double-warn.
    if (wallet_auto_recovered_ || show_wallet_recovered_dialog_) return;
    // Only meaningful once the wallet is truly loaded AND fully synced: a mid-sync wallet reads empty.
    if (!state_.connected || !state_.encryption_state_known) return;
    if (state_.warming_up || state_.daemon_initializing || !state_.sync.isSynced()) return;
    // Wait for the first Core refresh to land. The ConnectionInit prefetch sets sync.blocks but NOT headers,
    // so isSynced() (blocks >= headers-2) is spuriously true in the window before the Core refresh — during
    // which balance/addresses also read 0. last_balance_update flips non-zero only when the Core refresh
    // applies (network_refresh_service.cpp), by which point balance & headers are real.
    if (state_.last_balance_update == 0) return;
    // And wait for the ADDRESS list to have loaded at least once — otherwise getAddressCount()==0 is
    // ambiguous ("no keys" vs "not fetched yet"), which would false-fire on a spent-down wallet (0 balance
    // but has addresses) whose address refresh lands a beat after the balance refresh.
    if (state_.last_address_update == 0) return;
    // "Empty" = no addresses and no funds. A salvage-created fresh wallet has no keys; a legitimately
    // spent-down wallet keeps its addresses, so requiring zero addresses avoids nagging the latter.
    if (state_.getAddressCount() != 0) return;
    if (state_.totalBalance > 0.0 || state_.spendableTotalBalance > 0.0) return;
    if (settings_->isEmptyWalletWarnAcked(settings_->getActiveWalletFile())) return;  // dismissed for this file

    empty_wallet_warn_checked_ = true;   // evaluate the on-disk state once for this wallet-open
    scanFundedSiblingsAsync();
}

// Off-UI-thread: enumerate the datadir's OTHER wallet files (incl. salvage wallet.<ts>.bak backups) and
// offline-probe each for key material. Read-only; never opens a file in the daemon. The probe reads up to a
// bounded prefix per file, so it runs on its own task thread (not the RPC worker) and the result is
// marshaled back to the main thread before touching any UI state. Routing, applied on the main thread:
//   • a funded salvage .bak exists  → the coins were moved aside by an unwitnessed salvage; hand off to the
//     existing recovery dialog, whose Restore action swaps the .bak back (the correct, tested fix).
//   • otherwise a funded sibling .dat exists → the user simply opened the wrong (empty) wallet; show the
//     lightweight warning modal that routes to the wallet manager to switch.
void App::scanFundedSiblingsAsync()
{
    if (empty_wallet_scan_in_flight_) return;
    empty_wallet_scan_in_flight_ = true;
    const std::string datadir    = util::Platform::getDragonXDataDir();
    const std::string activeFile = settings_ ? settings_->getActiveWalletFile() : std::string("wallet.dat");
    async_tasks_.submit("Empty-wallet sibling scan",
                        [this, datadir, activeFile](const util::AsyncTaskManager::Token& tok) {
        std::vector<FundedSibling> funded;   // funded plain-.dat wallets → the "switch wallet" modal
        bool hasSalvageBak = false;          // a funded wallet.<ts>.bak → route to the recovery/restore dialog
        for (const auto& path : util::enumerateDatadirWalletFiles(datadir, activeFile, /*includeSalvageBaks=*/true)) {
            if (tok.cancelled()) return;
            const auto bt = util::parseWalletBtree(path);
            if (!(bt.parsed && bt.addresses() > 0)) continue;   // ignore junk / empty siblings
            const std::string name = std::filesystem::path(path).filename().string();
            const bool isBak = name.size() > 4 && name.compare(name.size() - 4, 4, ".bak") == 0;
            if (isBak) {
                // Only a salvage-pattern wallet.<ts>.bak has a defined restore path; other .bak files are
                // ignored (the wallet manager lists only .dat, so routing them there would be a dead end).
                if (daemon::parseWalletSalvageBakTs(name) >= 0) hasSalvageBak = true;
                continue;
            }
            FundedSibling s;
            s.fileName        = name;
            s.transparentKeys = bt.transparentKeys;
            s.shieldedKeys    = bt.shieldedKeys;
            funded.push_back(std::move(s));
        }
        // On teardown/cancel, skip posting (shutdown only; the in-flight flag is irrelevant then).
        if (tok.cancelled() || !worker_) return;
        // Apply UI state on the main thread only (the render loop reads these members).
        worker_->post([this, funded, hasSalvageBak]() -> rpc::RPCWorker::MainCb {
            return [this, funded, hasSalvageBak]() {
                empty_wallet_scan_in_flight_ = false;
                if (wallet_auto_recovered_ || show_wallet_recovered_dialog_) return;  // recovery already owns it
                // Re-validate emptiness on the main thread: balance/address refreshes may have landed while
                // the scan ran (it takes long enough to read+parse sibling files), so a warm-reconnect or a
                // spent-down wallet that momentarily read empty is now correctly excluded.
                if (!state_.connected || state_.getAddressCount() != 0 ||
                    state_.totalBalance > 0.0 || state_.spendableTotalBalance > 0.0) return;
                // Both cases surface OUR modal (renderEmptyWalletWarningDialog), keyed by has_salvage_bak.
                // We deliberately DON'T set the wallet_auto_recovered_ latch or auto-open the recovery dialog:
                // the daemon is healthy, and that latch gates the crash-restart loop (app_network.cpp:556) +
                // crash-toast suppression, so it would wedge the wallet offline on any later unrelated crash.
                // For the salvage case the modal's "Restore" button calls restoreOriginalWallet() directly
                // (self-contained: it drives the recovery dialog into its Working phase itself).
                if (hasSalvageBak) {
                    empty_wallet_has_salvage_bak_ = true;
                    empty_wallet_funded_siblings_.clear();
                    show_empty_wallet_warning_ = true;
                } else if (!funded.empty()) {
                    empty_wallet_has_salvage_bak_ = false;
                    empty_wallet_funded_siblings_ = funded;
                    show_empty_wallet_warning_ = true;
                }
            };
        });
    });
}

// One-shot (per connect) probe of the current wallet's mnemonic status, so the Settings
// Migrate-to-seed button can glow for a legacy wallet without opening the migration dialog. Same
// classification as the migration Intro pre-flight, but proactive and cached. Reads no secret past
// the exportSeedPhrase callback (which wipes the phrase). Retries next tick on a transient failure.
void App::probeWalletSeedStatus()
{
    if (capture_mode_) return;                                 // no live ops during a UI sweep
    if (lite_wallet_) return;                                  // lite has its own seed UX
    if (wallet_seed_status_ != WalletSeedStatus::Unknown) return;   // already classified
    if (!state_.connected || !state_.encryption_state_known) return;
    if (state_.isLocked()) return;                             // needs an unlocked wallet to read it
    if (wallet_seed_status_in_flight_) return;
    wallet_seed_status_in_flight_ = true;
    exportSeedPhrase([this](bool ok, bool noMnemonic, const std::string& /*phrase*/,
                            const std::string& error) {
        wallet_seed_status_in_flight_ = false;
        if (ok)
            wallet_seed_status_ = WalletSeedStatus::HasMnemonic;
        else if (noMnemonic)
            wallet_seed_status_ = WalletSeedStatus::NoMnemonic;     // legacy → migratable → glow
        else if (error.find("Method not found") != std::string::npos ||
                 error.find("-32601") != std::string::npos)
            wallet_seed_status_ = WalletSeedStatus::Incapable;     // old daemon: can't migrate
        else if (++wallet_seed_status_attempts_ >= 3)
            wallet_seed_status_ = WalletSeedStatus::Incapable;     // give up after a few transient errors
        // else: transient error → stay Unknown, retry next tick
    });
}

void App::beginCreateSeedWallet()
{
    if (seed_migration_in_flight_) return;
    seed_migration_in_flight_ = true;
    seed_migration_step_ = SeedMigrationStep::Working;
    {
        std::lock_guard<std::mutex> lk(seed_migration_mutex_);
        seed_migration_done_ = false;
        seed_migration_progress_ = "Starting…";
    }
    // Runs the isolated create on a background thread; the main thread picks up progress + the
    // result in pumpSeedMigration(). No funds move and the real wallet is never touched.
    async_tasks_.submit("Create seed wallet", [this](const util::AsyncTaskManager::Token&) {
        auto res = daemon::SeedWalletCreator::create(/*keepDatadir=*/true,
            [this](const std::string& msg) {
                std::lock_guard<std::mutex> lk(seed_migration_mutex_);
                seed_migration_progress_ = msg;
            });
        std::lock_guard<std::mutex> lk(seed_migration_mutex_);
        seed_migration_ok_ = res.ok;
        seed_migration_r_seed_ = res.seedPhrase;
        seed_migration_r_dest_ = res.destAddress;
        seed_migration_r_tmp_  = res.tempDatadir;
        seed_migration_r_err_  = res.error;
        seed_migration_done_   = true;
        if (!res.seedPhrase.empty()) sodium_memzero(&res.seedPhrase[0], res.seedPhrase.size());
    });
}

void App::showSeedMigrationDialog()
{
    show_seed_migration_ = true;
    // Resume a pending migration. If a sweep was already submitted (txid persisted), resume at the
    // confirm/adopt stage — re-derived from the chain — rather than sweeping again; otherwise start
    // at the Sweep step. With no pending migration, start fresh at the intro.
    const bool pending  = settings_ && settings_->getSeedMigrationPending();
    const bool haveDest = settings_ && !settings_->getSeedMigrationDest().empty();
    const std::string sweepTxid = settings_ ? settings_->getSeedMigrationSweepTxid() : std::string();
    const std::string sweepOpid = settings_ ? settings_->getSeedMigrationSweepOpid() : std::string();
    const bool connected = state_.connected && rpc_ && worker_;

    switch (decideSeedMigrationResume(pending, haveDest, sweepTxid, sweepOpid, connected)) {
    case MigrationResume::Confirming:
        seed_migration_dest_       = settings_->getSeedMigrationDest();
        seed_migration_temp_dir_   = settings_->getSeedMigrationTempDir();
        seed_migration_sweep_txid_ = sweepTxid;
        seed_migration_sweep_confs_ = 0;
        seed_migration_legacy_remaining_ = -1.0;
        seed_migration_poll_timer_ = 0.0f;  // poll immediately
        seed_migration_step_ = SeedMigrationStep::Confirming;
        break;
    case MigrationResume::RetrackOpid:
        // W3-3: a sweep opid was submitted but its txid was never persisted (app closed mid-Sweeping).
        // Re-track it to recover the txid. If the daemon forgot it (restart), the opid poller flags it
        // stale and makeSweepCompletionCallback(resumed) falls back to the Sweep gate — never a hang.
        // Only reached when connected (decideSeedMigrationResume), so the poller can actually run and
        // the buttonless "Sweeping" spinner is guaranteed an exit.
        seed_migration_dest_       = settings_->getSeedMigrationDest();
        seed_migration_temp_dir_   = settings_->getSeedMigrationTempDir();
        seed_migration_sweep_txid_.clear();
        pending_send_callbacks_[sweepOpid] = makeSweepCompletionCallback(/*resumed=*/true);
        trackOperation(sweepOpid);
        seed_migration_step_ = SeedMigrationStep::Sweeping;
        seed_migration_status_ = "Checking on the previous sweep…";
        break;
    case MigrationResume::SweepGate:
        // No txid, and either no opid or not connected to re-track it (a persisted opid is left in
        // place so a later reconnect+reopen can re-track it). The Sweep step is dismissable and
        // reloads the balance, so the user is never trapped while offline.
        seed_migration_dest_     = settings_->getSeedMigrationDest();
        seed_migration_temp_dir_ = settings_->getSeedMigrationTempDir();
        seed_migration_sweep_txid_.clear();
        seed_migration_step_ = SeedMigrationStep::Sweep;
        seed_migration_balance_loaded_ = false;
        seed_migration_nofunds_confirmed_ = false;
        refreshSeedMigrationBalance();
        // If a sweep was already broadcast (the marker survives a lost opid), try to locate its tx so
        // we resume at the confirmation gate instead of risking a premature "no funds -> adopt".
        if (settings_ && settings_->getSeedMigrationSweepSubmitted()) locateSweepTxid();
        break;
    case MigrationResume::Intro:
    default:
        seed_migration_step_ = SeedMigrationStep::Intro;
        // Fresh start: the Intro step will pre-flight the wallet (legacy vs already-seeded vs old
        // daemon) before offering to create anything.
        seed_migration_precheck_ = SeedMigrationPrecheck::Pending;
        seed_migration_precheck_started_ = false;
        break;
    }
}

// Intro pre-flight: probe z_exportmnemonic on the CURRENT wallet so we can branch the Intro:
//   ok            → the wallet is already mnemonic-backed (offer backup, not a pointless migration)
//   "not derived" → a legacy wallet (proceed with create)
//   "Method not found" → the daemon is too old to create a seed wallet (explain + bail)
// The probe needs an unlocked, connected wallet; the Intro gates on that before calling this.
void App::beginSeedMigrationPrecheck()
{
    seed_migration_precheck_started_ = true;
    seed_migration_precheck_ = SeedMigrationPrecheck::Pending;
    exportSeedPhrase([this](bool ok, bool noMnemonic, const std::string& /*phrase*/,
                            const std::string& error) {
        // exportSeedPhrase wipes the phrase after this callback — we only need the classification.
        if (ok)
            seed_migration_precheck_ = SeedMigrationPrecheck::AlreadyMnemonic;
        else if (noMnemonic)
            seed_migration_precheck_ = SeedMigrationPrecheck::Legacy;
        else if (error.find("Method not found") != std::string::npos ||
                 error.find("-32601") != std::string::npos)
            seed_migration_precheck_ = SeedMigrationPrecheck::DaemonTooOld;
        else
            seed_migration_precheck_ = SeedMigrationPrecheck::CheckFailed;  // transient — allow re-check
    });
}

void App::refreshSeedMigrationBalance()
{
    if (!rpc_ || !worker_ || !state_.connected) return;
    worker_->post([this]() -> rpc::RPCWorker::MainCb {
        double total = 0.0; bool ok = false;
        rpc::RPCClient::TraceScope trace("Migrate / balance");
        try {
            auto res = rpc_->call("z_gettotalbalance");
            if (res.contains("total") && res["total"].is_string())
                total = std::stod(res["total"].get<std::string>());
            ok = true;
        } catch (...) {}
        return [this, total, ok]() { if (ok) { seed_migration_balance_ = total; seed_migration_balance_loaded_ = true; } };
    });
}

// Phase 2 step 1: sweep ALL legacy funds (transparent + shielded) into the new seed wallet's
// address. z_mergetoaddress with 0,0 limits merges as many inputs as fit in one transaction; a
// huge-input wallet may leave a small remainder in the (preserved) legacy backup.
void App::beginSweepToSeedWallet()
{
    if (!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…";
    // Mark the sweep as submitted BEFORE broadcasting (persisted synchronously). If the app or daemon
    // then dies after the tx reaches the mempool but before its opid/txid is persisted, a resumed
    // migration still knows a sweep is in flight and won't mistake the resulting ~0 legacy balance for
    // an empty wallet and adopt (swap wallet.dat) prematurely — funds in an unconfirmed sweep can still
    // revert on a drop/reorg. Cleared below only if the submit definitively fails (nothing broadcast).
    if (settings_) { settings_->setSeedMigrationSweepSubmitted(true); settings_->save(); }
    const std::string dest = seed_migration_dest_;
    worker_->post([this, dest]() -> rpc::RPCWorker::MainCb {
        std::string opid, err;
        rpc::RPCClient::TraceScope trace("Migrate / sweep");
        try {
            json fromAddrs = json::array({"ANY_TADDR", "ANY_ZADDR"});
            auto res = rpc_->call("z_mergetoaddress", {fromAddrs, dest, DRAGONX_DEFAULT_FEE, 0, 0});
            opid = res.value("opid", std::string());
            if (opid.empty()) err = "The daemon returned no operation id for the sweep.";
        } catch (const std::exception& e) { err = e.what(); }
        return [this, opid, err]() {
            if (!err.empty() || opid.empty()) {
                // Submit failed — nothing was broadcast, so drop the "sweep in flight" marker.
                if (settings_) { settings_->setSeedMigrationSweepSubmitted(false); settings_->save(); }
                seed_migration_status_ = err.empty() ? "The sweep failed to start." : err;
                seed_migration_step_ = SeedMigrationStep::Error;
                return;
            }
            // W3-3: adopt this new opid atomically — persist it AND clear any prior sweep txid in the
            // SAME settings write. Persisting the opid lets an app-close during Sweeping (opid
            // submitted, not yet resolved to a txid) re-poll it on resume instead of dropping it. Doing
            // the swap HERE — only once the new submit has succeeded — rather than speculatively at
            // function entry means a FAILED "Sweep remaining" remainder re-sweep leaves the
            // already-mined first sweep's txid intact and resumable to Confirming; and the txid and
            // opid are never both authoritative at once (torn-write safe; resume checks txid first).
            seed_migration_sweep_txid_.clear();
            if (settings_) {
                settings_->setSeedMigrationSweepTxid("");
                settings_->setSeedMigrationSweepOpid(opid);
                settings_->save();
            }
            pending_send_callbacks_[opid] = makeSweepCompletionCallback(/*resumed=*/false);
            trackOperation(opid);
            seed_migration_status_ = "Waiting for the sweep transaction to be accepted…";
        };
    });
}

// W3-3: terminal handling for the sweep operation, shared by the initial submit (resumed=false) and
// a resume re-track (resumed=true). On success it persists the txid and clears the opid in the SAME
// settings write, so the txid always outranks the opid on a later resume (torn-write safe).
std::function<void(bool, const std::string&)> App::makeSweepCompletionCallback(bool resumed)
{
    return [this, resumed](bool ok, const std::string& result) {
        if (ok) {
            seed_migration_sweep_txid_ = result;
            // Persist the txid (and drop the now-redundant opid) so a restart resumes at the
            // confirm/adopt stage and never re-sweeps from scratch. The Confirming step gates adopt on
            // this tx being mined (>= 1 confirmation) AND the legacy balance dropping to ~0.
            if (settings_) {
                settings_->setSeedMigrationSweepTxid(result);
                settings_->setSeedMigrationSweepOpid("");
                settings_->save();
            }
            seed_migration_sweep_confs_ = 0;
            seed_migration_legacy_remaining_ = -1.0;
            seed_migration_poll_timer_ = 0.0f;
            seed_migration_status_.clear();
            seed_migration_step_ = SeedMigrationStep::Confirming;
        } else if (resumed) {
            // A resumed opid the daemon no longer knows (it restarted — the op queue is in-memory
            // only). "Stale" can't be told apart from "failed", and the earlier sweep may in fact have
            // already broadcast/mined, so DON'T dead-end at Error: drop the stale opid and return to
            // the Sweep gate, re-fetching the legacy balance. If that sweep did complete, the balance
            // reads ~0 and the Sweep step short-circuits to adopt; otherwise the user can sweep again.
            if (settings_) { settings_->setSeedMigrationSweepOpid(""); settings_->save(); }
            seed_migration_sweep_txid_.clear();
            seed_migration_balance_loaded_ = false;
            seed_migration_nofunds_confirmed_ = false;
            seed_migration_status_ =
                "Couldn't confirm the earlier sweep — it may have already completed. "
                "Check your balance below before sweeping again.";
            seed_migration_step_ = SeedMigrationStep::Sweep;
            refreshSeedMigrationBalance();  // else the Sweep step sits on a permanent "Checking balance…"
        } else {
            // A fresh sweep that genuinely failed. Clear the persisted opid so it can't mis-resume.
            if (settings_) { settings_->setSeedMigrationSweepOpid(""); settings_->save(); }
            seed_migration_status_ = result.empty() ? "The sweep transaction failed." : result;
            seed_migration_step_ = SeedMigrationStep::Error;
        }
    };
}

// Confirming step: poll the sweep tx's confirmations + the legacy wallet's remaining balance. The
// adopt step is gated on the tx being mined (confs >= 1) AND the legacy balance being ~0, so we
// never swap wallet.dat while the funds could still bounce back (dropped/reorged tx) or while a
// remainder is left behind (a sweep too big for one transaction).
void App::pollSweepStatus()
{
    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;
            }
        };
    });
}

// Best-effort recovery when a sweep was broadcast but its opid was lost (e.g. a daemon restart wiped
// the in-memory op queue) so we have no txid to confirm against. The sweep is an outgoing send to
// seed_migration_dest_; find it among the wallet's own sends (z_listsentbyaddress) and, if present,
// adopt its txid and resume at the confirmation gate (pollSweepStatus / Confirming). Read-only, and
// backstopped by that gate (confs >= 1 && legacy balance ~0), so a wrong match cannot lose funds —
// it only avoids the premature "no funds -> adopt" short-circuit while a sweep is unconfirmed.
void App::locateSweepTxid()
{
    if (capture_mode_) return;
    if (!rpc_ || !worker_ || !state_.connected) return;
    if (seed_migration_confirm_in_flight_ || !seed_migration_sweep_txid_.empty()) return;
    if (seed_migration_dest_.empty()) return;
    seed_migration_confirm_in_flight_ = true;
    const std::string dest = seed_migration_dest_;
    worker_->post([this, dest]() -> rpc::RPCWorker::MainCb {
        std::string found;
        rpc::RPCClient::TraceScope trace("Migrate / locate sweep");
        try {
            // z_listsentbyaddress ignores its address argument and returns ALL wallet sends
            // (oldest -> newest); the sweep is the latest send whose outputs include the destination.
            auto sent = rpc_->call("z_listsentbyaddress", {dest, 0});
            if (sent.is_array()) {
                for (const auto& e : sent) {
                    if (!e.is_object() || !e.contains("sends") || !e["sends"].is_object()) continue;
                    const auto& s = e["sends"];
                    bool toDest = false;
                    for (const char* leg : {"saplingSends", "transparentSends"}) {
                        auto it = s.find(leg);
                        if (it == s.end() || !it->is_array()) continue;
                        for (const auto& o : *it) {
                            if (o.is_object() && o.value("address", std::string()) == dest) {
                                toDest = true;
                                break;
                            }
                        }
                        if (toDest) break;
                    }
                    if (toDest) {
                        auto t = e.value("txid", std::string());
                        if (!t.empty()) found = t;  // keep scanning; entries are oldest->newest
                    }
                }
            }
        } catch (...) {}
        return [this, found]() {
            seed_migration_confirm_in_flight_ = false;
            if (found.empty() || !seed_migration_sweep_txid_.empty()) return;
            // Adopt the recovered txid and hand off to the confirmation gate (which alone permits adopt).
            seed_migration_sweep_txid_ = found;
            if (settings_) {
                settings_->setSeedMigrationSweepTxid(found);
                settings_->setSeedMigrationSweepOpid("");
                settings_->save();
            }
            seed_migration_sweep_confs_ = 0;
            seed_migration_legacy_remaining_ = -1.0;
            seed_migration_poll_timer_ = 0.0f;
            seed_migration_status_.clear();
            seed_migration_step_ = SeedMigrationStep::Confirming;
        };
    });
}

// Phase 2 step 2: adopt the new seed wallet as the primary wallet. Stop the daemon, move the
// legacy wallet.dat aside to a timestamped backup (NEVER delete it), copy the new wallet in, and
// restart with -rescan so the new keys pick up the swept funds. Runs on a background thread.
void App::beginAdoptSeedWallet()
{
    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_;
    // W3-1: adopt must swap the ACTIVE wallet file (multi-wallet), not a hardcoded "wallet.dat" —
    // otherwise a migration run while e.g. wallet-2.dat is active would install the swept seed wallet
    // into an unloaded wallet.dat and leave the daemon loading the (now-emptied) legacy wallet.
    // Captured on the main thread; wallet switching is blocked during migration so this can't race.
    const std::string activeWalletName = (settings_ && !settings_->getActiveWalletFile().empty())
                                             ? settings_->getActiveWalletFile() : std::string("wallet.dat");
    async_tasks_.submit("Adopt seed wallet", [this, base, activeWalletName](const util::AsyncTaskManager::Token&) {
        namespace fs = std::filesystem;
        std::string err;       // fatal (swap did not happen; migration incomplete)
        std::string warn;      // non-fatal (swap done but the daemon did not restart)
        bool swapDone = false;
        try {
            std::error_code ec;
            // 1. Stop the main daemon and wait for it to FULLY release wallet.dat + the RPC port before we
            //    swap the file. Use the wallet-switch stop path: for an ADOPTED (external) daemon it sends a
            //    graceful RPC "stop" and gates on the port actually freeing — isEmbeddedDaemonRunning() is
            //    process-handle-only and would falsely read "stopped" for an adopted daemon, letting the swap
            //    run against a live daemon still holding wallet.dat. (For an owned daemon stopEmbeddedDaemon()
            //    already blocks for full process exit, so the file is closed before the swap.)
            const bool port_free = stopDaemonForWalletSwitch();
            if (!port_free) {
                err = "The daemon did not stop in time; your wallet was not changed.";
            } else {
                // 2. Swap wallet.dat. Move the legacy one aside to a timestamped backup (NEVER
                //    delete), then copy the new seed wallet in. On any failure, restore the legacy.
                const std::string datadir = util::Platform::getDragonXDataDir();
                const std::string legacy = datadir + "/" + activeWalletName;
                const std::string newWallet = base + "/DRAGONX/wallet.dat";
                std::time_t t = std::time(nullptr);
                std::tm tmv{};  // thread-safe local time (the UI thread also uses localtime)
#ifdef _WIN32
                localtime_s(&tmv, &t);
#else
                localtime_r(&t, &tmv);
#endif
                char ts[32]; std::strftime(ts, sizeof(ts), "%Y%m%d-%H%M%S", &tmv);
                const std::string backup = legacy + ".legacy-migrated-" + ts + ".bak";
                if (!fs::exists(newWallet)) {
                    err = "The new wallet file is missing; your wallet was not changed.";
                } else {
                    bool movedLegacy = false;
                    if (fs::exists(legacy)) {
                        fs::rename(legacy, backup, ec);
                        if (ec) err = "Could not back up your current wallet; nothing was changed.";
                        else movedLegacy = true;
                    }
                    if (err.empty()) {
                        fs::copy_file(newWallet, legacy, fs::copy_options::overwrite_existing, ec);
                        if (ec) {
                            if (movedLegacy) { std::error_code ec2; fs::rename(backup, legacy, ec2); }
                            err = "Could not install the new wallet; your original wallet was restored.";
                        } else {
                            swapDone = true;
                        }
                    }
                }
            }

            // 3. Rescan on next start (only if the swap happened) and bring the daemon back up —
            //    unless we're quitting, in which case don't resurrect it.
            if (swapDone && daemon_controller_) { daemon_controller_->setRescanOnNextStart(true); user_initiated_rescan_ = true; }
            if (!shutting_down_) {
                // We stopped the daemon ourselves (port_free) — clear the adopted-external latch so the
                // relaunched process is treated as owned (stop/isRunning/exit behave normally afterward).
                // Skip when the stop failed: the old adopted daemon is still up, and we must not mark a
                // process we can't control as owned.
                if (port_free && daemon_controller_) daemon_controller_->clearExternalDaemonDetected();
                if (!startEmbeddedDaemon() && swapDone)
                    warn = "Your wallet was swapped, but the daemon did not restart — start it from Settings.";
            }
        } catch (const std::exception& e) {
            err = std::string("Migration failed: ") + e.what();
        } catch (...) {
            err = "Migration failed due to an unexpected error.";
        }
        daemon_restarting_ = false;  // ALWAYS re-arm the reconnect gate, even on a throw

        std::lock_guard<std::mutex> lk(seed_migration_mutex_);
        // Migration counts as done once the swap succeeded (funds already moved + wallet installed);
        // a restart hiccup is only a warning. If the swap never happened, it's a full error and the
        // pending state is kept so the user can retry.
        seed_migration_adopt_ok_ = swapDone;
        seed_migration_adopt_err_ = swapDone ? warn : err;
        seed_migration_adopt_done_ = true;
    });
}

// Undo a daemon wallet auto-recovery: swap the untouched original (wallet.<ts>.bak) back over the
// salvaged copy and clear the stale BDB env that triggered the false recovery, then restart. Modeled on
// beginAdoptSeedWallet — stop daemon → file ops (copy/rename only, NEVER delete user data) → restart.
void App::restoreOriginalWallet()
{
    if (!supportsFullNodeLifecycleActions()) {
        ui::Notifications::instance().warning("Full-node lifecycle actions are unavailable in lite build");
        return;
    }
    if (daemon_restarting_) {
        ui::Notifications::instance().warning(TR("wallet_restore_busy"));
        return;
    }
    // Keep the recovery dialog OPEN and drive it into the Working phase — it shows progress and the
    // honest outcome in place (pumpWalletRestore flips it to Done/Failed). Presentation only; every
    // file-safety step below is unchanged.
    show_wallet_recovered_dialog_ = true;
    recovery_phase_ = RecoveryPhase::Working;
    recovery_last_action_rebuild_ = false;
    { std::lock_guard<std::mutex> lk(wallet_restore_mutex_); wallet_restore_done_ = false; }
    daemon_restarting_ = true;                 // gate the reconnect loop while we swap files
    connection_status_ = TR("sb_restarting_daemon");
    if (rpc_ && rpc_->isConnected()) rpc_->disconnect();
    onDisconnected("Restoring original wallet");
    ui::Notifications::instance().info(TR("wallet_restore_started"), 12.0f);

    const std::string activeWalletName = (settings_ && !settings_->getActiveWalletFile().empty())
                                             ? settings_->getActiveWalletFile() : std::string("wallet.dat");

    async_tasks_.submit("Restore original wallet", [this, activeWalletName](const util::AsyncTaskManager::Token&) {
        namespace fs = std::filesystem;
        std::string err, warn;
        try {
            const std::string datadir = util::Platform::getDragonXDataDir();
            // 1. Find the LARGEST salvage backup (offline — no daemon needed). Largest = least-salvaged =
            //    the original: a salvage cascade shrinks the wallet each round, so the newest .bak can be
            //    empty ("Salvage found no records") while the original is untouched and huge.
            std::vector<std::pair<std::string, unsigned long long>> files;
            {
                std::error_code lec;
                for (const auto& e : fs::directory_iterator(datadir, lec)) {
                    if (lec) break;
                    std::error_code se;
                    const auto sz = fs::is_regular_file(e, se) ? fs::file_size(e, se) : 0;
                    files.emplace_back(e.path().filename().string(),
                                       se ? 0ull : static_cast<unsigned long long>(sz));
                }
            }
            const std::string bak = daemon::largestWalletSalvageBak(files);
            if (bak.empty()) {
                err = TR("wallet_restore_no_backup");
            } else if (!util::probeWalletFile(datadir + "/" + bak).isBerkeleyDB) {
                err = TR("wallet_restore_bad_backup");   // don't overwrite a working wallet with a bad .bak
            } else if (!stopDaemonForWalletSwitch()) {   // 2. Release wallet.dat + the RPC port first.
                err = TR("wallet_restore_stop_failed");
            } else {
                std::error_code ec;
                std::time_t t = std::time(nullptr);
                std::tm tmv{};
#ifdef _WIN32
                localtime_s(&tmv, &t);
#else
                localtime_r(&t, &tmv);
#endif
                char ts[32]; std::strftime(ts, sizeof(ts), "%Y%m%d-%H%M%S", &tmv);
                const std::string active = datadir + "/" + activeWalletName;
                const std::string salvagedAside = active + ".salvaged-" + ts + ".dat";
                // 3. Move the salvaged copy aside (NEVER delete), then copy the original .bak into place
                //    (copy, so the .bak itself stays as a backup). Roll back the move if the copy fails.
                bool movedSalvaged = false;
                if (fs::exists(active)) {
                    fs::rename(active, salvagedAside, ec);
                    if (ec) err = TR("wallet_restore_move_failed");
                    else movedSalvaged = true;
                }
                if (err.empty()) {
                    fs::copy_file(datadir + "/" + bak, active, fs::copy_options::overwrite_existing, ec);
                    if (ec) {
                        if (movedSalvaged) { std::error_code e2; fs::rename(salvagedAside, active, e2); }
                        err = TR("wallet_restore_copy_failed");
                    }
                }
                // 4. Clear the stale BDB environment that triggered the false recovery — otherwise the
                //    daemon would just re-salvage the restored wallet on the next start. Move database/
                //    aside (keeps its logs) and drop the transient __db.* region files.
                if (err.empty()) {
                    std::error_code e2;
                    if (fs::exists(datadir + "/database"))
                        fs::rename(datadir + "/database", datadir + "/database.pre-restore-" + ts + ".bak", e2);
                    for (const auto& e : fs::directory_iterator(datadir, e2)) {
                        if (e.path().filename().string().rfind("__db.", 0) == 0) {
                            std::error_code e3; fs::remove(e.path(), e3);
                        }
                    }
                }
            }

            // 5. Bring the daemon back up (unless quitting). Even on a restore failure we relaunch so the
            //    node isn't left down; the connect loop reconnects and onConnected clears the gate.
            if (!shutting_down_) {
                if (daemon_controller_) daemon_controller_->clearExternalDaemonDetected();
                if (!startEmbeddedDaemon() && err.empty())
                    warn = TR("wallet_restore_no_restart");
            }
        } catch (const std::exception& e) {
            err = std::string("Restore failed: ") + e.what();
        } catch (...) {
            err = "Restore failed due to an unexpected error.";
        }
        daemon_restarting_ = false;   // ALWAYS re-arm the reconnect gate

        std::lock_guard<std::mutex> lk(wallet_restore_mutex_);
        wallet_restore_severity_ = !err.empty() ? 2 : (!warn.empty() ? 1 : 0);
        wallet_restore_msg_ = !err.empty() ? err : warn;
        wallet_restore_done_ = true;
    });
}

void App::pumpWalletRestore()
{
    if (capture_mode_) return;
    bool done = false; int sev = 0; std::string msg;
    {
        std::lock_guard<std::mutex> lk(wallet_restore_mutex_);
        if (wallet_restore_done_) { done = true; sev = wallet_restore_severity_; msg = wallet_restore_msg_; wallet_restore_done_ = false; }
    }
    if (!done) return;
    // Primary outcome channel: if the recovery dialog is still up (Working), flip it to Done/Failed in
    // place with the real result. The toast below stays as the secondary echo for a dismissed/alt-tabbed
    // user. sev 2 = failed (op discarded, nothing changed); sev 0/1 = done (1 carries a warning message).
    if (show_wallet_recovered_dialog_ && recovery_phase_ == RecoveryPhase::Working) {
        recovery_outcome_sev_ = sev;
        recovery_outcome_msg_ = msg;
        recovery_phase_ = (sev == 2) ? RecoveryPhase::Failed : RecoveryPhase::Done;
        // Clean success (sev 0) → the daemon is now restarting with a full rescan. Flag it so the loading
        // overlay shows a calm "finishing repair" screen (not the scary generic stall) until it connects.
        // NOT for sev 1 (a warning like "node didn't restart") — nothing is rescanning then, and the Done
        // dialog already shows that message. Timestamp is stamped on the first overlay frame.
        if (sev == 0) { post_recovery_rescan_ = true; post_recovery_rescan_since_ = 0.0; }
    }
    if (sev == 2)      ui::Notifications::instance().error(msg, 25.0f);
    else if (sev == 1) ui::Notifications::instance().warning(msg, 20.0f);
    else               ui::Notifications::instance().success(msg.empty() ? TR("wallet_restore_ok") : msg, 12.0f);
}

// Locate the bundled dragonx-wallet-rebuild helper (exe dir → daemon dir). "" if not present.
static std::string findWalletRebuildHelper()
{
    namespace fs = std::filesystem;
#ifdef _WIN32
    const char* exe = "dragonx-wallet-rebuild.exe";
#else
    const char* exe = "dragonx-wallet-rebuild";
#endif
    for (const std::string& d : { util::Platform::getExecutableDirectory(),
                                  dragonx::resources::getDaemonDirectory() }) {
        if (d.empty()) continue;
        std::error_code ec;
        const std::string p = d + "/" + exe;
        if (fs::exists(p, ec)) return p;
    }
    // Not sitting next to the app/daemon — but a self-contained exe carries it embedded. Extract it on
    // demand (first-run param extraction is gated on needsParamsExtraction(), so it may never have run
    // on a machine that already had the Sapling params). Returns "" on non-embedded builds.
    return dragonx::resources::ensureWalletRebuildHelperExtracted();
}

bool App::walletRebuildAvailable() const { return !findWalletRebuildHelper().empty(); }

// Rebuild a BDB-inconsistent wallet into a fresh, daemon-loadable one via the offline helper (see
// tools/wallet_rebuild). This is the real fix for the salvage cascade: plain "Restore original" just
// hands the same broken file back and the daemon re-salvages it. Modeled on restoreOriginalWallet:
// stop daemon → run helper → verify → safe swap (copy/rename only, never delete) → rescan → restart.
void App::rebuildWalletDatabase()
{
    if (!supportsFullNodeLifecycleActions()) {
        ui::Notifications::instance().warning("Full-node lifecycle actions are unavailable in lite build");
        return;
    }
    if (daemon_restarting_) { ui::Notifications::instance().warning(TR("wallet_restore_busy")); return; }
    const std::string helper = findWalletRebuildHelper();
    if (helper.empty()) { ui::Notifications::instance().error(TR("wallet_rebuild_no_helper"), 15.0f); return; }

    // Keep the recovery dialog OPEN through the rebuild (Working → Done/Failed in place). Presentation
    // only; the run-helper → verify-before-swap → copy/rename-never-delete steps below are unchanged.
    show_wallet_recovered_dialog_ = true;
    recovery_phase_ = RecoveryPhase::Working;
    recovery_last_action_rebuild_ = true;
    { std::lock_guard<std::mutex> lk(wallet_restore_mutex_); wallet_restore_done_ = false; }
    daemon_restarting_ = true;
    connection_status_ = TR("sb_restarting_daemon");
    if (rpc_ && rpc_->isConnected()) rpc_->disconnect();
    onDisconnected("Rebuilding wallet database");
    ui::Notifications::instance().info(TR("wallet_rebuild_started"), 15.0f);

    const std::string activeWalletName = (settings_ && !settings_->getActiveWalletFile().empty())
                                             ? settings_->getActiveWalletFile() : std::string("wallet.dat");

    async_tasks_.submit("Rebuild wallet database", [this, helper, activeWalletName](const util::AsyncTaskManager::Token&) {
        namespace fs = std::filesystem;
        std::string err, warn;
        try {
            const std::string datadir = util::Platform::getDragonXDataDir();
            const std::string active = datadir + "/" + activeWalletName;
            // 1. Rebuild SOURCE = the largest readable wallet file (the active wallet or any salvage .bak).
            //    Largest = most records = the original / least-salvaged (a salvaged copy is tiny).
            std::string src; unsigned long long best = 0;
            {
                std::error_code ec;
                for (const auto& e : fs::directory_iterator(datadir, ec)) {
                    if (ec) break;
                    const std::string n = e.path().filename().string();
                    if (n != activeWalletName && daemon::parseWalletSalvageBakTs(n) < 0) continue;
                    std::error_code se;
                    const auto sz = fs::is_regular_file(e, se) ? fs::file_size(e, se) : 0;
                    const auto usz = se ? 0ull : static_cast<unsigned long long>(sz);
                    if (usz > best && util::probeWalletFile(e.path().string()).isBerkeleyDB) {
                        best = usz; src = e.path().string();
                    }
                }
            }
            if (src.empty()) {
                err = TR("wallet_rebuild_no_source");
            } else if (!stopDaemonForWalletSwitch()) {   // 2. release wallet.dat + the port
                err = TR("wallet_restore_stop_failed");
            } else {
                std::time_t t = std::time(nullptr);
                std::tm tmv{};
#ifdef _WIN32
                localtime_s(&tmv, &t);
#else
                localtime_r(&t, &tmv);
#endif
                char ts[32]; std::strftime(ts, sizeof(ts), "%Y%m%d-%H%M%S", &tmv);
                const std::string tmpOut = datadir + "/wallet.rebuilt-" + std::string(ts) + ".tmp";
                { std::error_code ec; fs::remove(tmpOut, ec); }   // helper uses DB_EXCL — path must be fresh

                // 3. Run the helper (src -> tmpOut). Quote both paths; capture its JSON line. Windowless
                //    (runHiddenCapture) so a wallet rebuild never flashes a cmd.exe console; it runs the
                //    helper via CreateProcess directly on Windows, so no cmd.exe outer-quote wrap is needed.
                const std::string cmd = "\"" + helper + "\" \"" + src + "\" \"" + tmpOut + "\"";
                int rc = -1;
                const std::string jout = util::Platform::runHiddenCapture(cmd, /*mergeStderr=*/false, &rc);
                DEBUG_LOGF("[App] wallet-rebuild helper rc=%d out=%s\n", rc, jout.c_str());

                // 4. Verify-before-swap: the output must be a readable BDB with the fund-critical keys.
                const auto probe = util::parseWalletBtree(tmpOut);
                if (rc != 0 || !probe.parsed || probe.addresses() == 0) {
                    std::error_code ec; fs::remove(tmpOut, ec);
                    err = TR("wallet_rebuild_failed");
                } else {
                    // 5. Swap: set the current wallet aside (kept), install the rebuilt one, clear stale env.
                    std::error_code ec;
                    const std::string aside = active + ".prerebuild-" + std::string(ts) + ".dat";
                    bool moved = false;
                    if (fs::exists(active)) {
                        fs::rename(active, aside, ec);
                        if (ec) err = TR("wallet_restore_move_failed"); else moved = true;
                    }
                    if (err.empty()) {
                        fs::rename(tmpOut, active, ec);
                        if (ec) {
                            if (moved) { std::error_code e2; fs::rename(aside, active, e2); }
                            err = TR("wallet_rebuild_install_failed");
                        }
                    }
                    if (err.empty()) {
                        std::error_code e2;
                        if (fs::exists(datadir + "/database"))
                            fs::rename(datadir + "/database", datadir + "/database.prerebuild-" + std::string(ts) + ".bak", e2);
                        for (const auto& e : fs::directory_iterator(datadir, e2))
                            if (e.path().filename().string().rfind("__db.", 0) == 0) { std::error_code e3; fs::remove(e.path(), e3); }
                        if (daemon_controller_) { daemon_controller_->setRescanOnNextStart(true); user_initiated_rescan_ = true; }
                    }
                }
            }

            if (!shutting_down_) {
                if (daemon_controller_) daemon_controller_->clearExternalDaemonDetected();
                if (!startEmbeddedDaemon() && err.empty()) warn = TR("wallet_restore_no_restart");
            }
        } catch (const std::exception& e) {
            err = std::string("Rebuild failed: ") + e.what();
        } catch (...) {
            err = "Rebuild failed due to an unexpected error.";
        }
        daemon_restarting_ = false;

        std::lock_guard<std::mutex> lk(wallet_restore_mutex_);
        wallet_restore_severity_ = !err.empty() ? 2 : (!warn.empty() ? 1 : 0);
        wallet_restore_msg_ = !err.empty() ? err : (!warn.empty() ? warn : std::string(TR("wallet_rebuild_ok")));
        wallet_restore_done_ = true;
    });
}

void App::pumpSeedMigration()
{
    if (capture_mode_) return;  // no live ops during a UI sweep (steps are set directly)
    // --- Adopt handoff (Phase 2, background daemon swap) ---
    {
        bool done = false, ok = false; std::string err;
        {
            std::lock_guard<std::mutex> lk(seed_migration_mutex_);
            if (seed_migration_adopt_done_) {
                done = true; ok = seed_migration_adopt_ok_; err = seed_migration_adopt_err_;
                seed_migration_adopt_done_ = false;
            }
        }
        if (done) {
            if (ok) {
                if (!seed_migration_temp_dir_.empty()) {
                    std::error_code ec; std::filesystem::remove_all(seed_migration_temp_dir_, ec);
                }
                if (settings_) {
                    settings_->setSeedMigrationPending(false);
                    settings_->setSeedMigrationDest("");
                    settings_->setSeedMigrationTempDir("");
                    settings_->setSeedMigrationSweepTxid("");
                    settings_->setSeedMigrationSweepOpid("");  // W3-3
                    settings_->setSeedMigrationSweepSubmitted(false);
                    settings_->save();
                }
                seed_migration_status_ = err;  // a non-empty warning here (e.g. restart hiccup) is shown on Done
                seed_migration_step_ = SeedMigrationStep::Done;
            } else {
                seed_migration_status_ = err.empty() ? "Adopting the new wallet failed." : err;
                seed_migration_step_ = SeedMigrationStep::Error;
            }
        }
    }

    // --- Create handoff (Phase 1, background isolated create) ---
    if (!seed_migration_in_flight_) return;
    bool done = false, ok = false;
    std::string seed, dest, tmp, err;
    {
        std::lock_guard<std::mutex> lk(seed_migration_mutex_);
        seed_migration_status_ = seed_migration_progress_;  // live progress for the Working step
        done = seed_migration_done_;
        if (done) {
            ok  = seed_migration_ok_;
            seed = std::move(seed_migration_r_seed_);
            dest = std::move(seed_migration_r_dest_);
            tmp  = std::move(seed_migration_r_tmp_);
            err  = std::move(seed_migration_r_err_);
            seed_migration_done_ = false;
            seed_migration_r_seed_.clear();
        }
    }
    if (!done) return;
    seed_migration_in_flight_ = false;
    if (ok) {
        seed_migration_seed_     = std::move(seed);
        seed_migration_dest_     = std::move(dest);
        seed_migration_temp_dir_ = std::move(tmp);
        seed_migration_backed_up_ = false;
        seed_migration_step_ = SeedMigrationStep::ShowSeed;
        // Persist the pending migration so a later sweep/adopt step can find the new wallet.
        if (settings_) {
            settings_->setSeedMigrationPending(true);
            settings_->setSeedMigrationDest(seed_migration_dest_);
            settings_->setSeedMigrationTempDir(seed_migration_temp_dir_);
            // W3-3: a brand-new migration has done no sweep yet — clear any sweep artifacts left over
            // from a prior aborted run so reopening this fresh migration can't mis-resume on a stale
            // txid/opid (the resume block reads these whenever the migration is pending).
            settings_->setSeedMigrationSweepTxid("");
            settings_->setSeedMigrationSweepOpid("");
            settings_->setSeedMigrationSweepSubmitted(false);
            settings_->save();
        }
    } else {
        seed_migration_status_ = err.empty() ? std::string("Could not create the seed wallet.") : err;
        seed_migration_step_ = SeedMigrationStep::Error;
    }
}

void App::backupWallet(const std::string& destination, std::function<void(bool, const std::string&)> callback)
{
    if (!state_.connected || !rpc_) {
        if (callback) callback(false, "Not connected");
        return;
    }
    
    // Export all keys and save to file.
    exportAllKeys([destination, callback](const std::string& keys, int exported, int total) {
        // NEVER write (or report success for) a keyless backup — the usual cause is an encrypted+locked
        // wallet, and a "success" here would leave the user believing they have a recovery file when the
        // file contains only the header comments and ZERO keys.
        if (exported == 0) {
            if (callback) callback(false, total == 0
                ? "No addresses to back up."
                : "Backup failed: 0 of " + std::to_string(total) + " keys could be exported. "
                  "Unlock the wallet (if encrypted) and try again.");
            return;
        }

        // Write the key backup atomically and owner-only (0600) — it must never be even briefly
        // world-readable, and the previous std::ofstream left it at the umask default.
        if (!util::Platform::writeFileAtomically(destination, keys, /*restrictPermissions=*/true)) {
            if (callback) callback(false, "Could not write file: " + destination);
            return;
        }

        std::string msg = "Wallet backup saved to " + destination + " — "
                        + std::to_string(exported) + " of " + std::to_string(total) + " keys.";
        if (exported < total)
            msg += " NOTE: some addresses had no spending key or the wallet is locked — this backup is INCOMPLETE.";
        // A partial backup is a real risk for recovery, so surface it as a warning (not a green success).
        if (callback) callback(exported == total, msg);
    });
}

// ============================================================================
// Transaction Operations
// ============================================================================

void App::sendTransaction(const std::string& from, const std::string& to,
                         double amount, double fee, const std::string& memo,
                         std::function<void(bool success, const std::string& result)> callback)
{
    // Lite build: route to the controller's async broadcast. `from`/`fee` are ignored — the
    // backend selects inputs and adds the network fee itself. The result (txid/error) is
    // delivered to `callback` from update() once takeBroadcastResult() yields it.
    if (lite_wallet_) {
        wallet::LiteSendRequest req;
        wallet::LiteSendRecipient recipient;
        recipient.address = to;
        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()
        // Claim the shared broadcast channel so the chat note-buffer coordinator yields to this user send
        // and routes the one global broadcast result back to lite_send_callback_ (not a chat echo).
        inflight_op_ = LiteInflightOp{ LiteOpKind::UserSend, {}, chat_session_generation_, ImGui::GetTime() };
        return;
    }

    if (!state_.connected || !rpc_) {
        if (callback) callback(false, "Not connected");
        return;
    }

    // Single-flight guard: a rapid double-click (or any second caller) must not issue two
    // z_sendmany calls before the first returns its opid. The send form already guards this in the
    // UI, but the controller entry point must not depend on that. (send_submissions_in_flight_ is
    // main-thread only: ++ here, -- in the worker's main-thread result callback.)
    if (send_submissions_in_flight_ > 0) {
        if (callback) callback(false, "A transaction is already being submitted — please wait.");
        return;
    }

    // Check that we have the spending key for the from address
    if (!from.empty() && from[0] == 'z') {
        bool spendable = false;
        for (const auto& addr : state_.z_addresses) {
            if (addr.address == from) {
                spendable = addr.has_spending_key;
                break;
            }
        }
        if (!spendable) {
            if (callback) callback(false, "This is a view-only address (no spending key). Import the spending key to send from this address.");
            return;
        }
    }
    
    // Build recipients array
    nlohmann::json recipients = nlohmann::json::array();
    nlohmann::json recipient;
    recipient["address"] = to;
    recipient["amount"] = util::formatAmountFixed(amount);
    if (!memo.empty()) {
        // The daemon rejects a raw memo — it wants hex or a "utf8:" prefix. Prefix the user's plain-text
        // memo so it's UTF-8-encoded into the note (same as the chat path). The unprefixed `memo` is still
        // passed to submitZSendMany below for the tx-history display.
        recipient["memo"] = "utf8:" + memo;
    }
    recipients.push_back(recipient);
    
    // Run z_sendmany on worker thread to avoid blocking UI
    if (!worker_) {
        send_progress_active_ = false;
        if (callback) callback(false, "RPC worker unavailable");
        return;
    }

    // z_sendmany signature is (fromaddress, amounts, minconf, fee). The fee MUST be a JSON number:
    // the daemon reads it with get_real(), so a string is rejected ("JSON value is not a number as
    // expected"). get_real() parses the double directly and accepts any number notation (incl. the
    // "5e-05" form of a small fee), so passing the raw double is correct for every fee value.
    // (The recipient "amount" above is intentionally a fixed-decimal STRING — that field is parsed
    // with ParseFixedPoint, which a scientific-notation double would break.)
    submitZSendMany(from, to, amount, fee, memo, recipients, "Send tab / Submit transaction",
                    /*markFeeGapRetry*/ false, std::move(callback));
}

void App::submitZSendMany(const std::string& from, const std::string& to, double amount, double fee,
                          const std::string& memo, const nlohmann::json& recipients,
                          const char* traceLabel, bool markFeeGapRetry,
                          std::function<void(bool, const std::string&)> callback,
                          bool background)
{
    if (!background) send_progress_active_ = true;   // autonomous chat/split: status shown on the message
    ++send_submissions_in_flight_;
    worker_->post([this, from, to, amount, fee, memo, recipients, callback, traceLabel,
                   markFeeGapRetry, background]() -> rpc::RPCWorker::MainCb {
        bool ok = false;
        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, background]() {
            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;
                force_balance_refresh_ = true;  // the user's own send must update the balance now, past the throttle
                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 {
                if (!background) 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"] = "utf8:" + memo;   // daemon needs hex or a "utf8:" prefix (see submit)
    recipients.push_back(primary);
    nlohmann::json selfOut;
    selfOut["address"] = from;
    selfOut["amount"] = util::formatAmountFixed(fee);
    recipients.push_back(selfOut);

    submitZSendMany(from, to, amount, fee, memo, recipients, "Send tab / Fee-gap retry",
                    /*markFeeGapRetry*/ true, std::move(callback));
}

// --------------------------------------------------------------------------------------------
// Bootstrapped/pruned-node rescan support
//
// A node restored from a bootstrap snapshot only has block data from the snapshot base upward;
// blocks below it are absent on disk. The startup -rescan flag rescans from genesis and fails on
// such a node ("error in HDD data"), and rescanblockchain(0) hits the same missing blocks. The
// fix is to rescan from a height the snapshot includes — found here by binary-searching for the
// lowest height whose block data the node can actually read.
// --------------------------------------------------------------------------------------------
void App::detectLowestAvailableBlockHeight(std::function<void(bool, int, bool)> cb)
{
    if (!rpc_ || !rpc_->isConnected()) {
        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;
    user_initiated_rescan_ = true;   // user clicked Rescan → completion should toast
    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
