Files
ObsidianDragon/src/app.h
T
DanSandClaude Opus 4.8 fbe09c6a4c fix(security): follow the daemon's lock state on Set-PIN and expiry
Setting a PIN always unlocked the wallet with a 5 s probe and then
re-locked it, even when the wallet was already unlocked, so a PIN check
silently re-locked an unlocked wallet. And once the daemon's absolute
unlock window ran out the GUI still showed "unlocked", so the next send
failed with nothing on screen explaining it.

- The Set-PIN check now decides on the main thread: an unlocked wallet
  is verified with the time it has left (capped at seven days) and is
  not re-locked, so the GUI and the daemon keep agreeing; a locked
  wallet still uses the 5 s probe and re-locks. Either way the daemon's
  lock state is re-read afterwards, which self-heals any drift.
- When the daemon's unlock window has expired but the GUI still says
  "unlocked", a bounded reconcile (once per expiry, 30 s back-off)
  re-reads getwalletinfo so the lock screen and the transaction-cache
  lock follow the daemon. Lite wallets, which have no daemon timer, are
  left alone.

Behaviour change: with auto-lock disabled the daemon still re-locks at
its window, and the GUI now lands on the lock screen instead of
appearing unlocked until a send fails.

Tests: the Set-PIN verify plan (unlocked keeps its remaining time and
skips the re-lock, locked or near-expiry falls back to the 5 s probe and
re-lock, long windows are capped) and the unlock-expiry predicate across
encrypted, locked, no-deadline and not-yet-expired cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-12 21:42:23 -05:00

