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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user