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) <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
> - `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.
|
> - `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).
|
- 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`).
|
- 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.
|
- Hook the controller into `App::update()`'s refresh dispatch alongside (not inside) the full-node path.
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
#include "../data/wallet_state.h"
|
#include "../data/wallet_state.h"
|
||||||
|
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <unordered_map>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
|
|
||||||
#include <sodium.h>
|
#include <sodium.h>
|
||||||
@@ -37,13 +39,26 @@ void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model,
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (model.hasAddresses) {
|
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<std::string, std::uint64_t> 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.addresses.clear();
|
||||||
state.z_addresses.clear();
|
state.z_addresses.clear();
|
||||||
state.t_addresses.clear();
|
state.t_addresses.clear();
|
||||||
for (const auto& addr : model.addresses) {
|
for (const auto& addr : model.addresses) {
|
||||||
AddressInfo info;
|
AddressInfo info;
|
||||||
info.address = addr.address;
|
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<double>(it->second) / kZatoshisPerCoin
|
||||||
|
: 0.0;
|
||||||
info.type = (addr.kind == LiteWalletAppAddressKind::Shielded) ? "shielded" : "transparent";
|
info.type = (addr.kind == LiteWalletAppAddressKind::Shielded) ? "shielded" : "transparent";
|
||||||
info.has_spending_key = addr.spendabilityKnown ? addr.spendable : true;
|
info.has_spending_key = addr.spendabilityKnown ? addr.spendable : true;
|
||||||
if (addr.kind == LiteWalletAppAddressKind::Shielded) {
|
if (addr.kind == LiteWalletAppAddressKind::Shielded) {
|
||||||
|
|||||||
@@ -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<int>(state.z_addresses.size()), 1);
|
||||||
|
EXPECT_EQ(static_cast<int>(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
|
// 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).
|
// other commands still populate the bundle (graceful degradation against real-shape drift).
|
||||||
void testLiteWalletGatewayRefreshSkipsFailedCommand()
|
void testLiteWalletGatewayRefreshSkipsFailedCommand()
|
||||||
@@ -4809,6 +4840,7 @@ int main()
|
|||||||
testLiteWalletControllerLifecycle();
|
testLiteWalletControllerLifecycle();
|
||||||
testLiteChainNameMigration();
|
testLiteChainNameMigration();
|
||||||
testLiteRefreshModelAppliesToWalletState();
|
testLiteRefreshModelAppliesToWalletState();
|
||||||
|
testLitePerAddressBalances();
|
||||||
testLiteSyncStatusParserRealShapes();
|
testLiteSyncStatusParserRealShapes();
|
||||||
testLiteWalletControllerRefreshPopulatesState();
|
testLiteWalletControllerRefreshPopulatesState();
|
||||||
testLiteWalletGatewayRefreshSkipsFailedCommand();
|
testLiteWalletGatewayRefreshSkipsFailedCommand();
|
||||||
|
|||||||
Reference in New Issue
Block a user