2064 lines
133 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#pragma once
#include <memory>
#include <string>
#include <functional>
#include <thread>
#include <atomic>
#include <mutex>
#include <chrono>
#include <unordered_map>
#include <unordered_set>
#include <deque>
#include <condition_variable>
#include <cstdint>
#include <optional>
#include <nlohmann/json_fwd.hpp>
#include "data/transaction_history_cache.h"
#include "data/transaction_history_store_policy.h"
#include "data/address_book.h"
#include "data/wallet_index.h"
#include "data/wallet_state.h"
#include "rpc/connection.h"
#include "daemon/stop_delivery.h"
#include "daemon/daemon_busy_detect.h"
#include "rpc/rpc_busy_gate.h"
#include "rpc/long_call_lane.h"
#include "services/history_scan_policy.h"
#include "services/connect_scan_gate.h"
#include "services/scan_policy.h"
#include "services/wallet_scan_governor.h"
#include "services/chat_scan_policy.h"
#include "services/note_consolidation.h"
#include "services/mining_thread_commit.h"
#include "services/network_refresh_service.h"
#include "services/wallet_security_controller.h"
#include "services/wallet_security_workflow.h"
#include "util/async_task_manager.h"
#include "resources/provision_state.h"
#include "util/background_sampler.h"
#include "util/coalescing_writer.h"
#include "util/fetch_backoff.h"
#include "util/idle_tracker.h"
#include "util/pool_stats_service.h"
#include "wallet/wallet_capabilities.h"
#include "chat/chat_service.h"
#include "chat/chat_database.h"
#include "ui/sidebar.h"
#include "ui/windows/console_tab.h"
#include "imgui.h"
// Forward declarations
namespace dragonx {
namespace rpc {
class RPCClient;
class RPCWorker;
}
namespace config { class Settings; }
namespace daemon { class DaemonController; class EmbeddedDaemon; class XmrigManager; }
namespace util { class Bootstrap; class SecureVault; }
namespace wallet { class LiteWalletController; struct LiteWalletAppRefreshModel; }
}
namespace dragonx {
/**
* @brief First-run wizard states
*/
enum class WizardPhase {
None, // No wizard — normal operation
BootstrapOffer, // Step 1: offer bootstrap download
BootstrapInProgress,// downloading / extracting
BootstrapFailed, // error with retry/skip option
Appearance, // Step 2: visual effects / performance options
EncryptOffer, // Step 3: offer wallet encryption
EncryptInProgress, // encrypting + daemon restarting
PinSetup, // Step 4: optional PIN setup (after encryption)
Done // wizard complete, launch normally
};
/**
* @brief Encrypt-wallet dialog phases (settings page flow)
*/
enum class EncryptDialogPhase {
PassphraseEntry, // Enter & confirm passphrase
Encrypting, // In-progress animation
PinSetup, // Offer PIN after successful encryption
Done // Finished — close dialog
};
// A status string written by a background/worker thread and read every frame by the UI thread. Its
// operator= locks, so all the plain `x = "..."` assignment sites stay unchanged; readers call get()
// for a consistent per-frame snapshot instead of racing a non-atomic std::string. (M-05, L-06)
class GuardedStatus {
public:
GuardedStatus() = default;
GuardedStatus& operator=(std::string v) { std::lock_guard<std::mutex> lk(m_); v_ = std::move(v); return *this; }
std::string get() const { std::lock_guard<std::mutex> lk(m_); return v_; }
private:
mutable std::mutex m_;
std::string v_;
};
/**
* @brief Main application class
*
* Manages application state, RPC connection, and coordinates UI rendering.
*/
class App {
public:
App();
void wipeSecrets(); // scrub all resident secret buffers; called from ~App() AND the forced-exit path (L-05)
~App();
// Non-copyable
App(const App&) = delete;
App& operator=(const App&) = delete;
/**
* @brief Initialize the application
* @return true if initialization succeeded
*/
bool init();
/**
* @brief Update application state (called every frame)
*/
void update();
/**
* @brief Wall-clock seconds since the previous update(), capped at kMaxLogicDeltaSeconds.
*
* Logic timers (refresh scheduler, poll intervals, countdowns) advance by this. Visual
* interpolation keeps io.DeltaTime, which update() clamps to 0.25 s so animations don't jump. The
* main loop idles between frames (one frame per 2 s wake when nothing is happening), so a timer
* fed the clamped delta would run up to 8x slow.
*/
float logicDeltaTime() const { return logic_dt_; }
static constexpr float kMaxLogicDeltaSeconds = 6.0f;
/**
* @brief Push the saved appearance prefs (low-spec, theme effects, blur, UI and noise opacity,
* console scanline) to the live effects state. Low-spec overrides the rest without touching the
* saved values. Called at startup, by the low-spec hotkey, the first-run wizard and the Settings
* page, so every path applies the same thing.
*/
void applyVisualSettings();
/**
* @brief Pre-frame tasks that must run BEFORE ImGui::NewFrame().
*
* Font atlas rebuilds (hot-reload, user font scale changes) must
* happen before NewFrame() because NewFrame() caches font pointers.
* Rebuilding mid-frame causes dangling pointer crashes.
*/
void preFrame();
/**
* @brief Render the application UI (called every frame)
*/
void render();
/**
* @brief Clean shutdown
*/
void shutdown();
/**
* @brief Wait (bounded, 3 s) for queued settings.json / wallets.json writes to land on disk.
* main() calls it around shutdown(): _Exit() skips ~App(), so nothing else would.
*/
void flushPendingWrites() { file_writer_.flush(std::chrono::milliseconds(3000)); }
/**
* @brief Check if app should exit (render loop can stop)
* Enforces minimum 1-second display of shutdown screen so user sees feedback.
*/
bool shouldQuit() const {
if (!quit_requested_ || !shutdown_complete_) return false;
auto elapsed = std::chrono::steady_clock::now() - shutdown_start_time_;
return elapsed >= std::chrono::seconds(1) && !bootstrapQuitPending();
}
/**
* @brief A bootstrap cancelled by beginShutdown() is still replacing the chain data (its abort removes the
* partial chain). The shutdown screen stays up until it is done (capped), so main()'s exit watchdog never
* cuts it off.
*/
bool bootstrapQuitPending() const;
/**
* @brief Request application exit — begins async shutdown
*/
void requestQuit();
/**
* @brief Begin graceful shutdown (called on window close)
* Starts daemon stop on a background thread while UI keeps rendering.
*/
void beginShutdown();
/**
* @brief Whether we are in the shutdown phase
*/
bool isShuttingDown() const { return shutting_down_; }
// True while the wallet-switch progress modal is open — keeps the frame loop redrawing so its live
// phase/spinner update in real time even when the app is otherwise idle.
bool isWalletSwitchInProgress() const { return wallet_switch_dialog_open_.load(); }
wallet::WalletCapabilities walletCapabilities() const { return wallet::currentWalletCapabilities(); }
bool isLiteBuild() const { return wallet::isLiteBuild(walletCapabilities()); }
bool supportsEmbeddedDaemon() const { return wallet::supportsEmbeddedDaemon(walletCapabilities()); }
bool supportsFullNodeLifecycleActions() const { return wallet::supportsFullNodeLifecycleActions(walletCapabilities()); }
// Daemon (v1.3.0+) coinbase auto-shield status, from z_autoshieldstatus. "Not probed" / all-false on
// pre-1.3.0 daemons (no such RPC) — callers treat that as "the wallet handles auto-shield itself".
bool daemonAutoShieldProbed() const { return daemon_autoshield_probed_; }
bool daemonAutoShieldActive() const { return daemon_autoshield_active_; }
const std::string& daemonAutoShieldAddress() const { return daemon_autoshield_address_; }
const std::string& daemonAutoShieldDisabledReason() const { return daemon_autoshield_disabled_reason_; }
bool daemonAutoShieldSeedRecoverable() const { return daemon_autoshield_seed_recoverable_; }
// W7 QoL: a plaintext support-diagnostics snapshot (version, variant, daemon/RPC/wallet/log state)
// for the "Copy diagnostics" action. Contains no secrets.
std::string buildDiagnosticsReport();
bool supportsSoloMining() const { return wallet::supportsSoloMining(walletCapabilities()); }
bool supportsPoolMining() const { return wallet::supportsPoolMining(walletCapabilities()); }
bool supportsLiteBackend() const { return wallet::supportsLiteBackend(walletCapabilities()); }
/**
* @brief Render the shutdown overlay (called instead of normal UI during shutdown)
*/
void renderShutdownScreen();
/**
* @brief Tail the last N lines of the daemon's debug.log (best-effort, reads only the file tail).
* Fallback for the shutdown screen when we have no captured stdout — e.g. an external daemon we
* attached to rather than spawned — so the user can still see the node flushing/exiting.
*/
std::vector<std::string> tailDaemonDebugLog(int maxLines) const;
// Whether beginShutdown() should pause and confirm before stopping the daemon: this quit would stop it,
// and it is rebuilding witnesses (its own log says so) or has been alive but too busy to answer RPC for
// a while. Stopping mid-rebuild throws the rebuild away, and a node killed then replays and redoes it.
bool shouldConfirmDaemonStop() const;
// The node is alive and its RPC has only timed out / been refused as busy for >= 30s (a rebuild that
// logged no marker, e.g. an external node whose output we can't read).
bool rpcBusyWhileAlive() const;
// The node is rebuilding witnesses (our own node's log says so) or has been too busy to answer for a
// while: stopping it now either waits up to an hour or makes it start over. UI thread only.
bool nodeBusyForStop() const;
// May quitting (or a default-policy stop) stop a node this session didn't launch? With stop_external_daemon
// on, or when it is the node an earlier session of ours launched and left running (its ownership record
// matches a live process). That one is stopped gracefully and waited for, never killed.
bool mayStopExternalNode() const;
// For an operation that would stop the node (restart, rescan, switch, reinstall, bootstrap, ...): when
// nodeBusyForStop(), tell the user to try again later and return true; the caller then does nothing.
bool refuseIfNodeBusy();
// Long wallet RPCs (rescan, key imports; C61) run one at a time on long_lane_. Main thread. Decide a refusal
// BEFORE copying a key into a closure: true when one can't start now, with the reason in English in *why
// (the console's text) and as an i18n key in *trKey (the GUI's toast / status line).
bool longWalletOpRefused(std::string* why, const char** trKey = nullptr) const;
// Main thread. Runs body on the lane's own ungated client (no client timeout, aborted at quit) on an async
// task; body must catch everything and returns the continuation, which runs on the main thread once the lane
// is free again. false (body never runs) when the lane is already taken.
using LongWalletBody = std::function<std::function<void()>(rpc::RPCClient&)>;
bool runLongWalletTask(LongWalletBody body);
// Record how an RPC to the node just went, for rpcBusyWhileAlive(): busy kinds (ReplyTimeout /
// QueueFull) start the clock, an answer stops it, anything else leaves it alone.
void noteDaemonRpcResult(bool answered, rpc::RpcFailureKind kind);
// Called by main.cpp: brings the window back (restore + raise) when a quit is deferred for the confirm
// modal while minimized or behind other windows — the modal only renders in a visible frame.
void setRaiseWindowCallback(std::function<void()> cb) { raise_window_ = std::move(cb); }
// The "node is rebuilding — stop anyway / keep running / cancel" modal, rendered from render().
void renderDaemonStopConfirm();
/**
* @brief Render loading overlay in content area while daemon is starting/syncing
* @param contentH Height of the content area child window
*/
void renderLoadingOverlay(float contentH);
// Accessors for subsystems
rpc::RPCClient* rpc() { return rpc_.get(); }
rpc::RPCWorker* worker() { return worker_.get(); }
// Console backend accessors (fast-lane-preferring, defined in app.cpp where the
// subsystem types are complete) used by the shared console executor.
rpc::RPCClient* consoleRpc();
rpc::RPCWorker* consoleWorker();
daemon::EmbeddedDaemon* consoleDaemon();
daemon::XmrigManager* consoleXmrig();
config::Settings* settings() { return settings_.get(); }
// Request a font-atlas rebuild before the next frame (e.g. after toggling color emoji). Handled in
// preFrame() via Typography::reload — safe to call from UI code mid-frame.
void requestFontRebuild() { font_rebuild_requested_ = true; }
/// Load the base ui.toml (disk, synced to the embedded copy when sizes differ; embedded fallback). Idempotent.
/// main() calls it before the first Typography::load so the atlas is built once at the final sizes.
static bool loadBaseUiSchema();
// Lite wallet controller (non-null only in lite builds with a linked backend).
wallet::LiteWalletController* liteWallet() { return lite_wallet_.get(); }
// HushChat service (identity + in-memory message store); the Chat tab reads its store.
chat::ChatService& chatService() { return chat_service_; }
// HushChat composing: construct, broadcast (broadcastChatMemos), and locally echo an outgoing
// message (to a conversation whose peer key we know) / a new-conversation contact request.
void sendChatMessage(const std::string& conversationId, const std::string& text);
void startChatConversation(const std::string& peerZaddr, const std::string& text);
// Send a contact request into an existing conversation (used to retry a failed request in place).
void sendContactRequestForCid(const std::string& cid, const std::string& peerZaddr,
const std::string& text);
// Debug/sweep convenience: give the Chat tab a demo identity + a few sample conversations
// (in-memory only, not persisted) so the screenshot sweep captures the populated UI. No-op when
// the chat feature is off. Returns true when it had to fabricate the (random) demo identity
// because none was provisioned, so the sweep can drop it again at the end.
bool seedChatDemoData();
// Reason the lite wallet failed to auto-open this session (empty if none / opened OK).
const std::string& liteOpenError() const { return lite_open_error_; }
// Show the lite send-time unlock modal (called when a spend is attempted on a locked wallet).
void requestLiteUnlock() { lite_unlock_prompt_ = true; }
// Lock the lite wallet AND immediately tear down the chat session (the lite backend `lock`
// doesn't update state_.locked until the next poll, so chat secrets would otherwise linger).
bool lockLiteWallet();
// (Re)build the lite controller from current settings so a changed lite-server selection
// takes effect. No-op on non-lite/unlinked builds; preserves a live wallet (see app.cpp).
void rebuildLiteWallet(bool force = false);
WalletState& state() { return state_; }
const WalletState& state() const { return state_; }
const WalletState& getWalletState() const { return state_; }
// Shared contact store (Contacts tab / Send picker / future Chat roster). App-owned so
// every surface reads one source of truth instead of a per-dialog singleton.
data::AddressBook& addressBook() { return address_book_; }
const data::AddressBook& addressBook() const { return address_book_; }
// Hash of the active wallet's identity (derived from its address list). This is the tx-history
// cache key — it changes when the address set changes, so DON'T scope persistent user data on it.
std::string activeWalletIdentityHash() const;
// Stable per-wallet id ("w:"+hex) for scoping persistent per-wallet data (address-book contacts).
// Generated once and persisted in the wallet index (keyed by wallet file), never recomputed from
// the mutable address set — so creating addresses / locking / disconnecting never changes it.
// Empty only when there is no active wallet file. Establishes + persists the id on first call,
// except during a full UI sweep (capture_mode_), where it returns the existing id or "".
std::string activeWalletScopeId();
data::WalletIndex& walletIndex() { return wallet_index_; }
const data::WalletIndex& walletIndex() const { return wallet_index_; }
// Connection state (convenience wrappers)
bool isConnected() const { return state_.connected; }
int getBlockHeight() const { return state_.sync.blocks; }
const std::string& getConnectionStatus() const { return connection_status_; }
std::string getDaemonStatus() const; // thread-safe copy (daemon_status_ is written off-thread)
// Balance info (convenience wrappers)
double getShieldedBalance() const { return state_.shielded_balance; }
double getTransparentBalance() const { return state_.transparent_balance; }
double getTotalBalance() const { return state_.total_balance; }
double getUnconfirmedBalance() const { return state_.unconfirmed_balance; }
// Addresses
const std::vector<AddressInfo>& getZAddresses() const { return state_.z_addresses; }
const std::vector<AddressInfo>& getTAddresses() const { return state_.t_addresses; }
// Transactions
const std::vector<TransactionInfo>& getTransactions() const { return state_.transactions; }
// Mining
const MiningInfo& getMiningInfo() const { return state_.mining; }
void startMining(int threads);
void stopMining();
bool isMiningToggleInProgress() const { return mining_toggle_in_progress_.load(std::memory_order_relaxed); }
// Pool mining (xmrig)
void startPoolMining(int threads);
void stopPoolMining();
// Change the thread count of the pool miner that is running (or queued to start). Never starts one.
void retargetPoolMining(int threads);
int getXmrigRequestedThreads() const {
return xmrig_manager_ ? xmrig_manager_->getRequestedThreads() : 0;
}
// Thread count of the newest pool start/retarget posted and not stopped since (0 = none). Leads
// getXmrigRequestedThreads(), which only changes once the mining-control thread runs that job.
int poolThreadsTarget() const { return pool_threads_target_.load(std::memory_order_relaxed); }
// A thread-count pick from the Mining tab (grid tile, -/+, Enter). The settings value changes at once;
// the save and the miner change happen ONCE, when the input settles (drag released, or ~0.5 s after
// the last click). Latest wins.
void selectMiningThreads(int threads);
void setMiningThreadDragActive(bool active) { mining_thread_drag_ = active; }
// A pick has not reached the miner yet (the grid must not snap back to the old count meanwhile).
bool miningThreadCommitPending() const {
return mining_thread_commit_.pending() || mining_solo_threads_pending_ > 0;
}
// True while the pool miner process is live — used to refuse replacing the binary under it.
bool isPoolMinerRunning() const {
return xmrig_manager_ && xmrig_manager_->isRunning();
}
// Auto-balance: latest per-pool hashrate snapshot (for the mining tab pool list),
// and a request to refresh it now (Refresh button / switching into Auto mode).
util::PoolStatsService::Snapshot poolStatsSnapshot() const {
return pool_stats_service_.snapshot();
}
void requestPoolBalanceRefresh() { balance_refresh_pending_ = true; }
// Installed miner version (detected from `xmrig --version`, cached; kicks the one-shot
// detection on first call) so the mining tab can show it before mining starts.
std::string poolMiningInstalledVersion();
// Mine-when-idle state query
bool isIdleMiningActive() const { return idle_mining_active_; }
// Peers
const std::vector<PeerInfo>& getPeers() const { return state_.peers; }
const std::vector<BannedPeer>& getBannedPeers() const { return state_.bannedPeers; }
bool isPeerRefreshInProgress() const {
return network_refresh_.jobInProgress(services::NetworkRefreshService::Job::Peers);
}
void banPeer(const std::string& ip, int duration_seconds = 86400);
void unbanPeer(const std::string& ip);
void clearBans();
// Address operations
void createNewZAddress(std::function<void(const std::string&)> callback = nullptr);
void createNewTAddress(std::function<void(const std::string&)> callback = nullptr);
// Hide/unhide addresses from the address list (persisted in settings)
void hideAddress(const std::string& addr);
void unhideAddress(const std::string& addr);
bool isAddressHidden(const std::string& addr) const;
int getHiddenAddressCount() const;
void favoriteAddress(const std::string& addr);
void unfavoriteAddress(const std::string& addr);
bool isAddressFavorite(const std::string& addr) const;
// Address metadata (labels, icons, custom ordering)
void setAddressLabel(const std::string& addr, const std::string& label);
void setAddressIcon(const std::string& addr, const std::string& icon);
std::string getAddressLabel(const std::string& addr) const;
std::string getAddressIcon(const std::string& addr) const;
int getAddressSortOrder(const std::string& addr) const;
void setAddressSortOrder(const std::string& addr, int order);
int getNextSortOrder() const;
void swapAddressOrder(const std::string& a, const std::string& b);
// Assign dense sort orders (0..N-1) to the given addresses in the given order and
// persist once. Used by drag-reorder so a drop always takes effect (even from the
// default un-ordered state, where a pairwise swap would be a no-op).
void reorderAddresses(const std::vector<std::string>& orderedAddrs);
bool isMiningAddress(const std::string& addr) const;
void setMiningAddress(const std::string& addr, bool mining);
void invalidateAddressValidationCache();
// Key export/import
void exportPrivateKey(const std::string& address, std::function<void(const std::string&)> callback);
// callback receives (keys, exportedCount, totalAddresses) so callers can detect a keyless/partial export.
void exportAllKeys(std::function<void(const std::string&, int, int)> callback);
// callback(success, errorOrEmpty, importedAddress). address is "" on failure or when the RPC
// returns none; the import routes to z_importviewingkey / z_importkey / importprivkey by key type.
// startHeight > 0 rescans from that block (shielded RPCs only; ignored for transparent WIF).
void importPrivateKey(const std::string& key, int startHeight,
std::function<void(bool, const std::string&, const std::string&)> callback);
// Sweep a spending key: import it (rescan) then z_sendmany ALL its funds (balance − fee) to a
// destination you own — a freshly generated shielded address when destMode == 0, else destExisting.
// Drives the sweep_step_ / sweep_status_ / sweep_txid_ state; reuses the async-operation tracker.
void sweepPrivateKey(const std::string& key, int startHeight, int destMode,
const std::string& destExisting);
// Wallet backup
void backupWallet(const std::string& destination, std::function<void(bool, const std::string&)> callback);
// Export the wallet's BIP39 seed phrase (z_exportmnemonic). The callback receives
// (ok, noMnemonic, phrase, error): ok+phrase on success; noMnemonic=true when the
// wallet's seed is not mnemonic-derived (legacy wallet). Full-node only; the phrase
// is a secret and is wiped after the callback returns.
void exportSeedPhrase(std::function<void(bool ok, bool noMnemonic,
const std::string& phrase,
const std::string& error)> callback);
// Transaction operations
void 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);
// Register a daemon async operation id (z_shieldcoinbase / z_mergetoaddress /
// auto-shield) with the shared opid poller so its eventual success/failure is
// surfaced and balances/transactions refresh on completion. z_sendmany uses the
// richer pending-send path internally; this is for operations with no optimistic
// transaction row of their own. The origin decides what its completion re-reads: the client
// auto-shield is Auto (re-read, balance unforced); everything the user starts is User.
void trackOperation(const std::string& opid,
services::NetworkRefreshService::OperationOrigin origin = services::NetworkRefreshService::OperationOrigin::User);
// The connected daemon rejected the opid-filtered z_getoperationstatus, so polls use the unfiltered
// form (every retained op) for the rest of this connection.
bool opidFilterUnsupported() const { return opid_filter_unsupported_.load(std::memory_order_relaxed); }
// Something changed the wallet that the scans' change signals (tip, mempool) don't show: a send or other
// operation finished, an import, a Console command. The change-gated scans run again.
void noteWalletMutation() { ++wallet_mutation_epoch_; }
// The window is in the background: minimized, hidden, or covered while the app has no focus (debounced
// by the main loop; any input ends it). The background refresh profile then holds the wallet scans and
// the peripheral polls (mining, price), and no page counts as visible to the scan budget.
void setWindowBackground(bool background);
bool isWindowBackground() const { return window_background_; }
// A balance scan is in flight (the staleness badge waits for it).
bool isBalanceRefreshInFlight() const { return scan_governor_.inFlight(services::ScanStream::Balance); }
// The balance is waiting for the scan budget and still within what it promises (its floor plus one scan):
// an old value then is the budget at work, not a failing node.
bool isBalanceHeldByScanBudget() const;
// RAII around one long ("maintenance") rpc_ call made on a worker thread with a timeout above the
// default 30 s, e.g. a merge or a whole-wallet scan given 120 s. rpc_ holds its curl lock for the
// whole transfer and disconnect() takes the same lock, so these are counted for
// abortMaintenanceCalls().
class MaintenanceCallScope {
public:
explicit MaintenanceCallScope(App& app) : app_(app)
{
// Count first, then read the flag; abortMaintenanceCalls() sets the flag, then reads the count.
// Either this sees the abort and never sends, or the abort sees this call and cuts it off. (A
// call queued behind the one disconnect() waits on could otherwise take the curl lock next
// and hold disconnect() for its full 120 s.)
app_.maint_calls_.fetch_add(1);
if (app_.maint_abort_.load()) {
app_.maint_calls_.fetch_sub(1);
throw rpc::RpcTransportError(rpc::RpcFailureKind::Aborted, "RPC aborted: disconnecting");
}
}
~MaintenanceCallScope() { app_.maint_calls_.fetch_sub(1); }
MaintenanceCallScope(const MaintenanceCallScope&) = delete;
MaintenanceCallScope& operator=(const MaintenanceCallScope&) = delete;
private:
App& app_;
};
// Main thread, right before rpc_->disconnect(): abort rpc_'s in-flight call if it is a maintenance
// call, so the disconnect doesn't freeze the UI for up to its 120 s, and refuse maintenance calls that
// start before the next connect (onConnected() lifts that). Ordinary calls are left to finish (at most
// 30 s, as before), so a user's own send is never cut off mid-call.
void abortMaintenanceCalls();
// Whether a whole-wallet scan the user didn't ask for may be sent now: a connected, unlocked, synced
// full node that is past warmup, not rescanning and not busy.
bool walletScanReady() const;
// C210: the "Keep in each address" consolidation run. Owned here so it keeps going when the Merge dialog
// closes; pumped from update(). Full node only (refused in Lite). Main thread.
const services::ConsolidationRun& consolidation() const { return consolidation_; }
bool startConsolidation(const std::vector<services::NoteGroup>& groups, double fee, int batchLimit);
void stopConsolidation() { consolidation_.requestStop(); }
void dismissConsolidation() { consolidation_.dismiss(); } // no-op while running
// A one-off wallet-wide scan the user asked for (the Merge dialog's scope read, a merge): never held back,
// but charged to the scan budget (ScanStream::Adhoc) so the routine scans make room. Main thread.
services::ScanTicket beginAdhocWalletScan();
void endAdhocWalletScan(const services::ScanTicket& ticket, double costMs, services::ScanOutcome outcome);
// A GUI wallet scan is in the daemon or queued for it: a one-off scan waits rather than stack on cs_main.
bool walletScanInFlight() const { return scan_governor_.anyInFlight(); }
// Force refresh
void refreshNow();
// Called on window restore: drop any daemon-output backlog (update() runs while minimized, but its
// output parser skips while the wallet is locked), so a background witness rebuild that started and
// finished meanwhile isn't parsed in one batch and mistaken for a completed rescan (spurious toast).
void skipDaemonOutputBacklog();
// A real user input event (key, text, mouse, touch, pen, gamepad button) was ingested. Main thread only.
// Called from main.cpp's noteEvent; do NOT call from an SDL event watch/filter (resizeWatcher).
void noteUserInput() { idle_.noteActivity(); }
void refreshMiningInfo();
void refreshPeerInfo();
void refreshMarketData();
// The Market tab's refresh button: lift any failure backoff (at most once per 5 s, against click
// spam), then refreshMarketData().
void retryMarketDataNow();
// Fetch the live exchange/pair list from CoinGecko once per session (venues are
// near-static); populates state.market.exchanges. Safe to call every frame.
void refreshExchanges();
// Fetch historical USD price series (CoinGecko market_chart) that back the portfolio
// sparkline intervals; self-throttled to ~30 min. Safe to call every frame.
void refreshMarketChart();
// Fetch the SELECTED pair's candles from that exchange's own API (data/exchange_candles.h) so the
// Market chart shows the real per-exchange price. Re-fetches on pair change; falls back to the
// CoinGecko aggregate for unmapped venues / failed fetches. Safe to call every frame.
void refreshExchangeChart();
// True while a market price-history fetch is in flight (CoinGecko aggregate OR per-exchange). The
// Market chart shows a loading indicator instead of the empty state during pair switches.
bool isMarketChartLoading() const { return chart_fetch_in_flight_ || exchange_chart_fetch_in_flight_; }
/// @brief Per-category refresh intervals, adjusted by active tab
using RefreshIntervals = services::NetworkRefreshService::Intervals;
/// @brief Get recommended refresh intervals for a given page
static RefreshIntervals getIntervalsForPage(ui::NavPage page);
// UI navigation
void setCurrentPage(ui::NavPage page);
ui::NavPage getCurrentPage() const { return current_page_; }
// Debug: screenshot sweep — cycles every skin x every enabled tab, one PNG each, into a
// timestamped folder under the config dir. Driven from App::render(); main.cpp polls
// wantsScreenshotThisFrame() after drawing the frame, saves screenshotSweepPath(), then calls
// onScreenshotCaptured() to advance. Transient — restores the original skin/page when done.
void startScreenshotSweep();
// Full UI sweep: like the tab sweep, but also drives every modal / dialog / multi-step flow /
// state overlay into view (with injected demo data, offline, firing no live ops) and captures
// each under every skin. Output: <config>/screenshots-full/<surface>/<skin>.png + an index.
void startFullUiSweep();
std::string screenshotFullDir() const;
// Debug option: restrict either sweep to just the currently-active theme instead of cycling all.
bool sweepCurrentThemeOnly() const { return sweep_current_theme_only_; }
void setSweepCurrentThemeOnly(bool v) { sweep_current_theme_only_ = v; }
bool isScreenshotSweeping() const { return screenshot_sweep_active_; }
bool wantsScreenshotThisFrame() const { return sweep_capture_this_frame_; }
const std::string& screenshotSweepPath() const { return sweep_current_path_; }
void onScreenshotCaptured();
std::string screenshotDir() const; // <config>/screenshots (fixed; sweeps overwrite in place)
// Dialog triggers (used by settings page to open modal dialogs)
void showImportKeyDialog() { import_view_mode_ = false; show_import_key_ = true; } // spending
void showImportViewingKeyDialog() { import_view_mode_ = true; show_import_key_ = true; } // watch-only
void showExportKeyDialog() { show_export_key_ = true; }
void showBackupDialog() { show_backup_ = true; }
void showSeedBackupDialog() { show_seed_backup_ = true; }
void showSeedMigrationDialog(); // opens the migration modal (resumes a pending one at Sweep)
// True when the current full-node wallet is a legacy, pre-seed-phrase wallet (no BIP39 mnemonic)
// that a capable daemon could migrate — the Migrate-to-seed button glows to nudge the user.
bool isPreSeedWallet() const { return wallet_seed_status_ == WalletSeedStatus::NoMnemonic; }
// Authoritative BIP39-mnemonic status of the ACTIVE wallet, decided at runtime by z_exportmnemonic
// (not the offline file probe, which can't tell an HD-but-no-mnemonic wallet from a seed-phrase one).
// 0 = unknown/undecidable, 1 = has a seed phrase, 2 = legacy (no mnemonic).
int activeWalletSeedBadge() const {
if (wallet_seed_status_ == WalletSeedStatus::HasMnemonic) return 1;
if (wallet_seed_status_ == WalletSeedStatus::NoMnemonic) return 2;
return 0;
}
void showAboutDialog() { show_about_ = true; }
void showFaqDialog() { show_faq_ = true; }
// Legacy tab compat — maps int to NavPage
void setCurrentTab(int tab);
int getCurrentTab() const { return static_cast<int>(current_page_); }
// Payment URI handling
void handlePaymentURI(const std::string& uri);
bool hasPendingPayment() const { return pending_payment_valid_; }
void clearPendingPayment() { pending_payment_valid_ = false; }
std::string getPendingToAddress() const { return pending_to_address_; }
double getPendingAmount() const { return pending_amount_; }
std::string getPendingMemo() const { return pending_memo_; }
std::string getPendingLabel() const { return pending_label_; }
// Embedded daemon control
bool startEmbeddedDaemon();
// How long stopEmbeddedDaemon() may wait, and who decides. It never kills the node on its own.
enum class StopMode {
AppShutdown, // quitting: no time limit; the shutdown screen offers "Leave running" (and "Kill" when stuck)
Background, // restart/rescan/switch/...: up to 120s, and let go as soon as the app starts quitting
Fallback, // the synchronous shutdown fallback on the UI thread: up to 20s
};
// Which node it may stop. Policy: our own, or an external one only when the "stop external daemon"
// setting says so. Connected: also the node we're connected to (adopted or external), with the
// credentials we connected with — a wallet switch, a reinstall and decrypt must free the port, the binary
// and wallet.dat. Only a node that accepts those credentials obeys.
enum class StopScope { Policy, Connected };
daemon::StopOutcome stopEmbeddedDaemon(StopMode mode = StopMode::Background, StopScope scope = StopScope::Policy);
// Stop the node for a wallet switch / adopt / restore / rebuild, which swap wallet.dat or relaunch on
// -wallet=<name>. Unlike stopEmbeddedDaemon()'s default policy (which leaves an adopted/external node
// running) it stops the node we're connected to too (StopScope::Connected). True only once the process
// has exited and the RPC port is free; false if it is still running, or the app started quitting.
bool stopDaemonForWalletSwitch();
bool isEmbeddedDaemonRunning() const;
bool isUsingEmbeddedDaemon() const { return supportsEmbeddedDaemon() && use_embedded_daemon_; }
void setUseEmbeddedDaemon(bool use) { use_embedded_daemon_ = use && supportsEmbeddedDaemon(); }
void rescanBlockchain(); // restart daemon with -rescan flag (full-history nodes)
// Runtime rescanblockchain RPC starting at a snapshot-available height. Unlike the
// -rescan restart, this works on bootstrapped/pruned nodes (which lack pre-snapshot
// block data), reconciling the wallet's stale spent-state without a daemon restart.
// Runs on the long-call lane; false when it did not start (refused, not connected, one already running).
bool runtimeRescan(int startHeight);
// Probe for the lowest block height the node still has on disk, on worker_ (never on the UI thread).
// cb(ok, lowestHeight, fullHistory) runs on the UI thread: from worker_->drainResults(), or synchronously
// when there is nothing to probe. fullHistory==true when block 1 is readable (a normal, non-bootstrapped
// node). ok==false means "could not tell" (busy node, timeout, reconnect): never "blocks are missing".
void detectLowestAvailableBlockHeight(std::function<void(bool ok, int lowestHeight, bool fullHistory)> cb);
// Flag that a bootstrap just finished so the wallet auto-reconciles spent-state once the
// daemon is back up (consumed in update()).
void markPostBootstrapRescanPending() {
post_bootstrap_rescan_pending_ = true;
post_bootstrap_probe_attempts_ = 0;
post_bootstrap_probe_retry_at_ = {};
}
bool runtimeRescanActive() const { return runtime_rescan_active_; }
void repairWallet(); // restart daemon with -zapwallettxes=2 (wipe & rebuild wallet tx records)
void reinstallBundledDaemon(); // stop daemon, overwrite installed binary with the bundled one, restart
void deleteBlockchainData(); // stop daemon, delete chain data, restart fresh
bool stopDaemonForBootstrap(); // stop daemon + disconnect for bootstrap, returns true if was running
bool isBootstrapDownloading() const { return bootstrap_downloading_; }
void setBootstrapDownloading(bool v) { bootstrap_downloading_ = v; }
// Get daemon memory usage in MB (uses embedded daemon handle if available,
// falls back to platform-level process scan for external daemons)
double getDaemonMemoryUsageMB() const;
// Diagnostic string describing daemon memory detection path (for debugging)
const std::string& getDaemonMemDiag() const { return daemon_mem_diag_; }
// Background gradient overlay texture (a replaced texture is retired, freed at the next preFrame())
void setGradientTexture(ImTextureID tex) { if (tex != gradient_tex_) retireTexture(gradient_tex_); gradient_tex_ = tex; }
ImTextureID getGradientTexture() const { return gradient_tex_; }
// Logo texture accessor (wallet branding icon)
ImTextureID getLogoTexture() const { return logo_tex_; }
int getLogoWidth() const { return logo_w_; }
int getLogoHeight() const { return logo_h_; }
// Coin logo texture accessor (DragonX currency icon for balance tab)
ImTextureID getCoinLogoTexture() const { return coin_logo_tex_; }
// DragonX custom chat emoji (the ":drgx:" shortcode) — the mark recolored to the theme accent (like
// the logo), re-rasterized on theme change. Used by the emoji picker tile + inline in chat bubbles.
ImTextureID getDrgxEmojiTexture() const { return drgx_emoji_tex_; }
/**
* @brief Reload theme images (background gradient + logo) from new paths
* @param bgPath Path to background image override (empty = use default)
* @param logoPath Path to logo image override (empty = use default)
*/
void reloadThemeImages(const std::string& bgPath, const std::string& logoPath);
// Load / recolor-per-theme the DragonX header logo (SVG rasterized to the theme accent). Called at
// the top of render() so the wizard, lock screen, and main header all show it.
void ensureLogoTexture();
// Wizard / first-run
WizardPhase getWizardPhase() const { return wizard_phase_; }
bool isFirstRun() const;
/**
* @brief Stop daemon and re-run the setup wizard
* Called from Settings. Daemon restarts automatically when wizard completes.
*/
void restartWizard();
/**
* @brief Restart the embedded daemon (e.g. after changing debug categories)
* Shows "Restarting daemon..." in the loading overlay while the daemon cycles.
*/
void restartDaemon();
// Switch the active wallet: persist the new -wallet=<name>, stop the node, restart on it
// (rescan only if it was never synced in this datadir). Per-wallet data follows automatically
// via the identity-scoped caches (P1). No-op if that wallet is already active.
// stopDaemonConfirmed: skip the "stop the running node?" confirmation (set true when re-entered from
// that dialog). salvage: start the target node with -salvagewallet (repair a corrupt wallet).
void switchToWallet(const std::string& walletFile, bool stopDaemonConfirmed = false, bool salvage = false);
// Main-thread continuation: if a wallet switch's daemon failed to start, revert active_wallet_file.
void processWalletSwitchRevert();
// Wallet encryption helpers
void encryptWalletWithPassphrase(const std::string& passphrase);
// Post-encrypt daemon restart: the daemon shuts itself down after
// encryptwallet, so restart it off the main thread. Shared by the
// immediate and deferred encryption continuations. When
// announceRestartStatus is true, connection_status_ is updated first so
// the loading overlay explains the restart.
void restartDaemonAfterEncryption(const char* taskName, bool announceRestartStatus);
void unlockWallet(const std::string& passphrase, int timeout);
void lockWallet();
void changePassphrase(const std::string& oldPass, const std::string& newPass);
// Dialog triggers for encryption (from settings page)
void showEncryptDialog() {
show_encrypt_dialog_ = true;
encrypt_dialog_phase_ = EncryptDialogPhase::PassphraseEntry;
encrypt_status_.clear();
memset(encrypt_pass_buf_, 0, sizeof(encrypt_pass_buf_));
memset(encrypt_confirm_buf_, 0, sizeof(encrypt_confirm_buf_));
memset(enc_dlg_pin_buf_, 0, sizeof(enc_dlg_pin_buf_));
memset(enc_dlg_pin_confirm_buf_, 0, sizeof(enc_dlg_pin_confirm_buf_));
enc_dlg_saved_passphrase_.clear();
enc_dlg_pin_status_.clear();
}
void showChangePassphraseDialog() { show_change_passphrase_ = true; }
void showDecryptDialog() {
show_decrypt_dialog_ = true;
wallet_security_workflow_.reset();
memset(decrypt_pass_buf_, 0, sizeof(decrypt_pass_buf_));
}
// Dialog triggers for PIN (from settings page)
void showPinSetupDialog() { show_pin_setup_ = true; pin_status_.clear(); }
void showPinChangeDialog() { show_pin_change_ = true; pin_status_.clear(); }
void showPinRemoveDialog() { show_pin_remove_ = true; pin_status_.clear(); }
bool hasPinVault() const;
// The PIN vault seals a passphrase the wallet no longer accepts (C87): changed in this session, or a
// PIN unlock got RPC -14. Session-only; the vault file is kept. Settings shows "PIN out of date".
bool isPinVaultStale() const { return pin_vault_stale_; }
// A wallet encrypt / passphrase change is in flight (PIN controls stay disabled meanwhile).
bool isEncryptInProgress() const { return encrypt_in_progress_; }
// Debug-options gate: does revealing the debug dropdown need re-authentication (a PIN vault or
// an encrypted wallet)? And verify the entered PIN/passphrase — cb(ok) is invoked on the main
// thread (PIN via the vault, else the wallet passphrase via RPC).
bool debugGateRequiresAuth() const;
void verifyDebugCredential(const std::string& secret, std::function<void(bool)> cb);
/// @brief Something finished on a background thread and waits for update() to apply it on the main thread
/// (the RPC worker lanes, the HTTP lane, lite wallet ops, the startup node-file job). main.cpp's idle loop
/// polls this every <= 200 ms (hasImmediateWork) and renders a frame when it is true, so the result shows
/// without input, even while the window is hidden. A new lane whose results are applied in update() adds
/// its term HERE.
bool hasPendingBackgroundResults() const;
bool hasTransactionSendProgress() const { return send_progress_active_ || send_submissions_in_flight_ > 0 || !pending_opids_.empty(); }
std::string transactionSendProgressText() const;
std::string transactionRefreshProgressText() const;
// Copy a SECRET (seed phrase, private key) to the clipboard and arm an auto-clear: after a
// short delay the clipboard is wiped IF it still holds this secret (so we don't clobber
// something the user copied afterwards). Only a hash of the secret is retained, never the
// plaintext. Call pumpSecretClipboardClear() each frame to action the clear.
void copySecretToClipboard(const std::string& secret);
void pumpSecretClipboardClear();
// Immediately clear the clipboard if it still holds the armed secret (ignores the 45s timer).
// Called on app shutdown so a copied key/seed does not outlive the process in the OS clipboard.
void clearSecretClipboardIfArmed();
bool isTransactionRefreshInProgress() const {
return network_refresh_.jobInProgress(services::NetworkRefreshService::Job::Transactions);
}
private:
friend class AppDaemonLifecycleRuntime;
friend class AppStopRuntime;
friend class AppStopTaskContext;
friend class AppDaemonLifecycleTaskContext;
// Global keyboard-shortcut handling, dispatched once per frame from update().
void handleGlobalShortcuts();
bool sendStopCommandSafely(rpc::RPCClient& client, const char* context);
void maybeFinishTransactionSendProgress();
// Shared body of createNewZAddress/createNewTAddress (which are thin public forwarders).
// `shielded` selects the z_getnewaddress/getnewaddress RPC, the "shielded"/"transparent" type
// string, and the z_addresses/t_addresses target (the new AddressInfo is pushed into both that
// list and state_.addresses). Lite builds derive locally via the controller and early-return.
void createNewAddress(bool shielded, std::function<void(const std::string&)> callback);
void upsertPendingSendTransaction(const std::string& opid,
const std::string& from,
const std::string& to,
double amount,
const std::string& memo,
double fee = 0.0);
// Work around a dragonxd note-selection bug: its z_sendmany picks notes to cover the recipient
// total but not the miner fee, so a shielded send whose largest notes sum exactly to the amount
// fails with "Insufficient shielded funds, have H, need H+fee" despite ample balance. When a
// failed opid matches that (H >= the requested amount), re-issue the send once with a tiny
// self-output that lifts the daemon's selection target past the boundary so it grabs another
// note; the recipient still receives the exact amount. Returns true if a retry was issued.
bool maybeRetrySendForFeeGap(const std::string& opid, const std::string& rawMsg);
void 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);
// Shared z_sendmany submit path for sendTransaction (single recipient, markFeeGapRetry=false)
// and resendWithFeeGapWorkaround (recipient + self-output, markFeeGapRetry=true). Owns the
// in-flight increment, the worker post + call, and the result closure (dirty flags, opid
// tracking, pending-send bookkeeping, callback delivery). Callers build `recipients` (and pass
// the recipient `to`/`amount`/`memo`/`fee` used to record the optimistic pending-send row).
// When markFeeGapRetry is set, the returned opid is recorded in send_feegap_retried_opids_ so a
// retry of a retry is reported as a real error.
// background=true (autonomous chat sends / note-buffer splits): keep the single-flight + opid
// accounting but DON'T raise the global "transaction in progress" UI (status is on the chat message).
void 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 = false);
void markPendingSendTransactionSucceeded(const std::string& opid,
const std::string& txid);
void removePendingSendTransactions(const std::vector<std::string>& opids,
bool restoreBalances);
// Apply a signed per-address balance delta for a pending send: walk z_addresses then
// t_addresses for `fromAddress` and clamp its balance at >=0. A positive `signedAmount`
// restores a debit; a negative one applies it. When `includeAggregates` is set, adjust the
// private/transparent bucket (chosen by the address's leading 'z') and totalBalance with the
// same clamp. Shared by the three pending-send delta sites so they can't drift. `includeAddress`
// off leaves the per-address balances alone (the Core poll refreshes only the aggregates).
void applyPendingSendDelta(const std::string& fromAddress, double signedAmount,
bool includeAggregates, bool includeAddress = true);
// Deliver a deferred z_sendmany result to its waiting UI callback once the opid
// reaches a terminal status. Returns true if a callback was registered (and fired).
bool invokeSendResultCallback(const std::string& opid, bool ok,
const std::string& result);
// Re-apply every pending send's debit on top of freshly fetched balances: the aggregates after a
// Core poll that carried a balance, the per-address balances after an address scan. Only onto
// fresh values, or a debit applied again on every tick would drain them.
void applyPendingSendBalanceDeltas(bool includeAggregateBalances, bool includeAddressBalances = true);
std::string transactionHistoryCacheWalletIdentity() const;
bool ensureTransactionHistoryCacheUnlockedFor(const std::string& walletIdentity);
// Record the active wallet's cached metadata (balance, address count, identity, size) into the
// wallet index (wallets.json). Cheap + throttled: only writes when a value changed. markOpened
// stamps last-opened + syncedHere (call once per connect).
void updateWalletIndexForActiveWallet(bool markOpened);
void unlockTransactionHistoryCacheWithPassphrase(const std::string& passphrase);
// Shared main-thread continuations for a wallet unlock attempt, so the passphrase
// and PIN paths cannot drift. applyUnlockFailure applies the escalating lockout
// curve on every failed path (a PIN RPC failure used to skip it).
void applyUnlockSuccess(const std::string& passphrase, int timeout);
void applyUnlockFailure(const std::string& errorMessage);
// Clear all rescan + witness-rebuild progress/accumulator state. Shared by the four
// rescan-completion sites so a new witness field can't be forgotten in one copy.
void resetWitnessRescanProgress();
void loadTransactionHistoryCacheIfAvailable();
// Persist the tx-history snapshot. Coalesce: only a change that matters, rate-limited (content <= every
// 20 s, scan heights/confirmations/tip <= every 10 min). Now: anything not yet on disk, immediately (user
// action, disconnect, lock, exit). The write itself runs on history_writer_; this only copies the rows.
void storeTransactionHistoryCacheIfAvailable(
data::HistoryStoreMode mode = data::HistoryStoreMode::Coalesce);
void pumpTransactionHistoryStore(); // update(): release a deferred store, retry a failed write
void wipePendingTransactionHistoryCachePassphrase();
void resetTransactionHistoryCacheSession();
// The wallet locked: drop the cache key, keep the history and its scan progress (an unlock used to
// force a full re-scan of every address, every auto-lock cycle).
void lockTransactionHistoryCacheSession();
void pruneShieldedHistoryScanProgress();
// Forget the receive-scan heights so every address is scanned again. keepSentScan leaves the sent
// sweep's watermark (and the enrichment backlog), so a plain refresh doesn't cost a whole-wallet sweep.
void invalidateShieldedHistoryScanProgress(bool persistCache, bool keepSentScan = false);
// Auto-balance pool selection: drive the periodic hashrate refresh and apply a
// freshly-completed snapshot (weighted-random pick + optional miner restart).
void updatePoolAutoBalance();
void applyPoolAutoBalance(const util::PoolStatsService::Snapshot& snap);
// Subsystems
std::unique_ptr<rpc::RPCClient> rpc_;
std::unique_ptr<rpc::RPCWorker> worker_;
// Fast-lane: dedicated RPC connection + worker for 1-second mining polls.
// Runs on its own thread with its own curl handle so it never blocks behind
// the main refresh batch.
std::unique_ptr<rpc::RPCClient> fast_rpc_;
std::unique_ptr<rpc::RPCWorker> fast_worker_;
// HTTP lane: price / tickers / market chart / exchange candle GETs on their own thread, so a slow or
// blocked HTTP endpoint never queues in front of wallet RPCs on worker_ (and wallet scans never delay the
// price). Not tied to the daemon connection: started in init(), stopped only at shutdown. Work functions
// touch only http_cancel_ and their captures; results apply in MainCbs (main thread). http_cancel_ is
// declared BEFORE http_worker_ on purpose: members die in reverse order, and the lane's thread reads the
// flag until ~RPCWorker joins it.
std::atomic<bool> http_cancel_{false}; // set at shutdown: queued GETs return at once, an in-flight one aborts
std::unique_ptr<rpc::RPCWorker> http_worker_;
// Saved connection credentials (needed to open the fast-lane connection)
rpc::ConnectionConfig saved_config_;
util::CoalescingWriter file_writer_; // background atomic writer for settings.json / wallets.json
std::unique_ptr<config::Settings> settings_;
std::unique_ptr<wallet::LiteWalletController> lite_wallet_; // lite builds w/ linked backend
// Pending send_tab callback for an in-flight lite send (delivered in update() once the
// controller's async broadcast result arrives). Only one lite send runs at a time.
std::function<void(bool, const std::string&)> lite_send_callback_;
// One-shot guard: auto-open an existing lite wallet on the first update() tick (kept off
// init() so a slow initialize_existing network call doesn't freeze startup before the window).
bool lite_autoopen_done_ = false;
double lite_open_last_attempt_ = 0.0; // ImGui time of the last async open attempt (retry timer)
// Reason an existing lite wallet failed to auto-open (e.g. server unreachable). Surfaced in
// the UI so a stuck "disconnected" state isn't silent; cleared once a wallet opens.
std::string lite_open_error_;
// A forced lite rebuild (Network-tab server switch) deferred while a wallet op is in flight; update() retries it
// (rebuildLiteWallet). deferred_at bounds the wait (wallet::kLiteServerSwitchMaxDeferSeconds).
bool lite_rebuild_pending_ = false;
std::chrono::steady_clock::time_point lite_rebuild_deferred_at_{};
// Retire the current lite controller without blocking the UI thread (fail its pending op callbacks, stop its
// worker, shut its client down) and destroy it on a detached thread. lite_wallet_ is null afterwards.
void retireLiteWallet();
// HushChat (experimental; gated by DRAGONX_ENABLE_CHAT — inert when OFF). App owns the chat
// service so the transaction-refresh harvest can decrypt incoming memos into threaded messages.
// The identity is derived from the wallet's OWN SDXLite-compatible seed phrase (full-node
// z_exportmnemonic / lite exportSeed → the same KDF), so it is portable across both variants.
chat::ChatService chat_service_;
chat::ChatDatabase chat_db_; // persistent backing (seed-derived encryption at rest)
bool chat_identity_provisioned_ = false; // identity set on the service this session
bool chat_identity_fetch_in_flight_ = false; // a z_exportmnemonic worker job is pending
std::chrono::steady_clock::time_point chat_identity_retry_at_{}; // after a transient failure
// Bumped by resetChatSession() on every wallet change; an in-flight identity fetch captures the
// value at post time and its completion callback discards its (previous-wallet) secret if the id
// no longer matches — so wallet A's seed can't be provisioned under wallet B via a stale job.
int chat_session_generation_ = 0;
bool chat_identity_unavailable_ = false; // provisioning failed definitively (e.g. non-mnemonic wallet)
// Per-conversation "last seen" watermark (message timestamp) for unread tracking (Q1). Updated when a
// thread is viewed (markChatConversationSeen); wiped in resetChatSession so unread doesn't leak across
// wallets. In-memory only (resets on app restart).
std::map<std::string, std::int64_t> chat_seen_watermark_;
// ── Per-frame render caches (avoid O(N) recompute every frame; see the respective call sites) ──
// Chat nav-badge unread count — recomputed only when the store revision changes or after a short
// interval (mute/hide/seen changes don't bump the store). See App::chatUnreadCount().
mutable std::uint64_t chat_unread_rev_ = ~0ull; // ~0 forces the first compute
mutable int chat_unread_cached_ = 0;
mutable double chat_unread_computed_at_ = 0.0;
// Sidebar unconfirmed-tx badge — recomputed only when the tx list changes (keyed on last_tx_update +
// size), not every frame. See App::render().
std::int64_t sb_unconf_key_ts_ = -1;
std::size_t sb_unconf_key_n_ = 0;
int sb_unconf_count_ = 0;
// Daemon-memory probe is expensive (/proc scan on Linux, popen on macOS): it runs on this sampler's own
// thread, at most once per 3 s and only while a reader asks (Mining tab memory bar, Peers tab Memory cell).
// See App::getDaemonMemoryUsageMB(). Mutable: that accessor is const. Probes capture values only, so any
// member order is safe; shutdown() stops it.
mutable util::BackgroundSampler daemon_mem_sampler_;
// ── Chat note buffer (BOTH variants) ────────────────────────────────────────────────────────
// Each chat message is a shielded tx that spends a note; its change needs a few confirmations before
// it's spendable again (lite: backend ANCHOR_OFFSET+1 = 5; full node: z_sendmany minconf = 1), so
// rapid sends run out of verified funds. We keep a buffer of ~kChatBufferTarget small self-notes so a
// burst of messages each spends a separate verified note, refilled from change + background self-
// splits. Chat sends, self-splits and user sends share the wallet's single send channel; inflight_op_
// + (lite) lite_send_callback_ / (full node) send_submissions_in_flight_+pending_opids_ serialize them
// so exactly one send is ever outstanding. Implemented in app_network.cpp; pumped from update().
enum class LiteOpKind { None, ChatSend, ContactRequest, Split, UserSend };
struct LiteInflightOp {
LiteOpKind kind = LiteOpKind::None;
std::string echoLocalId; // ChatSend/ContactRequest: the echo to resolve when it completes
int sessionGen = 0; // chat_session_generation_ snapshot at submit (stale-guard)
double submittedAt = 0.0;
};
struct QueuedChatOp {
LiteOpKind kind = LiteOpKind::ChatSend;
chat::OutgoingChatMemos memos; // kept so a transient-funds retry re-broadcasts, never recomposes
std::string echoLocalId;
int sessionGen = 0;
int retries = 0;
};
LiteInflightOp inflight_op_; // the single chat/split op currently on the send channel
std::deque<QueuedChatOp> chat_send_queue_; // chat/contact sends awaiting a free channel + verified note
// Note-availability estimate between refreshes/scans: reset from a fresh count, decremented on each chat
// submit (the count lags a spend by a cycle, but the wallet still picks a fresh note per send). Zeroed on
// a transient-funds failure so we stop draining until the next refresh/scan restores the truth.
int chat_verified_note_budget_ = 0;
int chat_pipeline_note_count_ = 0; // verified + maturing self-notes (drives shouldSplit)
std::uint64_t chat_verified_shielded_zat_ = 0; // verified shielded balance (split affordability)
bool chat_note_model_seen_ = false; // saw a refresh/scan carrying per-note visibility
// Single-split-in-flight guard: a self-split's OUTPUT notes are invisible until mined (~1 block), far
// longer than any wall-clock cooldown — so we permit only ONE outstanding split and clear the flag when
// the pipeline recovers (outputs mined) or a watchdog expires (a split that never mines mustn't wedge
// refill forever). Prevents runaway splitting that would drain balance into fees.
bool chat_split_outstanding_ = false;
double chat_split_submitted_at_ = 0.0; // scanClockNow() when the outstanding split was submitted (watchdog)
// Full-node only: a coordinator-owned z_listunspent worker scan feeds the per-note counts (the shared
// balance poll discards per-note data). Set with its scan ticket; cleared only by its reply.
bool chat_note_scan_in_flight_ = false;
double chat_note_scan_last_ = 0.0; // scanClockNow() of the last note-scan dispatch or address-scan feed
std::int64_t chat_note_scan_tip_ = -1; // the settled tip the note view was read at (-1: look again)
bool chat_note_rescan_requested_ = false; // a chat op finished: its notes changed
bool chat_note_first_look_sent_ = false; // the session's one explicit look (Chat page, no model)
services::ChatPayerMap chat_verified_funds_by_addr_; // verified notes by address (the chat payer view)
std::int64_t chat_split_hold_tip_ = -1; // a split failed at this tip: none before the next block
// Chat spends the payer view's producers (a note scan, an address pass) posted earlier can't see: worker_ is
// FIFO, so a reply reflects every spend submitted before it was posted, none after. Re-applied on its reply.
struct ChatSpendLogEntry {
std::uint64_t seq = 0;
std::string payer;
services::ChatPayerSpend spend = services::ChatPayerSpend::OneNote;
};
std::uint64_t chat_spend_seq_ = 0;
std::deque<ChatSpendLogEntry> chat_spend_log_;
std::int64_t chat_note_scan_spend_seq_ = -1; // the spend seq an outstanding note scan was posted at
std::int64_t addr_pass_spend_seq_ = -1; // ...and an outstanding address pass
void debitChatPayerLogged(const std::string& payer, services::ChatPayerSpend spend);
void reapplyChatSpendsAfter(std::uint64_t seq);
// The 0-conf inbox: a txcount probe per chat tick, the inbox scan when the wallet gained a tx.
std::int64_t chat_inbox_basis_txcount_ = -1; // txcount the last successful inbox scan is current for
double chat_inbox_last_scan_at_ = 0.0; // scanClockNow() at that scan's dispatch (safety net)
bool chat_inbox_scan_wanted_ = false; // the probe found a need the budget hasn't admitted yet
std::int64_t chat_inbox_wanted_txcount_ = -1; // the count that need was found at: the next basis
double chat_inbox_probe_at_ = 0.0; // scanClockNow() of the last probe
double chat_inbox_probe_ms_ = 0.0; // its cost (self-throttle)
bool chatInboxScanAllowed() const; // the inbox's gates (identity, the hard gates, the Chat page)
void admitChatInboxScan(); // ask the scan budget; post the inbox scan when admitted
// Most-recent chain tip, cached across refreshes: a lite refresh model that carries spendableOutputs
// may NOT carry sync status that same cycle (tolerated partial refresh), so verifiedSelfNoteCount reads
// this cache rather than requiring the current model to have both — else the budget flickers to 0.
std::int64_t chat_last_chain_height_ = 0;
int chat_fast_scan_last_seen_ = -1; // dedup: last memo-note count logged by the 0-conf scan
// Coordinator helpers (both variants unless noted; see app_network.cpp).
void refreshChatNoteBudget(const wallet::LiteWalletAppRefreshModel& model); // lite: recompute caches on a fresh model
// Full node: the z_listunspent note-buffer scan, on need (services::shouldScanChatNotes), under the scan
// budget (ChatNotes); also off the Chat page while a queued send or a split waits on it.
void refreshChatNoteBudgetNode();
// Feeds the SAME chat send-budget from an already-collected z_listunspent (the address refresh),
// so the dedicated scan above is skipped while the address refresh is active (dedup — see #3).
// feedTip: the tip that pass may vouch for (-1 when the tip moved too recently for the wallet notifier).
// spendSeq: the chat spend seq the pass was posted at (its reply re-applies the spends after it).
void updateChatNoteBudgetFromUnspent(const std::vector<services::NetworkRefreshService::UnspentNoteLite>& unspentNotes,
std::int64_t feedTip, std::uint64_t spendSeq);
void pumpChatNoteBuffer(); // per-frame: drain the queue / build the buffer
int verifiedSelfNoteCount(const wallet::LiteWalletAppRefreshModel& model); // lite
int pipelineSelfNoteCount(const wallet::LiteWalletAppRefreshModel& model); // lite
bool shouldSplitChatBuffer();
// What one split can spend: on the full node the richest verified payer (z_sendmany pays from ONE address).
std::uint64_t chatSplitAffordableZat() const;
bool broadcastSelfSplitLite(int noteCount); // lite: self-send minting noteCount reply-address notes
bool broadcastSelfSplitNode(int noteCount); // full node: z_sendmany self-send minting noteCount notes
void enqueueChatSend(LiteOpKind kind, const chat::OutgoingChatMemos& memos, const std::string& echoLocalId);
void onChatBroadcastResult(const LiteInflightOp& op, bool ok, const std::string& error);
static bool isTransientVerifiedFundsError(const std::string& error);
int chatConfsRequired() const; // verified-note confs threshold: 5 (lite) / 1 (full node)
public:
// Status-bar summary of the chat note buffer (empty when not applicable). Shown while the Chat tab is
// active so the buffer's state (ready / building / sending) is visible.
std::string chatBufferStatusText();
private:
public:
// Total unread incoming chat messages across all conversations (for the sidebar badge). 0 when the
// feature is off / no identity.
int chatUnreadCount() const;
// Mark a conversation read up to latestTs (called by the Chat tab while a thread is displayed).
void markChatConversationSeen(const std::string& cid, std::int64_t latestTs);
// Drop the seen-watermark for a conversation (used on revive-delete so a re-imported message — even one
// whose stamped time predates the deleted thread's last message — still badges as unread).
void forgetChatConversationSeen(const std::string& cid);
private:
// Provision the chat identity once the wallet seed is reachable+unlocked (per-tick, both
// variants); derives via deriveChatIdentityFromSecret and wipes the secret. No-op when the
// feature is off, already provisioned, in flight, or unavailable.
void maybeProvisionChatIdentity();
// Drop the in-memory chat identity + decrypted message store, lock the chat DB, and re-arm
// provisioning. MUST be called whenever the loaded wallet changes (switch / seed-migration adopt)
// so wallet A's private chat can't surface — or be signed with A's keys — under wallet B.
void resetChatSession();
// One-time nudge: on a full-node wallet that has a mnemonic, remind the user (once per
// install) to back up their seed phrase. Cheap early-outs keep it idle until it can act.
void maybeRemindSeedBackup();
void maybeWarnEmptyWalletWithFundedSiblings(); // full-node: empty active wallet + a funded sibling → warn once
void maybeWarnLargeWallet(); // full-node: wallet.dat past bloat threshold → one-time toast + clickable alert
void scanFundedSiblingsAsync(); // off-UI-thread probe of sibling wallet files
static void scrubAndRemoveExport(const std::string& path); // zero + delete a plaintext key export (H-02)
void sweepStaleDecryptExports(); // startup net: purge stale obsidiandecryptexport* files (H-02)
// Seed-wallet migration (Phase 1: create a new mnemonic wallet in isolation, no funds moved).
void beginCreateSeedWallet(); // starts the isolated create on a background thread
void pumpSeedMigration(); // main thread: pick up background progress/result each frame
// Phase 2: sweep all legacy funds into the new wallet, then adopt it as the primary wallet.
void refreshSeedMigrationBalance(); // query the legacy total (shown on the Sweep step)
// W3-3: the terminal callback for the sweep opid, shared by the initial submit and a resume
// re-track. `resumed` selects the failure behaviour: a fresh sweep that fails -> Error; a resumed
// opid the daemon no longer knows (stale) -> back to the dismissable Sweep gate (re-check balance).
std::function<void(bool, const std::string&)> makeSweepCompletionCallback(bool resumed);
void beginSweepToSeedWallet(); // z_mergetoaddress ["ANY_TADDR","ANY_ZADDR"] -> dest
void pollSweepStatus(); // Confirming step: poll sweep confirmations + legacy balance
void locateSweepTxid(); // recover a lost sweep txid (send to dest) -> Confirming gate
void beginAdoptSeedWallet(); // stop daemon -> swap wallet.dat -> restart with -rescan
void provisionChatIdentityFromSecret(std::string secret);
std::string chatReplyZaddr(); // a stable (persisted) wallet z-addr — chat IDENTITY (reply-to)
// A spendable z-address that can actually PAY `need` (a fee, or a split's notes plus its fee), preferring
// the identity reply address; from the chat note view's verified notes when there is one. The reply-to in
// the memo stays the identity address, so paying from a different funded note is transport-transparent.
// Empty if no z-address can cover it.
std::string chatPayFromZaddr(double need) const;
std::string generateChatLocalId(const char* prefix, int numBytes) const; // unique echo id / cid
// Broadcast the memos and, when the async op resolves, flip the echo (echoLocalId) to Sent/Failed.
bool broadcastChatMemos(const chat::OutgoingChatMemos& memos, const std::string& echoLocalId); // true if submitted
bool broadcastChatMemosLite(const chat::OutgoingChatMemos& memos); // lite two-recipient send
void ingestLiteChatMemos(const wallet::LiteWalletAppRefreshModel& model); // lite chat receive harvest
// Full-node 0-conf fast path on the Chat page: a txcount probe each chat tick, and the inbox scan (the chat
// reply address at minconf=0) when the wallet gained a tx, as the scan budget admits it (ChatFast), so
// incoming messages surface at mempool speed. Hidden conversations are skipped (they still un-hide via the
// normal confirmed harvest). Self-gated; no-op without a chat identity.
void fastScanChatMemos();
bool chat_fast_scan_in_flight_ = false; // the inbox probe or scan is out; cleared only by its reply
float chat_fast_scan_accum_ = 0.0f; // seconds since the last chat tick
bool font_rebuild_requested_ = false; // set by requestFontRebuild(); consumed in preFrame()
// Lite first-run welcome prompt: dismissed for the session once the user picks an action.
bool lite_firstrun_dismissed_ = false;
// Lite send-time unlock: set to show the unlock modal when a spend is attempted while locked.
bool lite_unlock_prompt_ = false;
// One-shot: prompt to unlock on startup once we learn the auto-opened wallet is encrypted+locked.
bool lite_startup_lock_checked_ = false;
std::unique_ptr<daemon::DaemonController> daemon_controller_;
std::unique_ptr<daemon::XmrigManager> xmrig_manager_;
// Serialized async mining-control queue: xmrig start/stop (SIGTERM->SIGKILL->join, up to ~3s) run on
// this dedicated FIFO thread instead of the render thread, so the UI never blocks and stop/start
// ordering is preserved across the ~13 call sites. (M-03/L-06/L-08/L-09/L-13)
std::thread mining_ctl_thread_;
std::mutex mining_ctl_mutex_;
std::condition_variable mining_ctl_cv_;
std::deque<std::function<void()>> mining_ctl_queue_;
bool mining_ctl_stop_ = false;
void postMiningControl(std::function<void()> job); // enqueue a blocking xmrig op onto the FIFO thread
void stopMiningControlThread(); // signal + join the control thread (shutdown)
// Builds the pool config on the main thread and posts stop(if running)+start to the control thread.
// retargetOnly: a thread-count change, decided on the control thread (never starts a miner).
void postPoolMinerJob(int threads, bool retargetOnly);
// Coalesced thread-count picks (main thread only; see services/mining_thread_commit.h).
services::MiningThreadCommit mining_thread_commit_;
bool mining_thread_drag_ = false; // the thread grid is mid-drag (set by the Mining tab)
bool mining_threads_save_pending_ = false; // settings hold a picked count that is not on disk yet
int mining_solo_threads_pending_ = 0; // settled pick waiting for an in-flight setgenerate
void tickMiningThreadCommit(); // per frame, from update()
// Auto-balance runtime state (pool mining, full-node only). The service fetches
// pool hashrates off-thread; the RNG drives the weighted-random pick.
util::PoolStatsService pool_stats_service_;
std::mt19937 balance_rng_;
long long last_balance_eval_ms_ = 0; // steady-clock ms of the last refresh kick
bool balance_refresh_pending_ = false; // UI asked for an immediate refresh
bool balance_snapshot_seen_ = false; // the current in-flight snapshot was applied
bool exchanges_fetch_started_ = false; // CoinGecko tickers fetch in flight or done (once per session)
bool chart_fetch_in_flight_ = false; // a market_chart history fetch is on the worker
// Failed market fetches back off instead of retrying: the Market tab calls these every frame, so a
// failure (offline, rate-limited) used to retry on the very next frame, ~20 requests a second.
util::FetchBackoff exchanges_backoff_;
util::FetchBackoff market_chart_backoff_;
util::FetchBackoff exchange_chart_backoff_; // for exchange_chart_fail_key_ only: other pairs still load
std::string exchange_chart_fail_key_;
std::chrono::steady_clock::time_point market_manual_retry_at_{};
bool price_fetch_failed_ = false; // last price fetch failed (connectivity probe for the backoffs)
bool price_fetch_in_flight_ = false; // a price GET is on http_worker_ (main thread only)
// Per-exchange candle chart (refreshExchangeChart): which pair the loaded series is for, an
// in-flight guard, and a slow refresh timer (candles move slowly, like the aggregate chart).
std::string exchange_chart_key_; // "<identifier>:<BASE>/<QUOTE>" of the loaded series ("" = none)
bool exchange_chart_fetch_in_flight_ = false;
std::chrono::steady_clock::time_point exchange_chart_last_fetch_{};
// Per-pair candle cache: switching back to a recently-viewed venue loads instantly (no re-fetch)
// instead of overwriting the single active buffer. Keyed like exchange_chart_key_.
struct ExchangeChartCache {
std::vector<std::pair<std::time_t, double>> closeIntraday, closeDaily;
std::vector<data::Candle> ohlcIntraday, ohlcDaily;
std::chrono::steady_clock::time_point fetchedAt{};
};
std::unordered_map<std::string, ExchangeChartCache> exchange_chart_cache_;
// Startup node files (Windows: extract the bundled params/node/miner; Linux/macOS: copy them from the exe
// dir; full node: then verify the Sapling params) on the "extract-node-files" async task. Declared BEFORE
// async_tasks_ so it outlives the task threads if the App is destroyed without shutdown().
std::shared_ptr<resources::StartupProvision> startup_files_;
std::thread::id ui_thread_id_{}; // set first thing in init(); startEmbeddedDaemon() never blocks it
bool daemon_start_after_files_ = false; // UI thread only: a daemon start was deferred while the job ran
void startStartupFilesProvision();
void pollStartupFiles(); // UI thread, every update()
void renderStartupFilesChip(); // UI thread, top of render()
// The long wallet RPC lane (C61). Its task runs on async_tasks_, so it is declared BEFORE async_tasks_ and
// outlives the task threads. Aborted at quit (beginShutdown / shutdown); never to make way for another flow:
// those are refused while it is active (nodeBusyForStop), because an aborted client does not stop the scan.
rpc::LongCallLane long_lane_;
util::AsyncTaskManager async_tasks_;
bool pending_antivirus_dialog_ = false; // Show Windows Defender help dialog
// Wallet state
WalletState state_;
// Shutdown state
std::atomic<bool> shutting_down_{false};
std::atomic<bool> shutdown_complete_{false};
bool address_list_dirty_ = false; // P8: dedup rebuildAddressList
GuardedStatus shutdown_status_; // thread-safe: written by the shutdown thread, read by the UI (M-05)
std::thread shutdown_thread_;
// Confirm-before-stopping-daemon-mid-witness-rebuild guard (see beginShutdown / renderDaemonStopConfirm)
bool pending_shutdown_confirm_ = false; // a quit is deferred, waiting to open the confirm modal
bool daemon_stop_confirm_open_ = false; // the confirm modal is currently showing
bool shutdown_confirmed_ = false; // user chose to proceed — bypass the guard on re-entry
bool shutdown_keep_daemon_override_ = false; // user chose "keep node running" for this shutdown only
float shutdown_timer_ = 0.0f;
std::chrono::steady_clock::time_point shutdown_start_time_;
// One stop session at a time (stopEmbeddedDaemon), and the shutdown screen's view of it.
std::mutex stop_mutex_;
std::atomic<bool> stop_session_active_{false};
std::atomic<int> stop_user_choice_{0}; // daemon::StopUserChoice, set by the shutdown screen
std::atomic<bool> stop_kill_offered_{false}; // the node looks stuck: the screen may offer "Kill"
std::atomic<int> stop_idle_minutes_{0};
std::mutex stop_client_mutex_;
rpc::RPCClient* active_stop_client_ = nullptr; // aborted on "Leave running" so a pending attempt ends at once
bool stop_kill_confirm_ = false;
void chooseStopAction(daemon::StopUserChoice choice);
// Daemon restart (e.g. after changing debug log categories)
std::atomic<bool> daemon_restarting_{false};
// Wallet-switch failure recovery: the switch worker sets wallet_switch_failed_ when the new
// wallet's daemon won't start/stay up; the main loop then reverts active_wallet_file to
// wallet_switch_prev_file_ (settings writes stay on the main thread) so a broken wallet isn't
// persisted across restarts. daemon_restarting_ stays set until the revert re-arms the reconnect.
std::atomic<bool> wallet_switch_failed_{false};
// Distinguishes the failure reason for the revert message: true when the switch failed because the
// running node wouldn't release the RPC port in time (not because the target wallet is bad).
std::atomic<bool> switch_stop_failed_{false};
// True from a switch until the new wallet's daemon actually connects (onConnected). If the daemon
// instead crash-wedges (a wallet that fails LATE in init, past the fast start grace), the connect
// loop flags a revert so a broken wallet still can't stick.
std::atomic<bool> wallet_switch_pending_confirm_{false};
std::string wallet_switch_prev_file_;
// Confirm-before-stop for a switch that would stop an ADOPTED (externally-running) node. switchToWallet
// sets these and defers to renderSwitchStopDaemonDialog; confirming re-calls switchToWallet(w, true).
bool show_switch_stop_daemon_confirm_ = false;
std::string pending_switch_wallet_file_;
// Block-database recovery: set when the embedded node aborts because its block DB is unreadable
// (a daemon-vs-chaindata format mismatch after an update, or a corrupt index). While set, the
// connect loop STOPS crash-restarting into the same abort and offers a one-click reindex instead.
bool block_db_reindex_available_ = false; // node needs its block DB rebuilt (gates restart loop)
bool show_block_db_reindex_confirm_ = false; // auto-shown offer dialog
// Wallet auto-recovery: the daemon moved wallet.dat to wallet.<ts>.bak and loaded a salvaged copy
// (BDB-verify failure — often a false positive from stale/cross-platform env state). We warn loudly
// so a possibly-incomplete salvaged wallet isn't mistaken for fund loss. Warned once per session.
bool wallet_auto_recovered_ = false; // a salvage happened this session
bool wallet_auto_recovered_warned_ = false; // guard: only surface it once per session
bool wallet_degraded_ = false; // v1.3.0+ opened the wallet in DEGRADED mode (no new HD keys)
bool wallet_degraded_warned_ = false; // guard: surface the degraded-mode notice once per session
bool show_wallet_recovered_dialog_ = false; // auto-shown warning dialog
// Complementary on-disk safety net for a salvage we DIDN'T witness this launch (happened on a prior
// run, or under an external daemon whose startup output we never captured): if the active wallet loads
// empty while a sibling wallet file in the datadir still holds keys, warn once so the user's funds
// (likely in a wallet.<ts>.bak) aren't mistaken for loss. See maybeWarnEmptyWalletWithFundedSiblings().
struct FundedSibling { std::string fileName; int transparentKeys = 0; int shieldedKeys = 0; };
bool show_empty_wallet_warning_ = false; // auto-shown warning modal
bool empty_wallet_warn_checked_ = false; // evaluated this wallet-open already (reset in onConnected)
bool empty_wallet_scan_in_flight_ = false; // a sibling scan is running (main-thread only)
bool empty_wallet_has_salvage_bak_ = false; // modal variant: a funded salvage .bak → offer Restore
std::vector<FundedSibling> empty_wallet_funded_siblings_; // scan result (main-thread only)
// The recovery dialog is the ONE authoritative surface: it stays open through the async rebuild/
// restore, driven Offer → Working → Done/Failed (pumpWalletRestore sets the outcome). Presentation
// only — the fund-safety file ops in rebuildWalletDatabase()/restoreOriginalWallet() are unchanged.
enum class RecoveryPhase { Offer, Working, Done, Failed };
RecoveryPhase recovery_phase_ = RecoveryPhase::Offer;
int recovery_outcome_sev_ = 0; // 0 ok / 1 warn / 2 error, set at Done/Failed
std::string recovery_outcome_msg_; // honest result string for the Done/Failed body
bool recovery_last_action_rebuild_ = false; // which handler ran (for "try the other option")
// After a successful repair the daemon restarts with a full rescan — minutes long, and it won't
// answer RPC yet. This makes the loading overlay show a calm "finishing your wallet repair" screen
// (instead of the generic "daemon stuck / RPC timeout / restart daemon" text) and suppresses the
// daemon-crash toast. Set on repair success; cleared on connect (onConnected).
bool post_recovery_rescan_ = false;
double post_recovery_rescan_since_ = 0.0; // stamped on first overlay frame (ImGui::GetTime)
// "Restore original wallet" background op: worker sets these under the mutex, pumpWalletRestore()
// (main thread) shows the result. 0 = success, 1 = warning, 2 = error.
std::mutex wallet_restore_mutex_;
bool wallet_restore_done_ = false;
int wallet_restore_severity_ = 0;
std::string wallet_restore_msg_;
// Live progress for the switch modal: it stays open from confirm through stop → wait-for-exit → start →
// reconnect and auto-closes when the new node connects (onConnected). Phase is worker-updated;
// dialog_open_ gates rendering — both atomic since onConnected/the worker may run off the main thread.
// error_ is written only on the main thread (processWalletSwitchRevert) and read by the (main-thread) UI.
enum class WalletSwitchPhase : int { None = 0, Stopping, Starting, Reconnecting, Failed };
std::atomic<int> wallet_switch_phase_{0};
std::atomic<bool> wallet_switch_dialog_open_{false};
std::string wallet_switch_error_;
double wallet_switch_started_time_ = 0.0; // ImGui::GetTime() captured on first progress frame (elapsed)
// Set when a failed switch's node output indicates the target wallet is CORRUPT — the Failed modal then
// offers a one-click "-salvagewallet" repair, retrying the switch to wallet_switch_target_file_.
std::atomic<bool> switch_wallet_corrupt_{false};
std::string wallet_switch_target_file_; // the wallet we were switching TO (for a salvage retry)
// Set by the deleteBlockchainData worker (item count); the main loop surfaces a completion toast
// and resets it to -1. Atomic because the worker thread writes it and the UI thread reads/clears it.
std::atomic<int> pending_delete_result_{-1};
std::atomic<bool> pending_reinstall_node_busy_{false}; // reinstall gave up: the old node never exited
// A worker stopped the node for an operation and it didn't exit: the operation was cancelled. Told (and,
// for a rescan/repair, its armed state undone) on the UI thread. Bits: kStopFailed*.
static constexpr int kStopFailedNotify = 1, kStopFailedUndoRescan = 2;
std::atomic<int> pending_stop_failed_{0};
// Encryption state check timeout
float encryption_check_timer_ = 0.0f;
// UI State
bool quit_requested_ = false;
bool show_demo_window_ = false;
bool show_settings_ = false;
bool show_about_ = false;
bool show_faq_ = false;
bool show_import_key_ = false;
bool show_export_key_ = false;
bool show_backup_ = false;
bool show_seed_backup_ = false;
// Seed-phrase backup dialog state. seed_backup_phrase_ holds a SECRET (the revealed
// mnemonic) and is wiped with sodium_memzero when the dialog closes.
std::string seed_backup_phrase_;
std::string seed_backup_status_;
bool seed_backup_fetch_started_ = false;
bool seed_backup_loading_ = false;
bool seed_backup_no_mnemonic_ = false;
bool seed_backup_reminder_in_flight_ = false; // guards the one-time backup nudge probe
std::chrono::steady_clock::time_point seed_backup_reminder_retry_at_{}; // after an inconclusive probe
bool large_wallet_checked_ = false; // gate: stat wallet.dat for the bloat nudge once per launch
// Cached mnemonic status of the current wallet, driving the Migrate-to-seed button glow. Probed
// once per connect (probeWalletSeedStatus, via exportSeedPhrase); NoMnemonic = a legacy wallet a
// capable daemon can migrate; Incapable = the daemon lacks z_exportmnemonic (can't tell / can't
// migrate). Reset to Unknown on wallet switch so it re-probes the new wallet.
enum class WalletSeedStatus { Unknown, HasMnemonic, NoMnemonic, Incapable };
WalletSeedStatus wallet_seed_status_ = WalletSeedStatus::Unknown;
bool wallet_seed_status_in_flight_ = false;
int wallet_seed_status_attempts_ = 0; // give up (Incapable) after a few transient probe failures
void probeWalletSeedStatus(); // one-shot per connect; classifies the wallet's mnemonic status
// --- Seed-wallet migration (Phase 1: create; Phase 2: sweep + adopt) ---
enum class SeedMigrationStep { Intro, Working, ShowSeed, Sweep, Sweeping, Confirming, Adopting, Done, Error };
bool show_seed_migration_ = false;
SeedMigrationStep seed_migration_step_ = SeedMigrationStep::Intro;
// Intro pre-flight: probe the current wallet before offering to create a seed wallet, so we can
// skip a pointless migration (AlreadyMnemonic → offer backup) or explain why it can't run
// (DaemonTooOld). Set from beginSeedMigrationPrecheck()'s async callback (main thread).
enum class SeedMigrationPrecheck { Pending, Legacy, AlreadyMnemonic, DaemonTooOld, CheckFailed };
SeedMigrationPrecheck seed_migration_precheck_ = SeedMigrationPrecheck::Pending;
bool seed_migration_precheck_started_ = false;
bool seed_migration_balance_loaded_ = false; // Sweep step: distinguishes "0 funds" from "not loaded yet"
bool seed_migration_nofunds_confirmed_ = false; // "replace my wallet" gate for the no-funds adopt
// "A newer node is bundled — update?" startup prompt (see maybeOfferDaemonUpdate).
bool show_daemon_update_prompt_ = false;
unsigned long long daemon_update_bundled_size_ = 0; // bundled daemon size the prompt offers
bool seed_migration_in_flight_ = false; // main-thread guard while the bg create task runs
std::string seed_migration_seed_; // SECRET — the revealed phrase, wiped on close
std::string seed_migration_dest_; // new shielded z-address (Phase 2 sweep target)
std::string seed_migration_temp_dir_; // temp datadir root holding the new wallet (kept)
bool seed_migration_backed_up_ = false; // "I've written it down" confirmation
std::string seed_migration_status_; // main-thread display (progress / error text)
double seed_migration_balance_ = 0.0; // legacy total to sweep (shown on the Sweep step)
std::string seed_migration_sweep_txid_; // the z_mergetoaddress sweep transaction id
int seed_migration_sweep_confs_ = 0; // confirmations of the sweep tx (adopt gate: >= 1)
double seed_migration_legacy_remaining_ = -1.0; // legacy balance after sweep (-1 = unknown)
float seed_migration_poll_timer_ = 0.0f; // throttles the Confirming-step poll
bool seed_migration_confirm_in_flight_ = false; // guards the confirm poll
// Cross-thread handoff from the background create task (guarded by seed_migration_mutex_).
std::mutex seed_migration_mutex_;
std::string seed_migration_progress_; // latest progress line
bool seed_migration_done_ = false; // create result ready to consume
bool seed_migration_ok_ = false;
std::string seed_migration_r_seed_, seed_migration_r_dest_, seed_migration_r_tmp_, seed_migration_r_err_;
// Cross-thread handoff from the background adopt task (guarded by seed_migration_mutex_).
bool seed_migration_adopt_done_ = false;
bool seed_migration_adopt_ok_ = false;
std::string seed_migration_adopt_err_;
// Embedded daemon state
bool use_embedded_daemon_ = wallet::supportsEmbeddedDaemon(wallet::currentWalletCapabilities());
// Written by the DaemonController state callback (which fires on EmbeddedDaemon's monitor thread)
// AND read on the UI thread — so all access goes through set/getDaemonStatus() under this mutex.
// getDaemonStatus() returns a COPY: a returned reference could dangle if the monitor thread
// reassigned the string mid-read (data race / use-after-free).
mutable std::mutex daemon_status_mutex_;
std::string daemon_status_; // guarded by daemon_status_mutex_ — use set/getDaemonStatus()
void setDaemonStatus(std::string s);
mutable std::string daemon_mem_diag_; // diagnostic info for daemon memory detection
size_t daemon_output_offset_ = 0; // for incremental output parsing (rescan detection)
// What the node's own output says it is doing (witness rebuild / startup replay). Fed on the UI thread
// from the captured output; node_rebuilding_ is its snapshot for the shutdown thread's stop loop.
daemon::DaemonBusyTracker daemon_busy_;
std::atomic<bool> node_rebuilding_{false};
std::atomic<bool> daemon_busy_reset_pending_{false}; // set from any thread; applied by feedDaemonBusy()
std::chrono::steady_clock::time_point rpc_busy_since_{}; // epoch = not busy
std::function<void()> raise_window_;
// S6: once the node is too busy to answer (a reply timeout or a full queue), the polling clients
// (rpc_, fast_rpc_) hold back everything that needs cs_main, and one canary request on its own
// client waits for the node to answer again. rpc_busy_gate_ is shared with those clients.
std::shared_ptr<rpc::RpcBusyGate> rpc_busy_gate_ = std::make_shared<rpc::RpcBusyGate>();
rpc::ConnectRetryBackoff connect_backoff_; // S8: per-failure-kind delay before the next connect attempt
std::thread canary_thread_;
std::atomic<int> canary_state_{0}; // CanaryState
std::mutex canary_mutex_;
rpc::RPCClient* canary_client_ = nullptr; // aborted on shutdown
rpc::ConnectRetryBackoff canary_backoff_; // a canary refused as queue-full waits before the next
std::chrono::steady_clock::time_point canary_retry_at_{};
void pumpRpcBusyCanary();
// A node this session didn't launch (left running by an earlier session, or found running): feed the busy
// tracker from its <datadir>/debug.log, while that log is tied to the live process (see
// daemon::externalLogFeedOffset). Our own running node's output feeds the tracker directly. UI thread.
void pumpExternalDaemonLog();
long externalNodePid() const; // that node: our ownership record, or (POSIX) its hushd.pid; 0 if none
bool busyTrackerIsLive() const; // the tracker describes a node that is running now
bool ext_log_tied_ = false;
long ext_log_pid_ = 0;
std::uintmax_t ext_log_offset_ = 0;
double ext_log_next_at_ = 0.0;
void abortRpcBusyCanary();
void feedDaemonBusy(const std::string& chunk);
void resetDaemonBusy();
std::chrono::steady_clock::time_point busy_first_seen_{}; // when the tracker last turned busy (UI thread)
// What to tell the user while the node is busy (overlay, banner, quit prompt). busy=false: it isn't.
struct NodeBusyText {
bool busy = false;
bool rebuilding = false;
std::string title; // e.g. "Node busy: rebuilding note witnesses — do not close"
std::string detail; // running time, what it's doing, progress when the node reports any
std::string elapsed; // "12:03"
std::string compact; // one line for the banner: "Running for 12:03 · Reading blocks: 45438 / 62583"
};
NodeBusyText nodeBusyText() const;
// Export/Import state
char export_result_[256] = {0}; // SECRET exported key — fixed buffer so it can be sodium_memzero'd
bool export_in_progress_ = false; // async key fetch running (spinner + disable Export)
bool export_error_ = false; // last export returned no key (locked wallet / failure)
char import_key_input_[512] = {0};
std::string export_address_;
std::string import_status_;
bool import_success_ = false;
bool import_key_reveal_ = false; // show the key in plaintext (default masked)
bool import_in_progress_ = false; // an import + rescan is running (disable/spinner)
std::string import_result_address_; // address imported on success (shown as a copy field)
bool import_view_mode_ = false; // dialog mode: false = spending key, true = viewing key
char import_key_scan_height_[16] = {0}; // optional rescan start height (shielded spend / viewing-key imports)
// --- Sweep: import a spending key then move all its funds to one of your own addresses, instead
// of keeping the key in the wallet (spending-key / non-view mode only). ---
bool import_sweep_mode_ = false; // sweep instead of a plain import
int sweep_dest_mode_ = 0; // destination: 0 = fresh shielded address, 1 = an existing one
char sweep_dest_pick_[128] = {0}; // chosen existing destination (sweep_dest_mode_ == 1)
enum class SweepStep { Idle, Running, Done, Error };
SweepStep sweep_step_ = SweepStep::Idle;
std::string sweep_status_; // progress / error text
std::string sweep_txid_; // sweep transaction id (on success)
std::string sweep_dest_shown_; // the destination address the funds were swept to
std::string backup_status_;
bool backup_success_ = false;
// Connection
std::string connection_status_ = "Disconnected";
bool connection_in_progress_ = false;
bool remote_rpc_plaintext_warning_shown_ = false;
// Startup daemon-launch diagnostics: bound the "RPC port busy, no config" wait before warning,
// and show the embedded-daemon start failure (binary/params/spawn) only once. Reset on connect.
int daemon_wait_attempts_ = 0;
bool daemon_start_error_shown_ = false;
int daemon_last_seen_crashes_ = 0; // surface each new embedded-daemon crash reason once
bool refresh_policy_syncing_ = false; // whether the sync-throttle refresh profile is active
bool refresh_policy_background_ = false; // whether the background refresh profile is active
// Sync-settle hysteresis + adaptive balance-poll throttle. Balance polling (z_gettotalbalance) is
// O(mapWallet) and holds the daemon's cs_main, which starves block connection on a large shielded
// wallet — so we keep the low-impact profile briefly after catching up, and back the balance poll
// off in proportion to its own measured cost. See effectivelySyncing() / balanceRefreshDue().
bool was_core_syncing_ = false; // previous Core-refresh sync state, to detect the caught-up edge
std::time_t sync_settle_until_ = 0; // hold the sync-throttle until this wall-clock time (0 = not settling)
bool force_balance_refresh_ = false; // a wallet mutation forces the next balance poll through the throttle
// Bumped with each forced-refresh request, so the poll that carried the forced balance clears the
// flag only if no newer request came in meanwhile (see requestForcedBalanceRefresh()).
std::uint32_t force_balance_seq_ = 0;
// The exact minconf=0 total of the last balance poll: the minconf=1 call is skipped while it holds
// still and nothing is pending (NetworkRefreshService::CoreRefreshPlan).
std::optional<std::int64_t> last_total_balance_zat_;
// Force the next balance poll through the throttle (the user's own send). Cleared by the Core poll
// that actually sent the forced balance call, not at dispatch, so a dispatch the job queue refuses
// (a Core job already in flight) or one that skips the balance (node behind or busy) keeps it.
void requestForcedBalanceRefresh() { force_balance_refresh_ = true; ++force_balance_seq_; }
// A wallet mutation committed (a User/Auto op succeeded, or a send returned no opid): re-read what it changed.
void markWalletMutated(bool forceBalance);
bool hasPendingUserOperations() const {
return services::NetworkRefreshService::anyUserOperationPending(pending_opids_, op_origin_);
}
// One budget for every wallet-wide scan the GUI sends on its own (balance, addresses, history, the
// between-blocks probe): one in flight at a time across both lanes, ~15% of wall-clock in total. They
// used to be throttled one by one at 10% each, which added up to 30-40% with all of them due.
// Explicit needs (dirty sets, the user's own send, a never-loaded list) bypass it but are charged.
services::WalletScanGovernor scan_governor_;
// The change signals the routine scans are gated on, besides the tip hash (state_.sync.best_blockhash).
std::uint64_t wallet_mutation_epoch_ = 0; // bumped by noteWalletMutation()
bool mempool_known_ = false; // the last Core poll's getmempoolinfo
double tip_changed_at_ = 0.0; // scanClockNow() when a Core poll saw a new tip hash (chat settle)
std::int64_t mempool_txs_ = 0;
std::int64_t mempool_bytes_ = 0;
bool window_background_ = false; // setWindowBackground()
static constexpr float kScanRetrySec = 2.0f; // a routine scan the governor held back asks again this soon
services::ScanSignals currentScanSignals() const;
services::ScanVisibility currentScanVisibility() const;
static double scanClockNow(); // the governor's clock: steady-clock seconds
// C44: holds the wallet-wide scans for a moment after the node starts answering (connect, warmup end),
// until the lock state is known and the node knows its tip or the wallet is small (20 s at most).
services::ConnectScanGate scan_gate_;
// After an address enumeration failed: no address refresh before this (the previous lists stay).
std::chrono::steady_clock::time_point address_retry_not_before_{};
// A full address pass (the per-address balances) has run this connection. Until then the first one runs
// even while the node catches up.
bool address_balances_loaded_ = false;
// The session's first balance read was sent while the node was catching up (once per connection).
bool first_balance_read_tried_ = false;
// Paces the address passes the held profiles' timers can't bring (a stale list, the first balances).
std::chrono::steady_clock::time_point address_owed_next_try_{};
bool scanGateOpen() const;
// The address list was never loaded this session, or the address set may have changed (import, sweep,
// decrypt-import): it loads even while the wallet scans are held (list-only).
bool addressListStale() const;
// What the scan entry points check before sending a wallet-wide scan (services::scan_policy.h).
services::ScanContext scanContext() const;
// The full address pass could be sent now (the hard gates, and no retry hold).
bool addressScanCanRunNow() const;
void armScanGate(const services::NetworkRefreshService::ConnectionInfoResult& info);
// Auto-clear for secrets copied to the clipboard. Only a hash of the copied secret is kept.
std::uint64_t clipboard_secret_hash_ = 0;
double clipboard_clear_deadline_ = 0.0;
float loading_timer_ = 0.0f; // spinner animation for loading overlay
double connect_stall_since_ = 0.0; // ImGui::GetTime() when the daemon first went "reachable but not ready"; 0 = not stalling (see util/connect_stall.h)
bool encryption_incomplete_warned_ = false; // W2-2: once-per-session guard for the "encryption didn't complete" warning
bool lock_failure_warned_ = false; // W2-4: guard so a repeatedly-failing auto-lock warns once, not every retry
std::uint64_t alerts_seen_total_ = 0; // Notifications::totalPushed() at last alert-panel open; drives the bell's unread dot
// Current page (sidebar navigation)
ui::NavPage current_page_ = ui::NavPage::Overview;
ui::NavPage prev_page_ = ui::NavPage::Overview;
float page_alpha_ = 1.0f; // 0→1 fade on page switch
bool sidebar_collapsed_ = false; // true = icon-only mode
// Debug screenshot sweep state.
bool screenshot_sweep_active_ = false;
bool sweep_current_theme_only_ = false; // Debug Options: sweep only the active theme
bool sweep_capture_this_frame_ = false;
int sweep_skin_idx_ = 0;
int sweep_settle_frames_ = 0; // frames to let a new skin/surface settle before capture
std::vector<std::string> sweep_skins_; // skin ids to cycle
std::string sweep_dir_; // output folder for this sweep
std::string sweep_current_path_; // PNG path for the frame about to be captured
std::string sweep_saved_skin_; // restore on completion
ui::NavPage sweep_saved_page_ = ui::NavPage::Overview;
void updateScreenshotSweep(); // called at the top of render() while active
void applySweepTarget(); // apply current (skin,page/surface), path, arm settle
// --- Full UI sweep: the capture unit is a "surface" (a tab, optionally with a modal / step /
// state forced on top). A tab is a surface with a null setup. ---
struct SweepTarget {
std::string name; // fs-safe id, e.g. "modal-seed-backup" / "overview"
ui::NavPage page = ui::NavPage::Overview; // base tab under the surface
std::function<void(App&)> setup; // reveal the surface (null = plain tab)
std::function<void(App&)> teardown; // clear it (null = nothing to undo)
int settle = 4;// blur overlays override to 8
};
std::vector<SweepTarget> sweep_targets_;
int sweep_target_idx_ = 0;
bool sweep_full_ = false; // full-UI sweep (drives surfaces) vs the legacy tab-only sweep
// capture_mode_: set only during a full sweep. CONTRACT: while true, NO live op may fire — no
// RPC, no auto-lock, no async pump. New async paths that could run mid-sweep must guard on it.
bool capture_mode_ = false;
void startSweepImpl(bool full);
// Why a full sweep must not start now (a real wallet workflow is in flight), or nullptr.
const char* fullSweepBlockedReason() const;
void buildSweepCatalog();
void installDemoWalletData();
void clearDemoWalletData();
// The full sweep's private temp dir (demo wallet files and folders); "" when none.
std::string sweep_sandbox_;
bool sweep_chat_identity_fabricated_ = false; // seedChatDemoData() made the demo chat identity
void applyHealthyDemoState(); // reset the connection/encryption flags to the healthy demo values
void writeSweepManifest() const;
// Snapshot of the state_ fields the demo installer mutates, restored at sweep end. Kept as a
// plain struct because WalletState has reference-alias members and isn't copy-assignable.
struct SweepStateSnapshot {
bool valid = false;
bool connected=false, warming_up=false, daemon_initializing=false;
bool encrypted=false, locked=false, encryption_state_known=false;
std::string warmup_status, warmup_description;
SyncInfo sync;
double privateBalance=0, transparentBalance=0, totalBalance=0, unconfirmedBalance=0;
std::vector<AddressInfo> addresses, z_addresses, t_addresses;
std::vector<TransactionInfo> transactions;
double market_price_usd=0;
double market_change_24h=0;
WizardPhase wizard_phase = WizardPhase::None; // the demo state dismisses the wizard
} sweep_state_snapshot_;
bool sidebar_user_toggled_ = false; // user manually toggled — suppress auto-collapse
float sidebar_width_anim_ = 0.0f; // animated width (0 = uninitialized)
float prev_dpi_scale_ = 0.0f; // detect DPI changes to snap sidebar width
// Background gradient overlay
ImTextureID gradient_tex_ = 0;
// Textures retired mid-frame (draw lists may still reference them); freed at the next preFrame().
std::vector<ImTextureID> pending_texture_destroy_;
void retireTexture(ImTextureID& t) { if (t) { pending_texture_destroy_.push_back(t); t = 0; } }
// Logo texture (reload on skin change / dark↔light switch)
ImTextureID logo_tex_ = 0;
int logo_w_ = 0;
int logo_h_ = 0;
bool logo_loaded_ = false;
bool logo_is_dark_variant_ = true; // tracks which variant is currently loaded
ImU32 logo_accent_ = 0; // theme accent the SVG logo was last rasterized with (re-render on change)
ImTextureID drgx_emoji_tex_ = 0; // ":drgx:" custom chat emoji (themed to the accent, like the logo)
int drgx_emoji_w_ = 0, drgx_emoji_h_ = 0;
// Coin logo texture (DragonX currency icon, separate from wallet branding)
ImTextureID coin_logo_tex_ = 0;
int coin_logo_w_ = 0;
int coin_logo_h_ = 0;
bool coin_logo_loaded_ = false;
// Console tab + its backend executor (full-node RPC or lite backend), created lazily.
ui::ConsoleTab console_tab_;
std::unique_ptr<ui::ConsoleCommandExecutor> console_exec_;
// Pending payment from URI
bool pending_payment_valid_ = false;
std::string pending_to_address_;
double pending_amount_ = 0.0;
std::string pending_memo_;
std::string pending_label_;
// Per-category refresh timers, policy, and worker queue guards.
services::NetworkRefreshService network_refresh_;
int mining_slow_counter_ = 0; // counts fast ticks; fires slow refresh every N
// Mining toggle guard (prevents concurrent setgenerate calls)
std::atomic<bool> mining_toggle_in_progress_{false};
// True from a successful startPoolMining() until the miner is confirmed connected/hashing in the
// poll — drives the "connecting…" → "connected" feedback for pool mining (which has a connect delay).
std::atomic<bool> pool_starting_{false};
// Thread count of the newest pool start/retarget job posted (0 = none / stopped). A hint for the main
// thread: set when a job is posted, cleared by stopPoolMining(), by a start that fails and by a retarget
// that finds no miner. The control thread re-checks the real state, so a stale value can only skip a
// job the newest posted one already covers, or post a no-op one.
std::atomic<int> pool_threads_target_{0};
void clearPoolThreadsTarget(int mine) { // only if it still holds this job's value
int expected = mine;
pool_threads_target_.compare_exchange_strong(expected, 0, std::memory_order_relaxed);
}
// Auto-shield guard (prevents concurrent auto-shield operations)
std::atomic<bool> auto_shield_pending_{false};
// v1.3.0+ daemons auto-shield coinbase themselves; probe z_autoshieldstatus once per connection and
// defer the wallet's own client-side auto-shield when the daemon is doing it (otherwise both race for
// the same coinbase UTXOs and split funds across different z-addresses). Fail-closed: a pre-1.3.0
// daemon lacks the RPC → active stays false → the wallet keeps shielding client-side (no regression).
bool daemon_autoshield_probed_ = false;
bool daemon_autoshield_active_ = false;
std::atomic<bool> daemon_autoshield_probe_inflight_{false};
std::string daemon_autoshield_address_; // z_autoshieldstatus fields (O1); empty on old daemons
std::string daemon_autoshield_disabled_reason_; // daemon's reason auto-shield is off (e.g. seed not recoverable)
bool daemon_autoshield_seed_recoverable_ = false;
// P4: Incremental transaction cache
int last_tx_block_height_ = -1; // block height at last full tx fetch
static constexpr int MAX_VIEWTX_PER_CYCLE = 25; // cap z_viewtransaction calls per refresh
std::size_t shielded_history_scan_cursor_ = 0;
// False until the first full shielded-history scan finishes. Drives the History tab's
// "Loading older history…" progress so the user knows transactions are still streaming in
// after the first batch appears; goes quiet for the routine per-block re-scans afterward.
bool initial_history_scan_complete_ = false;
std::unordered_map<std::string, int> shielded_history_scan_heights_;
// The receive-scan pass over every z-address (see services::ShieldedHistoryPass).
services::ShieldedHistoryPass history_pass_;
std::uint64_t history_scan_epoch_ = 0; // bumped when the scan heights are invalidated
std::uint64_t history_sent_epoch_ = 0; // bumped when the sent sweep's watermark is reset
int history_sent_scan_height_ = -1; // tip of the last successful z_listsentbyaddress; -1 = none
int history_full_sweep_failures_ = 0; // whole-wallet sweeps failed in a row (backoff)
std::time_t history_full_sweep_retry_at_ = 0;
std::unordered_set<std::string> history_enrich_backlog_; // txids found, not enriched yet
double history_addr_scan_ms_ = 0.0; // per-address z_listreceivedbyaddress cost (estimate)
std::size_t history_transparent_rows_ = 0; // rows the last listtransactions chain returned
int history_transparent_tip_ = -1; // ...and the tip it ran at (same tip: rows can be reused)
std::uint64_t tx_dirty_generation_ = 0; // bumped by markTransactionsDirty()
std::uint64_t addr_dirty_generation_ = 0; // bumped by markAddressesDirty()
// Addresses holding notes the history doesn't show yet (from the address scan's z_listunspent): scanned
// first in the next wallet-wide cycle, so a receive or payout shows within one cycle on a large wallet
// instead of when the routine pass reaches its address. Each txid hints once per session after it is
// mined (a mempool hint doesn't use that up, so the row also catches its confirmation).
std::unordered_map<std::string, int> history_hint_floors_;
std::unordered_set<std::string> history_hinted_txids_;
// The between-blocks probe's progress through the z-addresses since what it watches last changed.
services::ProbeRotation recent_probe_rotation_;
// The z-addresses of the last successful address enumeration this session: a never-scanned address new
// to it (an imported key) resets the sent sweep's watermark, even if the list in between came back blank.
std::unordered_set<std::string> history_listed_z_;
// P4b: z_viewtransaction result cache — avoids re-calling the RPC for
// txids we've already enriched. Keyed by txid.
using ViewTxCacheEntry = services::NetworkRefreshService::TransactionViewCacheEntry;
services::NetworkRefreshService::TransactionViewCache viewtx_cache_;
// P4c: Confirmed transaction cache — deeply-confirmed txns (>= 10 confs)
// are accumulated here and reused across refresh cycles. Only
// recent/unconfirmed txns are re-fetched from the daemon each time.
std::vector<TransactionInfo> confirmed_tx_cache_;
std::unordered_set<std::string> confirmed_tx_ids_; // fast lookup
int confirmed_cache_block_ = -1; // block height when cache was last built
// Dirty flags for demand-driven refresh
bool addresses_dirty_ = true; // true → refreshAddresses() will run
bool address_validation_cache_dirty_ = true;
bool transactions_dirty_ = false; // true → force tx refresh regardless of block height
bool encryption_state_prefetched_ = false; // suppress duplicate getwalletinfo on connect
bool rescan_status_poll_in_progress_ = false;
// True once we've actually observed the rescan running (daemon restarted into -rescan warmup).
// Gates the "rescan complete" detection so a getrescaninfo poll that hits the still-running
// pre-restart daemon (which reports rescanning=false) can't fire a false "complete" instantly.
bool rescan_confirmed_active_ = false;
// A runtime rescanblockchain RPC is in flight (vs the -rescan daemon restart). While set,
// the per-second mining/rescan-status pollers are suppressed (the daemon holds cs_main for
// the whole scan and would block them); completion is signalled by the rescan RPC callback.
bool runtime_rescan_active_ = false;
// True only for a rescan the WALLET/USER initiated (the Rescan button, a -rescan/salvage/zap/reindex
// restart, key import, seed migration) — not an autonomous background witness rebuild the daemon does
// on its own. Gates the "Blockchain rescan complete" toast so background rebuilds don't fire it;
// cleared when the toast is shown.
std::atomic<bool> user_initiated_rescan_{false}; // atomic: some rescan triggers run on worker threads
// Set when a bootstrap completes; consumed once the daemon is connected to auto-run a rescan
// that reconciles the preserved wallet.dat against the freshly-imported chain.
bool post_bootstrap_rescan_pending_ = false;
bool post_bootstrap_probe_in_flight_ = false; // its detectLowestAvailableBlockHeight() is on worker_
int post_bootstrap_probe_attempts_ = 0; // probes that could not tell; gives up after 6
std::chrono::steady_clock::time_point post_bootstrap_probe_retry_at_{};
// Largest "blocks remaining" seen during the current witness-rebuild phase. The daemon's
// "Building Witnesses for block" fraction resets every call (it's re-invoked per connected
// block, each walking from its own start height to the tip), so we derive a stable, monotonic
// overall percentage from how far "remaining" has fallen below this peak. Reset per phase.
int witness_rebuild_total_blocks_ = 0;
// The daemon's primary witness signal is "Setting Initial Sapling Witness for tx <hash>, <i>
// of <N>", logged once per wallet tx as its initial witness is set. The <i> is the tx's slot in
// an UNORDERED map, so it bounces wildly (was the cause of the resetting progress). The honest
// monotonic metric is how many DISTINCT txs have been witnessed (the set only grows; it also
// dedups the daemon's occasional double-prints) over the reported total N.
std::unordered_set<std::string> witness_seen_txids_;
int witness_total_txs_ = 0;
bool opid_poll_in_progress_ = false;
std::atomic<bool> opid_filter_unsupported_{false}; // see opidFilterUnsupported(); set on the worker
std::atomic<int> maint_calls_{0}; // maintenance calls in flight (MaintenanceCallScope)
std::atomic<bool> maint_abort_{false}; // set by abortMaintenanceCalls(), cleared on connect
services::ConsolidationRun consolidation_; // C210 (see consolidation())
std::uint64_t rpc_session_ = 0; // ++ in onConnected/onDisconnected: an orphan merge is tracked only within one
double run_lock_resync_at_ = 0.0; // steadySeconds() before which the run asks for no lock-state re-read
void pumpConsolidation();
void postConsolidationCall(int addrIdx);
void postConsolidationPoll();
void finishConsolidation();
// Consecutive Core-refresh cycles where BOTH core RPCs failed → likely a dead
// connection. After kCoreFailuresBeforeDisconnect, tear down and reconnect.
int consecutive_core_failures_ = 0;
// Pending z_sendmany operation tracking
bool send_progress_active_ = false;
int send_submissions_in_flight_ = 0;
std::vector<std::string> pending_opids_; // opids to poll for completion
// Non-User ops only (absent = User): what each op's completion re-reads.
std::unordered_map<std::string, services::NetworkRefreshService::OperationOrigin> op_origin_;
struct PendingSendInfo {
std::string from;
std::string to;
std::string memo;
double amount = 0.0;
double fee = 0.0;
std::int64_t timestamp = 0;
};
std::unordered_map<std::string, PendingSendInfo> pending_send_info_;
// Opids issued as a fee-gap auto-retry (see maybeRetrySendForFeeGap). Tracked so a retry that
// fails again is reported to the user instead of looping.
std::unordered_set<std::string> send_feegap_retried_opids_;
// z_sendmany UI callbacks held until the opid reaches a terminal status, so the
// user isn't told "sent successfully" before the tx is actually built/broadcast.
std::unordered_map<std::string, std::function<void(bool, const std::string&)>>
pending_send_callbacks_;
// Txids from completed z_sendmany operations.
// Ensures shielded sends are discoverable by z_viewtransaction
// even when they don't appear in listtransactions or
// z_listreceivedbyaddress.
std::unordered_set<std::string> send_txids_;
// Tip height when each tracked txid was added (TTL backstop, see pruneTrackedSendTxids()).
std::unordered_map<std::string, int> send_txid_heights_;
static constexpr int kTrackedSendTtlBlocks = 20;
void pruneTrackedSendTxids();
// First-run wizard state
WizardPhase wizard_phase_ = WizardPhase::None;
std::unique_ptr<util::Bootstrap> bootstrap_;
bool bootstrap_downloading_ = false; // true while settings bootstrap dialog is active
std::string wizard_pending_passphrase_; // held until daemon connects
std::string wizard_saved_passphrase_; // held until PinSetup completes/skipped
// Wallet security flow state shared by wizard/settings encryption paths.
services::WalletSecurityController wallet_security_;
services::WalletSecurityWorkflow wallet_security_workflow_;
// Wizard: stopping an external daemon before bootstrap
std::atomic<bool> wizard_stopping_external_{false}; // written by the stop worker, read by the UI (L-06)
GuardedStatus wizard_stop_status_; // thread-safe: written by the stop worker, read by the UI (L-06)
// PIN vault
std::unique_ptr<util::SecureVault> vault_;
data::TransactionHistoryCache transaction_history_cache_;
data::AddressBook address_book_; // shared contact store; loaded once in init(), self-saves on mutation
data::WalletIndex wallet_index_; // per-wallet metadata cache (wallets.json); populated after each load
std::string pending_transaction_history_cache_passphrase_;
bool transaction_history_cache_loaded_ = false;
// Background writer for the history snapshot. Declared AFTER transaction_history_cache_ (destroyed first,
// so a destructor-only teardown joins it before the cache goes); its jobs reference only that cache.
// App::shutdown() drains it (bounded) and joins it once idle; that is the real join (main.cpp _Exit()s past
// ~App()).
util::CoalescingWriter history_writer_;
struct HistoryStoreState {
bool have = false; // a snapshot of this session is on disk (submitted or loaded)
data::HistorySnapshotSig sig; // its signature
std::chrono::steady_clock::time_point lastSubmit{};
} history_store_; // UI thread only
bool history_store_pending_ = false; // a content change waits out kHistoryMinStoreIntervalSec
// Lock screen state
bool lock_screen_was_visible_ = false; // tracks lock→unlock transitions for auto-focus
bool lock_use_pin_ = true; // true = PIN input, false = passphrase input
// The active wallet's PIN vault holds an outdated passphrase (see isPinVaultStale()). Cleared when a
// PIN is set (which also re-offers PIN entry) or removed and on a wallet switch; a Change PIN keeps it
// (same sealed passphrase).
bool pin_vault_stale_ = false;
char lock_pin_buf_[16] = {};
char lock_passphrase_buf_[256] = {};
std::string lock_error_msg_;
float lock_error_timer_ = 0.0f;
int lock_attempts_ = 0;
float lock_lockout_timer_ = 0.0f;
std::chrono::steady_clock::time_point lock_timer_armed_at_{}; // when lock_error/lockout timers were last armed
bool lock_unlock_in_progress_ = false;
// Encrypt wallet dialog state
bool show_encrypt_dialog_ = false;
bool show_change_passphrase_ = false;
EncryptDialogPhase encrypt_dialog_phase_ = EncryptDialogPhase::PassphraseEntry;
char encrypt_pass_buf_[256] = {};
char encrypt_confirm_buf_[256] = {};
char change_old_pass_buf_[256] = {};
char change_new_pass_buf_[256] = {};
char change_confirm_buf_[256] = {};
std::string encrypt_status_;
bool encrypt_in_progress_ = false;
std::string enc_dlg_saved_passphrase_; // held for PIN setup after encrypt
char enc_dlg_pin_buf_[16] = {};
char enc_dlg_pin_confirm_buf_[16] = {};
std::string enc_dlg_pin_status_;
// PIN setup dialog state (settings page)
bool show_pin_setup_ = false;
bool show_pin_change_ = false;
bool show_pin_remove_ = false;
char pin_buf_[16] = {};
char pin_confirm_buf_[16] = {};
char pin_old_buf_[16] = {};
char pin_passphrase_buf_[256] = {};
std::string pin_status_;
bool pin_in_progress_ = false;
// Decrypt wallet dialog state
bool show_decrypt_dialog_ = false;
char decrypt_pass_buf_[256] = {};
// Wizard PIN setup state
char wizard_pin_buf_[16] = {};
char wizard_pin_confirm_buf_[16] = {};
std::string wizard_pin_status_;
// Auto-lock on idle. Fed by real input events (noteUserInput, from main.cpp's event ingest), NOT by
// widget state: a focused-but-untouched text field must not keep the wallet unlocked (C21).
util::IdleTracker idle_;
// C90: back-off clock for the daemon-unlock-expiry reconcile (steady clock, wall-clock independent).
std::chrono::steady_clock::time_point unlock_reconcile_retry_at_{};
// Wall-clock delta for logic timers (see logicDeltaTime()).
std::chrono::steady_clock::time_point last_update_time_{};
float logic_dt_ = 0.0f;
// Mine-when-idle: auto-start/stop mining based on system idle state
bool idle_mining_active_ = false; // true when mining was auto-started by idle detection
std::chrono::steady_clock::time_point idle_mining_next_check_{}; // checkIdleMining runs at 1 Hz
std::chrono::steady_clock::time_point idle_gpu_next_sample_{}; // GPU probe at most every 5 s
bool idle_gpu_busy_ = false; // last GPU-aware verdict (with hysteresis)
bool idle_scaled_to_idle_ = false; // true when threads have been scaled up for idle
// Private methods - rendering
void renderStatusBar();
// Persistent node/RPC error strip at the top of the content column when the wallet can't
// reach its node (or the embedded daemon gave up crashing). Decision logic is the pure
// evaluateNodeStatusBanner() in ui/node_status_banner.h; this draws it and wires the action.
void renderNodeStatusBanner();
// Body of the status-bar alert-history popup: recent alerts (incl. ones whose toast faded),
// newest first, with severity icon + relative age + a Clear action. See src/ui/notifications.h.
void renderAlertHistoryPanel();
void renderLiteFirstRunPrompt(); // lite-only welcome modal when no wallet exists yet
void renderLiteUnlockPrompt(); // lite-only send-time unlock modal
void renderImportKeyDialog();
void renderExportKeyDialog();
void renderBackupDialog();
void renderSeedBackupDialog(); // full-node "Back up seed phrase" modal (z_exportmnemonic)
void renderSeedMigrationDialog(); // "Migrate to a seed wallet" guided modal (Phase 1: create)
void beginSeedMigrationPrecheck(); // Intro: probe whether the wallet is legacy / already-seeded
void maybeOfferDaemonUpdate(); // at startup, flag the prompt if a newer daemon is bundled
void renderDaemonUpdatePrompt(); // "a newer node is bundled — update the installed daemon?"
void renderFirstRunWizard();
void renderLockScreen();
void renderEncryptWalletDialog();
void renderDecryptWalletDialog();
void renderPinDialogs();
void renderAntivirusHelpDialog();
void renderSwitchStopDaemonDialog(); // confirm before stopping an adopted node to switch wallets
void renderBlockDbReindexDialog(); // offer to rebuild an unreadable block database (-reindex)
void reindexBlockDatabase(); // restart the daemon with -reindex to rebuild the block DB
void renderWalletRecoveredDialog(); // warn that the node auto-recovered/salvaged wallet.dat
void renderEmptyWalletWarningDialog();// warn that the active wallet is empty while a sibling holds funds
void detectWalletAutoRecovery(); // scan daemon output for a salvage; fire the warning once/session
void detectWalletDegraded(); // scan daemon output for a DEGRADED-mode open; warn once/session
void restoreOriginalWallet(); // swap the wallet.<ts>.bak back over the salvaged copy + restart
void pumpWalletRestore(); // main-thread: surface the restore/rebuild op's result
void rebuildWalletDatabase(); // rebuild a BDB-inconsistent wallet into a loadable one (helper)
bool walletRebuildAvailable() const; // the dragonx-wallet-rebuild helper is present
void processDeferredEncryption();
// Private methods - connection
void tryConnect();
void onConnected();
void onDisconnected(const std::string& reason);
// Set the "node is initializing" UI state (status line + overlay description) from the
// embedded/external daemon's launch state and its own console output (current phase + block
// height), so a connect probe that times out while the daemon loads shows WHAT it's doing.
// `reachableButBusy` is true when the probe connected but got no RPC reply (a timeout),
// false when the daemon is merely launching (not bound yet). Returns the status title.
std::string applyDaemonInitStatus(bool reachableButBusy);
// The overlay text applyDaemonInitStatus last produced. It runs only on a connect failure, which a busy
// node backs off to every 30s, so update() re-applies it once a second (the elapsed time and the
// node's own progress tick) for as long as nothing else has replaced the status.
std::string init_status_title_;
bool init_status_busy_ = false;
double status_refresh_at_ = 0.0;
// The warm-up overlay text, from the node's last -28 message — unless its own log says it is rebuilding
// witnesses: a startup rebuild runs under "Activating best chain...", which hides the step users must
// not interrupt. update() re-applies it once a second too (see init_status_title_).
void applyWarmupText(const std::string& rpcMessage);
std::string warmup_rpc_message_;
// Tear down a connection that died mid-session (daemon crash / restart / dropped
// socket) so update()'s reconnect branch re-enters tryConnect(). Unlike onDisconnected
// alone, this also rpc_->disconnect()s so rpc_->isConnected() actually flips to false.
void handleLostConnection(const std::string& reason);
void applyDefaultBanlist();
// Private methods - data refresh
void refreshData(); // Orchestrator: dispatches per-category refreshes
void refreshCoreData(bool allowBalance = true); // Balance + blockchain info (can use fast_worker_)
// Address lists + balances. With no ticket it is an explicit refresh (charged to the scan budget).
void refreshAddressData(services::ScanTicket ticket = {});
// Transaction history: decides the cycle (services::decideTxCycle) and starts it. Safe to call any
// time: it refuses when the node can't be scanned (not connected, locked, warming up, rescanning, busy).
// Returns true when a routine scan was wanted but the scan budget held it back (ask again soon).
bool refreshTransactionData();
void startTransactionCycle(bool full, bool routineAllowed, services::ScanTicket ticket); // Full or Light
void startRecentTransactionPoll(bool probe, services::ScanTicket ticket); // between blocks
void markTransactionsDirty() { transactions_dirty_ = true; ++tx_dirty_generation_; }
// A pass sent before this can't clear it (refreshAddressData compares the generation).
void markAddressesDirty() { addresses_dirty_ = true; ++addr_dirty_generation_; }
std::vector<std::string> shieldedAddressList() const;
services::ShieldedHistoryPass::Work historyScanWork() const;
// From the address scan's z_listunspent: addresses holding received notes the history doesn't show
// yet (no row, or rows still unconfirmed while the note is mined) become priority scans.
void noteHistoryHintsFromUnspent(const std::vector<services::NetworkRefreshService::UnspentNoteLite>& notes);
bool refreshEncryptionState(); // Wallet encryption/lock state
void refreshBalance(); // Legacy: balance-only refresh (used by specific callers)
void refreshAddresses(); // Legacy: standalone address refresh
void refreshPrice();
void refreshWalletEncryptionState();
// C88: reconcile the persisted "encryption requested" flag against one parsed getwalletinfo result
// (connect-time ConnectionInit and every Encryption job). Main thread only.
void reconcileEncryptionPending(const services::NetworkRefreshService::WalletEncryptionResult& enc);
void applyRefreshPolicy(ui::NavPage page);
bool effectivelySyncing() const; // syncing, or within the post-sync settle window (hysteresis)
bool currentPageNeedsWalletDataRefresh() const;
bool shouldRunWalletTransactionRefresh() const;
bool shouldRefreshRecentTransactions() const;
void checkAutoLock();
// C90: the daemon's absolute unlock window ran out while the GUI still says "unlocked": re-read getwalletinfo
// (once per expiry, backed off) so the lock screen / tx-cache lock follow the daemon.
void reconcileUnlockExpiry();
void checkIdleMining();
};
} // namespace dragonx