Files
smirk-backend-core/src/api/wallet/xmr_wow.rs
T
jwinterm b2d727be08 fix(lws): serialize XMR/WOW atomic amounts as strings (u64 > 2^53 safe)
XMR/WOW atomic amounts (piconero/wownoshi) can exceed 2^53, which a
JavaScript number cannot hold exactly, so a large balance or output was
silently rounded when the wallet JSON.parsed it. Serialize every atomic
amount (balance total_received/locked/pending, spent + unspent output
amounts, history totals) as a decimal string; heights, indices, counts,
and fee params stay numbers. utoipa value_type=String keeps the OpenAPI
honest. Tests assert the string wire contract for a 2^53+1 value.
2026-07-19 18:12:14 -04:00

683 lines
24 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Monero/Wownero wallet handlers — authenticated, stateless-forward proxies
//! over the [`LwsClient`](crate::infra::lws::LwsClient).
//!
//! The backend is non-custodial and holds no view secret: the wallet sends its
//! private view key per request, the backend forwards it to the LWS (which holds
//! it only for scanning), and the wallet constructs + signs spends locally. The
//! one-time `register` call forwards the view key to the LWS `add_account` so it
//! begins scanning.
//!
//! Conventions (matching [`crate::api::users`] / [`super::btc_ltc`]):
//! * JWT-gated; the view key is `skip`-ped from every span so it is never logged.
//! * `asset` is a lowercase string (`"xmr"`/`"wow"`); disabled/unknown is a 400.
//! * Amounts are atomic units (piconero / wownoshi) as `u64`.
//! * snake_case wire fields; every DTO derives `utoipa::ToSchema`.
//! * Routes are RELATIVE to the `/api/v1` mount point; see [`routes`].
use std::sync::Arc;
use axum::{
extract::{DefaultBodyLimit, State},
http::HeaderMap,
routing::post,
Json, Router,
};
use serde::{Deserialize, Serialize};
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::{
sum_mempool_received, AddressTx, LwsClient, RandomOutput, SpentOutput, UnspentOutput,
};
use crate::AppState;
/// Cap on a submitted Monero/Wownero tx hex. CryptoNote txs (rings, bulletproofs)
/// are larger than UTXO txs; 2 MiB of hex is well above any real single tx.
const MAX_CN_TX_HEX_LEN: usize = 2 * 1024 * 1024;
/// Resolve the LWS client for a CryptoNote asset, or a 400 (unknown / disabled).
fn lws_for<'a>(state: &'a AppState, asset: &str) -> Result<&'a LwsClient, AppError> {
let client = match asset {
"xmr" => state.chains.xmr.as_ref(),
"wow" => state.chains.wow.as_ref(),
other => {
return Err(AppError::ValidationError(format!(
"Invalid CryptoNote asset: {other} (expected xmr or wow)"
)))
}
};
client.ok_or_else(|| {
AppError::ValidationError(format!("{asset} support is not enabled on this server"))
})
}
// ── request DTOs ──────────────────────────────────────────────────────────────
/// Address + private view key for a per-account query.
// Deliberately omits `Debug`: it carries the private `view_key`, matching the
// crate convention for secret-bearing request structs — so it can't be dumped in
// cleartext via a stray `{:?}`, panic message, or added log line.
#[derive(Deserialize, utoipa::ToSchema)]
pub struct ViewRequest {
/// `xmr` or `wow`.
pub asset: String,
pub address: String,
/// Private view key (64 hex). Forwarded to the LWS; never stored or logged.
pub view_key: String,
}
/// Register (or import-with-height) an account for LWS scanning.
// Omits `Debug` (carries the private `view_key`) — see `ViewRequest`.
#[derive(Deserialize, utoipa::ToSchema)]
pub struct RegisterRequest {
pub asset: String,
pub address: String,
/// Private view key (64 hex). Forwarded to the LWS; never stored or logged.
pub view_key: String,
/// Scan from this block height (wallet birthday). Omit to scan from now.
pub start_height: Option<u64>,
/// Restore proof-of-work nonce. Required when the instance prices the
/// requested restore depth (see `/capabilities` → `restore.pow_*`); ignored
/// otherwise. Bound to `(asset, address, start_height)` (see `restore_pow`).
#[serde(default)]
pub restore_pow_nonce: Option<u64>,
}
/// Request decoy outputs for ring construction.
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct RandomOutsRequest {
pub asset: String,
/// Decoys per real output (protocol ring size; clamped server-side).
pub count: u32,
/// Amounts to request decoys for (`["0"]` for RingCT).
pub amounts: Vec<String>,
}
/// Submit a finalized, signed transaction.
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct SubmitRequest {
pub asset: String,
/// Hex-encoded signed transaction blob.
pub tx_hex: String,
}
/// Asset-only query.
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct LwsAssetRequest {
/// `xmr` or `wow`.
pub asset: String,
}
/// Confirmation-count query for a tx.
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct ConfirmationsRequest {
pub asset: String,
/// Transaction id (64 hex).
pub txid: String,
}
// ── response DTOs ─────────────────────────────────────────────────────────────
/// Serialize a `u64` atomic amount as a decimal STRING. XMR/WOW amounts can exceed
/// 2^53, which a JavaScript `number` cannot hold exactly, so every atomic amount
/// crosses the wire as a string; the wallet BigInt-parses it. Heights, indices,
/// counts, and fee params stay numbers (always well under 2^53).
fn ser_u64_str<S: serde::Serializer>(v: &u64, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&v.to_string())
}
/// 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,
#[serde(serialize_with = "ser_u64_str")]
#[schema(value_type = String, example = "12345000000000")]
pub total_received: u64,
#[serde(serialize_with = "ser_u64_str")]
#[schema(value_type = String)]
pub locked_balance: u64,
/// Unconfirmed (mempool) received — 0-conf. `0` until the LWS reports mempool
/// rows (the monero-lws mempool feature); never negative.
#[serde(serialize_with = "ser_u64_str")]
#[schema(value_type = String)]
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).
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct SpentOutputDto {
#[serde(serialize_with = "ser_u64_str")]
#[schema(value_type = String)]
pub amount: u64,
pub key_image: String,
pub tx_pub_key: String,
pub out_index: u64,
pub mixin: u64,
}
/// A transaction in the account's history.
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct TxDto {
pub hash: String,
pub height: u64,
pub timestamp: String,
#[serde(serialize_with = "ser_u64_str")]
#[schema(value_type = String)]
pub total_received: u64,
/// "Possible" sent — candidate spends, not authoritative.
#[serde(serialize_with = "ser_u64_str")]
#[schema(value_type = String)]
pub total_sent: u64,
pub mempool: bool,
pub unlock_time: u64,
pub payment_id: Option<String>,
pub spent_outputs: Vec<SpentOutputDto>,
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct LwsHistoryResponse {
pub asset: String,
pub transactions: Vec<TxDto>,
}
/// An unspent output for spend construction.
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct UnspentOutputDto {
#[serde(serialize_with = "ser_u64_str")]
#[schema(value_type = String)]
pub amount: u64,
pub public_key: String,
pub tx_pub_key: String,
pub index: u32,
pub global_index: u64,
pub height: u64,
pub timestamp: String,
pub tx_hash: String,
pub rct: String,
/// Key images seen on-chain that may correspond to this output being spent.
pub spend_key_images: Vec<String>,
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct UnspentOutsResponse {
pub asset: String,
pub outputs: Vec<UnspentOutputDto>,
pub per_byte_fee: u64,
pub fee_mask: u64,
pub fork_version: u8,
}
/// A decoy output.
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct RandomOutputDto {
pub global_index: u64,
pub public_key: String,
pub rct: String,
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct AmountOutsDto {
pub amount: String,
pub outputs: Vec<RandomOutputDto>,
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct RandomOutsResponse {
pub asset: String,
pub amount_outs: Vec<AmountOutsDto>,
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct HeightResponse {
pub asset: String,
pub height: u64,
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct ConfirmationsResponse {
pub asset: String,
/// Confirmations, or `null` if the tx is unknown / not yet in a block.
pub confirmations: Option<u64>,
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct OkResponse {
pub ok: bool,
}
// ── mappers (infra type -> wire DTO) ──────────────────────────────────────────
fn spent_dto(s: SpentOutput) -> SpentOutputDto {
SpentOutputDto {
amount: s.amount,
key_image: s.key_image,
tx_pub_key: s.tx_pub_key,
out_index: s.out_index,
mixin: s.mixin,
}
}
fn tx_dto(t: AddressTx) -> TxDto {
TxDto {
hash: t.hash,
height: t.height,
timestamp: t.timestamp,
total_received: t.total_received,
total_sent: t.total_sent,
mempool: t.mempool,
unlock_time: t.unlock_time,
payment_id: t.payment_id,
spent_outputs: t.spent_outputs.into_iter().map(spent_dto).collect(),
}
}
fn unspent_dto(u: UnspentOutput) -> UnspentOutputDto {
UnspentOutputDto {
amount: u.amount,
public_key: u.public_key,
tx_pub_key: u.tx_pub_key,
index: u.index,
global_index: u.global_index,
height: u.height,
timestamp: u.timestamp,
tx_hash: u.tx_hash,
rct: u.rct,
spend_key_images: u.spend_key_images,
}
}
fn random_dto(r: RandomOutput) -> RandomOutputDto {
RandomOutputDto {
global_index: r.global_index,
public_key: r.public_key,
rct: r.rct,
}
}
// ── handlers ──────────────────────────────────────────────────────────────────
/// Register an account (its view key) with the LWS so it begins scanning.
/// Idempotent at the LWS; pass `start_height` to scan from a wallet birthday.
#[utoipa::path(
security(("bearer_auth" = [])),
post,
path = "/wallet/lws/register",
request_body = RegisterRequest,
responses(
(status = 200, description = "Account registered for scanning", body = OkResponse),
(status = 400, description = "Invalid/disabled asset, address, or view key"),
(status = 401, description = "Missing or invalid token"),
(status = 503, description = "Upstream node unavailable")
),
tag = "xmr_wow"
)]
#[instrument(skip(state, headers, req), fields(asset = %req.asset))]
pub async fn register(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(req): Json<RegisterRequest>,
) -> Result<Json<OkResponse>, AppError> {
extract_user_id_from_token(&state, &headers).await?;
let asset = req.asset.to_lowercase();
let client = lws_for(&state, &asset)?;
validate_cn_address(&req.address)?;
validate_view_key(&req.view_key)?;
match req.start_height {
Some(h) => {
// Restore: gate the scan depth against this instance's policy (the
// backfill cost lands on our LWS). `None` (create) needs no check.
let tip = client.get_blockchain_height().await?;
state.cfg().restore.enforce(&asset, h, tip)?;
state.cfg().restore.enforce_restore_pow(
&asset,
&req.address,
h,
tip,
req.restore_pow_nonce,
)?;
client
.import_account(&req.address, &req.view_key, h)
.await?
}
None => client.register_account(&req.address, &req.view_key).await?,
}
Ok(Json(OkResponse { ok: true }))
}
/// Balance + scan state for an account.
#[utoipa::path(
security(("bearer_auth" = [])),
post,
path = "/wallet/lws/balance",
request_body = ViewRequest,
responses(
(status = 200, description = "Account balance and scan state", body = LwsBalanceResponse),
(status = 400, description = "Invalid/disabled asset, address, or view key"),
(status = 401, description = "Missing or invalid token"),
(status = 503, description = "Upstream node unavailable")
),
tag = "xmr_wow"
)]
#[instrument(skip(state, headers, req), fields(asset = %req.asset))]
pub async fn balance(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(req): Json<ViewRequest>,
) -> Result<Json<LwsBalanceResponse>, AppError> {
extract_user_id_from_token(&state, &headers).await?;
let asset = req.asset.to_lowercase();
let client = lws_for(&state, &asset)?;
validate_cn_address(&req.address)?;
validate_view_key(&req.view_key)?;
// 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.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,
}))
}
/// Transaction history (confirmed + mempool) for an account.
#[utoipa::path(
security(("bearer_auth" = [])),
post,
path = "/wallet/lws/history",
request_body = ViewRequest,
responses(
(status = 200, description = "Account transaction history", body = LwsHistoryResponse),
(status = 400, description = "Invalid/disabled asset, address, or view key"),
(status = 401, description = "Missing or invalid token"),
(status = 503, description = "Upstream node unavailable")
),
tag = "xmr_wow"
)]
#[instrument(skip(state, headers, req), fields(asset = %req.asset))]
pub async fn history(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(req): Json<ViewRequest>,
) -> Result<Json<LwsHistoryResponse>, AppError> {
extract_user_id_from_token(&state, &headers).await?;
let asset = req.asset.to_lowercase();
let client = lws_for(&state, &asset)?;
validate_cn_address(&req.address)?;
validate_view_key(&req.view_key)?;
let txs = client.get_address_txs(&req.address, &req.view_key).await?;
Ok(Json(LwsHistoryResponse {
asset,
transactions: txs.transactions.into_iter().map(tx_dto).collect(),
}))
}
/// Unspent outputs for an account (for client-side spend construction).
#[utoipa::path(
security(("bearer_auth" = [])),
post,
path = "/wallet/lws/unspent_outs",
request_body = ViewRequest,
responses(
(status = 200, description = "Unspent outputs + fee parameters", body = UnspentOutsResponse),
(status = 400, description = "Invalid/disabled asset, address, or view key"),
(status = 401, description = "Missing or invalid token"),
(status = 503, description = "Upstream node unavailable")
),
tag = "xmr_wow"
)]
#[instrument(skip(state, headers, req), fields(asset = %req.asset))]
pub async fn unspent_outs(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(req): Json<ViewRequest>,
) -> Result<Json<UnspentOutsResponse>, AppError> {
extract_user_id_from_token(&state, &headers).await?;
let asset = req.asset.to_lowercase();
let client = lws_for(&state, &asset)?;
validate_cn_address(&req.address)?;
validate_view_key(&req.view_key)?;
let outs = client.get_unspent_outs(&req.address, &req.view_key).await?;
Ok(Json(UnspentOutsResponse {
asset,
outputs: outs.outputs.into_iter().map(unspent_dto).collect(),
per_byte_fee: outs.per_byte_fee,
fee_mask: outs.fee_mask,
fork_version: outs.fork_version,
}))
}
/// Random decoy outputs for ring construction.
#[utoipa::path(
security(("bearer_auth" = [])),
post,
path = "/wallet/lws/random_outs",
request_body = RandomOutsRequest,
responses(
(status = 200, description = "Decoy outputs", body = RandomOutsResponse),
(status = 400, description = "Invalid/disabled asset or request"),
(status = 401, description = "Missing or invalid token"),
(status = 503, description = "Upstream node unavailable")
),
tag = "xmr_wow"
)]
#[instrument(skip(state, headers, req), fields(asset = %req.asset))]
pub async fn random_outs(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(req): Json<RandomOutsRequest>,
) -> Result<Json<RandomOutsResponse>, AppError> {
extract_user_id_from_token(&state, &headers).await?;
let asset = req.asset.to_lowercase();
let client = lws_for(&state, &asset)?;
// The client clamps `count` and caps `amounts`/fan-out defensively.
let outs = client.get_random_outs(req.count, req.amounts).await?;
Ok(Json(RandomOutsResponse {
asset,
amount_outs: outs
.amount_outs
.into_iter()
.map(|a| AmountOutsDto {
amount: a.amount,
outputs: a.outputs.into_iter().map(random_dto).collect(),
})
.collect(),
}))
}
/// Broadcast a finalized, signed Monero/Wownero transaction.
#[utoipa::path(
security(("bearer_auth" = [])),
post,
path = "/wallet/lws/submit_tx",
request_body = SubmitRequest,
responses(
(status = 200, description = "Transaction submitted", body = OkResponse),
(status = 400, description = "Invalid/disabled asset or malformed tx hex"),
(status = 401, description = "Missing or invalid token"),
(status = 503, description = "Upstream node unavailable")
),
tag = "xmr_wow"
)]
#[instrument(skip(state, headers, req), fields(asset = %req.asset))]
pub async fn submit_tx(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(req): Json<SubmitRequest>,
) -> Result<Json<OkResponse>, AppError> {
extract_user_id_from_token(&state, &headers).await?;
let asset = req.asset.to_lowercase();
let client = lws_for(&state, &asset)?;
validate_hex(&req.tx_hex, "tx_hex", MAX_CN_TX_HEX_LEN)?;
client.submit_raw_tx(&req.tx_hex).await?;
Ok(Json(OkResponse { ok: true }))
}
/// Current chain height (daemon) for confirmation counting.
#[utoipa::path(
security(("bearer_auth" = [])),
post,
path = "/wallet/lws/height",
request_body = LwsAssetRequest,
responses(
(status = 200, description = "Chain height", body = HeightResponse),
(status = 400, description = "Invalid/disabled asset"),
(status = 401, description = "Missing or invalid token"),
(status = 503, description = "Upstream node unavailable")
),
tag = "xmr_wow"
)]
#[instrument(skip(state, headers, req), fields(asset = %req.asset))]
pub async fn height(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(req): Json<LwsAssetRequest>,
) -> Result<Json<HeightResponse>, AppError> {
extract_user_id_from_token(&state, &headers).await?;
let asset = req.asset.to_lowercase();
let height = lws_for(&state, &asset)?.get_blockchain_height().await?;
Ok(Json(HeightResponse { asset, height }))
}
/// Confirmation count for a transaction (daemon).
#[utoipa::path(
security(("bearer_auth" = [])),
post,
path = "/wallet/lws/confirmations",
request_body = ConfirmationsRequest,
responses(
(status = 200, description = "Confirmation count", body = ConfirmationsResponse),
(status = 400, description = "Invalid/disabled asset or txid"),
(status = 401, description = "Missing or invalid token"),
(status = 503, description = "Upstream node unavailable")
),
tag = "xmr_wow"
)]
#[instrument(skip(state, headers, req), fields(asset = %req.asset))]
pub async fn confirmations(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(req): Json<ConfirmationsRequest>,
) -> Result<Json<ConfirmationsResponse>, AppError> {
extract_user_id_from_token(&state, &headers).await?;
let asset = req.asset.to_lowercase();
let client = lws_for(&state, &asset)?;
validate_hex(&req.txid, "txid", 64)?;
let confirmations = client.get_transaction_confirmations(&req.txid).await?;
Ok(Json(ConfirmationsResponse {
asset,
confirmations,
}))
}
// ── router ────────────────────────────────────────────────────────────────────
/// Monero/Wownero routes, RELATIVE to the `/api/v1` mount point.
pub fn routes() -> Router<Arc<AppState>> {
Router::new()
.route("/wallet/lws/register", post(register))
.route("/wallet/lws/balance", post(balance))
.route("/wallet/lws/history", post(history))
.route("/wallet/lws/unspent_outs", post(unspent_outs))
.route("/wallet/lws/random_outs", post(random_outs))
// submit_tx carries a raw tx hex (up to MAX_CN_TX_HEX_LEN); raise its
// body cap above the global limit, with headroom for the JSON envelope.
.route(
"/wallet/lws/submit_tx",
post(submit_tx).layer(DefaultBodyLimit::max(MAX_CN_TX_HEX_LEN + 64 * 1024)),
)
.route("/wallet/lws/height", post(height))
.route("/wallet/lws/confirmations", post(confirmations))
}
#[cfg(test)]
mod amount_wire_tests {
use super::*;
// XMR/WOW atomic amounts can exceed 2^53, so they must cross the wire as decimal
// STRINGS (a JS number would round them). This locks that contract.
const BIG: u64 = 9_007_199_254_740_993; // 2^53 + 1
#[test]
fn unspent_output_amount_serializes_as_string() {
let dto = UnspentOutputDto {
amount: BIG,
public_key: "aa".into(),
tx_pub_key: "bb".into(),
index: 0,
global_index: 1,
height: 2,
timestamp: "t".into(),
tx_hash: "h".into(),
rct: String::new(),
spend_key_images: vec![],
};
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(v["amount"], serde_json::json!("9007199254740993"));
assert!(v["amount"].is_string(), "amount must be a JSON string");
// A non-amount field stays a number.
assert!(v["global_index"].is_number());
}
#[test]
fn balance_amounts_serialize_as_strings() {
let bal = LwsBalanceResponse {
asset: "xmr".into(),
total_received: BIG,
locked_balance: 0,
pending_balance: 0,
start_height: 100,
scanned_height: 100,
blockchain_height: 100,
transaction_count: 1,
spent_outputs: vec![],
};
let v = serde_json::to_value(&bal).unwrap();
assert_eq!(v["total_received"], serde_json::json!("9007199254740993"));
assert!(v["locked_balance"].is_string() && v["pending_balance"].is_string());
assert!(v["blockchain_height"].is_number(), "heights stay numbers");
}
}