From 02fa1a228a8a619eb0b399a96e2c906add38fc5d Mon Sep 17 00:00:00 2001 From: DanS Date: Mon, 19 Oct 2026 15:11:47 -0500 Subject: [PATCH] fix(lite): per-address spendable honours the backend spendable flag; flag imported view-only addresses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/wallet/lite_result_parsers.cpp | 8 ++++ src/wallet/lite_wallet_controller.cpp | 25 +++++++++- src/wallet/lite_wallet_state_mapper.cpp | 26 +++++++++++ tests/test_phase4.cpp | 62 +++++++++++++++++++++++++ 4 files changed, 119 insertions(+), 2 deletions(-) diff --git a/src/wallet/lite_result_parsers.cpp b/src/wallet/lite_result_parsers.cpp index 8f4210d..f934b39 100644 --- a/src/wallet/lite_result_parsers.cpp +++ b/src/wallet/lite_result_parsers.cpp @@ -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; } diff --git a/src/wallet/lite_wallet_controller.cpp b/src/wallet/lite_wallet_controller.cpp index 59c171b..a288960 100644 --- a/src/wallet/lite_wallet_controller.cpp +++ b/src/wallet/lite_wallet_controller.cpp @@ -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 perAddressZatoshis; + std::unordered_map 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 priorBalances; + std::unordered_map priorSpendable; + std::unordered_map 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(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 { diff --git a/src/wallet/lite_wallet_state_mapper.cpp b/src/wallet/lite_wallet_state_mapper.cpp index 84ce95c..ae3c7c7 100644 --- a/src/wallet/lite_wallet_state_mapper.cpp +++ b/src/wallet/lite_wallet_state_mapper.cpp @@ -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(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; diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index a7c2a0e..2a7a522 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -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(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();