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
co-authored by Claude Opus 4.8
parent 13b225d8f5
commit ea26c0cbbb
10 changed files with 115 additions and 50 deletions
+3 -3
View File
@@ -324,12 +324,12 @@ void RenderSharedAddressList(App* app, float listH, float availW,
s_dragIdx < (int)rows.size()) {
const auto& srcRow = rows[s_dragIdx];
const auto& dstRow = rows[s_dropTargetIdx];
if (srcRow.info->balance > 1e-9) {
if (srcRow.info->spendableBalance > 1e-9) { // only offer a transfer of CONFIRMED funds
AddressTransferDialog::TransferInfo ti;
ti.fromAddr = srcRow.info->address;
ti.toAddr = dstRow.info->address;
ti.fromBalance = srcRow.info->balance;
ti.toBalance = dstRow.info->balance;
ti.fromBalance = srcRow.info->spendableBalance; // spend cap — z_sendmany runs at minconf=1
ti.toBalance = dstRow.info->balance; // destination display only
ti.fromIsZ = srcRow.isZ;
ti.toIsZ = dstRow.isZ;
AddressTransferDialog::show(app, ti);
+7 -5
View File
@@ -137,13 +137,15 @@ static double GetAvailableBalance(App* app) {
// not a stored list index. The index desyncs from s_from_address after an address-list
// refresh, and is left at -1 when the source is chosen from another tab ("Send from this
// address") — which previously made the sufficiency check see 0 and block a valid send.
// CONFIRMED balance only — this is the spend ceiling (Max button, slider, validation, pre-broadcast
// re-check). z_sendmany runs at minconf=1, so offering 0-conf change here would just make the send fail.
if (s_from_address[0] != '\0') {
for (const auto& a : state.addresses) {
if (a.address == s_from_address) return a.balance;
if (a.address == s_from_address) return a.spendableBalance;
}
}
if (s_selected_from_idx >= 0 && s_selected_from_idx < static_cast<int>(state.addresses.size())) {
return state.addresses[s_selected_from_idx].balance;
return state.addresses[s_selected_from_idx].spendableBalance;
}
return 0.0;
}
@@ -252,7 +254,7 @@ static void RenderSourceDropdown(App* app, float width) {
std::string trunc = util::truncateMiddle(addr.address,
static_cast<int>(std::max(S.drawElement("tabs.send", "addr-preview-trunc-min").size, width / S.drawElement("tabs.send", "addr-preview-trunc-divisor").size)));
snprintf(buf, sizeof(buf), "%s %s — %.8f %s",
tag, trunc.c_str(), addr.balance, DRAGONX_TICKER);
tag, trunc.c_str(), addr.spendableBalance, DRAGONX_TICKER);
s_source_preview = buf;
} else {
s_source_preview = TR("send_select_source");
@@ -282,7 +284,7 @@ static void RenderSourceDropdown(App* app, float width) {
std::string trunc = util::truncateMiddle(addr.address, (int)addrTruncLen);
snprintf(buf, sizeof(buf), "%s %s — %.8f %s",
tag, trunc.c_str(), addr.balance, DRAGONX_TICKER);
tag, trunc.c_str(), addr.spendableBalance, DRAGONX_TICKER);
ImGui::PushID(static_cast<int>(i));
if (ImGui::Selectable(buf, isCurrent)) {
@@ -292,7 +294,7 @@ static void RenderSourceDropdown(App* app, float width) {
}
if (ImGui::IsItemHovered()) {
material::Tooltip("%s\nBalance: %.8f %s",
addr.address.c_str(), addr.balance, DRAGONX_TICKER);
addr.address.c_str(), addr.spendableBalance, DRAGONX_TICKER);
}
ImGui::PopID();
}