mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 15:31:38 +02:00
feat: LWS client for Monero/Wownero (hardened, generic)
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).
This commit is contained in:
Generated
+15
@@ -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"
|
||||
|
||||
+3
-2
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<String>) -> 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<String> 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());
|
||||
}
|
||||
}
|
||||
@@ -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, AppError> {
|
||||
Self::new(CryptoNoteNetwork::Monero, cfg)
|
||||
}
|
||||
|
||||
/// Build a Wownero LWS client from its config.
|
||||
pub fn wownero(cfg: &LwsConfig) -> Result<Self, AppError> {
|
||||
Self::new(CryptoNoteNetwork::Wownero, cfg)
|
||||
}
|
||||
|
||||
/// Construct a client with a shared timeout-bounded HTTP client.
|
||||
pub fn new(network: CryptoNoteNetwork, cfg: &LwsConfig) -> Result<Self, AppError> {
|
||||
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<AddressInfo, AppError> {
|
||||
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<u64, AppError> {
|
||||
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<u64, AppError> {
|
||||
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<AddressTxsResponse, AppError> {
|
||||
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<u64, AppError> {
|
||||
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<UnspentOutsResponse, AppError> {
|
||||
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<String>,
|
||||
) -> Result<RandomOutsResponse, AppError> {
|
||||
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<u64>,
|
||||
) -> 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<ListAccountsResponse, AppError> {
|
||||
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<Option<u64>, 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<String>,
|
||||
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<String>, 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<u64, AppError> {
|
||||
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<GetInfo>,
|
||||
#[serde(default)]
|
||||
error: Option<serde_json::Value>,
|
||||
}
|
||||
#[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<Option<u64>, 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<String>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct Resp {
|
||||
#[serde(default)]
|
||||
txs: Option<Vec<TxInfo>>,
|
||||
#[serde(default)]
|
||||
status: String,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct TxInfo {
|
||||
block_height: Option<u64>,
|
||||
in_pool: Option<bool>,
|
||||
}
|
||||
|
||||
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<B, R>(
|
||||
&self,
|
||||
url: String,
|
||||
label: &'static str,
|
||||
body: &B,
|
||||
) -> Result<R, AppError>
|
||||
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::<R>(&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<B: Serialize>(
|
||||
&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<u64>,
|
||||
in_pool: Option<bool>,
|
||||
current_height: u64,
|
||||
) -> Option<u64> {
|
||||
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<Vec<u8>, AppError> {
|
||||
use futures::StreamExt;
|
||||
let mut stream = resp.bytes_stream();
|
||||
let mut buf: Vec<u8> = 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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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<u64, D::Error>
|
||||
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<u64, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum StringOrIntOrNull {
|
||||
String(String),
|
||||
Int(u64),
|
||||
Null,
|
||||
}
|
||||
match Option::<StringOrIntOrNull>::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<u32, D::Error>
|
||||
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<u8, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum StringOrIntOrNull {
|
||||
String(String),
|
||||
Int(u8),
|
||||
Null,
|
||||
}
|
||||
match Option::<StringOrIntOrNull>::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<u64>,
|
||||
}
|
||||
|
||||
#[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<u32>,
|
||||
pub use_dust: bool,
|
||||
pub dust_threshold: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct GetRandomOutsRequest {
|
||||
pub count: u32,
|
||||
pub amounts: Vec<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
pub status: AccountStatus,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(crate) struct RescanRequest {
|
||||
pub auth: String,
|
||||
pub params: RescanParams,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(crate) struct RescanParams {
|
||||
pub addresses: Vec<String>,
|
||||
/// 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<AddressTx>,
|
||||
#[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<String>,
|
||||
/// Candidate spends — must be verified client-side with the spend key.
|
||||
#[serde(default)]
|
||||
pub spent_outputs: Vec<SpentOutput>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
/// Response from `get_unspent_outs`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UnspentOutsResponse {
|
||||
#[serde(default)]
|
||||
pub outputs: Vec<UnspentOutput>,
|
||||
#[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<AmountOuts>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct AmountOuts {
|
||||
pub amount: String,
|
||||
pub outputs: Vec<RandomOutput>,
|
||||
}
|
||||
|
||||
/// 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<AccountEntry>,
|
||||
#[serde(default)]
|
||||
pub hidden: Vec<AccountEntry>,
|
||||
#[serde(default)]
|
||||
pub inactive: Vec<AccountEntry>,
|
||||
}
|
||||
|
||||
/// 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::<U64Holder>(r#"{"v":"42"}"#)
|
||||
.unwrap()
|
||||
.v,
|
||||
42
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<U64Holder>(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::<U64Holder>(r#"{"v":"not-a-number"}"#).is_err());
|
||||
assert!(
|
||||
serde_json::from_str::<U64Holder>(r#"{"v":"99999999999999999999999999"}"#).is_err()
|
||||
);
|
||||
assert!(serde_json::from_str::<U64Holder>(r#"{"v":"-1"}"#).is_err());
|
||||
assert!(serde_json::from_str::<U64Holder>(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::<U64DefHolder>(r#"{"v":null}"#)
|
||||
.unwrap()
|
||||
.v,
|
||||
0
|
||||
);
|
||||
assert_eq!(serde_json::from_str::<U64DefHolder>(r#"{}"#).unwrap().v, 0);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<U64DefHolder>(r#"{"v":"7"}"#)
|
||||
.unwrap()
|
||||
.v,
|
||||
7
|
||||
);
|
||||
// Garbage still errors (default is only for null/missing).
|
||||
assert!(serde_json::from_str::<U64DefHolder>(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);
|
||||
}
|
||||
}
|
||||
@@ -3,3 +3,4 @@
|
||||
|
||||
pub mod db;
|
||||
pub mod electrum;
|
||||
pub mod lws;
|
||||
|
||||
Reference in New Issue
Block a user