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;
}
+23 -2
View File
@@ -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 {
+26
View File
@@ -190,6 +190,32 @@ LiteWalletStateMapResult mapLiteWalletRefreshBundle(const LiteWalletRefreshBundl
result.model.sync.complete = bundle.syncStatus.complete;
}
// Per-address spending-key evidence from the backend's per-note 'spendable' flag. Only POSITIVE evidence
// sets spendabilityKnown: a spendable note proves a key; a confirmed, long-mature (well past any anchor
// offset) unspent note that is still not spendable can only mean a view-only address. Fresh/immature
// notes, no notes, or no chain height stay unknown (the controller then keeps its spendable default).
if (result.model.hasAddresses && result.model.hasSpendableOutputs) {
constexpr std::int64_t kMatureDepth = 100;
const std::int64_t tip = result.model.hasSyncStatus
? static_cast<std::int64_t>(result.model.sync.chainHeight) : 0;
for (auto& addr : result.model.addresses) {
if (addr.kind != LiteWalletAppAddressKind::Shielded) continue;
bool anySpendable = false;
bool matureNonSpendable = false;
for (const auto& out : result.model.spendableOutputs) {
if (out.address != addr.address || out.kind != LiteSpendableOutputKind::UnspentNote) continue;
if (out.spent || out.unconfirmedSpent || out.pending) continue;
if (out.spendable) { anySpendable = true; break; }
if (tip > 0 && out.createdInBlock && *out.createdInBlock > 0 &&
tip - *out.createdInBlock >= kMatureDepth) {
matureNonSpendable = true;
}
}
if (anySpendable) { addr.spendabilityKnown = true; addr.spendable = true; }
else if (matureNonSpendable) { addr.spendabilityKnown = true; addr.spendable = false; }
}
}
if (!modelHasAnyMappedField(result.model)) {
addIssue(result, LiteWalletStateMapIssue::EmptyBundle, "lite refresh bundle has no mappable fields");
result.error = result.issues.back().message;
+62
View File
@@ -11881,6 +11881,67 @@ void testLitePartialRefreshKeepsPriorAddressBalances()
EXPECT_NEAR(state.z_addresses[0].balance, 1.2, 1e-9); // preserved, not zeroed
}
// Lite per-address spendable: the backend's per-note 'spendable' flag drives spendableBalance (balance stays
// gross), a mature non-spendable note marks the address view-only, and a partial refresh keeps both.
void testLitePerAddressSpendableAndViewOnly()
{
using namespace dragonx::wallet;
const auto parsed = parseLiteNotesResponse(std::string(R"({
"unspent_notes":[
{"address":"zsKey","created_in_txid":"a","created_in_block":100,"value":100000000,"spendable":true},
{"address":"zsKey","created_in_txid":"b","created_in_block":995,"value":50000000,"spendable":false},
{"address":"zsView","created_in_txid":"c","created_in_block":100,"value":200000000,"spendable":false},
{"address":"zsFresh","created_in_txid":"d","created_in_block":999,"value":30000000,"spendable":false}],
"utxos":[{"address":"t1x","created_in_txid":"e","created_in_block":100,"value":70000000}],
"pending_notes":[],"pending_utxos":[]})"));
EXPECT_TRUE(parsed.ok);
if (!parsed.ok) return;
EXPECT_TRUE(parsed.notes.unspentNotes[0].spendable);
EXPECT_FALSE(parsed.notes.unspentNotes[1].spendable);
EXPECT_TRUE(parsed.notes.utxos[0].spendable); // no flag on utxos -> confirmed/unspent rule
LiteWalletRefreshBundle bundle;
bundle.complete = true;
bundle.hasAddresses = true;
bundle.addresses.zAddresses = {"zsKey", "zsView", "zsFresh"};
bundle.addresses.tAddresses = {"t1x"};
bundle.hasNotes = true;
bundle.notes = parsed.notes;
bundle.hasSyncStatus = true;
bundle.syncStatus.syncedBlocks = 1000;
bundle.syncStatus.totalBlocks = 1000;
bundle.syncStatus.complete = true;
dragonx::WalletState state;
applyLiteRefreshModelToWalletState(mapLiteWalletRefreshBundle(bundle).model, state);
EXPECT_EQ(static_cast<int>(state.z_addresses.size()), 3);
if (state.z_addresses.size() != 3) return;
const auto& key = state.z_addresses[0];
const auto& view = state.z_addresses[1];
const auto& fresh = state.z_addresses[2];
EXPECT_NEAR(key.balance, 1.5, 1e-9); // gross
EXPECT_NEAR(key.spendableBalance, 1.0, 1e-9); // only the anchored note
EXPECT_TRUE(key.has_spending_key);
EXPECT_NEAR(view.balance, 2.0, 1e-9);
EXPECT_NEAR(view.spendableBalance, 0.0, 1e-9);
EXPECT_FALSE(view.has_spending_key); // mature + non-spendable => view-only
EXPECT_FALSE(view.isSpendable());
EXPECT_TRUE(fresh.has_spending_key); // immature only => unknown, not mislabelled
EXPECT_NEAR(fresh.spendableBalance, 0.0, 1e-9);
EXPECT_NEAR(state.t_addresses[0].spendableBalance, 0.7, 1e-9);
// Partial refresh (notes failed): keep last-known balance, spendable and view-only verdict.
LiteWalletRefreshBundle partial;
partial.complete = true;
partial.hasAddresses = true;
partial.addresses.zAddresses = {"zsKey", "zsView", "zsFresh"};
partial.addresses.tAddresses = {"t1x"};
applyLiteRefreshModelToWalletState(mapLiteWalletRefreshBundle(partial).model, state);
EXPECT_NEAR(state.z_addresses[0].spendableBalance, 1.0, 1e-9);
EXPECT_FALSE(state.z_addresses[1].has_spending_key);
}
// M2b: the controller, after a wallet is ready, auto-starts sync and refreshWalletState()
// pulls balance/addresses/transactions/syncstatus through the shared bridge into WalletState.
void testLiteWalletControllerRefreshPopulatesState()
@@ -19526,6 +19587,7 @@ int main()
testLiteSendAmountIsNegative();
testLiteForeignSendFallsBackToRecordAmount();
testLitePartialRefreshKeepsPriorAddressBalances();
testLitePerAddressSpendableAndViewOnly();
testLitePerAddressBalances();
testLiteWalletControllerNewAddress();
testLiteSyncStatusParserRealShapes();