From e6b91ca66146077bceb4f5541ef71369948d30c0 Mon Sep 17 00:00:00 2001 From: DanS Date: Fri, 5 Jun 2026 10:24:36 -0500 Subject: [PATCH] feat(lite): per-address balances from unspent notes/utxos applyLiteRefreshModelToWalletState now derives each address's balance by summing its unspent notes/utxos (excluding spent and unconfirmed-spent outputs) instead of the aggregate-only zeros, so the Receive/Balance UI shows per-address amounts. The notes parser shape is confirmed against do_list_notes in the backend source. testLitePerAddressBalances covers the summing + spent-exclusion. Completes M2. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...allet-implementation-plan-v2-2026-06-04.md | 4 ++- src/wallet/lite_wallet_controller.cpp | 17 +++++++++- tests/test_phase4.cpp | 32 +++++++++++++++++++ 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/docs/lite-wallet-implementation-plan-v2-2026-06-04.md b/docs/lite-wallet-implementation-plan-v2-2026-06-04.md index 84b42e9..8b231bf 100644 --- a/docs/lite-wallet-implementation-plan-v2-2026-06-04.md +++ b/docs/lite-wallet-implementation-plan-v2-2026-06-04.md @@ -107,7 +107,9 @@ Each milestone is independently demoable and gated by a fake-backend test. Order > - `list` (`do_list_transactions`): send records carry `outgoing_metadata` (no top-level `address`), receives carry `address`+`amount`; our `parseTransactionRecord` branches on `outgoing_metadata` first — **matches** both. > - `syncstatus`: was the one mismatch — fixed earlier. > -> - ⏳ **Remaining for M2 polish:** per-address balances (notes-correlation; currently aggregate-only). Real *data* (vs shape) hasn't been observed end-to-end because a full sync takes ~30 min; shapes are confirmed, and gateway hardening covers any residual surprise. +> - ✅ **Per-address balances done.** `applyLiteRefreshModelToWalletState` now sums unspent notes/utxos per address (spent/unconfirmed-spent excluded) into `AddressInfo::balance`, replacing the aggregate-only zeros — so Receive/Balance show per-address amounts. Notes parser confirmed against `do_list_notes` (source). Covered by `testLitePerAddressBalances()`. +> +> **M2 is complete.** Remaining only: real *data* (vs shape) hasn't been observed end-to-end because a full sync takes ~30 min; all shapes are confirmed and gateway hardening covers any residual surprise. Next milestone: M3 (UI completeness). - Implement `LiteSyncService::startSync` (replace the "not implemented" stub) + a background worker polling `syncstatus`, mirroring `NetworkRefreshService`/`RefreshScheduler` (enqueue → worker → apply on main thread). - Drive `LiteWalletGateway` refresh (info/height/balance/addresses/notes/list/transactions) through `lite_result_parsers` → `lite_wallet_state_mapper` → `App` `WalletState` (`privateBalance`, `transparentBalance`, `addresses`, `transactions`, `sync`). - Hook the controller into `App::update()`'s refresh dispatch alongside (not inside) the full-node path. diff --git a/src/wallet/lite_wallet_controller.cpp b/src/wallet/lite_wallet_controller.cpp index 777f149..d7ef3d9 100644 --- a/src/wallet/lite_wallet_controller.cpp +++ b/src/wallet/lite_wallet_controller.cpp @@ -7,6 +7,8 @@ #include "../data/wallet_state.h" #include +#include +#include #include #include @@ -37,13 +39,26 @@ void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model, } if (model.hasAddresses) { + // Per-address balances from unspent notes/utxos (when the notes command succeeded). + // Sum the value of outputs at each address that are neither spent nor unconfirmed-spent. + std::unordered_map perAddressZatoshis; + if (model.hasSpendableOutputs) { + for (const auto& output : model.spendableOutputs) { + if (output.spent || output.unconfirmedSpent) continue; + perAddressZatoshis[output.address] += output.valueZatoshis; + } + } + state.addresses.clear(); state.z_addresses.clear(); state.t_addresses.clear(); for (const auto& addr : model.addresses) { AddressInfo info; info.address = addr.address; - info.balance = 0.0; // lite address listing is aggregate-only; per-address balance is M2b (notes correlation) + const auto it = perAddressZatoshis.find(addr.address); + info.balance = it != perAddressZatoshis.end() + ? static_cast(it->second) / kZatoshisPerCoin + : 0.0; info.type = (addr.kind == LiteWalletAppAddressKind::Shielded) ? "shielded" : "transparent"; info.has_spending_key = addr.spendabilityKnown ? addr.spendable : true; if (addr.kind == LiteWalletAppAddressKind::Shielded) { diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index cf74e40..6341437 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -4687,6 +4687,37 @@ void testLiteSyncStatusParserRealShapes() } } +// Per-address balances: unspent notes/utxos are summed per address (spent ones excluded) +// into the WalletState address list, instead of aggregate-only zeros. +void testLitePerAddressBalances() +{ + using namespace dragonx::wallet; + + LiteWalletRefreshBundle bundle; + bundle.complete = true; + bundle.hasAddresses = true; + bundle.addresses.zAddresses = {"zs1aaa"}; + bundle.addresses.tAddresses = {"t1bbb"}; + bundle.hasNotes = true; + + LiteSpendableOutput note; note.address = "zs1aaa"; note.value = 150000000; note.createdInTxid = "n1"; + LiteSpendableOutput utxo; utxo.address = "t1bbb"; utxo.value = 50000000; utxo.createdInTxid = "u1"; + LiteSpendableOutput spentNote; spentNote.address = "zs1aaa"; spentNote.value = 999; spentNote.spent = true; spentNote.createdInTxid = "n2"; + bundle.notes.unspentNotes.push_back(note); + bundle.notes.unspentNotes.push_back(spentNote); // excluded: spent + bundle.notes.utxos.push_back(utxo); + + const auto mapped = mapLiteWalletRefreshBundle(bundle); + EXPECT_TRUE(mapped.ok); + + dragonx::WalletState state; + applyLiteRefreshModelToWalletState(mapped.model, state); + EXPECT_EQ(static_cast(state.z_addresses.size()), 1); + EXPECT_EQ(static_cast(state.t_addresses.size()), 1); + EXPECT_NEAR(state.z_addresses[0].balance, 1.5, 1e-9); // 150000000; the spent note is excluded + EXPECT_NEAR(state.t_addresses[0].balance, 0.5, 1e-9); // 50000000 +} + // Gateway hardening: one command's parse failure must not abort the whole refresh — the // other commands still populate the bundle (graceful degradation against real-shape drift). void testLiteWalletGatewayRefreshSkipsFailedCommand() @@ -4809,6 +4840,7 @@ int main() testLiteWalletControllerLifecycle(); testLiteChainNameMigration(); testLiteRefreshModelAppliesToWalletState(); + testLitePerAddressBalances(); testLiteSyncStatusParserRealShapes(); testLiteWalletControllerRefreshPopulatesState(); testLiteWalletGatewayRefreshSkipsFailedCommand();