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>
This commit is contained in:
2026-08-13 16:40:14 -05:00
parent 13b225d8f5
commit ea26c0cbbb
10 changed files with 115 additions and 50 deletions

View File

@@ -21,13 +21,17 @@ namespace dragonx {
*/
struct AddressInfo {
std::string address;
double balance = 0.0;
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"; }
@@ -252,12 +256,18 @@ struct WalletState {
// Sync status
SyncInfo sync;
// Balances (named to match UI usage)
double privateBalance = 0.0; // shielded balance
// 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;
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;
@@ -325,6 +335,7 @@ struct WalletState {
sync = SyncInfo{};
privateBalance = transparentBalance = totalBalance = 0.0;
unconfirmedBalance = 0.0;
spendablePrivateBalance = spendableTransparentBalance = spendableTotalBalance = 0.0;
encrypted = false;
locked = false;
unlocked_until = 0;