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:
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,12 +256,18 @@ 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;
|
||||||
double& transparent_balance = transparentBalance;
|
double& transparent_balance = transparentBalance;
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
|||||||
Reference in New Issue
Block a user