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
+43
View File
@@ -11981,6 +11981,48 @@ void testLiteParserAmountRangeChecks()
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
// !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.
@@ -19730,6 +19772,7 @@ int main()
testLiteWalletControllerAsyncAppliesPassphrase();
testLiteNewPassphraseRules();
testLiteChainNameMigration();
testLiteUnconfirmedAndTransparentSpendable();
testLiteRefreshModelAppliesToWalletState();
testLiteTransactionsAreNewestFirst();
testSameTransactionList();