From 7ddb586cbebfe674c170902fba3313d206d3fc29 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Mon, 22 Jun 2026 22:25:28 -0400 Subject: [PATCH] feat: LWS client for Monero/Wownero (hardened, generic) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reworked clean from the chain-audit playbook into one generic client over CryptoNoteNetwork (Monero + Wownero share the LWS API). The LWS may be remote/community-run, so every response is treated as hostile. Security (closes the audit CRITICAL/HIGH/MEDIUM findings): - The two `&str` byte-slice panics are GONE: responses are parsed via a generic post_json -> read_capped -> serde_json::from_slice path. No byte-indexing of an untrusted body anywhere; the JSON-error-column slice is deleted. - Streaming size cap (8 MiB) enforced as bytes arrive (content-length is attacker-asserted) + shared reqwest client with request/connect timeouts. No unbounded read, no slowloris. - Admin key held in a redacting `Secret` (Debug -> "***"); secret-bearing request structs drop `Debug`; #[instrument] skips view_key. - Non-success -> generic NodeError tagged with a static endpoint label + status code only; the upstream body is never interpolated into a log or error. Transport (connect) errors are logged privately, redacted publicly. - Amounts are u64 end-to-end via the fail-closed string_or_* deserializers (garbage/overflow -> Err, never panic/silent-zero); mempool sum and confirmation math use saturating ops; future-block confirmation -> 0. - Ring/decoy count clamped (MAX_DECOY_COUNT) + request/parsed fan-out capped (MAX_PARSED_VEC). 0-conf architecture: default is REST-poll (compute pending on-demand from get_address_txs, which includes mempool on current monero-lws). HTTP webhooks are NOT brought over (redundant + fragile per docs/lws-update.md); ZMQ-push stays an optional, feature-flagged unit for single/own backends wanting instant confirms. Kept the account-lifecycle calls (modify_account_status/rescan/account_scan_height) — those are the deep-sleep machinery REST-poll uses to wake/sleep accounts for scale. Dropped the /login pending flow + all dead/tip surface. Adds a shared redacting `Secret` (core::secret, grin reuses) and the reqwest `stream` feature for bounded reads. types.rs kept (the string_or_* fail-closed helpers) with secret request structs de-Debug'd and error-not-panic tests. 15 L2 tests. Adversarially reviewed (7/7 audit findings closed; future-block confirmation hardened). --- Cargo.lock | 15 + Cargo.toml | 5 +- src/core/mod.rs | 1 + src/core/secret.rs | 62 +++++ src/infra/lws/client.rs | 597 ++++++++++++++++++++++++++++++++++++++++ src/infra/lws/mod.rs | 17 ++ src/infra/lws/types.rs | 480 ++++++++++++++++++++++++++++++++ src/infra/mod.rs | 1 + 8 files changed, 1176 insertions(+), 2 deletions(-) create mode 100644 src/core/secret.rs create mode 100644 src/infra/lws/client.rs create mode 100644 src/infra/lws/mod.rs create mode 100644 src/infra/lws/types.rs diff --git a/Cargo.lock b/Cargo.lock index acae2a7..26b60b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2271,10 +2271,12 @@ dependencies = [ "tokio", "tokio-native-tls", "tokio-rustls 0.24.1", + "tokio-util", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", "webpki-roots 0.25.4", "winreg", @@ -3784,6 +3786,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "web-sys" version = "0.3.102" diff --git a/Cargo.toml b/Cargo.toml index 3c12095..643e346 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,8 +43,9 @@ serde_json = "1.0" # via utoipa, then drives the generated TypeScript client. Single source of truth. utoipa = { version = "5", features = ["chrono", "uuid"] } -# HTTP Client -reqwest = { version = "0.11", features = ["json", "rustls-tls"] } +# HTTP Client. `stream` enables bounded, cap-while-reading response bodies +# (content-length is attacker-asserted, so we enforce the cap as bytes arrive). +reqwest = { version = "0.11", features = ["json", "rustls-tls", "stream"] } # Cryptography hmac = "0.12" diff --git a/src/core/mod.rs b/src/core/mod.rs index 7b3f598..a61339f 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -5,6 +5,7 @@ pub mod crypto; pub mod pow; +pub mod secret; pub mod session; // address and grin_confirmation are wired in as they land. diff --git a/src/core/secret.rs b/src/core/secret.rs new file mode 100644 index 0000000..ac3f6e3 --- /dev/null +++ b/src/core/secret.rs @@ -0,0 +1,62 @@ +//! A string secret that never reveals itself through `Debug`. +//! +//! Wrap in-memory credentials (LWS admin keys, Grin API secrets, wallet +//! passwords) carried by structs that derive `Debug` or are captured by +//! `#[instrument]`, so an accidental `{:?}` or a traced field can't leak them. +//! Read the underlying value only at the point of use via [`Secret::expose`], +//! and never log the result. + +/// A redacting wrapper around a sensitive `String`. +#[derive(Clone)] +pub struct Secret(String); + +impl Secret { + /// Wrap a sensitive value. + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } + + /// Borrow the secret value. Call only where the value is actually consumed + /// (e.g. building a request body); never log or format the result. + pub fn expose(&self) -> &str { + &self.0 + } + + /// Whether the secret is empty (e.g. an unconfigured credential). + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +impl std::fmt::Debug for Secret { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("Secret(***)") + } +} + +impl From for Secret { + fn from(value: String) -> Self { + Self(value) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn debug_is_redacted() { + let s = Secret::new("super-secret-admin-key"); + assert_eq!(format!("{s:?}"), "Secret(***)"); + // The value is never present in the debug rendering. + assert!(!format!("{s:?}").contains("super-secret")); + } + + #[test] + fn expose_returns_value() { + let s = Secret::new("value"); + assert_eq!(s.expose(), "value"); + assert!(!s.is_empty()); + assert!(Secret::new("").is_empty()); + } +} diff --git a/src/infra/lws/client.rs b/src/infra/lws/client.rs new file mode 100644 index 0000000..80c99e5 --- /dev/null +++ b/src/infra/lws/client.rs @@ -0,0 +1,597 @@ +//! LWS (light-wallet-server) client for Monero/Wownero. +//! +//! The backend runs no Monero/Wownero wallet — an LWS scans the chain for +//! registered view keys and answers balance/history/output/broadcast queries. +//! Wownero is a Monero fork with an identical LWS API, so one generic client +//! serves both, keyed by [`CryptoNoteNetwork`]. +//! +//! Every response is treated as hostile (the LWS may be remote/community-run): +//! +//! * a single shared `reqwest::Client` with request + connect timeouts; +//! * each response body is read through a **streaming size cap** and parsed +//! with `serde_json::from_slice` — no byte-indexing of an untrusted body +//! (the old `&text[..2000]` / error-column slices are gone); +//! * non-success responses map to a generic [`AppError::NodeError`] tagged +//! with a static endpoint label + status — the response body is never +//! interpolated into a log or error; +//! * the admin key is held in a redacting [`Secret`] and skipped in tracing; +//! * the decoy/ring count and request fan-out are clamped/capped. + +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use std::time::Duration; +use tracing::instrument; + +use super::types::*; +use crate::config::LwsConfig; +use crate::core::secret::Secret; +use crate::error::AppError; + +/// Per-request deadline. +const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); +/// TCP connect deadline. +const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); +/// Hard cap on any LWS/daemon response body (enforced while streaming). +const MAX_LWS_BODY_BYTES: usize = 8 * 1024 * 1024; +/// Defensive ceiling on the per-output decoy count (the protocol ring is ~16). +const MAX_DECOY_COUNT: u32 = 64; +/// Ceiling on amounts requested in one `get_random_outs` call, and on parsed +/// fan-out Vecs — defends against a hostile server inflating compute/memory +/// (the body cap is the primary bound; these are belt-and-suspenders). +const MAX_PARSED_VEC: usize = 100_000; + +/// Generic LWS client for a Monero-family network. +#[derive(Clone)] +pub struct LwsClient { + network: CryptoNoteNetwork, + user_url: String, + admin_url: String, + admin_key: Secret, + daemon_url: String, + http: reqwest::Client, +} + +impl LwsClient { + /// Build a Monero LWS client from its config. + pub fn monero(cfg: &LwsConfig) -> Result { + Self::new(CryptoNoteNetwork::Monero, cfg) + } + + /// Build a Wownero LWS client from its config. + pub fn wownero(cfg: &LwsConfig) -> Result { + Self::new(CryptoNoteNetwork::Wownero, cfg) + } + + /// Construct a client with a shared timeout-bounded HTTP client. + pub fn new(network: CryptoNoteNetwork, cfg: &LwsConfig) -> Result { + let http = reqwest::Client::builder() + .timeout(REQUEST_TIMEOUT) + .connect_timeout(CONNECT_TIMEOUT) + .build() + .map_err(|_| AppError::NodeError("failed to build LWS HTTP client".into()))?; + Ok(Self { + network, + user_url: cfg.lws_url.clone(), + admin_url: cfg.lws_admin_url.clone(), + admin_key: Secret::new(cfg.lws_admin_key.clone()), + daemon_url: cfg.daemon_url.clone(), + http, + }) + } + + /// The network this client serves. + pub fn network(&self) -> CryptoNoteNetwork { + self.network + } + + // ── user API ──────────────────────────────────────────────────────────── + + /// Address info (balance + scan state). + #[instrument(skip(self, view_key), fields(net = %self.network))] + pub async fn get_address_info( + &self, + address: &str, + view_key: &str, + ) -> Result { + let url = format!("{}/get_address_info", self.user_url); + let body = GetAddressInfoRequest { + address: address.to_string(), + view_key: view_key.to_string(), + }; + self.post_json(url, "get_address_info", &body).await + } + + /// Balance (received) in atomic units. + pub async fn get_balance(&self, address: &str, view_key: &str) -> Result { + Ok(self.get_address_info(address, view_key).await?.balance()) + } + + /// Unlocked (spendable) balance in atomic units. + pub async fn get_unlocked_balance( + &self, + address: &str, + view_key: &str, + ) -> Result { + Ok(self + .get_address_info(address, view_key) + .await? + .unlocked_balance()) + } + + /// Transaction history (confirmed + mempool). + #[instrument(skip(self, view_key), fields(net = %self.network))] + pub async fn get_address_txs( + &self, + address: &str, + view_key: &str, + ) -> Result { + let url = format!("{}/get_address_txs", self.user_url); + let body = GetAddressTxsRequest { + address: address.to_string(), + view_key: view_key.to_string(), + }; + let resp: AddressTxsResponse = self.post_json(url, "get_address_txs", &body).await?; + if resp.transactions.len() > MAX_PARSED_VEC { + return Err(self.node_err("get_address_txs", "response too large")); + } + Ok(resp) + } + + /// Sum of unconfirmed (mempool) received amounts — the "pending" balance. + pub async fn get_mempool_balance( + &self, + address: &str, + view_key: &str, + ) -> Result { + let txs = self.get_address_txs(address, view_key).await?; + Ok(sum_mempool_received(&txs.transactions)) + } + + /// Unspent outputs for transaction construction. + #[instrument(skip(self, view_key), fields(net = %self.network))] + pub async fn get_unspent_outs( + &self, + address: &str, + view_key: &str, + ) -> Result { + let url = format!("{}/get_unspent_outs", self.user_url); + let body = GetUnspentOutsRequest { + address: address.to_string(), + view_key: view_key.to_string(), + amount: "0".to_string(), // all + mixin: None, + use_dust: true, + dust_threshold: "0".to_string(), + }; + let resp: UnspentOutsResponse = self.post_json(url, "get_unspent_outs", &body).await?; + if resp.outputs.len() > MAX_PARSED_VEC { + return Err(self.node_err("get_unspent_outs", "response too large")); + } + Ok(resp) + } + + /// Random decoy outputs for ring selection. `count` is the per-output decoy + /// count (protocol-fixed, ~16); it is clamped and the amount list is bounded + /// so a hostile/buggy caller or server cannot inflate the fan-out. + #[instrument(skip(self), fields(net = %self.network))] + pub async fn get_random_outs( + &self, + count: u32, + amounts: Vec, + ) -> Result { + if amounts.len() > MAX_PARSED_VEC { + return Err(AppError::ValidationError( + "too many amounts requested".into(), + )); + } + let count = count.min(MAX_DECOY_COUNT); + let url = format!("{}/get_random_outs", self.user_url); + let body = GetRandomOutsRequest { count, amounts }; + let resp: RandomOutsResponse = self.post_json(url, "get_random_outs", &body).await?; + if resp.amount_outs.len() > MAX_PARSED_VEC { + return Err(self.node_err("get_random_outs", "response too large")); + } + Ok(resp) + } + + /// Broadcast a signed raw transaction. + #[instrument(skip(self, tx_hex), fields(net = %self.network))] + pub async fn submit_raw_tx(&self, tx_hex: &str) -> Result<(), AppError> { + let url = format!("{}/submit_raw_tx", self.user_url); + let body = SubmitRawTxRequest { + tx: tx_hex.to_string(), + }; + self.post_ok(url, "submit_raw_tx", &body).await + } + + // ── admin API ──────────────────────────────────────────────────────────── + + /// Register + activate an account (admin `add_account`), scanning from the + /// current height. + #[instrument(skip(self, view_key), fields(net = %self.network))] + pub async fn register_account(&self, address: &str, view_key: &str) -> Result<(), AppError> { + self.admin_add_account(address, view_key, None).await + } + + /// Register + activate an account scanning from a specific `start_height` + /// (avoids re-scanning from genesis for an older wallet). + #[instrument(skip(self, view_key), fields(net = %self.network))] + pub async fn import_account( + &self, + address: &str, + view_key: &str, + start_height: u64, + ) -> Result<(), AppError> { + self.admin_add_account(address, view_key, Some(start_height)) + .await + } + + async fn admin_add_account( + &self, + address: &str, + view_key: &str, + start_height: Option, + ) -> Result<(), AppError> { + let url = format!("{}/add_account", self.admin_url); + let body = AdminAddAccountRequest { + auth: self.admin_key.expose().to_string(), + params: AdminAddAccountParams { + address: address.to_string(), + key: view_key.to_string(), + start_height, + }, + }; + self.post_ok(url, "add_account", &body).await + } + + /// List all registered accounts (active / inactive / hidden). + #[instrument(skip(self), fields(net = %self.network))] + pub async fn list_accounts(&self) -> Result { + let url = format!("{}/list_accounts", self.admin_url); + let body = ListAccountsBody { + auth: self.admin_key.expose().to_string(), + }; + self.post_json(url, "list_accounts", &body).await + } + + /// Current `scan_height` for `address`, or `None` if the LWS doesn't know it. + /// + /// Filters `list_accounts` client-side; fine at small account counts. If the + /// account set grows large, switch to a per-account query. + pub async fn account_scan_height(&self, address: &str) -> Result, AppError> { + let accounts = self.list_accounts().await?; + for bucket in [&accounts.active, &accounts.inactive, &accounts.hidden] { + if let Some(entry) = bucket.iter().find(|e| e.address == address) { + return Ok(Some(entry.scan_height)); + } + } + Ok(None) + } + + /// Set the scan status of accounts (active / inactive / hidden). + #[instrument(skip(self), fields(net = %self.network, status = ?status))] + pub async fn modify_account_status( + &self, + addresses: Vec, + status: AccountStatus, + ) -> Result<(), AppError> { + if addresses.is_empty() { + return Ok(()); + } + let url = format!("{}/modify_account_status", self.admin_url); + let body = ModifyAccountStatusRequest { + auth: self.admin_key.expose().to_string(), + params: ModifyAccountStatusParams { addresses, status }, + }; + self.post_ok(url, "modify_account_status", &body).await + } + + /// Tell the LWS to rescan `addresses` from `height`. + /// + /// **SAFETY: backwards-only.** monero-lws resets `scan_height` to `height` + /// and re-scans forward. `height >= current scan_height` is undefined + /// behavior (can leave the account inactive or corrupt LMDB state). Callers + /// MUST read the current `scan_height` first and only invoke when strictly + /// lowering it. + #[instrument(skip(self), fields(net = %self.network, height, n = addresses.len()))] + pub async fn rescan(&self, addresses: Vec, height: u64) -> Result<(), AppError> { + if addresses.is_empty() { + return Ok(()); + } + let url = format!("{}/rescan", self.admin_url); + let body = RescanRequest { + auth: self.admin_key.expose().to_string(), + params: RescanParams { addresses, height }, + }; + self.post_ok(url, "rescan", &body).await + } + + // ── daemon API (direct node queries) ────────────────────────────────────── + + /// Current chain height from the daemon (`get_info`). + #[instrument(skip(self), fields(net = %self.network))] + pub async fn get_blockchain_height(&self) -> Result { + if self.daemon_url.is_empty() { + return Err(self.node_err("daemon", "daemon URL not configured")); + } + + #[derive(Serialize)] + struct Req { + jsonrpc: &'static str, + id: &'static str, + method: &'static str, + } + #[derive(Deserialize)] + struct Resp { + result: Option, + #[serde(default)] + error: Option, + } + #[derive(Deserialize)] + struct GetInfo { + height: u64, + } + + let url = format!("{}/json_rpc", self.daemon_url); + let body = Req { + jsonrpc: "2.0", + id: "0", + method: "get_info", + }; + let resp: Resp = self.post_json(url, "daemon get_info", &body).await?; + if resp.error.is_some() { + return Err(self.node_err("daemon get_info", "daemon returned an error")); + } + resp.result + .map(|r| r.height) + .ok_or_else(|| self.node_err("daemon get_info", "no result")) + } + + /// Confirmation count for `txid`, or `None` if not found / still in mempool + /// without a height. Queries the daemon's `/get_transactions`. + #[instrument(skip(self), fields(net = %self.network))] + pub async fn get_transaction_confirmations(&self, txid: &str) -> Result, AppError> { + if self.daemon_url.is_empty() { + return Err(self.node_err("daemon", "daemon URL not configured")); + } + let current_height = self.get_blockchain_height().await?; + + #[derive(Serialize)] + struct Req { + txs_hashes: Vec, + } + #[derive(Deserialize)] + struct Resp { + #[serde(default)] + txs: Option>, + #[serde(default)] + status: String, + } + #[derive(Deserialize)] + struct TxInfo { + block_height: Option, + in_pool: Option, + } + + let url = format!("{}/get_transactions", self.daemon_url); + let body = Req { + txs_hashes: vec![txid.to_string()], + }; + let resp: Resp = self + .post_json(url, "daemon get_transactions", &body) + .await?; + if resp.status != "OK" { + return Ok(None); + } + let Some(tx) = resp.txs.and_then(|txs| txs.into_iter().next()) else { + return Ok(None); + }; + Ok(confirmations_from( + tx.block_height, + tx.in_pool, + current_height, + )) + } + + /// Liveness probe — reaches the admin API. + pub async fn health_check(&self) -> Result<(), AppError> { + self.list_accounts().await?; + Ok(()) + } + + // ── internal HTTP helpers ───────────────────────────────────────────────── + + /// POST a JSON body and deserialize a successful response from a size-capped + /// buffer. Non-success → generic `NodeError` (no body interpolation). + async fn post_json( + &self, + url: String, + label: &'static str, + body: &B, + ) -> Result + where + B: Serialize, + R: DeserializeOwned, + { + let resp = self.http.post(&url).json(body).send().await.map_err(|e| { + // Transport-level failure (connect/DNS/timeout) — not a response + // body. Safe to log privately; redacted from the client response. + AppError::NodeError(format!("{} LWS {label} request failed: {e}", self.network)) + })?; + if !resp.status().is_success() { + return Err(self.node_err_status(label, resp.status())); + } + let bytes = read_capped(resp, MAX_LWS_BODY_BYTES).await?; + serde_json::from_slice::(&bytes).map_err(|_| self.node_err(label, "invalid response")) + } + + /// POST a JSON body where only success/failure matters (no response body). + async fn post_ok( + &self, + url: String, + label: &'static str, + body: &B, + ) -> Result<(), AppError> { + let resp = self.http.post(&url).json(body).send().await.map_err(|e| { + // Transport-level failure (connect/DNS/timeout) — not a response + // body. Safe to log privately; redacted from the client response. + AppError::NodeError(format!("{} LWS {label} request failed: {e}", self.network)) + })?; + if resp.status().is_success() { + Ok(()) + } else { + Err(self.node_err_status(label, resp.status())) + } + } + + /// A network-tagged node error. `label`/`detail` are static; the untrusted + /// response body is never included. + fn node_err(&self, label: &str, detail: &str) -> AppError { + AppError::NodeError(format!("{} LWS {label}: {detail}", self.network)) + } + + fn node_err_status(&self, label: &str, status: reqwest::StatusCode) -> AppError { + AppError::NodeError(format!( + "{} LWS {label} failed (HTTP {})", + self.network, + status.as_u16() + )) + } +} + +/// 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 { + txs.iter() + .filter(|t| t.mempool) + .map(|t| t.total_received.saturating_sub(t.total_sent)) + .fold(0u64, |acc, v| acc.saturating_add(v)) +} + +/// Confirmation count for a tx given the daemon's view of it. `None` = not +/// found / no height yet; `Some(0)` = in the mempool, or an *inconsistent* +/// block claim (a height beyond our reported tip — a lagging or lying daemon), +/// which is treated as not-yet-confirmed rather than a bogus `1`. +fn confirmations_from( + block_height: Option, + in_pool: Option, + current_height: u64, +) -> Option { + if in_pool == Some(true) { + return Some(0); + } + match block_height { + Some(h) if h > current_height => Some(0), + Some(h) => Some(current_height.saturating_sub(h).saturating_add(1)), + None => None, + } +} + +/// Read a response body into memory, enforcing `cap` as bytes arrive (the +/// content-length header is attacker-asserted, so it is not trusted). +async fn read_capped(resp: reqwest::Response, cap: usize) -> Result, AppError> { + use futures::StreamExt; + let mut stream = resp.bytes_stream(); + let mut buf: Vec = Vec::new(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|_| AppError::NodeError("LWS read failed".into()))?; + if buf.len().saturating_add(chunk.len()) > cap { + return Err(AppError::NodeError( + "LWS response exceeded size limit".into(), + )); + } + buf.extend_from_slice(&chunk); + } + Ok(buf) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cfg() -> LwsConfig { + LwsConfig { + lws_url: "http://127.0.0.1:8443".into(), + lws_admin_url: "http://127.0.0.1:9443".into(), + lws_admin_key: "test-admin-key".into(), + daemon_url: String::new(), + } + } + + #[test] + fn constructors_set_network() { + assert_eq!( + LwsClient::monero(&cfg()).unwrap().network(), + CryptoNoteNetwork::Monero + ); + assert_eq!( + LwsClient::wownero(&cfg()).unwrap().network(), + CryptoNoteNetwork::Wownero + ); + } + + #[test] + fn admin_key_is_redacted_in_debug() { + let client = LwsClient::monero(&cfg()).unwrap(); + // The Secret wrapper must hide the key even if the field is formatted. + assert_eq!(format!("{:?}", client.admin_key), "Secret(***)"); + assert!(!format!("{:?}", client.admin_key).contains("test-admin-key")); + } + + fn tx(mempool: bool, received: u64, sent: u64) -> AddressTx { + AddressTx { + hash: "h".into(), + height: 0, + timestamp: String::new(), + total_received: received, + total_sent: sent, + mempool, + unlock_time: 0, + payment_id: None, + spent_outputs: vec![], + } + } + + #[test] + fn mempool_sum_counts_only_mempool_and_saturates() { + let txs = vec![ + tx(true, 1000, 200), // net 800 (mempool) + tx(false, 9999, 0), // confirmed — excluded + tx(true, 50, 100), // net 0 via saturating_sub (sent > received) + tx(true, u64::MAX, 0), // huge — total saturates, never panics + ]; + assert_eq!(sum_mempool_received(&txs), u64::MAX); + + let modest = vec![tx(true, 1000, 200), tx(true, 500, 0), tx(false, 1, 0)]; + assert_eq!(sum_mempool_received(&modest), 1300); + } + + #[tokio::test] + async fn get_random_outs_rejects_too_many_amounts() { + let client = LwsClient::monero(&cfg()).unwrap(); + // The amount-count guard fires before any network call. + let amounts = vec!["0".to_string(); MAX_PARSED_VEC + 1]; + let err = client.get_random_outs(11, amounts).await.unwrap_err(); + assert!(matches!(err, AppError::ValidationError(_)), "{err:?}"); + } + + #[test] + fn confirmations_math_handles_mempool_tip_and_future() { + // Mempool → 0 confirmations. + assert_eq!(confirmations_from(None, Some(true), 110), Some(0)); + // In a past block: current - height + 1. + assert_eq!(confirmations_from(Some(100), Some(false), 110), Some(11)); + // In the tip block: exactly 1. + assert_eq!(confirmations_from(Some(110), None, 110), Some(1)); + // A block beyond our tip (lagging/lying daemon) → 0, not a bogus 1. + assert_eq!(confirmations_from(Some(120), Some(false), 110), Some(0)); + // Confirmed flag absent and no height → unknown. + assert_eq!(confirmations_from(None, None, 110), None); + } + + #[tokio::test] + async fn daemon_height_requires_daemon_url() { + let client = LwsClient::monero(&cfg()).unwrap(); // daemon_url empty + let err = client.get_blockchain_height().await.unwrap_err(); + assert!(matches!(err, AppError::NodeError(_)), "{err:?}"); + } +} diff --git a/src/infra/lws/mod.rs b/src/infra/lws/mod.rs new file mode 100644 index 0000000..83d4a95 --- /dev/null +++ b/src/infra/lws/mod.rs @@ -0,0 +1,17 @@ +//! Light-wallet-server (LWS) client for Monero/Wownero. +//! +//! The backend holds no spend key and runs no wallet. An LWS scans the chain +//! for registered view keys and answers balance / history / output / broadcast +//! queries; this module is a hardened client of that service (config-pointed, +//! timeout- and size-bounded, secret-redacting). Monero and Wownero share the +//! API, so one [`LwsClient`] serves both via [`CryptoNoteNetwork`]. + +mod client; +mod types; + +pub use client::LwsClient; +pub use types::{ + AccountEntry, AccountStatus, AddressInfo, AddressTx, AddressTxsResponse, AmountOuts, + CryptoNoteNetwork, ListAccountsResponse, RandomOutput, RandomOutsResponse, SpentOutput, + UnspentOutput, UnspentOutsResponse, +}; diff --git a/src/infra/lws/types.rs b/src/infra/lws/types.rs new file mode 100644 index 0000000..d95ab17 --- /dev/null +++ b/src/infra/lws/types.rs @@ -0,0 +1,480 @@ +//! Type definitions for the LWS (light-wallet-server) API. +//! +//! monero-lws sometimes encodes numeric fields as JSON strings (e.g. `"0"`), +//! so amounts use the `string_or_*` fail-closed deserializers: a malformed or +//! out-of-range value becomes a deserialization *error*, never a panic and +//! never a silently-wrong number. All amounts are atomic units (piconero / +//! wownoshi) carried as `u64`. +//! +//! Request structs that carry a secret (`auth` admin key, `view_key`) do **not** +//! derive `Debug`, so they cannot be accidentally logged. + +use serde::{Deserialize, Serialize}; + +// ============================================================================ +// Serde helpers — string-or-integer, fail-closed (Err, never panic) +// ============================================================================ + +/// Deserialize a `u64` that may arrive as a JSON string or integer. +pub mod string_or_u64 { + use serde::{Deserialize, Deserializer}; + + pub fn deserialize<'de, D>(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(untagged)] + enum StringOrInt { + String(String), + Int(u64), + } + match StringOrInt::deserialize(deserializer)? { + StringOrInt::String(s) => s.parse().map_err(serde::de::Error::custom), + StringOrInt::Int(n) => Ok(n), + } + } +} + +/// Deserialize a `u64` that may arrive as a string, integer, or null/missing +/// (null/missing → 0). +pub mod string_or_u64_default { + use serde::{Deserialize, Deserializer}; + + pub fn deserialize<'de, D>(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(untagged)] + enum StringOrIntOrNull { + String(String), + Int(u64), + Null, + } + match Option::::deserialize(deserializer)? { + Some(StringOrIntOrNull::String(s)) => s.parse().map_err(serde::de::Error::custom), + Some(StringOrIntOrNull::Int(n)) => Ok(n), + Some(StringOrIntOrNull::Null) | None => Ok(0), + } + } +} + +/// Deserialize a `u32` that may arrive as a JSON string or integer. +pub mod string_or_u32 { + use serde::{Deserialize, Deserializer}; + + pub fn deserialize<'de, D>(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(untagged)] + enum StringOrInt { + String(String), + Int(u32), + } + match StringOrInt::deserialize(deserializer)? { + StringOrInt::String(s) => s.parse().map_err(serde::de::Error::custom), + StringOrInt::Int(n) => Ok(n), + } + } +} + +/// Deserialize a `u8` that may arrive as a string, integer, or null/missing +/// (null/missing → 0). +pub mod string_or_u8_default { + use serde::{Deserialize, Deserializer}; + + pub fn deserialize<'de, D>(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(untagged)] + enum StringOrIntOrNull { + String(String), + Int(u8), + Null, + } + match Option::::deserialize(deserializer)? { + Some(StringOrIntOrNull::String(s)) => s.parse().map_err(serde::de::Error::custom), + Some(StringOrIntOrNull::Int(n)) => Ok(n), + Some(StringOrIntOrNull::Null) | None => Ok(0), + } + } +} + +// ============================================================================ +// Network +// ============================================================================ + +/// A CryptoNote network served by an LWS. Monero and Wownero share the LWS API +/// (Wownero is a Monero fork); the distinction is the atomic-unit scale and +/// daemon ports. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CryptoNoteNetwork { + Monero, + Wownero, +} + +impl std::fmt::Display for CryptoNoteNetwork { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Monero => f.write_str("monero"), + Self::Wownero => f.write_str("wownero"), + } + } +} + +// ============================================================================ +// Request types (internal). Secret-bearing structs deliberately omit `Debug`. +// ============================================================================ + +/// Admin `add_account`: directly add + activate an account (view-only scan). +#[derive(Serialize)] +pub(crate) struct AdminAddAccountRequest { + pub auth: String, + pub params: AdminAddAccountParams, +} + +#[derive(Serialize)] +pub(crate) struct AdminAddAccountParams { + pub address: String, + /// The private view key. + pub key: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub start_height: Option, +} + +#[derive(Serialize)] +pub(crate) struct GetAddressInfoRequest { + pub address: String, + pub view_key: String, +} + +#[derive(Serialize)] +pub(crate) struct GetAddressTxsRequest { + pub address: String, + pub view_key: String, +} + +#[derive(Serialize)] +pub(crate) struct GetUnspentOutsRequest { + pub address: String, + pub view_key: String, + /// "0" for all. + pub amount: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub mixin: Option, + pub use_dust: bool, + pub dust_threshold: String, +} + +#[derive(Debug, Serialize)] +pub(crate) struct GetRandomOutsRequest { + pub count: u32, + pub amounts: Vec, +} + +#[derive(Debug, Serialize)] +pub(crate) struct SubmitRawTxRequest { + /// Hex-encoded transaction blob. + pub tx: String, +} + +#[derive(Serialize)] +pub(crate) struct ListAccountsBody { + pub auth: String, +} + +#[derive(Serialize)] +pub(crate) struct ModifyAccountStatusRequest { + pub auth: String, + pub params: ModifyAccountStatusParams, +} + +#[derive(Serialize)] +pub(crate) struct ModifyAccountStatusParams { + pub addresses: Vec, + pub status: AccountStatus, +} + +#[derive(Serialize)] +pub(crate) struct RescanRequest { + pub auth: String, + pub params: RescanParams, +} + +#[derive(Serialize)] +pub(crate) struct RescanParams { + pub addresses: Vec, + /// Target start height — MUST be strictly less than the account's current + /// `scan_height`. Higher values are undefined behavior (see `LwsClient::rescan`). + pub height: u64, +} + +// ============================================================================ +// Response types (public) +// ============================================================================ + +/// Response from `get_address_txs`. +#[derive(Debug, Clone, Deserialize)] +pub struct AddressTxsResponse { + #[serde(default)] + pub transactions: Vec, + #[serde(default, deserialize_with = "string_or_u64_default::deserialize")] + pub total_received: u64, + #[serde(default, deserialize_with = "string_or_u64_default::deserialize")] + pub scanned_height: u64, + #[serde(default, deserialize_with = "string_or_u64_default::deserialize")] + pub blockchain_height: u64, +} + +/// A transaction from `get_address_txs`. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct AddressTx { + pub hash: String, + #[serde(default, deserialize_with = "string_or_u64_default::deserialize")] + pub height: u64, + #[serde(default)] + pub timestamp: String, + #[serde(default, deserialize_with = "string_or_u64_default::deserialize")] + pub total_received: u64, + /// "Possible" sent — outputs that MAY be spent. LWS cannot confirm spends + /// without the spend key, so this is never treated as authoritative. + #[serde(default, deserialize_with = "string_or_u64_default::deserialize")] + pub total_sent: u64, + #[serde(default)] + pub mempool: bool, + #[serde(default, deserialize_with = "string_or_u64_default::deserialize")] + pub unlock_time: u64, + #[serde(default)] + pub payment_id: Option, + /// Candidate spends — must be verified client-side with the spend key. + #[serde(default)] + pub spent_outputs: Vec, +} + +/// A candidate spent output from LWS (verify with the spend key before trusting). +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct SpentOutput { + #[serde(default, deserialize_with = "string_or_u64_default::deserialize")] + pub amount: u64, + pub key_image: String, + pub tx_pub_key: String, + #[serde(default, deserialize_with = "string_or_u64_default::deserialize")] + pub out_index: u64, + #[serde(default, deserialize_with = "string_or_u64_default::deserialize")] + pub mixin: u64, +} + +/// Address info (balance and scan state) from `get_address_info`. +#[derive(Debug, Clone, Deserialize)] +pub struct AddressInfo { + #[serde(default, deserialize_with = "string_or_u64_default::deserialize")] + pub locked_funds: u64, + #[serde(default, deserialize_with = "string_or_u64_default::deserialize")] + pub total_received: u64, + #[serde(default, deserialize_with = "string_or_u64_default::deserialize")] + pub total_sent: u64, + #[serde(default, deserialize_with = "string_or_u64_default::deserialize")] + pub scanned_height: u64, + #[serde(default, deserialize_with = "string_or_u64_default::deserialize")] + pub start_height: u64, + #[serde(default, deserialize_with = "string_or_u64_default::deserialize")] + pub transaction_count: u64, + #[serde(default)] + pub scanned_block_hash: String, + #[serde(default, deserialize_with = "string_or_u64_default::deserialize")] + pub blockchain_height: u64, +} + +impl AddressInfo { + /// Balance in atomic units. + /// + /// Uses `total_received` only, NOT `total_received - total_sent`: LWS's + /// `total_sent` is "possible spends" (it cannot confirm a spend without the + /// spend key). As a view-only service we report received funds. + pub fn balance(&self) -> u64 { + self.total_received + } + + /// Unlocked (confirmed) balance = received minus locked (unconfirmed) funds. + pub fn unlocked_balance(&self) -> u64 { + self.total_received.saturating_sub(self.locked_funds) + } +} + +/// An unspent output from LWS. +#[derive(Debug, Clone, Deserialize)] +pub struct UnspentOutput { + #[serde(deserialize_with = "string_or_u64::deserialize")] + pub amount: u64, + pub public_key: String, + pub tx_pub_key: String, + #[serde(deserialize_with = "string_or_u32::deserialize")] + pub index: u32, + #[serde(deserialize_with = "string_or_u64::deserialize")] + pub global_index: u64, + #[serde(deserialize_with = "string_or_u64::deserialize")] + pub height: u64, + #[serde(default)] + pub timestamp: String, + #[serde(default)] + pub tx_hash: String, + #[serde(default)] + pub rct: String, + /// Key images LWS has seen on-chain that may correspond to this output being + /// spent. Non-empty ⇒ likely spent. + #[serde(default)] + pub spend_key_images: Vec, +} + +/// Response from `get_unspent_outs`. +#[derive(Debug, Deserialize)] +pub struct UnspentOutsResponse { + #[serde(default)] + pub outputs: Vec, + #[serde(default, deserialize_with = "string_or_u64_default::deserialize")] + pub per_byte_fee: u64, + #[serde(default, deserialize_with = "string_or_u64_default::deserialize")] + pub fee_mask: u64, + #[serde(default, deserialize_with = "string_or_u8_default::deserialize")] + pub fork_version: u8, +} + +/// A decoy output for ring selection. +#[derive(Debug, Clone, Deserialize)] +pub struct RandomOutput { + #[serde(deserialize_with = "string_or_u64::deserialize")] + pub global_index: u64, + pub public_key: String, + pub rct: String, +} + +/// Response from `get_random_outs`. +#[derive(Debug, Deserialize)] +pub struct RandomOutsResponse { + #[serde(default)] + pub amount_outs: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AmountOuts { + pub amount: String, + pub outputs: Vec, +} + +/// An account entry from `list_accounts`. +#[derive(Debug, Clone, Deserialize)] +pub struct AccountEntry { + pub address: String, + #[serde(default, deserialize_with = "string_or_u64_default::deserialize")] + pub scan_height: u64, + #[serde(default, deserialize_with = "string_or_u64_default::deserialize")] + pub access_time: u64, +} + +/// Response from `list_accounts`. +#[derive(Debug, Deserialize)] +pub struct ListAccountsResponse { + #[serde(default)] + pub active: Vec, + #[serde(default)] + pub hidden: Vec, + #[serde(default)] + pub inactive: Vec, +} + +/// Account scan status for `modify_account_status`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum AccountStatus { + /// Actively scanning. + Active, + /// Preserved but not scanning (reactivatable). + Inactive, + /// Not scanning, minimal storage. + Hidden, +} + +#[cfg(test)] +mod tests { + use super::*; + + // The string_or_* helpers must FAIL (Err), never panic, on hostile input — + // they run on bytes from a possibly-malicious LWS. + + #[derive(Deserialize)] + struct U64Holder { + #[serde(deserialize_with = "string_or_u64::deserialize")] + v: u64, + } + + #[test] + fn string_or_u64_accepts_string_and_int() { + assert_eq!( + serde_json::from_str::(r#"{"v":"42"}"#) + .unwrap() + .v, + 42 + ); + assert_eq!( + serde_json::from_str::(r#"{"v":42}"#).unwrap().v, + 42 + ); + } + + #[test] + fn string_or_u64_rejects_garbage_without_panic() { + // Non-numeric string, overflow, float, and bool all error cleanly. + assert!(serde_json::from_str::(r#"{"v":"not-a-number"}"#).is_err()); + assert!( + serde_json::from_str::(r#"{"v":"99999999999999999999999999"}"#).is_err() + ); + assert!(serde_json::from_str::(r#"{"v":"-1"}"#).is_err()); + assert!(serde_json::from_str::(r#"{"v":true}"#).is_err()); + } + + #[derive(Deserialize)] + struct U64DefHolder { + #[serde(default, deserialize_with = "string_or_u64_default::deserialize")] + v: u64, + } + + #[test] + fn string_or_u64_default_handles_null_and_missing() { + assert_eq!( + serde_json::from_str::(r#"{"v":null}"#) + .unwrap() + .v, + 0 + ); + assert_eq!(serde_json::from_str::(r#"{}"#).unwrap().v, 0); + assert_eq!( + serde_json::from_str::(r#"{"v":"7"}"#) + .unwrap() + .v, + 7 + ); + // Garbage still errors (default is only for null/missing). + assert!(serde_json::from_str::(r#"{"v":"x"}"#).is_err()); + } + + #[test] + fn balance_uses_received_not_difference() { + let info = AddressInfo { + locked_funds: 1000, + total_received: 10_000, + total_sent: 3000, + scanned_height: 100, + start_height: 50, + transaction_count: 5, + scanned_block_hash: String::new(), + blockchain_height: 100, + }; + assert_eq!(info.balance(), 10_000); + assert_eq!(info.unlocked_balance(), 9000); + } +} diff --git a/src/infra/mod.rs b/src/infra/mod.rs index 384b3da..6c3edf8 100644 --- a/src/infra/mod.rs +++ b/src/infra/mod.rs @@ -3,3 +3,4 @@ pub mod db; pub mod electrum; +pub mod lws;