fix(lite): per-address spendable honours the backend spendable flag; flag imported view-only addresses

Lite-parity audit: per-address spendableBalance was set equal to the gross balance, ignoring the backend's
per-note 'spendable' flag (spending key present AND note at/below the anchor height), and has_spending_key
was effectively always true (mapAddress never set spendabilityKnown). So the Send auto-picker could select
an address that can't fund the send, and an imported view-only (zxviews) address looked spendable.

- Parser: carry the backend per-note 'spendable' flag when present (Sapling notes; transparent utxos keep
  the confirmed/unspent rule). It can only narrow spendability, never widen it.
- Mapper: derive per-address spending-key evidence from positive signals only — a spendable note proves a
  key (spendable), a confirmed long-mature (>=100 blocks) still-non-spendable note proves view-only
  (!spendable); fresh/immature/no notes stay unknown so nothing is mislabelled.
- Controller: info.balance stays the gross sum; info.spendableBalance now counts only backend-spendable
  outputs; a tolerated partial refresh keeps the last-known spendable balance and view-only verdict.

Display/selection only — gating can only get stricter for view-only/unanchored funds; no send/sweep/encrypt
call changes. Test: testLitePerAddressSpendableAndViewOnly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 15:11:47 -05:00
co-authored by Claude Opus 4.8
parent 099d5df3e1
commit 02fa1a228a
4 changed files with 119 additions and 2 deletions
+8
View File
@@ -285,6 +285,14 @@ bool parseSpendableOutput(const json& value,
if (!readOptionalBoolField(value, "spent", output.spent, result)) return false;
if (!readOptionalBoolField(value, "unconfirmed_spent", output.unconfirmedSpent, result)) return false;
output.spendable = !output.pending && !output.spent && !output.unconfirmedSpent;
// The backend's per-note 'spendable' (we hold the spending key AND the note is at/below the anchor
// height) is authoritative when present (Sapling notes only; transparent utxos carry no such field,
// so they keep the confirmed/unspent rule above). It can only narrow, never widen, spendability.
bool backendSpendable = true;
if (!readOptionalBoolField(value, "spendable", backendSpendable, result)) return false;
if (value.contains("spendable") && value.at("spendable").is_boolean()) {
output.spendable = output.spendable && backendSpendable;
}
return true;
}