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:
@@ -376,10 +376,12 @@ void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model,
|
||||
// unconfirmed-spent, or still pending (notes/utxos from an unconfirmed received tx) so
|
||||
// the per-address figures match the confirmed balance the wallet treats as available.
|
||||
std::unordered_map<std::string, std::uint64_t> perAddressZatoshis;
|
||||
std::unordered_map<std::string, std::uint64_t> perAddressSpendableZatoshis; // backend 'spendable' only
|
||||
if (model.hasSpendableOutputs) {
|
||||
for (const auto& output : model.spendableOutputs) {
|
||||
if (output.spent || output.unconfirmedSpent || output.pending) continue;
|
||||
perAddressZatoshis[output.address] += output.valueZatoshis;
|
||||
if (output.spendable) perAddressSpendableZatoshis[output.address] += output.valueZatoshis;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -388,8 +390,14 @@ void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model,
|
||||
// of zeroing every address — a zeroed breakdown next to a correct nonzero total looks
|
||||
// like fund loss and breaks "send from this address".
|
||||
std::unordered_map<std::string, double> priorBalances;
|
||||
std::unordered_map<std::string, double> priorSpendable;
|
||||
std::unordered_map<std::string, bool> priorHasKey;
|
||||
if (!model.hasSpendableOutputs) {
|
||||
for (const auto& a : state.addresses) priorBalances[a.address] = a.balance;
|
||||
for (const auto& a : state.addresses) {
|
||||
priorBalances[a.address] = a.balance;
|
||||
priorSpendable[a.address] = a.spendableBalance;
|
||||
priorHasKey[a.address] = a.has_spending_key;
|
||||
}
|
||||
}
|
||||
|
||||
state.addresses.clear();
|
||||
@@ -407,9 +415,22 @@ void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model,
|
||||
} else {
|
||||
info.balance = 0.0; // notes succeeded and address has no spendable outputs
|
||||
}
|
||||
info.spendableBalance = info.balance; // lite per-address balance is already confirmed/spendable
|
||||
// balance stays the gross confirmed-unspent sum; spendableBalance counts only outputs the backend
|
||||
// marks spendable (spending key + anchor depth). Notes failed this cycle -> keep last-known.
|
||||
if (model.hasSpendableOutputs) {
|
||||
const auto sit = perAddressSpendableZatoshis.find(addr.address);
|
||||
info.spendableBalance = sit != perAddressSpendableZatoshis.end()
|
||||
? static_cast<double>(sit->second) / kZatoshisPerCoin : 0.0;
|
||||
} else {
|
||||
const auto psit = priorSpendable.find(addr.address);
|
||||
info.spendableBalance = psit != priorSpendable.end() ? psit->second : 0.0;
|
||||
}
|
||||
info.type = (addr.kind == LiteWalletAppAddressKind::Shielded) ? "shielded" : "transparent";
|
||||
info.has_spending_key = addr.spendabilityKnown ? addr.spendable : true;
|
||||
if (!addr.spendabilityKnown && !model.hasSpendableOutputs) {
|
||||
const auto pkit = priorHasKey.find(addr.address); // keep last-known view-only verdict
|
||||
if (pkit != priorHasKey.end()) info.has_spending_key = pkit->second;
|
||||
}
|
||||
if (addr.kind == LiteWalletAppAddressKind::Shielded) {
|
||||
state.z_addresses.push_back(info);
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user