// DragonX Wallet - ImGui Edition // Copyright 2024-2026 The Hush Developers // Released under the GPLv3 #pragma once #include #include #include #include #include #include #include #include "exchange_info.h" #include "candle.h" namespace dragonx { /** * @brief Represents an address with its balance */ struct AddressInfo { std::string address; double balance = 0.0; // DISPLAY total incl. pending 0-conf change (minconf=0) std::string type; // "shielded" or "transparent" bool has_spending_key = true; // false for view-only (imported via z_importviewingkey) // For display std::string label; // CONFIRMED balance (minconf>=1) — what z_sendmany can actually spend now. Kept last so positional // brace-init of the leading fields (used in tests) still compiles. double spendableBalance = 0.0; // Derived bool isZAddr() const { return !address.empty() && address[0] == 'z'; } bool isShielded() const { return type == "shielded"; } bool isSpendable() const { return has_spending_key; } }; std::vector sortedSpendableAddressIndices(const std::vector& addresses, bool requirePositiveBalance = true); int bestSpendableAddressIndex(const std::vector& addresses); // Index of the address whose string equals `address` (exact match), or -1 (empty string / not listed). int addressIndexByString(const std::vector& addresses, const std::string& address); // The Send tab's source-address resolution, pure. `current` (s_from_address) is the single source of truth: // the one-shot auto-pick (highest confirmed balance) only fills an EMPTY source, and never replaces one the // user - or another tab's "Send from this address" - already set. A source that is not in the list (the list // can be transiently short) is kept as is with index -1; it must NOT trigger a re-pick. struct SourceSelection { std::string address; // what s_from_address must hold afterwards int index = -1; // its row in `addresses`, -1 if not listed bool autoLatched = false; // new value of the one-shot latch }; SourceSelection resolveSourceSelection(const std::vector& addresses, const std::string& current, bool autoLatched, bool connected); /** * @brief Represents a wallet transaction */ struct TransactionInfo { std::string txid; std::string type; // "send", "receive", "mined" double amount = 0.0; int64_t timestamp = 0; // Unix timestamp int confirmations = 0; std::string address; // destination (send) or source (receive) std::string from_address; // source address for sends std::string memo; // Computed fields std::string getTimeString() const; std::string getTypeDisplay() const; bool isConfirmed() const { return confirmations >= 1; } bool isMature() const { return confirmations >= 100; } // gettransaction reports confirmations < 0 for a tx conflicted with the chain (double-spent / reorged out). bool isConflicted() const { return confirmations < 0; } // Unconfirmed for longer than maxAgeSec since the wallet first saw it: most likely dropped from the // mempool or expired. A display heuristic only (timestamp 0 = unknown -> never flagged). bool isStalePending(int64_t now, int64_t maxAgeSec = 3600) const { return confirmations == 0 && timestamp > 0 && now - timestamp > maxAgeSec; } }; // Exact field-wise equality: decides whether a refreshed list really differs. `amount` is compared // BIT-exactly (like the old History fingerprint): -0.0 and +0.0 render differently ("+-0.0" vs "+0.0"). // Must list EVERY TransactionInfo field (tests mutate each one). inline bool sameTransactionInfo(const TransactionInfo& a, const TransactionInfo& b) { return a.timestamp == b.timestamp && a.confirmations == b.confirmations && std::memcmp(&a.amount, &b.amount, sizeof(double)) == 0 && a.txid == b.txid && a.type == b.type && a.address == b.address && a.from_address == b.from_address && a.memo == b.memo; } // Same row except the confirmation COUNT: the pending (confirmations==0) state must still match because it drives the // History tab's pending-first sort and shield-leg merge. A delta for which every row passes this changes nothing the // tab's merged/sorted display list is built from, so it can patch confirmations in place instead of rebuilding. inline bool sameTransactionLayout(const TransactionInfo& a, const TransactionInfo& b) { return a.timestamp == b.timestamp && (a.confirmations == 0) == (b.confirmations == 0) && std::memcmp(&a.amount, &b.amount, sizeof(double)) == 0 && a.txid == b.txid && a.type == b.type && a.address == b.address && a.from_address == b.from_address && a.memo == b.memo; } inline bool sameTransactionList(const std::vector& a, const std::vector& b) { if (a.size() != b.size()) return false; for (std::size_t i = 0; i < a.size(); ++i) if (!sameTransactionInfo(a[i], b[i])) return false; return true; } /** * @brief Represents a connected peer */ struct PeerInfo { int id = 0; std::string addr; std::string subver; std::string services; int version = 0; int64_t conntime = 0; int banscore = 0; double pingtime = 0.0; int64_t bytessent = 0; int64_t bytesrecv = 0; int startingheight = 0; int synced_headers = 0; int synced_blocks = 0; bool inbound = false; // TLS info std::string tls_cipher; bool tls_verified = false; std::string getConnectionTime() const; }; /** * @brief Represents a banned peer */ struct BannedPeer { std::string address; std::string subnet; int64_t banned_until = 0; std::string getBannedUntilString() const; }; /** * @brief Mining statistics */ struct MiningInfo { bool generate = false; int genproclimit = 0; // -1 means max CPUs double localHashrate = 0.0; // Local hashrate (H/s) from getlocalsolps RPC (RandomX) double networkHashrate = 0.0; // Network hashrate (H/s) int blocks = 0; double difficulty = 0.0; std::string chain; double daemon_memory_mb = 0.0; // Daemon process RSS in MB // History for chart std::vector hashrate_history; // Last N samples std::vector hashrate_history_t; // wall-clock stamp per sample (lockstep with hashrate_history) static constexpr int MAX_HISTORY = 300; // 5 minutes at ~1s intervals // Recent daemon log lines for the mining log panel std::vector log_lines; }; /** * @brief Blockchain synchronization info */ struct SyncInfo { int blocks = 0; int headers = 0; double verification_progress = 0.0; bool syncing = false; std::string best_blockhash; std::string lite_error; // lite: last sync failure text, "" = none // Rescan state (detected from daemon output) bool rescanning = false; float rescan_progress = 0.0f; // 0.0 - 1.0 std::string rescan_status; // e.g. "Rescanning... 25%" // Sapling note witness rebuild — a distinct, often-long phase after a rescan/zap. The daemon // reports it in TWO sub-phases with different signals, so we track which is active: // 1 = initial pass ("Setting Initial Sapling Witness for tx , of ") — progress // is distinct-txs-witnessed / N (the bounces, so it can't be used directly). // 2 = witness-cache walk ("Building Witnesses for block complete, remaining") // — progress derived from how far "remaining" has fallen from its per-phase peak. // The two are sequential with different scales, so progress is NOT carried across the boundary // (that would pin the bar at the initial pass's ~100% through the whole cache walk). bool building_witnesses = false; int witness_phase = 0; // 0 none, 1 initial-witness pass, 2 witness-cache walk float witness_progress = 0.0f; // 0.0 - 1.0, within the current sub-phase int witness_remaining = 0; // blocks left in the cache walk (0 if unknown / phase 1) // True only when the daemon reports a positive peer-derived tip (longestchain > 0). A node with // no peers reports longestchain == 0, so `blocks` is then only the LOCAL best height and cannot be // trusted as "at the network tip". isSynced() gates on this so a peerless / cold-started node is // never reported synced (which would enable Send against a stale balance). (audit: peerless-synced) bool tip_known = false; bool isSynced() const { return tip_known && !syncing && blocks > 0 && blocks >= headers - 2; } // Send precondition: the wallet is caught up to a KNOWN peer-derived tip. A fresh (re)connect or a // peerless node has tip_known == false, so its local height can't be trusted as the network tip. // The lite backend never populates tip_known, so lite is exempt (it has its own sync gate). bool sendTipReady(bool liteBuild) const { return liteBuild || (tip_known && !syncing && blocks > 0); } }; /** * @brief Market/price information */ struct MarketInfo { double price_usd = 0.0; double price_btc = 0.0; double volume_24h = 0.0; double change_24h = 0.0; double market_cap = 0.0; std::string last_updated; std::chrono::steady_clock::time_point last_fetch_time{}; bool price_loading = false; std::string price_error; // Live in-session price history: ~1 sample/minute, capped at MAX_HISTORY samples. // Backs the main chart and the "minute" portfolio-sparkline interval. std::vector price_history; static constexpr int MAX_HISTORY = 24; // 24 samples (~24 minutes at the 60s refresh) // Historical USD price series fetched from CoinGecko market_chart, so the portfolio-group // sparklines can show real hour/day/week/month trends instead of resampling the ~24-minute // in-session buffer. Timestamped (unix seconds), oldest->newest; empty until the first fetch. // Refreshed on a slow cadence (~30 min) since historical data moves slowly. std::vector> price_chart_intraday; // ~24h @ 5-minute granularity std::vector> price_chart_daily; // ~1yr @ daily granularity std::chrono::steady_clock::time_point chart_last_fetch_time{}; bool chart_loaded = false; // Per-EXCHANGE candle series for the SELECTED pair, fetched from that venue's own API (see // data/exchange_candles.h). When exchange_chart_active is true the Market chart draws these instead // of the CoinGecko cross-exchange aggregate above; it's set false while switching pairs or when the // selected venue has no adapter / its fetch failed, so the chart gracefully falls back to aggregate. std::vector> exchange_chart_intraday; std::vector> exchange_chart_daily; bool exchange_chart_active = false; // Full OHLC for the same per-exchange candles, backing the candlestick rendering (the aggregate // CoinGecko series is close-only, so it stays a line). Parallel to exchange_chart_* above. std::vector exchange_ohlc_intraday; std::vector exchange_ohlc_daily; // Exchanges/pairs fetched live from CoinGecko (empty until fetched; the Market tab // falls back to data::getExchangeRegistry() while empty). std::vector exchanges; }; /** * @brief Pool mining state (from xmrig HTTP API) */ struct PoolMiningState { bool pool_mode = false; // UI toggle: solo vs pool bool xmrig_running = false; std::string pool_url; std::string algo; std::string version; // running miner's version (from its API) double hashrate_10s = 0; double hashrate_60s = 0; double hashrate_15m = 0; int64_t accepted = 0; int64_t rejected = 0; int64_t uptime_sec = 0; double pool_diff = 0; bool connected = false; std::time_t running_since = 0; // wall-clock the miner was first seen running this session (0 = not running) std::string last_error; // newest xmrig log line that looks like a connection error ("" = none) // Memory/thread usage (bytes for memory) int64_t memory_used = 0; int threads_active = 0; // Pool-side hashrate (from pool stats API) double pool_hashrate = 0; // Hashrate history for chart (mirrors MiningInfo::hashrate_history) std::vector hashrate_history; std::vector hashrate_history_t; // wall-clock stamp per sample (lockstep with hashrate_history) static constexpr int MAX_HISTORY = 60; // ~2 min at the ~2s pool poll // Recent log lines for the log panel std::vector log_lines; }; /** * @brief Complete wallet state - all data fetched from daemon */ struct WalletState { // Connection bool connected = false; bool warming_up = false; // daemon reachable but in RPC warmup (error -28) // True when the daemon is up/launching but not yet answering RPC (e.g. the connect probe // times out because the node is loading the block index). Distinct from warming_up, which // needs a JSON-RPC -28 reply; here getinfo never returns, so we infer the state from the // daemon's launch state + its own console output. Drives the same loading overlay so the // user sees WHAT the node is doing instead of a bare "Connection failed". bool daemon_initializing = false; std::string warmup_status; // user-friendly title, e.g. "Processing blocks..." std::string warmup_description; // subtitle explaining the stage int daemon_version = 0; std::string daemon_subversion; int protocol_version = 0; int p2p_port = 0; int longestchain = 0; int notarized = 0; // Sync status SyncInfo sync; // Balances (named to match UI usage). These are the DISPLAY totals — minconf=0, so they include the // user's own pending change and don't crater during an unconfirmed send. double privateBalance = 0.0; // shielded balance (display, incl. pending change) double transparentBalance = 0.0; double totalBalance = 0.0; double unconfirmedBalance = 0.0; // = totalBalance - spendableTotalBalance (the pending portion) // CONFIRMED / spendable totals (minconf>=1) — what can actually be sent right now. z_sendmany runs at // minconf=1, so the Send form / Max / spend validation must size off these, never the display totals. double spendablePrivateBalance = 0.0; double spendableTransparentBalance = 0.0; double spendableTotalBalance = 0.0; // Aliases for backward compatibility double& shielded_balance = privateBalance; double& transparent_balance = transparentBalance; double& total_balance = totalBalance; double& unconfirmed_balance = unconfirmedBalance; // Addresses - combined list for UI convenience std::vector addresses; // Also keep separate lists for legacy code std::vector z_addresses; std::vector t_addresses; // Transactions std::vector transactions; // Peers std::vector peers; std::vector bannedPeers; // Aliases for banned_peers std::vector& banned_peers = bannedPeers; // Mining MiningInfo mining; // Pool mining (xmrig) PoolMiningState pool_mining; // Market MarketInfo market; // Wallet encryption state (populated from getwalletinfo) bool encrypted = false; // true if wallet has ever been encrypted bool locked = false; // true if encrypted && unlocked_until <= now int64_t unlocked_until = 0; // 0 = locked, >0 = unix timestamp when auto-lock fires bool encryption_state_known = false; // true once first getwalletinfo response processed bool isEncrypted() const { return encrypted; } bool isLocked() const { return encrypted && locked; } bool isUnlocked() const { return encrypted && !locked; } // Timestamps for refresh logic int64_t last_balance_update = 0; // A Core poll that skipped the balance confirmed it is still current (nothing it depends on changed): // the staleness badge counts from the later of the two. int64_t balance_verified_at = 0; // The last balance call that was sent failed (or timed out): the staleness badge then shows at its usual // thresholds even while the scan budget is holding the next one. bool balance_last_attempt_failed = false; int wallet_tx_count = -1; // getwalletinfo's txcount; -1 = unknown int64_t last_address_update = 0; // set when an address-list refresh applies; 0 = never loaded yet int64_t last_tx_update = 0; // Change counter for `transactions` (main thread only). Starts at 1 so a UI cache's 0 sentinel never matches; // only ever increases (clear() bumps, never resets) so a wallet switch can't alias an old cache key. EVERY // write to `transactions` (replace, insert/erase, in-place field edit) must use the helpers below or call // touchTransactions() afterwards. UI caches (History tab, ...) key on this, NOT on last_tx_update (a // wall-clock refresh stamp that lite never sets) nor App::tx_dirty_generation_ (a refresh-needed flag). std::uint64_t tx_revision = 1; // Bumps with tx_revision EXCEPT for a pure confirmation-count change (see sameTransactionLayout): the History tab's // O(N) merge+sort cache keys on this and just patches confirmations when only tx_revision moved. Every writer that // is not setTransactionsIfChanged goes through touchTransactions()/replaceTransactions() and bumps both (safe default). std::uint64_t tx_structure_revision = 1; void touchTransactions() { ++tx_revision; ++tx_structure_revision; } void replaceTransactions(std::vector&& next) { transactions = std::move(next); touchTransactions(); } // Replace only when the content differs; returns true (and bumps) if it did. Keeps the old list when identical. bool setTransactionsIfChanged(std::vector&& next) { if (sameTransactionList(transactions, next)) return false; bool layoutSame = transactions.size() == next.size(); for (std::size_t i = 0; layoutSame && i < next.size(); ++i) layoutSame = sameTransactionLayout(transactions[i], next[i]); transactions = std::move(next); ++tx_revision; if (!layoutSame) ++tx_structure_revision; return true; } int64_t last_peer_update = 0; int64_t last_mining_update = 0; // Helper methods int getAddressCount() const { return addresses.size(); } double getBalanceUSD() const { return totalBalance * market.price_usd; } void clear() { connected = false; warming_up = false; daemon_initializing = false; warmup_status.clear(); warmup_description.clear(); daemon_version = 0; daemon_subversion.clear(); protocol_version = 0; p2p_port = 0; longestchain = 0; notarized = 0; sync = SyncInfo{}; privateBalance = transparentBalance = totalBalance = 0.0; unconfirmedBalance = 0.0; spendablePrivateBalance = spendableTransparentBalance = spendableTotalBalance = 0.0; encrypted = false; locked = false; unlocked_until = 0; encryption_state_known = false; addresses.clear(); z_addresses.clear(); t_addresses.clear(); transactions.clear(); touchTransactions(); // never reset: a post-switch revision must differ from every cached key peers.clear(); bannedPeers.clear(); // W6-1: reset node-level mining state too — the daemon restarts on a wallet switch (mining // stops), so leaving the previous wallet's hashrate/blocks would show stale mining stats. mining = MiningInfo{}; pool_mining = PoolMiningState{}; // After a disconnect / wallet switch nothing is freshly known, so drop the "last successful // refresh" stamps. Otherwise the pre-teardown time survives and, on reconnect, the staleness // badge (and any "updated X ago" reader) briefly reports it as current until the first refresh // re-stamps it. All readers treat 0 as "never" (formatTimeAgoShort/timeAgo return ""). last_balance_update = last_address_update = last_tx_update = last_peer_update = last_mining_update = 0; balance_verified_at = 0; balance_last_attempt_failed = false; wallet_tx_count = -1; } // Rebuild combined addresses list from z/t lists void rebuildAddressList() { addresses.clear(); addresses.reserve(z_addresses.size() + t_addresses.size()); for (const auto& addr : z_addresses) { addresses.push_back(addr); } for (const auto& addr : t_addresses) { addresses.push_back(addr); } } }; } // namespace dragonx