Files
ObsidianDragon/src/data/wallet_state.h
DanS ea26c0cbbb fix(balance): stop the displayed balance cratering during a pending shielded send
Sending a small amount from an address holding a large balance made the displayed
balance collapse to ~0 until the tx confirmed. A shielded spend consumes the whole
source note; the change returns as a 0-confirmation note, and every balance query
used the default minconf=1 — so the spent note dropped out and the change wasn't
counted yet.

Split every balance into two views:

- DISPLAY (balance / privateBalance / transparentBalance / totalBalance) — now
  queried at minconf=0, so it INCLUDES the user's own pending change and no longer
  craters. This is what the Overview, balance tab, market portfolio and receive
  tab show. unconfirmedBalance is now populated (= total - spendable).
- SPENDABLE (new spendableBalance / spendable*Balance) — confirmed (minconf>=1),
  what z_sendmany (run at minconf=1) can actually spend. The Send form's available/
  Max/validation, the from-address selection, the drag-to-transfer dialog cap, the
  chat pay-from and the auto-shield gate all size off these, so they never offer
  0-conf change the daemon would reject.

Implementation: a single z_listunspent(0)/listunspent(0), partitioned per-note by
"confirmations">=1; z_gettotalbalance called at minconf 0 (display) and 1
(spendable); the z_getbalance fallback queries both. applyPendingSendDelta (the
optimistic post-send debit) now touches ONLY the spendable fields — debiting the
display too would re-crater it on top of the honest minconf=0 RPC. Lite mirrors
spendableBalance = balance (its per-address balance is already confirmed) so lite
sends aren't zeroed. The confirmed-only gates (seed-migration/sweep z_gettotalbalance,
sweep z_getbalance(addr,1), z_sendmany's minconf arg) are untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-13 16:40:14 -05:00

374 lines
14 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 <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);
/**
* @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; }
};
/**
* @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
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;
// 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)
bool isSynced() const { return !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;
static constexpr int MAX_HISTORY = 60; // 5 minutes at ~5s intervals
// 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;
int64_t last_tx_update = 0;
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();
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_tx_update = last_peer_update = last_mining_update = 0;
}
// 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