fix(sweep,lite): don't misread a funded key as empty; guard lite send amount; use spendable balance

Fund-correctness audit, sweep + lite paths:
- Key-import sweep: balAt() swallowed every RPC error as 0.0, so a transient
  timeout/warmup after the import misclassified a funded key as "no funds". Let
  the failure propagate to the outer handler so the user sees a real error and
  can retry, instead of a false __NOFUNDS__.
- Lite send: the double→zatoshi conversion was an unchecked llround+cast — a
  negative/NaN amount would wrap to a huge uint64. Route it through
  util::coinsToZatoshis and reject invalid amounts before broadcast.
- Lite balance: the spend gate used the gross shielded total (including
  unverified/unconfirmed notes). Use the backend's spendable_zbalance (a required
  field) for spendablePrivateBalance/Total, clamped to the shielded total.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 13:10:55 -05:00
co-authored by Claude Opus 4.8
parent 70d5c233c0
commit f4460beddd
2 changed files with 21 additions and 11 deletions
+8 -5
View File
@@ -359,12 +359,15 @@ void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model,
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;
// 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;
// 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
// true; clamp to the shielded total defensively. Transparent is already confirmed on lite.
const double spendableShielded = static_cast<double>(
std::min(model.balance.spendableShieldedZatoshis, model.balance.shieldedZatoshis)) / kZatoshisPerCoin;
state.spendablePrivateBalance = spendableShielded;
state.spendableTransparentBalance = state.transparentBalance;
state.spendableTotalBalance = state.totalBalance;
state.spendableTotalBalance = spendableShielded + state.transparentBalance;
}
if (model.hasAddresses) {