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:
@@ -378,7 +378,16 @@ void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model,
|
||||
state.privateBalance = static_cast<double>(model.balance.shieldedZatoshis) / kZatoshisPerCoin;
|
||||
state.transparentBalance = static_cast<double>(model.balance.transparentZatoshis) / 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
|
||||
// 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
|
||||
@@ -386,8 +395,21 @@ void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model,
|
||||
const double spendableShielded = static_cast<double>(
|
||||
std::min(model.balance.spendableShieldedZatoshis, model.balance.shieldedZatoshis)) / kZatoshisPerCoin;
|
||||
state.spendablePrivateBalance = spendableShielded;
|
||||
state.spendableTransparentBalance = state.transparentBalance;
|
||||
state.spendableTotalBalance = spendableShielded + state.transparentBalance;
|
||||
// Backend tbalance sums every utxo with spent==None (lightwallet.rs get_utxos), so a t-UTXO already spent by
|
||||
// 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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user