From b2d727be08f1841f044923db4b2ca3e04e7de099 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Sun, 19 Jul 2026 18:12:14 -0400 Subject: [PATCH] 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. --- openapi.json | 33 ++++++------------ src/api/wallet/xmr_wow.rs | 71 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 23 deletions(-) diff --git a/openapi.json b/openapi.json index 7c4da52..5d65d13 100644 --- a/openapi.json +++ b/openapi.json @@ -3728,15 +3728,11 @@ "minimum": 0 }, "locked_balance": { - "type": "integer", - "format": "int64", - "minimum": 0 + "type": "string" }, "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 + "type": "string", + "description": "Unconfirmed (mempool) received — 0-conf. `0` until the LWS reports mempool\nrows (the monero-lws mempool feature); never negative." }, "scanned_height": { "type": "integer", @@ -3756,9 +3752,8 @@ "minimum": 0 }, "total_received": { - "type": "integer", - "format": "int64", - "minimum": 0 + "type": "string", + "example": "12345000000000" }, "transaction_count": { "type": "integer", @@ -4626,9 +4621,7 @@ ], "properties": { "amount": { - "type": "integer", - "format": "int64", - "minimum": 0 + "type": "string" }, "key_image": { "type": "string" @@ -4742,15 +4735,11 @@ "type": "string" }, "total_received": { - "type": "integer", - "format": "int64", - "minimum": 0 + "type": "string" }, "total_sent": { - "type": "integer", - "format": "int64", - "description": "\"Possible\" sent — candidate spends, not authoritative.", - "minimum": 0 + "type": "string", + "description": "\"Possible\" sent — candidate spends, not authoritative." }, "unlock_time": { "type": "integer", @@ -4776,9 +4765,7 @@ ], "properties": { "amount": { - "type": "integer", - "format": "int64", - "minimum": 0 + "type": "string" }, "global_index": { "type": "integer", diff --git a/src/api/wallet/xmr_wow.rs b/src/api/wallet/xmr_wow.rs index 2c317f1..651d7e3 100644 --- a/src/api/wallet/xmr_wow.rs +++ b/src/api/wallet/xmr_wow.rs @@ -120,6 +120,14 @@ pub struct ConfirmationsRequest { // ── 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(v: &u64, s: S) -> Result { + 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 @@ -129,10 +137,16 @@ pub struct ConfirmationsRequest { #[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, @@ -146,6 +160,8 @@ pub struct LwsBalanceResponse { /// 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, @@ -159,8 +175,12 @@ 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, @@ -177,6 +197,8 @@ pub struct LwsHistoryResponse { /// 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, @@ -609,3 +631,52 @@ pub fn routes() -> Router> { .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"); + } +}