feat(lws): balance as a verification passthrough (spent_outputs + mempool pending)

POST /wallet/lws/balance returned a server-summarized {unlocked, locked} where
unlocked = total_received - locked OVER-COUNTED: a non-custodial backend cannot
verify spends without the spend key, so it never subtracted them. Reshape to the
wallet's verification-passthrough contract — aggregate get_address_info +
get_address_txs (parallel) and return total_received, locked_balance,
pending_balance (0-conf summed directly from mempool rows: webhook-free, cleaner
than the private backend's DB table), and spent_outputs[] for client-side
key-image verification. The wallet nets its own spends (privacy-correct).
spent_outputs fixes the over-count immediately; pending_balance activates once
the parity LWS mempool feature is deployed. Adversarially reviewed (3 lenses,
0 confirmed findings).
This commit is contained in:
jwinterm
2026-06-29 16:13:46 -04:00
parent 224ef36ad3
commit 99e1727204
4 changed files with 68 additions and 20 deletions
+25 -10
View File
@@ -2566,15 +2566,17 @@
},
"LwsBalanceResponse": {
"type": "object",
"description": "Balance + scan state. `total_received` is the balance (an LWS cannot confirm\nspends without the spend key; the wallet subtracts its own known spends).",
"description": "Balance + scan state, as a **verification passthrough**. The backend holds no\nspend key, so it cannot net out spends; the wallet computes the true spendable\nbalance client-side: `total_received − sum(spent_outputs it verifies with the\nspend key) − locked_balance`. `spent_outputs` are therefore CANDIDATES (some\nare ring decoys of the user's own outputs); `pending_balance` is the 0-conf\n(mempool) received.",
"required": [
"asset",
"total_received",
"unlocked",
"locked",
"locked_balance",
"pending_balance",
"start_height",
"scanned_height",
"blockchain_height",
"transaction_count"
"transaction_count",
"spent_outputs"
],
"properties": {
"asset": {
@@ -2585,16 +2587,34 @@
"format": "int64",
"minimum": 0
},
"locked": {
"locked_balance": {
"type": "integer",
"format": "int64",
"minimum": 0
},
"pending_balance": {
"type": "integer",
"format": "int64",
"description": "Unconfirmed (mempool) received — 0-conf. `0` until the LWS reports mempool\nrows (the monero-lws mempool feature); never negative.",
"minimum": 0
},
"scanned_height": {
"type": "integer",
"format": "int64",
"minimum": 0
},
"spent_outputs": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SpentOutputDto"
},
"description": "Candidate spent outputs (confirmed + mempool) for client-side key-image\nverification with the spend key. Never authoritative server-side."
},
"start_height": {
"type": "integer",
"format": "int64",
"minimum": 0
},
"total_received": {
"type": "integer",
"format": "int64",
@@ -2604,11 +2624,6 @@
"type": "integer",
"format": "int64",
"minimum": 0
},
"unlocked": {
"type": "integer",
"format": "int64",
"minimum": 0
}
}
},