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

@@ -1012,10 +1012,13 @@ void App::applyPendingSendDelta(const std::string& fromAddress, double signedAmo
// For a debit (signedAmount < 0) this clamps at >=0 exactly as the debit sites did. For a // For a debit (signedAmount < 0) this clamps at >=0 exactly as the debit sites did. For a
// restore (signedAmount > 0) the clamp is a no-op — max(0, bal+amt) == bal+amt when bal,amt>=0 — // restore (signedAmount > 0) the clamp is a no-op — max(0, bal+amt) == bal+amt when bal,amt>=0 —
// so the single clamped form reproduces the original restore's unclamped bal += amt. // so the single clamped form reproduces the original restore's unclamped bal += amt.
// Debit/restore the SPENDABLE view ONLY. The DISPLAY balances now come from an honest minconf=0 RPC
// that already reflects the outgoing spend AND the incoming 0-conf change, so debiting them here too
// would re-crater the display. This delta only lowers what can be re-spent before the change confirms.
auto applyToAddress = [&](std::vector<AddressInfo>& addresses) { auto applyToAddress = [&](std::vector<AddressInfo>& addresses) {
for (auto& address : addresses) { for (auto& address : addresses) {
if (address.address == fromAddress) { if (address.address == fromAddress) {
address.balance = std::max(0.0, address.balance + signedAmount); address.spendableBalance = std::max(0.0, address.spendableBalance + signedAmount);
return true; return true;
} }
} }
@@ -1024,11 +1027,12 @@ void App::applyPendingSendDelta(const std::string& fromAddress, double signedAmo
if (!applyToAddress(state_.z_addresses)) applyToAddress(state_.t_addresses); if (!applyToAddress(state_.z_addresses)) applyToAddress(state_.t_addresses);
if (includeAggregates) { if (includeAggregates) {
if (!fromAddress.empty() && fromAddress[0] == 'z') { if (!fromAddress.empty() && fromAddress[0] == 'z') {
state_.privateBalance = std::max(0.0, state_.privateBalance + signedAmount); state_.spendablePrivateBalance = std::max(0.0, state_.spendablePrivateBalance + signedAmount);
} else { } else {
state_.transparentBalance = std::max(0.0, state_.transparentBalance + signedAmount); state_.spendableTransparentBalance = std::max(0.0, state_.spendableTransparentBalance + signedAmount);
} }
state_.totalBalance = std::max(0.0, state_.totalBalance + signedAmount); state_.spendableTotalBalance = std::max(0.0, state_.spendableTotalBalance + signedAmount);
state_.unconfirmedBalance = std::max(0.0, state_.totalBalance - state_.spendableTotalBalance);
} }
} }
@@ -1685,7 +1689,7 @@ void App::refreshCoreData()
// Auto-shield transparent funds if enabled // Auto-shield transparent funds if enabled
if (result.balanceOk && settings_ && settings_->getAutoShield() && if (result.balanceOk && settings_ && settings_->getAutoShield() &&
state_.transparent_balance > 0.0001 && !state_.sync.syncing && state_.spendableTransparentBalance > 0.0001 && !state_.sync.syncing &&
!auto_shield_pending_.exchange(true)) { !auto_shield_pending_.exchange(true)) {
std::string targetZAddr; std::string targetZAddr;
for (const auto& addr : state_.addresses) { for (const auto& addr : state_.addresses) {
@@ -1696,7 +1700,7 @@ void App::refreshCoreData()
} }
if (!targetZAddr.empty() && worker_) { if (!targetZAddr.empty() && worker_) {
DEBUG_LOGF("[AutoShield] Shielding %.8f DRGX to %s\n", DEBUG_LOGF("[AutoShield] Shielding %.8f DRGX to %s\n",
state_.transparent_balance, targetZAddr.c_str()); state_.spendableTransparentBalance, targetZAddr.c_str());
// Use the user-configured fee, formatted fixed-decimal so the daemon's // Use the user-configured fee, formatted fixed-decimal so the daemon's
// ParseFixedPoint accepts it (a small double would serialize to "5e-05"). // ParseFixedPoint accepts it (a small double would serialize to "5e-05").
const std::string feeStr = const std::string feeStr =
@@ -3046,14 +3050,14 @@ std::string App::chatPayFromZaddr(double fee) const
std::string reply; std::string reply;
if (settings_) reply = settings_->getChatReplyZaddr(); if (settings_) reply = settings_->getChatReplyZaddr();
for (const auto& a : state_.z_addresses) for (const auto& a : state_.z_addresses)
if (a.address == reply && a.has_spending_key && a.balance >= fee) return reply; if (a.address == reply && a.has_spending_key && a.spendableBalance >= fee) return reply;
std::string best; std::string best;
double bestBal = -1.0; double bestBal = -1.0;
for (const auto& a : state_.z_addresses) for (const auto& a : state_.z_addresses)
if (a.has_spending_key && !a.address.empty() && a.balance >= fee && a.balance > bestBal) { if (a.has_spending_key && !a.address.empty() && a.spendableBalance >= fee && a.spendableBalance > bestBal) {
best = a.address; best = a.address;
bestBal = a.balance; bestBal = a.spendableBalance;
} }
return best; // empty → no z-address can cover the fee return best; // empty → no z-address can cover the fee
} }

View File

@@ -170,10 +170,10 @@ void App::installDemoWalletData()
} }
auto zaddr = [](const char* a, double bal, const char* label) { auto zaddr = [](const char* a, double bal, const char* label) {
AddressInfo i; i.address = a; i.balance = bal; i.type = "shielded"; i.label = label; return i; AddressInfo i; i.address = a; i.balance = bal; i.spendableBalance = bal; i.type = "shielded"; i.label = label; return i;
}; };
auto taddr = [](const char* a, double bal, const char* label) { auto taddr = [](const char* a, double bal, const char* label) {
AddressInfo i; i.address = a; i.balance = bal; i.type = "transparent"; i.label = label; return i; AddressInfo i; i.address = a; i.balance = bal; i.spendableBalance = bal; i.type = "transparent"; i.label = label; return i;
}; };
state_.z_addresses = { state_.z_addresses = {
zaddr("zs1demoprimaryshieldedaddressforuisweep000000000000000000000000000", 12.0, "Savings"), zaddr("zs1demoprimaryshieldedaddressforuisweep000000000000000000000000000", 12.0, "Savings"),

View File

@@ -19,12 +19,13 @@ std::vector<size_t> sortedSpendableAddressIndices(const std::vector<AddressInfo>
for (size_t i = 0; i < addresses.size(); ++i) { for (size_t i = 0; i < addresses.size(); ++i) {
const auto& address = addresses[i]; const auto& address = addresses[i];
if (!address.isSpendable()) continue; if (!address.isSpendable()) continue;
if (requirePositiveBalance && address.balance <= 0.0) continue; // Rank/filter by the CONFIRMED balance — an address holding only 0-conf change can't be sent from.
if (requirePositiveBalance && address.spendableBalance <= 0.0) continue;
indices.push_back(i); indices.push_back(i);
} }
std::sort(indices.begin(), indices.end(), [&](size_t lhs, size_t rhs) { std::sort(indices.begin(), indices.end(), [&](size_t lhs, size_t rhs) {
return addresses[lhs].balance > addresses[rhs].balance; return addresses[lhs].spendableBalance > addresses[rhs].spendableBalance;
}); });
return indices; return indices;
} }
@@ -34,8 +35,8 @@ int bestSpendableAddressIndex(const std::vector<AddressInfo>& addresses)
int bestIndex = -1; int bestIndex = -1;
double bestBalance = 0.0; double bestBalance = 0.0;
for (size_t i = 0; i < addresses.size(); ++i) { for (size_t i = 0; i < addresses.size(); ++i) {
if (addresses[i].isSpendable() && addresses[i].balance > bestBalance) { if (addresses[i].isSpendable() && addresses[i].spendableBalance > bestBalance) {
bestBalance = addresses[i].balance; bestBalance = addresses[i].spendableBalance;
bestIndex = static_cast<int>(i); bestIndex = static_cast<int>(i);
} }
} }

View File

@@ -21,13 +21,17 @@ namespace dragonx {
*/ */
struct AddressInfo { struct AddressInfo {
std::string address; 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" std::string type; // "shielded" or "transparent"
bool has_spending_key = true; // false for view-only (imported via z_importviewingkey) bool has_spending_key = true; // false for view-only (imported via z_importviewingkey)
// For display // For display
std::string label; 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 // Derived
bool isZAddr() const { return !address.empty() && address[0] == 'z'; } bool isZAddr() const { return !address.empty() && address[0] == 'z'; }
bool isShielded() const { return type == "shielded"; } bool isShielded() const { return type == "shielded"; }
@@ -252,11 +256,17 @@ struct WalletState {
// Sync status // Sync status
SyncInfo sync; SyncInfo sync;
// Balances (named to match UI usage) // Balances (named to match UI usage). These are the DISPLAY totals — minconf=0, so they include the
double privateBalance = 0.0; // shielded balance // 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 transparentBalance = 0.0;
double totalBalance = 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 // Aliases for backward compatibility
double& shielded_balance = privateBalance; double& shielded_balance = privateBalance;
@@ -325,6 +335,7 @@ struct WalletState {
sync = SyncInfo{}; sync = SyncInfo{};
privateBalance = transparentBalance = totalBalance = 0.0; privateBalance = transparentBalance = totalBalance = 0.0;
unconfirmedBalance = 0.0; unconfirmedBalance = 0.0;
spendablePrivateBalance = spendableTransparentBalance = spendableTotalBalance = 0.0;
encrypted = false; encrypted = false;
locked = false; locked = false;
unlocked_until = 0; unlocked_until = 0;

View File

@@ -37,16 +37,27 @@ void applyBalancesFromUnspent(std::vector<AddressInfo>& addresses, const json& u
{ {
if (!unspent.is_array()) return; 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) { for (const auto& output : unspent) {
auto address = readOptional<std::string>(output, "address"); auto address = readOptional<std::string>(output, "address");
auto amount = readOptional<double>(output, "amount"); auto amount = readOptional<double>(output, "amount");
if (address && amount) balances[*address] += *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) { for (auto& info : addresses) {
auto balance = balances.find(info.address); auto t = total.find(info.address);
if (balance != balances.end()) info.balance = balance->second; 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( 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; CoreRefreshResult result;
result.balanceOk = balanceOk && totalBalance.is_object(); result.balanceOk = balanceOk && totalBalance.is_object();
@@ -258,6 +269,11 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::parseCoreRefresh
result.transparentBalance = readBalanceString(totalBalance, "transparent"); result.transparentBalance = readBalanceString(totalBalance, "transparent");
result.totalBalance = readBalanceString(totalBalance, "total"); 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(); result.blockchainOk = blockOk && blockInfo.is_object();
if (result.blockchainOk) { if (result.blockchainOk) {
@@ -274,17 +290,21 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::parseCoreRefresh
NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefreshResult(RefreshRpcGateway& rpc, bool includeBalance) NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefreshResult(RefreshRpcGateway& rpc, bool includeBalance)
{ {
json totalBalance; json totalBalance;
json spendableBalance;
json blockInfo; json blockInfo;
bool balanceOk = false; bool balanceOk = false;
bool blockOk = false; bool blockOk = false;
if (includeBalance) { if (includeBalance) {
try { try { // DISPLAY total: minconf=0 — includes the user's own pending change so it doesn't crater
totalBalance = rpc.call("z_gettotalbalance", json::array()); totalBalance = rpc.call("z_gettotalbalance", json::array({0}));
balanceOk = true; balanceOk = true;
} catch (const std::exception& e) { } catch (const std::exception& e) {
DEBUG_LOGF("Balance error: %s\n", e.what()); 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 { try {
@@ -294,7 +314,7 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefre
DEBUG_LOGF("BlockchainInfo error: %s\n", e.what()); 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( NetworkRefreshService::MiningRefreshResult NetworkRefreshService::parseMiningRefreshResult(
@@ -611,15 +631,19 @@ NetworkRefreshService::AddressRefreshResult NetworkRefreshService::collectAddres
} }
try { 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); applyShieldedBalancesFromUnspent(result.shieldedAddresses, unspent);
} catch (const std::exception& e) { } catch (const std::exception& e) {
DEBUG_LOGF("z_listunspent unavailable (%s), falling back to z_getbalance\n", e.what()); DEBUG_LOGF("z_listunspent unavailable (%s), falling back to z_getbalance\n", e.what());
for (auto& info : result.shieldedAddresses) { for (auto& info : result.shieldedAddresses) {
try { try { // display total (minconf=0, includes pending change)
json balance = rpc.call("z_getbalance", json::array({info.address})); json total = rpc.call("z_getbalance", json::array({info.address, 0}));
if (!balance.is_null()) info.balance = balance.get<double>(); if (!total.is_null()) info.balance = total.get<double>();
} catch (...) {} } 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 { 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); applyTransparentBalancesFromUnspent(result.transparentAddresses, unspent);
} catch (const std::exception& e) { } catch (const std::exception& e) {
DEBUG_LOGF("listunspent error: %s\n", e.what()); 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.shieldedBalance) state.shielded_balance = *result.shieldedBalance;
if (result.transparentBalance) state.transparent_balance = *result.transparentBalance; if (result.transparentBalance) state.transparent_balance = *result.transparentBalance;
if (result.totalBalance) state.total_balance = *result.totalBalance; 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; state.last_balance_update = updatedAt;
} }

View File

@@ -98,9 +98,12 @@ public:
struct CoreRefreshResult { struct CoreRefreshResult {
bool balanceOk = false; bool balanceOk = false;
std::optional<double> shieldedBalance; std::optional<double> shieldedBalance; // display (minconf=0, incl. pending change)
std::optional<double> transparentBalance; std::optional<double> transparentBalance;
std::optional<double> totalBalance; std::optional<double> totalBalance;
std::optional<double> spendableShieldedBalance; // confirmed (minconf=1)
std::optional<double> spendableTransparentBalance;
std::optional<double> spendableTotalBalance;
bool blockchainOk = false; bool blockchainOk = false;
std::optional<int> blocks; std::optional<int> blocks;
std::optional<int> headers; std::optional<int> headers;
@@ -227,6 +230,7 @@ public:
RefreshRpcGateway& rpc, RefreshRpcGateway& rpc,
const std::optional<ConnectionInfoResult>& prefetchedInfo = std::nullopt); const std::optional<ConnectionInfoResult>& prefetchedInfo = std::nullopt);
static CoreRefreshResult parseCoreRefreshResult(const nlohmann::json& totalBalance, static CoreRefreshResult parseCoreRefreshResult(const nlohmann::json& totalBalance,
const nlohmann::json& spendableBalance,
bool balanceOk, bool balanceOk,
const nlohmann::json& blockInfo, const nlohmann::json& blockInfo,
bool blockOk); bool blockOk);

View File

@@ -324,12 +324,12 @@ void RenderSharedAddressList(App* app, float listH, float availW,
s_dragIdx < (int)rows.size()) { s_dragIdx < (int)rows.size()) {
const auto& srcRow = rows[s_dragIdx]; const auto& srcRow = rows[s_dragIdx];
const auto& dstRow = rows[s_dropTargetIdx]; 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; AddressTransferDialog::TransferInfo ti;
ti.fromAddr = srcRow.info->address; ti.fromAddr = srcRow.info->address;
ti.toAddr = dstRow.info->address; ti.toAddr = dstRow.info->address;
ti.fromBalance = srcRow.info->balance; ti.fromBalance = srcRow.info->spendableBalance; // spend cap — z_sendmany runs at minconf=1
ti.toBalance = dstRow.info->balance; ti.toBalance = dstRow.info->balance; // destination display only
ti.fromIsZ = srcRow.isZ; ti.fromIsZ = srcRow.isZ;
ti.toIsZ = dstRow.isZ; ti.toIsZ = dstRow.isZ;
AddressTransferDialog::show(app, ti); AddressTransferDialog::show(app, ti);

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 // 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 // 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. // 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') { if (s_from_address[0] != '\0') {
for (const auto& a : state.addresses) { 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())) { 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; return 0.0;
} }
@@ -252,7 +254,7 @@ static void RenderSourceDropdown(App* app, float width) {
std::string trunc = util::truncateMiddle(addr.address, 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))); 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", 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; s_source_preview = buf;
} else { } else {
s_source_preview = TR("send_select_source"); 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); std::string trunc = util::truncateMiddle(addr.address, (int)addrTruncLen);
snprintf(buf, sizeof(buf), "%s %s — %.8f %s", 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)); ImGui::PushID(static_cast<int>(i));
if (ImGui::Selectable(buf, isCurrent)) { if (ImGui::Selectable(buf, isCurrent)) {
@@ -292,7 +294,7 @@ static void RenderSourceDropdown(App* app, float width) {
} }
if (ImGui::IsItemHovered()) { if (ImGui::IsItemHovered()) {
material::Tooltip("%s\nBalance: %.8f %s", material::Tooltip("%s\nBalance: %.8f %s",
addr.address.c_str(), addr.balance, DRAGONX_TICKER); addr.address.c_str(), addr.spendableBalance, DRAGONX_TICKER);
} }
ImGui::PopID(); ImGui::PopID();
} }

View File

@@ -139,6 +139,12 @@ void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model,
state.transparentBalance = static_cast<double>(model.balance.transparentZatoshis) / kZatoshisPerCoin; state.transparentBalance = static_cast<double>(model.balance.transparentZatoshis) / kZatoshisPerCoin;
state.totalBalance = static_cast<double>(model.balance.totalZatoshis) / kZatoshisPerCoin; state.totalBalance = static_cast<double>(model.balance.totalZatoshis) / kZatoshisPerCoin;
state.unconfirmedBalance = static_cast<double>(model.balance.unconfirmedZatoshis) / kZatoshisPerCoin; state.unconfirmedBalance = static_cast<double>(model.balance.unconfirmedZatoshis) / kZatoshisPerCoin;
// Lite already tracks confirmed/unconfirmed itself and its per-address balances are confirmed
// (spendable outputs only). Mirror the aggregate spendable fields so full-node-shaped spend
// validation isn't zeroed on lite (the actual lite spend gate is per-address, set below).
state.spendablePrivateBalance = state.privateBalance;
state.spendableTransparentBalance = state.transparentBalance;
state.spendableTotalBalance = state.totalBalance;
} }
if (model.hasAddresses) { if (model.hasAddresses) {
@@ -178,6 +184,7 @@ void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model,
} else { } else {
info.balance = 0.0; // notes succeeded and address has no spendable outputs info.balance = 0.0; // notes succeeded and address has no spendable outputs
} }
info.spendableBalance = info.balance; // lite per-address balance is already confirmed/spendable
info.type = (addr.kind == LiteWalletAppAddressKind::Shielded) ? "shielded" : "transparent"; info.type = (addr.kind == LiteWalletAppAddressKind::Shielded) ? "shielded" : "transparent";
info.has_spending_key = addr.spendabilityKnown ? addr.spendable : true; info.has_spending_key = addr.spendabilityKnown ? addr.spendable : true;
if (addr.kind == LiteWalletAppAddressKind::Shielded) { if (addr.kind == LiteWalletAppAddressKind::Shielded) {

View File

@@ -934,6 +934,7 @@ void testSpendableFiltering()
addresses.push_back({"zs-low", 2.0, "shielded", true}); addresses.push_back({"zs-low", 2.0, "shielded", true});
addresses.push_back({"R-zero", 0.0, "transparent", true}); addresses.push_back({"R-zero", 0.0, "transparent", true});
addresses.push_back({"R-high", 5.0, "transparent", true}); addresses.push_back({"R-high", 5.0, "transparent", true});
for (auto& a : addresses) a.spendableBalance = a.balance; // selection now ranks by CONFIRMED balance
EXPECT_EQ(dragonx::bestSpendableAddressIndex(addresses), 3); EXPECT_EQ(dragonx::bestSpendableAddressIndex(addresses), 3);
@@ -1424,10 +1425,10 @@ void testNetworkRefreshRpcCollectors()
}); });
auto core = Refresh::collectCoreRefreshResult(coreRpc); auto core = Refresh::collectCoreRefreshResult(coreRpc);
EXPECT_TRUE(coreRpc.methodNames() == std::vector<std::string>({ EXPECT_TRUE(coreRpc.methodNames() == std::vector<std::string>({
"z_gettotalbalance", "getblockchaininfo" "z_gettotalbalance", "z_gettotalbalance", "getblockchaininfo" // display (minconf=0) + spendable (minconf=1)
})); }));
EXPECT_EQ(coreRpc.calls[0].params, json::array()); EXPECT_EQ(coreRpc.calls[0].params, json::array({0}));
EXPECT_EQ(coreRpc.calls[1].params, json::array()); EXPECT_EQ(coreRpc.calls[1].params, json::array({1}));
EXPECT_TRUE(core.balanceOk); EXPECT_TRUE(core.balanceOk);
EXPECT_TRUE(core.blockchainOk); EXPECT_TRUE(core.blockchainOk);
EXPECT_NEAR(*core.totalBalance, 4.25, 0.00000001); EXPECT_NEAR(*core.totalBalance, 4.25, 0.00000001);
@@ -1440,7 +1441,7 @@ void testNetworkRefreshRpcCollectors()
coreFallbackRpc.addResponse("getblockchaininfo", json{{"blocks", 8}, {"headers", 9}}); coreFallbackRpc.addResponse("getblockchaininfo", json{{"blocks", 8}, {"headers", 9}});
auto partialCore = Refresh::collectCoreRefreshResult(coreFallbackRpc); auto partialCore = Refresh::collectCoreRefreshResult(coreFallbackRpc);
EXPECT_TRUE(coreFallbackRpc.methodNames() == std::vector<std::string>({ EXPECT_TRUE(coreFallbackRpc.methodNames() == std::vector<std::string>({
"z_gettotalbalance", "getblockchaininfo" "z_gettotalbalance", "z_gettotalbalance", "getblockchaininfo"
})); }));
EXPECT_FALSE(partialCore.balanceOk); EXPECT_FALSE(partialCore.balanceOk);
EXPECT_TRUE(partialCore.blockchainOk); EXPECT_TRUE(partialCore.blockchainOk);
@@ -1566,7 +1567,7 @@ void testNetworkRefreshRpcCollectors()
auto fallbackAddresses = Refresh::collectAddressRefreshResult(fallbackRpc); auto fallbackAddresses = Refresh::collectAddressRefreshResult(fallbackRpc);
EXPECT_TRUE(fallbackRpc.methodNames() == std::vector<std::string>({ EXPECT_TRUE(fallbackRpc.methodNames() == std::vector<std::string>({
"z_listaddresses", "z_validateaddress", "z_listunspent", "z_listaddresses", "z_validateaddress", "z_listunspent",
"z_getbalance", "getaddressesbyaccount", "listunspent" "z_getbalance", "z_getbalance", "getaddressesbyaccount", "listunspent" // display (minconf=0) + spendable (minconf=1)
})); }));
EXPECT_TRUE(fallbackAddresses.shieldedAddresses[0].has_spending_key); EXPECT_TRUE(fallbackAddresses.shieldedAddresses[0].has_spending_key);
EXPECT_NEAR(fallbackAddresses.shieldedAddresses[0].balance, 4.75, 0.00000001); EXPECT_NEAR(fallbackAddresses.shieldedAddresses[0].balance, 4.75, 0.00000001);
@@ -1963,7 +1964,8 @@ void testNetworkRefreshResultModels()
dragonx::WalletState state; dragonx::WalletState state;
auto core = Refresh::parseCoreRefreshResult( auto core = Refresh::parseCoreRefreshResult(
json{{"private", "1.25000000"}, {"transparent", "0.50000000"}, {"total", "1.75000000"}}, json{{"private", "1.25000000"}, {"transparent", "0.50000000"}, {"total", "1.75000000"}}, // display (minconf=0)
json{{"private", "1.00000000"}, {"transparent", "0.50000000"}, {"total", "1.50000000"}}, // spendable (minconf=1)
true, true,
json{{"blocks", 100}, {"headers", 105}, {"bestblockhash", "apply-best-100"}, {"verificationprogress", 0.75}, json{{"blocks", 100}, {"headers", 105}, {"bestblockhash", "apply-best-100"}, {"verificationprogress", 0.75},
{"longestchain", 110}, {"notarized", 90}}, {"longestchain", 110}, {"notarized", 90}},
@@ -1972,6 +1974,10 @@ void testNetworkRefreshResultModels()
EXPECT_NEAR(state.shielded_balance, 1.25, 0.00000001); EXPECT_NEAR(state.shielded_balance, 1.25, 0.00000001);
EXPECT_NEAR(state.transparent_balance, 0.5, 0.00000001); EXPECT_NEAR(state.transparent_balance, 0.5, 0.00000001);
EXPECT_NEAR(state.total_balance, 1.75, 0.00000001); EXPECT_NEAR(state.total_balance, 1.75, 0.00000001);
// Confirmed/spendable stays at minconf=1; unconfirmed = display total - spendable total (the pending change).
EXPECT_NEAR(state.spendablePrivateBalance, 1.0, 0.00000001);
EXPECT_NEAR(state.spendableTotalBalance, 1.5, 0.00000001);
EXPECT_NEAR(state.unconfirmedBalance, 0.25, 0.00000001);
EXPECT_EQ(state.sync.blocks, 100); EXPECT_EQ(state.sync.blocks, 100);
EXPECT_EQ(state.sync.headers, 105); EXPECT_EQ(state.sync.headers, 105);
EXPECT_EQ(state.sync.best_blockhash, std::string("apply-best-100")); EXPECT_EQ(state.sync.best_blockhash, std::string("apply-best-100"));