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
+44 -14
View File
@@ -37,16 +37,27 @@ void applyBalancesFromUnspent(std::vector<AddressInfo>& addresses, const json& u
{
if (!unspent.is_array()) return;
std::map<std::string, double> balances;
// Partition each note/utxo by its per-entry "confirmations": `total` (minconf=0 — DISPLAY, includes
// the user's own pending 0-conf change) vs `spendable` (confirmations>=1 — what z_sendmany, run at
// minconf=1, can actually spend). This lets a single z_listunspent(0)/listunspent(0) feed both.
std::map<std::string, double> total;
std::map<std::string, double> spendable;
for (const auto& output : unspent) {
auto address = readOptional<std::string>(output, "address");
auto amount = readOptional<double>(output, "amount");
if (address && amount) balances[*address] += *amount;
auto amount = readOptional<double>(output, "amount");
if (!address || !amount) continue;
total[*address] += *amount;
auto conf = readOptional<int>(output, "confirmations");
if (conf && *conf >= 1) spendable[*address] += *amount;
}
// The address lists are rebuilt fresh (default 0) each refresh, so hard-set both — an address with no
// notes in this set is 0, and spendableBalance is always a subset sum of balance.
for (auto& info : addresses) {
auto balance = balances.find(info.address);
if (balance != balances.end()) info.balance = balance->second;
auto t = total.find(info.address);
auto s = spendable.find(info.address);
info.balance = (t != total.end()) ? t->second : 0.0;
info.spendableBalance = (s != spendable.end()) ? s->second : 0.0;
}
}
@@ -249,7 +260,7 @@ NetworkRefreshService::ConnectionInitResult NetworkRefreshService::collectConnec
}
NetworkRefreshService::CoreRefreshResult NetworkRefreshService::parseCoreRefreshResult(
const json& totalBalance, bool balanceOk, const json& blockInfo, bool blockOk)
const json& totalBalance, const json& spendableBalance, bool balanceOk, const json& blockInfo, bool blockOk)
{
CoreRefreshResult result;
result.balanceOk = balanceOk && totalBalance.is_object();
@@ -258,6 +269,11 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::parseCoreRefresh
result.transparentBalance = readBalanceString(totalBalance, "transparent");
result.totalBalance = readBalanceString(totalBalance, "total");
}
if (spendableBalance.is_object()) { // confirmed totals (minconf=1); left unset on old daemons
result.spendableShieldedBalance = readBalanceString(spendableBalance, "private");
result.spendableTransparentBalance = readBalanceString(spendableBalance, "transparent");
result.spendableTotalBalance = readBalanceString(spendableBalance, "total");
}
result.blockchainOk = blockOk && blockInfo.is_object();
if (result.blockchainOk) {
@@ -274,17 +290,21 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::parseCoreRefresh
NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefreshResult(RefreshRpcGateway& rpc, bool includeBalance)
{
json totalBalance;
json spendableBalance;
json blockInfo;
bool balanceOk = false;
bool blockOk = false;
if (includeBalance) {
try {
totalBalance = rpc.call("z_gettotalbalance", json::array());
try { // DISPLAY total: minconf=0 — includes the user's own pending change so it doesn't crater
totalBalance = rpc.call("z_gettotalbalance", json::array({0}));
balanceOk = true;
} catch (const std::exception& e) {
DEBUG_LOGF("Balance error: %s\n", e.what());
}
try { // SPENDABLE total: minconf=1 (confirmed). If absent, spendable degrades to display in apply.
spendableBalance = rpc.call("z_gettotalbalance", json::array({1}));
} catch (...) {}
}
try {
@@ -294,7 +314,7 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefre
DEBUG_LOGF("BlockchainInfo error: %s\n", e.what());
}
return parseCoreRefreshResult(totalBalance, balanceOk, blockInfo, blockOk);
return parseCoreRefreshResult(totalBalance, spendableBalance, balanceOk, blockInfo, blockOk);
}
NetworkRefreshService::MiningRefreshResult NetworkRefreshService::parseMiningRefreshResult(
@@ -611,15 +631,19 @@ NetworkRefreshService::AddressRefreshResult NetworkRefreshService::collectAddres
}
try {
json unspent = rpc.call("z_listunspent", json::array());
json unspent = rpc.call("z_listunspent", json::array({0, 9999999, false})); // minconf=0 → include 0-conf change
applyShieldedBalancesFromUnspent(result.shieldedAddresses, unspent);
} catch (const std::exception& e) {
DEBUG_LOGF("z_listunspent unavailable (%s), falling back to z_getbalance\n", e.what());
for (auto& info : result.shieldedAddresses) {
try {
json balance = rpc.call("z_getbalance", json::array({info.address}));
if (!balance.is_null()) info.balance = balance.get<double>();
try { // display total (minconf=0, includes pending change)
json total = rpc.call("z_getbalance", json::array({info.address, 0}));
if (!total.is_null()) info.balance = total.get<double>();
} catch (...) {}
try { // spendable (minconf=1); degrade to the display value on old daemons
json conf = rpc.call("z_getbalance", json::array({info.address, 1}));
info.spendableBalance = (!conf.is_null()) ? conf.get<double>() : info.balance;
} catch (...) { info.spendableBalance = info.balance; }
}
}
@@ -631,7 +655,7 @@ NetworkRefreshService::AddressRefreshResult NetworkRefreshService::collectAddres
}
try {
json unspent = rpc.call("listunspent", json::array());
json unspent = rpc.call("listunspent", json::array({0})); // minconf=0 → include 0-conf change
applyTransparentBalancesFromUnspent(result.transparentAddresses, unspent);
} catch (const std::exception& e) {
DEBUG_LOGF("listunspent error: %s\n", e.what());
@@ -1197,6 +1221,12 @@ void NetworkRefreshService::applyCoreRefreshResult(WalletState& state,
if (result.shieldedBalance) state.shielded_balance = *result.shieldedBalance;
if (result.transparentBalance) state.transparent_balance = *result.transparentBalance;
if (result.totalBalance) state.total_balance = *result.totalBalance;
// Confirmed/spendable totals; if the minconf=1 call was unavailable (old daemon) degrade to the
// display value so nothing is *over*-reported as spendable (z_sendmany stays the final gate).
state.spendablePrivateBalance = result.spendableShieldedBalance.value_or(state.privateBalance);
state.spendableTransparentBalance = result.spendableTransparentBalance.value_or(state.transparentBalance);
state.spendableTotalBalance = result.spendableTotalBalance.value_or(state.totalBalance);
state.unconfirmedBalance = std::max(0.0, state.totalBalance - state.spendableTotalBalance);
state.last_balance_update = updatedAt;
}