mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 15:31:38 +02:00
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:
+25
-10
@@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user