diff --git a/openapi.json b/openapi.json index 5bffa99..47ca51a 100644 --- a/openapi.json +++ b/openapi.json @@ -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 } } }, diff --git a/src/api/wallet/xmr_wow.rs b/src/api/wallet/xmr_wow.rs index bc634d2..3439b62 100644 --- a/src/api/wallet/xmr_wow.rs +++ b/src/api/wallet/xmr_wow.rs @@ -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, } /// 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 = 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, })) } diff --git a/src/infra/lws/client.rs b/src/infra/lws/client.rs index 80c99e5..0c7c0bd 100644 --- a/src/infra/lws/client.rs +++ b/src/infra/lws/client.rs @@ -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)) diff --git a/src/infra/lws/mod.rs b/src/infra/lws/mod.rs index 83d4a95..229b650 100644 --- a/src/infra/lws/mod.rs +++ b/src/infra/lws/mod.rs @@ -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,