fix(lite): unconfirmedBalance uses full-node pending semantics; drop pending-spent t-UTXOs from spendable

Lite-parity audit mediums (display / spendable-gating only — never over-reports spendable; no send call):
- unconfirmedBalance was just the backend's mempool-incoming figure, but the shared UI (balance_tab "pending"
  chip, the fast-poll hint) reads it as the full-node "total minus spendable" portion. Set it to the larger
  of the two (the not-yet-spendable shielded part vs the mempool-incoming), so neither is hidden and a note
  counted in both isn't double-reported.
- transparentBalance/spendableTransparentBalance included t-UTXOs already spent by a pending tx (the backend
  tbalance sums every utxo with spent==None). Subtract unconfirmed-spent t-UTXOs from the SPENDABLE figure
  only (clamped; gross transparent display unchanged) so the Send picker can't offer funds a pending tx
  already consumed. Falls back to no-subtraction when the notes call failed that cycle (as before).

Test: testLiteUnconfirmedAndTransparentSpendable. Multi-recipient history rows held (TransactionInfo is
single-output, same as the full node).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 18:58:32 -05:00
co-authored by Claude Opus 4.8
parent 46de4cbfee
commit 3a33e9cae9
2 changed files with 68 additions and 3 deletions
+25 -3
View File
@@ -378,7 +378,16 @@ void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model,
state.privateBalance = static_cast<double>(model.balance.shieldedZatoshis) / kZatoshisPerCoin; state.privateBalance = static_cast<double>(model.balance.shieldedZatoshis) / kZatoshisPerCoin;
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; // Full node defines unconfirmedBalance as total - spendable (the pending portion; balance_tab's "pending"
// chip and the fast-poll hint read it that way). Lite: the not-yet-spendable shielded part (unverified /
// unconfirmed notes) is that portion; the backend's own `unconfirmed` is mempool-incoming. Take the larger so
// neither is hidden and a note counted in both is not double-reported. Display-only; never feeds a spend gate.
{
const std::uint64_t notYetSpendable = model.balance.shieldedZatoshis -
std::min(model.balance.spendableShieldedZatoshis, model.balance.shieldedZatoshis);
state.unconfirmedBalance = static_cast<double>(
std::max(model.balance.unconfirmedZatoshis, notYetSpendable)) / kZatoshisPerCoin;
}
// Use the backend's SPENDABLE (verified + confirmed) shielded balance for the spend gate, not the // Use the backend's SPENDABLE (verified + confirmed) shielded balance for the spend gate, not the
// gross shielded total which includes unverified/unconfirmed notes you can't yet spend. The // gross shielded total which includes unverified/unconfirmed notes you can't yet spend. The
// spendable_zbalance field is required by the parser, so it's always present when hasBalance is // spendable_zbalance field is required by the parser, so it's always present when hasBalance is
@@ -386,8 +395,21 @@ void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model,
const double spendableShielded = static_cast<double>( const double spendableShielded = static_cast<double>(
std::min(model.balance.spendableShieldedZatoshis, model.balance.shieldedZatoshis)) / kZatoshisPerCoin; std::min(model.balance.spendableShieldedZatoshis, model.balance.shieldedZatoshis)) / kZatoshisPerCoin;
state.spendablePrivateBalance = spendableShielded; state.spendablePrivateBalance = spendableShielded;
state.spendableTransparentBalance = state.transparentBalance; // Backend tbalance sums every utxo with spent==None (lightwallet.rs get_utxos), so a t-UTXO already spent by
state.spendableTotalBalance = spendableShielded + state.transparentBalance; // an unconfirmed tx of ours is still in transparentBalance. Subtract those from the SPENDABLE figure only
// (clamped; gross transparentBalance display unchanged). Needs this cycle's utxo list: if the notes call
// failed, nothing is subtracted (same as before).
std::uint64_t unconfirmedSpentTransparent = 0;
if (model.hasSpendableOutputs) {
for (const auto& output : model.spendableOutputs) {
if (output.kind == LiteSpendableOutputKind::Utxo && output.unconfirmedSpent && !output.spent)
unconfirmedSpentTransparent += output.valueZatoshis;
}
}
const std::uint64_t spendableTransparentZat = model.balance.transparentZatoshis -
std::min(unconfirmedSpentTransparent, model.balance.transparentZatoshis);
state.spendableTransparentBalance = static_cast<double>(spendableTransparentZat) / kZatoshisPerCoin;
state.spendableTotalBalance = spendableShielded + state.spendableTransparentBalance;
} }
if (model.hasAddresses) { if (model.hasAddresses) {
+43
View File
@@ -11981,6 +11981,48 @@ void testLiteParserAmountRangeChecks()
EXPECT_TRUE(!note.ok); EXPECT_TRUE(!note.ok);
} }
// Lite-parity mediums: unconfirmedBalance uses the full-node pending semantics (total - spendable, or the
// backend mempool-incoming, whichever is larger), and a t-UTXO spent by a pending tx is excluded from the
// SPENDABLE transparent total (never over-reports spendable).
void testLiteUnconfirmedAndTransparentSpendable()
{
using namespace dragonx::wallet;
{
LiteWalletAppRefreshModel m;
m.hasBalance = true;
m.balance.shieldedZatoshis = 500000000; m.balance.spendableShieldedZatoshis = 300000000;
m.balance.transparentZatoshis = 100000000; m.balance.totalZatoshis = 600000000;
m.balance.unconfirmedZatoshis = 50000000;
dragonx::WalletState s;
applyLiteRefreshModelToWalletState(m, s);
EXPECT_NEAR(s.unconfirmedBalance, 2.0, 1e-9);
EXPECT_NEAR(s.totalBalance - s.spendableTotalBalance, 2.0, 1e-9);
m.balance.unconfirmedZatoshis = 900000000;
applyLiteRefreshModelToWalletState(m, s);
EXPECT_NEAR(s.unconfirmedBalance, 9.0, 1e-9);
}
{
LiteWalletAppRefreshModel m;
m.hasBalance = true;
m.balance.transparentZatoshis = 300000000; m.balance.totalZatoshis = 300000000;
m.hasSpendableOutputs = true;
LiteWalletAppSpendableOutputModel a; a.kind = LiteSpendableOutputKind::Utxo; a.address = "R1"; a.valueZatoshis = 100000000; a.unconfirmedSpent = true;
LiteWalletAppSpendableOutputModel b = a; b.unconfirmedSpent = false; b.valueZatoshis = 200000000;
m.spendableOutputs = {a, b};
dragonx::WalletState s;
applyLiteRefreshModelToWalletState(m, s);
EXPECT_NEAR(s.transparentBalance, 3.0, 1e-9);
EXPECT_NEAR(s.spendableTransparentBalance, 2.0, 1e-9);
EXPECT_NEAR(s.spendableTotalBalance, 2.0, 1e-9);
m.hasSpendableOutputs = false;
applyLiteRefreshModelToWalletState(m, s);
EXPECT_NEAR(s.spendableTransparentBalance, 3.0, 1e-9);
m.hasSpendableOutputs = true; m.spendableOutputs[0].valueZatoshis = 900000000;
applyLiteRefreshModelToWalletState(m, s);
EXPECT_NEAR(s.spendableTransparentBalance, 0.0, 1e-9);
}
}
// A tolerated partial refresh where the notes/utxo command failed (hasAddresses but // A tolerated partial refresh where the notes/utxo command failed (hasAddresses but
// !hasSpendableOutputs) must NOT zero every per-address balance — it keeps the last-known // !hasSpendableOutputs) must NOT zero every per-address balance — it keeps the last-known
// values so a correct total isn't accompanied by a misleading all-zero breakdown. // values so a correct total isn't accompanied by a misleading all-zero breakdown.
@@ -19730,6 +19772,7 @@ int main()
testLiteWalletControllerAsyncAppliesPassphrase(); testLiteWalletControllerAsyncAppliesPassphrase();
testLiteNewPassphraseRules(); testLiteNewPassphraseRules();
testLiteChainNameMigration(); testLiteChainNameMigration();
testLiteUnconfirmedAndTransparentSpendable();
testLiteRefreshModelAppliesToWalletState(); testLiteRefreshModelAppliesToWalletState();
testLiteTransactionsAreNewestFirst(); testLiteTransactionsAreNewestFirst();
testSameTransactionList(); testSameTransactionList();