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
}
}
},
+41 -9
View File
@@ -28,7 +28,9 @@ use tracing::instrument;
use super::{validate_cn_address, validate_hex, validate_view_key};
use crate::api::middleware::extract_user_id_from_token;
use crate::error::AppError;
use crate::infra::lws::{AddressTx, LwsClient, RandomOutput, SpentOutput, UnspentOutput};
use crate::infra::lws::{
sum_mempool_received, AddressTx, LwsClient, RandomOutput, SpentOutput, UnspentOutput,
};
use crate::AppState;
/// Cap on a submitted Monero/Wownero tx hex. CryptoNote txs (rings, bulletproofs)
@@ -113,17 +115,27 @@ pub struct ConfirmationsRequest {
// ── response DTOs ─────────────────────────────────────────────────────────────
/// Balance + scan state. `total_received` is the balance (an LWS cannot confirm
/// spends without the spend key; the wallet subtracts its own known spends).
/// Balance + scan state, as a **verification passthrough**. The backend holds no
/// spend key, so it cannot net out spends; the wallet computes the true spendable
/// balance client-side: `total_received − sum(spent_outputs it verifies with the
/// spend key) − locked_balance`. `spent_outputs` are therefore CANDIDATES (some
/// are ring decoys of the user's own outputs); `pending_balance` is the 0-conf
/// (mempool) received.
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct LwsBalanceResponse {
pub asset: String,
pub total_received: u64,
pub unlocked: u64,
pub locked: u64,
pub locked_balance: u64,
/// Unconfirmed (mempool) received — 0-conf. `0` until the LWS reports mempool
/// rows (the monero-lws mempool feature); never negative.
pub pending_balance: u64,
pub start_height: u64,
pub scanned_height: u64,
pub blockchain_height: u64,
pub transaction_count: u64,
/// Candidate spent outputs (confirmed + mempool) for client-side key-image
/// verification with the spend key. Never authoritative server-side.
pub spent_outputs: Vec<SpentOutputDto>,
}
/// A candidate spent output (verify with the spend key before trusting).
@@ -339,15 +351,35 @@ pub async fn balance(
validate_cn_address(&req.address)?;
validate_view_key(&req.view_key)?;
let info = client.get_address_info(&req.address, &req.view_key).await?;
// Verification passthrough: address-info gives received/locked/heights;
// address-txs gives the candidate spent_outputs (the wallet verifies them
// with its spend key) and the mempool rows (0-conf pending). Fetched
// together so a balance read is one round-trip of latency, not two.
let (info, txs) = tokio::join!(
client.get_address_info(&req.address, &req.view_key),
client.get_address_txs(&req.address, &req.view_key),
);
let info = info?;
let txs = txs?;
let pending_balance = sum_mempool_received(&txs.transactions);
let spent_outputs: Vec<SpentOutputDto> = txs
.transactions
.into_iter()
.flat_map(|t| t.spent_outputs)
.map(spent_dto)
.collect();
Ok(Json(LwsBalanceResponse {
asset,
total_received: info.balance(),
unlocked: info.unlocked_balance(),
locked: info.locked_funds,
total_received: info.total_received,
locked_balance: info.locked_funds,
pending_balance,
start_height: info.start_height,
scanned_height: info.scanned_height,
blockchain_height: info.blockchain_height,
transaction_count: info.transaction_count,
spent_outputs,
}))
}
+1 -1
View File
@@ -460,7 +460,7 @@ impl LwsClient {
/// Sum the mempool (unconfirmed) net-received amounts, saturating at each step
/// so a hostile per-tx value cannot overflow-panic the total.
fn sum_mempool_received(txs: &[AddressTx]) -> u64 {
pub(crate) fn sum_mempool_received(txs: &[AddressTx]) -> u64 {
txs.iter()
.filter(|t| t.mempool)
.map(|t| t.total_received.saturating_sub(t.total_sent))
+1
View File
@@ -9,6 +9,7 @@
mod client;
mod types;
pub(crate) use client::sum_mempool_received;
pub use client::LwsClient;
pub use types::{
AccountEntry, AccountStatus, AddressInfo, AddressTx, AddressTxsResponse, AmountOuts,