diff --git a/src/wallet/lite_wallet_controller.cpp b/src/wallet/lite_wallet_controller.cpp index e62f990..1b44ba0 100644 --- a/src/wallet/lite_wallet_controller.cpp +++ b/src/wallet/lite_wallet_controller.cpp @@ -378,7 +378,16 @@ void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model, state.privateBalance = static_cast(model.balance.shieldedZatoshis) / kZatoshisPerCoin; state.transparentBalance = static_cast(model.balance.transparentZatoshis) / kZatoshisPerCoin; state.totalBalance = static_cast(model.balance.totalZatoshis) / kZatoshisPerCoin; - state.unconfirmedBalance = static_cast(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( + 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( 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(spendableTransparentZat) / kZatoshisPerCoin; + state.spendableTotalBalance = spendableShielded + state.spendableTransparentBalance; } if (model.hasAddresses) { diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index fb3b717..52fadad 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -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();