The hashrate sparkline guessed the age of its oldest sample from the sample count, labelling the left edge "5m ago", "1m ago" or "start" regardless of how fast samples actually arrived. Solo and pool samples come in at different intervals, and a slow or stalled poll made those labels wrong. - Each hashrate sample now carries a wall-clock timestamp, kept in lockstep with the value as the history fills and rolls over, for both the solo and pool histories. - The chart's left-edge label is derived from the oldest sample's actual timestamp, falling back to "start" only when no stamp is available. Tests: a mining refresh records a timestamp per sample, and the timestamp history stays the same length as the value history once the history is capped. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
441 lines
18 KiB
C++
441 lines
18 KiB
C++
// DragonX Wallet - ImGui Edition
|
|
// Copyright 2024-2026 The Hush Developers
|
|
// Released under the GPLv3
|
|
|
|
#pragma once
|
|
|
|
#include <string>
|
|
#include <vector>
|
|
#include <cstdint>
|
|
#include <chrono>
|
|
#include <ctime>
|
|
#include <cstring>
|
|
#include <utility>
|
|
|
|
#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<size_t> sortedSpendableAddressIndices(const std::vector<AddressInfo>& addresses,
|
|
bool requirePositiveBalance = true);
|
|
int bestSpendableAddressIndex(const std::vector<AddressInfo>& addresses);
|
|
// Index of the address whose string equals `address` (exact match), or -1 (empty string / not listed).
|
|
int addressIndexByString(const std::vector<AddressInfo>& 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<AddressInfo>& 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; }
|
|
};
|
|
|
|
// 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;
|
|
}
|
|
inline bool sameTransactionList(const std::vector<TransactionInfo>& a, const std::vector<TransactionInfo>& 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<double> hashrate_history; // Last N samples
|
|
std::vector<std::time_t> 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<std::string> 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 <hash>, <i> of <N>") — progress
|
|
// is distinct-txs-witnessed / N (the <i> bounces, so it can't be used directly).
|
|
// 2 = witness-cache walk ("Building Witnesses for block <h> <frac> complete, <n> 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; }
|
|
};
|
|
|
|
/**
|
|
* @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<double> 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<std::pair<std::time_t, double>> price_chart_intraday; // ~24h @ 5-minute granularity
|
|
std::vector<std::pair<std::time_t, double>> 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<std::pair<std::time_t, double>> exchange_chart_intraday;
|
|
std::vector<std::pair<std::time_t, double>> 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<data::Candle> exchange_ohlc_intraday;
|
|
std::vector<data::Candle> exchange_ohlc_daily;
|
|
|
|
// Exchanges/pairs fetched live from CoinGecko (empty until fetched; the Market tab
|
|
// falls back to data::getExchangeRegistry() while empty).
|
|
std::vector<data::ExchangeInfo> 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;
|
|
|
|
// 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<double> hashrate_history;
|
|
std::vector<std::time_t> 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<std::string> 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<AddressInfo> addresses;
|
|
|
|
// Also keep separate lists for legacy code
|
|
std::vector<AddressInfo> z_addresses;
|
|
std::vector<AddressInfo> t_addresses;
|
|
|
|
// Transactions
|
|
std::vector<TransactionInfo> transactions;
|
|
|
|
// Peers
|
|
std::vector<PeerInfo> peers;
|
|
std::vector<BannedPeer> bannedPeers;
|
|
|
|
// Aliases for banned_peers
|
|
std::vector<BannedPeer>& 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;
|
|
void touchTransactions() { ++tx_revision; }
|
|
void replaceTransactions(std::vector<TransactionInfo>&& 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<TransactionInfo>&& next) {
|
|
if (sameTransactionList(transactions, next)) return false;
|
|
replaceTransactions(std::move(next));
|
|
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
|