diff --git a/.env.example b/.env.example index 88536e2..a83dcb4 100644 --- a/.env.example +++ b/.env.example @@ -107,6 +107,16 @@ GRIN_NODE_API_PASS= GRIN_NODE_FOREIGN_API_URL=http://127.0.0.1:3413/v2/foreign GRIN_NODE_FOREIGN_API_SECRET= +# ── Grin light-wallet-server (grin-lws) — optional add-on to the Grin chain ─ +# When GRIN_LWS_URL is set (and FEATURE_GRIN is on) the Grin scan proxies to +# grin-lws first, trusting it only when it is provably synced to the tip, and +# otherwise falling back to the authoritative grin-wallet scan. Leave GRIN_LWS_URL +# empty to use grin-wallet only. GRIN_LWS_ADMIN_URL / GRIN_LWS_ADMIN_KEY reach +# grin-lws's bearer-gated admin API (optional). +GRIN_LWS_URL= +GRIN_LWS_ADMIN_URL= +GRIN_LWS_ADMIN_KEY= + # ── Wallet restore (import) policy ──────────────────────────────────────── # How far back a wallet may ask THIS instance to scan on import. The backfill # cost lands on your LWS/node, so it is your call; it is advertised via diff --git a/openapi.json b/openapi.json index f35abc4..c52bcbe 100644 --- a/openapi.json +++ b/openapi.json @@ -895,6 +895,47 @@ } } }, + "/wallet/grin/address/{addr}/user": { + "get": { + "tags": [ + "grin" + ], + "summary": "Resolve a bare Grin address to its owning user's Nostr routing identity.", + "description": "The address→npub bridge (see [`GrinAddressUserResponse`]). JWT-gated: only\nauthenticated senders resolve routing. An address unknown to this backend\nreturns the constant-shape `registered: false` response — the caller then\nfalls back to manual clipboard or the same-instance relay.", + "operationId": "address_user", + "parameters": [ + { + "name": "addr", + "in": "path", + "description": "Grin address (registered public key)", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Address resolution result", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GrinAddressUserResponse" + } + } + } + }, + "401": { + "description": "Missing or invalid token" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, "/wallet/grin/broadcast": { "post": { "tags": [ @@ -2467,6 +2508,7 @@ "type": "object", "required": [ "grin_relay", + "grin_lws", "prices", "nostr_identity", "nostr_native_auth", @@ -2480,6 +2522,10 @@ "type": "boolean", "description": "Public curated Nostr feed for this instance. See `feed`." }, + "grin_lws": { + "type": "boolean", + "description": "Grin light-wallet-server (grin-lws) scan path is available — fast balance\nscans via grin-lws, with the authoritative grin-wallet scan as fallback." + }, "grin_relay": { "type": "boolean", "description": "Grin async slatepack relay mailbox." @@ -2612,6 +2658,33 @@ } } }, + "GrinAddressUserResponse": { + "type": "object", + "description": "Resolution of a bare Grin address to its owning user's routing identity.\n\nThe address→npub **bridge**: given a `grin1…` slatepack address, report\nwhether it belongs to a user registered on *this* backend and, if so, that\nuser's linked Nostr pubkey so a bare-address send can be upgraded to a\nfederated Nostr gift-wrap instead of the same-instance relay.\n\nNOTE (federation): this is a **same-instance convenience**. It can only\nresolve addresses whose owner registered their Grin key here (via `POST\n/keys`). It is *not* a federated directory — a wallet on another backend\nwon't be found. `registered: false` (a constant-shape miss, mirroring\n`GET /users/by-username`) means \"unknown to this instance\", at which point\nthe sender falls back to manual clipboard / the backend relay.", + "required": [ + "registered" + ], + "properties": { + "npub": { + "type": [ + "string", + "null" + ], + "description": "The owning user's linked Nostr pubkey (x-only hex), when they have one.\nThis is what the sender feeds the Nostr gift-wrap channel to route the\nslate over Nostr. `None` when the user registered no Nostr identity." + }, + "registered": { + "type": "boolean", + "description": "Whether the address resolved to a user on this backend." + }, + "user_id": { + "type": [ + "string", + "null" + ], + "description": "The owning user's id (UUID), when `registered`." + } + } + }, "GrinBroadcastRequest": { "type": "object", "description": "Broadcast a finalized, signed Grin transaction (built + signed by the wallet).", @@ -2672,6 +2745,13 @@ "is_coinbase": { "type": "boolean" }, + "key_id": { + "type": [ + "string", + "null" + ], + "description": "Recovered derivation key id — populated only on the grin-lws path." + }, "lock_height": { "type": "integer", "format": "int64", @@ -2682,6 +2762,22 @@ "format": "int64", "minimum": 0 }, + "n_child": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Recovered child index — populated only on the grin-lws path.", + "minimum": 0 + }, + "spendable": { + "type": [ + "boolean", + "null" + ], + "description": "Spendable at the current tip — populated only on the grin-lws path." + }, "value": { "type": "integer", "format": "int64", @@ -2729,6 +2825,15 @@ "last_pmmr_index" ], "properties": { + "blockchain_height": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "Chain tip observed by grin-lws at scan time — present only on the grin-lws\npath.", + "minimum": 0 + }, "last_pmmr_index": { "type": "integer", "format": "int64", @@ -2741,6 +2846,15 @@ "$ref": "#/components/schemas/GrinOutput" } }, + "scanned_height": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "How far grin-lws has scanned this account — present only on the grin-lws\npath (the authoritative grin-wallet path leaves it null).", + "minimum": 0 + }, "total_balance": { "type": "integer", "format": "int64", diff --git a/src/api/capabilities.rs b/src/api/capabilities.rs index f75c765..7f90ffe 100644 --- a/src/api/capabilities.rs +++ b/src/api/capabilities.rs @@ -41,6 +41,9 @@ pub struct ChainCapabilities { pub struct FeatureCapabilities { /// Grin async slatepack relay mailbox. pub grin_relay: bool, + /// Grin light-wallet-server (grin-lws) scan path is available — fast balance + /// scans via grin-lws, with the authoritative grin-wallet scan as fallback. + pub grin_lws: bool, /// Fiat price feed. pub prices: bool, /// Nostr-native identity (NIP-98 login/link, NIP-05 directory). @@ -198,6 +201,13 @@ pub(crate) fn chain_serviceable(config: &Config, asset: &str) -> bool { } } +/// Whether the grin-lws scan path is serviceable: the Grin chain is serviceable +/// AND a grin-lws URL is configured. grin-lws is an add-on to the Grin chain, so +/// it can never be advertised for an unserviceable Grin. +pub(crate) fn grin_lws_serviceable(config: &Config) -> bool { + chain_serviceable(config, "grin") && !config.chains.grin_lws.url.trim().is_empty() +} + /// Build the public capabilities projection with the secret-presence downgrade. pub fn effective_capabilities(config: &Config) -> CapabilitiesResponse { let utxo_net = |on: bool, net: &str| ChainCapability { @@ -227,6 +237,7 @@ pub fn effective_capabilities(config: &Config) -> CapabilitiesResponse { // The relay is a non-custodial mailbox (the wallet broadcasts // locally), so it is NOT coupled to this backend's Grin chain access. grin_relay: config.features.grin_relay, + grin_lws: grin_lws_serviceable(config), prices: config.features.prices, // Nostr identity needs the canonical PUBLIC_API_URL. nostr_identity: config.features.nostr_identity diff --git a/src/api/wallet/grin.rs b/src/api/wallet/grin.rs index 6fe45a4..509230c 100644 --- a/src/api/wallet/grin.rs +++ b/src/api/wallet/grin.rs @@ -29,8 +29,14 @@ use super::validate_hex64; use crate::api::middleware::extract_user_id_from_token; use crate::error::AppError; use crate::infra::grin::{GrinClient, ViewWalletOutputResult}; +use crate::infra::grin_lws::{GrinLwsClient, GrinLwsUnspentOut}; use crate::AppState; +/// Blocks grin-lws's per-account scan may lag the chain tip and still be trusted. +/// Beyond this margin the account is treated as not-yet-synced and the scan falls +/// back to the authoritative grin-wallet scan — the money-safety seam. +const SYNC_MARGIN: u64 = 2; + /// Resolve the Grin client, or a 400 if Grin support is disabled. fn grin_client(state: &AppState) -> Result<&GrinClient, AppError> { state.chains.grin.as_deref().ok_or_else(|| { @@ -64,6 +70,15 @@ pub struct GrinOutput { pub mmr_index: u64, pub is_coinbase: bool, pub lock_height: u64, + /// Recovered derivation key id — populated only on the grin-lws path. + #[serde(skip_serializing_if = "Option::is_none")] + pub key_id: Option, + /// Recovered child index — populated only on the grin-lws path. + #[serde(skip_serializing_if = "Option::is_none")] + pub n_child: Option, + /// Spendable at the current tip — populated only on the grin-lws path. + #[serde(skip_serializing_if = "Option::is_none")] + pub spendable: Option, } /// Result of a view-only scan: recognized outputs, total, and the resume index. @@ -73,6 +88,14 @@ pub struct GrinScanResponse { pub total_balance: u64, /// Resume point (`last_pmmr_index`) for the next incremental scan. pub last_pmmr_index: u64, + /// How far grin-lws has scanned this account — present only on the grin-lws + /// path (the authoritative grin-wallet path leaves it null). + #[serde(skip_serializing_if = "Option::is_none")] + pub scanned_height: Option, + /// Chain tip observed by grin-lws at scan time — present only on the grin-lws + /// path. + #[serde(skip_serializing_if = "Option::is_none")] + pub blockchain_height: Option, } #[derive(Debug, Serialize, utoipa::ToSchema)] @@ -126,6 +149,26 @@ fn output_dto(o: ViewWalletOutputResult) -> GrinOutput { mmr_index: o.mmr_index, is_coinbase: o.is_coinbase, lock_height: o.lock_height, + // The grin-wallet scan does not surface derivation paths / spendability. + key_id: None, + n_child: None, + spendable: None, + } +} + +/// Map a grin-lws unspent output (which carries the recovered derivation path and +/// spendability) into the wire DTO. +fn output_dto_lws(o: GrinLwsUnspentOut) -> GrinOutput { + GrinOutput { + commit: o.commit, + value: o.value, + height: o.height, + mmr_index: o.mmr_index, + is_coinbase: o.is_coinbase, + lock_height: o.lock_height, + key_id: o.key_id, + n_child: o.n_child, + spendable: Some(o.spendable), } } @@ -154,10 +197,11 @@ pub async fn scan( extract_user_id_from_token(&state, &headers).await?; validate_hex64(&req.rewind_hash, "rewind_hash")?; - let client = grin_client(&state)?; + // Restore-depth + PoW gate runs ONCE, before path selection, and is TERMINAL: + // a policy rejection returns 400 directly. It must never be masked by the + // grin-wallet fallback, so it is enforced before either scan path is chosen. if let Some(h) = req.start_height { - // Restore: gate the scan depth against this instance's policy. - let tip = client.get_height().await?; + let tip = grin_tip(&state).await?; state.config.restore.enforce("grin", h, tip)?; state.config.restore.enforce_restore_pow( "grin", @@ -167,6 +211,23 @@ pub async fn scan( req.restore_pow_nonce, )?; } + + // Default to grin-lws when configured. It is trusted ONLY when provably synced + // to the tip; otherwise (and on any transport error) the scan falls back to + // the authoritative grin-wallet scan below. + if let Some(lws) = state.chains.grin_lws.as_ref() { + match scan_via_grin_lws(lws, &req).await { + Ok(Some(resp)) => return Ok(Json(resp)), + Ok(None) => {} // still backfilling — fall through to grin-wallet + Err(e) => { + tracing::warn!(error = %e, "grin-lws scan failed; falling back to grin-wallet") + } + } + } + + // Authoritative grin-wallet scan. Its error propagates as-is (503) — we never + // synthesize an empty/zero success. + let client = grin_client(&state)?; let view = client .scan_rewind_hash(&req.rewind_hash, req.start_height) .await?; @@ -174,6 +235,54 @@ pub async fn scan( outputs: view.output_result.into_iter().map(output_dto).collect(), total_balance: view.total_balance, last_pmmr_index: view.last_pmmr_index, + scanned_height: None, + blockchain_height: None, + })) +} + +/// Current Grin chain tip for the restore gate: grin-lws's `/height` when +/// configured, else the authoritative grin-wallet node. A grin-lws height error +/// falls back to grin-wallet's tip so the gate can always be enforced. +async fn grin_tip(state: &AppState) -> Result { + if let Some(lws) = state.chains.grin_lws.as_ref() { + match lws.get_height().await { + Ok(h) => return Ok(h), + Err(e) => { + tracing::warn!(error = %e, "grin-lws height failed; falling back to grin-wallet tip") + } + } + } + grin_client(state)?.get_height().await +} + +/// Attempt the scan via grin-lws. +/// +/// Returns `Ok(Some(resp))` only when the account is provably synced to the tip +/// (`scanned_height + SYNC_MARGIN >= blockchain_height`) — the money-safety seam. +/// `Ok(None)` means grin-lws is still backfilling this account, so the caller +/// must fall back to the authoritative grin-wallet scan. A transport error +/// propagates (the caller treats it as a fallback signal, never a 0/empty scan). +async fn scan_via_grin_lws( + lws: &GrinLwsClient, + req: &GrinScanRequest, +) -> Result, AppError> { + lws.register(&req.rewind_hash, req.start_height).await?; + let bal = lws.get_balance(&req.rewind_hash).await?; + // Trust grin-lws only when its scan has effectively reached the tip. + // `saturating_add` so a hostile/garbled `scanned_height` can never overflow + // into a spurious "synced" — the worst case just falls back to grin-wallet. + if bal.scanned_height.saturating_add(SYNC_MARGIN) < bal.blockchain_height { + return Ok(None); + } + let outs = lws.get_unspent_outs(&req.rewind_hash).await?; + let max_mmr = outs.outputs.iter().map(|o| o.mmr_index).max().unwrap_or(0); + let outputs = outs.outputs.into_iter().map(output_dto_lws).collect(); + Ok(Some(GrinScanResponse { + outputs, + total_balance: bal.total, + last_pmmr_index: max_mmr, + scanned_height: Some(bal.scanned_height), + blockchain_height: Some(bal.blockchain_height), })) } diff --git a/src/config.rs b/src/config.rs index bc58619..c8e5506 100644 --- a/src/config.rs +++ b/src/config.rs @@ -245,6 +245,7 @@ pub struct ChainConfig { pub xmr: LwsConfig, pub wow: LwsConfig, pub grin: GrinConfig, + pub grin_lws: GrinLwsConfig, } /// Bitcoin/Litecoin chain access via Electrum/Fulcrum. The backend runs no @@ -287,6 +288,17 @@ pub struct GrinConfig { pub node_foreign_api_secret: String, } +/// grin-lws (Grin light-wallet-server) configuration. An optional add-on to the +/// Grin chain: when `url` is set (and `FEATURE_GRIN` is on) the backend's Grin +/// scan proxies to grin-lws first, falling back to the authoritative +/// grin-wallet scan. Unset = "use grin-wallet only". +#[derive(Clone)] +pub struct GrinLwsConfig { + pub url: String, + pub admin_url: Option, + pub admin_key: String, +} + /// Proof-of-work signup gate (ALTCHA). Feature-gated; when enabled the HMAC key /// is required (no source-visible fallback). #[derive(Clone)] @@ -783,6 +795,11 @@ impl Config { ), node_foreign_api_secret: env_or("GRIN_NODE_FOREIGN_API_SECRET", ""), }, + grin_lws: GrinLwsConfig { + url: env_or("GRIN_LWS_URL", ""), + admin_url: env_opt("GRIN_LWS_ADMIN_URL"), + admin_key: env_or("GRIN_LWS_ADMIN_KEY", ""), + }, }, pow: PowConfig { enabled: env_bool("FEATURE_POW", false), @@ -1364,6 +1381,11 @@ mod tests { node_foreign_api_url: String::new(), node_foreign_api_secret: String::new(), }, + grin_lws: GrinLwsConfig { + url: String::new(), + admin_url: None, + admin_key: String::new(), + }, }, pow: PowConfig { enabled: false, diff --git a/src/infra/chains.rs b/src/infra/chains.rs index 1cd5d0b..8dcba9b 100644 --- a/src/infra/chains.rs +++ b/src/infra/chains.rs @@ -10,6 +10,7 @@ use crate::config::Config; use crate::error::AppError; use crate::infra::electrum::ElectrumClient; use crate::infra::grin::GrinClient; +use crate::infra::grin_lws::GrinLwsClient; use crate::infra::lws::LwsClient; /// The enabled chains' data-source clients. Cloneable (the heavyweight grin @@ -21,6 +22,9 @@ pub struct ChainClients { pub xmr: Option, pub wow: Option, pub grin: Option>, + /// Optional grin-lws add-on to the Grin chain: present only when Grin is + /// enabled AND a grin-lws URL is configured. Cheap-clone (no `Arc`). + pub grin_lws: Option, } impl ChainClients { @@ -48,6 +52,9 @@ impl ChainClients { .grin .then(|| GrinClient::new(&cfg.chains.grin).map(Arc::new)) .transpose()?, + grin_lws: (flags.grin && !cfg.chains.grin_lws.url.trim().is_empty()) + .then(|| GrinLwsClient::new(&cfg.chains.grin_lws)) + .transpose()?, }) } } diff --git a/src/infra/grin_lws/client.rs b/src/infra/grin_lws/client.rs new file mode 100644 index 0000000..c06e6be --- /dev/null +++ b/src/infra/grin_lws/client.rs @@ -0,0 +1,219 @@ +//! grin-lws (Grin light-wallet-server) client. +//! +//! A faithful mirror of the Monero/Wownero [`LwsClient`](crate::infra::lws::LwsClient). +//! grin-lws scans the chain for registered `rewind_hash` view credentials and +//! answers register / balance / unspent-output / height queries; this module is a +//! hardened client of that service. Even though grin-lws is expected on loopback, +//! every response is treated as hostile: +//! +//! * 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; +//! * 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. + +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use std::time::Duration; +use tracing::instrument; + +use super::types::*; +use crate::config::GrinLwsConfig; +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 grin-lws response body (enforced while streaming). +const MAX_GRIN_LWS_BODY_BYTES: usize = 8 * 1024 * 1024; + +/// Client for a grin-lws instance. Cheap to clone (`reqwest::Client` is an `Arc` +/// internally), so it lives directly in the cloneable [`ChainClients`]. +#[derive(Clone)] +pub struct GrinLwsClient { + user_url: String, + /// Reserved for the bearer-gated admin API (`list_accounts` / `rescan`), + /// mirroring the monero-lws admin surface; not yet driven by a client method. + #[allow(dead_code)] + admin_url: Option, + #[allow(dead_code)] + admin_key: Secret, + http: reqwest::Client, +} + +impl GrinLwsClient { + /// Construct a client with a shared timeout-bounded HTTP client. + pub fn new(cfg: &GrinLwsConfig) -> Result { + let http = reqwest::Client::builder() + .timeout(REQUEST_TIMEOUT) + .connect_timeout(CONNECT_TIMEOUT) + .build() + .map_err(|_| AppError::NodeError("failed to build grin-lws HTTP client".into()))?; + Ok(Self { + user_url: cfg.url.clone(), + admin_url: cfg.admin_url.clone(), + admin_key: Secret::new(cfg.admin_key.clone()), + http, + }) + } + + // ── user API ──────────────────────────────────────────────────────────── + + /// `POST /register` — add a `rewind_hash` view credential to the scan set + /// (idempotent). `start_height` is the wallet birthday; omit for the tip. + #[instrument(skip(self, rewind_hash))] + pub async fn register( + &self, + rewind_hash: &str, + start_height: Option, + ) -> Result { + let url = format!("{}/register", self.user_url); + let body = RegisterBody { + rewind_hash: rewind_hash.to_string(), + start_height, + }; + self.post_json(url, "register", &body).await + } + + /// `POST /get_balance` — the account's unspent total + scan progress. + #[instrument(skip(self, rewind_hash))] + pub async fn get_balance(&self, rewind_hash: &str) -> Result { + let url = format!("{}/get_balance", self.user_url); + let body = RewindHashBody { + rewind_hash: rewind_hash.to_string(), + }; + self.post_json(url, "get_balance", &body).await + } + + /// `POST /get_unspent_outs` — the spendable set WITH derivation paths. + #[instrument(skip(self, rewind_hash))] + pub async fn get_unspent_outs( + &self, + rewind_hash: &str, + ) -> Result { + let url = format!("{}/get_unspent_outs", self.user_url); + let body = RewindHashBody { + rewind_hash: rewind_hash.to_string(), + }; + self.post_json(url, "get_unspent_outs", &body).await + } + + /// `GET /height` — current chain tip height. + #[instrument(skip(self))] + pub async fn get_height(&self) -> Result { + #[derive(Deserialize)] + struct HeightResponse { + height: u64, + } + let url = format!("{}/height", self.user_url); + let resp: HeightResponse = self.get_json(url, "height").await?; + Ok(resp.height) + } + + /// Liveness probe — reaches grin-lws (`GET /height`), discarding the result. + pub async fn health_check(&self) -> Result<(), AppError> { + self.get_height().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!("grin-lws {label} request failed: {e}")) + })?; + if !resp.status().is_success() { + return Err(self.node_err_status(label, resp.status())); + } + let bytes = read_capped(resp, MAX_GRIN_LWS_BODY_BYTES).await?; + serde_json::from_slice::(&bytes).map_err(|_| self.node_err(label, "invalid response")) + } + + /// GET and deserialize a successful response from a size-capped buffer. + /// Non-success → generic `NodeError` (no body interpolation). + async fn get_json(&self, url: String, label: &'static str) -> Result + where + R: DeserializeOwned, + { + let resp = self.http.get(&url).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!("grin-lws {label} request failed: {e}")) + })?; + if !resp.status().is_success() { + return Err(self.node_err_status(label, resp.status())); + } + let bytes = read_capped(resp, MAX_GRIN_LWS_BODY_BYTES).await?; + serde_json::from_slice::(&bytes).map_err(|_| self.node_err(label, "invalid response")) + } + + /// A node error tagged with the static endpoint `label`. `label`/`detail` are + /// static; the untrusted response body is never included. + fn node_err(&self, label: &str, detail: &str) -> AppError { + AppError::NodeError(format!("grin-lws {label}: {detail}")) + } + + fn node_err_status(&self, label: &str, status: reqwest::StatusCode) -> AppError { + AppError::NodeError(format!("grin-lws {label} failed (HTTP {})", status.as_u16())) + } +} + +/// 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("grin-lws read failed".into()))?; + if buf.len().saturating_add(chunk.len()) > cap { + return Err(AppError::NodeError( + "grin-lws response exceeded size limit".into(), + )); + } + buf.extend_from_slice(&chunk); + } + Ok(buf) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cfg() -> GrinLwsConfig { + GrinLwsConfig { + url: "http://127.0.0.1:8453".into(), + admin_url: Some("http://127.0.0.1:8454".into()), + admin_key: "test-admin-key".into(), + } + } + + #[test] + fn new_builds_a_client() { + assert!(GrinLwsClient::new(&cfg()).is_ok()); + } + + #[test] + fn admin_key_is_redacted_in_debug() { + let client = GrinLwsClient::new(&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")); + } +} diff --git a/src/infra/grin_lws/mod.rs b/src/infra/grin_lws/mod.rs new file mode 100644 index 0000000..b35ac69 --- /dev/null +++ b/src/infra/grin_lws/mod.rs @@ -0,0 +1,14 @@ +//! grin-lws (Grin light-wallet-server) client. +//! +//! An optional add-on to the view-only Grin path. When configured, the backend's +//! Grin scan proxies to grin-lws — which scans the chain for registered +//! `rewind_hash` view credentials — and trusts its result only when it is +//! provably synced to the tip, otherwise falling back to the authoritative +//! grin-wallet scan. A hardened client of that service (config-pointed, timeout- +//! and size-bounded, secret-redacting), mirroring [`crate::infra::lws`]. + +mod client; +mod types; + +pub use client::GrinLwsClient; +pub use types::{GrinLwsBalance, GrinLwsRegister, GrinLwsUnspentOut, GrinLwsUnspentOuts}; diff --git a/src/infra/grin_lws/types.rs b/src/infra/grin_lws/types.rs new file mode 100644 index 0000000..24ac45b --- /dev/null +++ b/src/infra/grin_lws/types.rs @@ -0,0 +1,86 @@ +//! Type definitions for the grin-lws (Grin light-wallet-server) client API. +//! +//! Mirrors the grin-lws HTTP surface (see grin-lws `src/api.rs`). grin-lws emits +//! every numeric field as a plain JSON integer, so amounts and indices are simple +//! `u64` (no `string_or_*` coercion is needed, unlike monero-lws). +//! +//! Request structs carry the `rewind_hash` VIEW credential, so — like the +//! monero-lws request DTOs — they deliberately omit `Debug` and can never be +//! accidentally logged. + +use serde::{Deserialize, Serialize}; + +// ============================================================================ +// Request types (internal). Credential-bearing structs deliberately omit `Debug`. +// ============================================================================ + +/// Body for `POST /register`. +#[derive(Serialize)] +pub(crate) struct RegisterBody { + pub rewind_hash: String, + /// Wallet birthday to scan from; omit to start at the current tip. + #[serde(skip_serializing_if = "Option::is_none")] + pub start_height: Option, +} + +/// Body for the `rewind_hash`-keyed user endpoints (`/get_balance`, +/// `/get_unspent_outs`). +#[derive(Serialize)] +pub(crate) struct RewindHashBody { + pub rewind_hash: String, +} + +// ============================================================================ +// Response types (public) +// ============================================================================ + +/// Response from `POST /register`. +#[derive(Debug, Deserialize)] +pub struct GrinLwsRegister { + pub registered: bool, + /// True if this call created a new account (vs a no-op on an existing one). + pub new_account: bool, + pub scan_height: u64, + pub start_height: u64, +} + +/// Response from `POST /get_balance`. +#[derive(Debug, Deserialize)] +pub struct GrinLwsBalance { + /// Total unspent (nanogrin), including immature/locked outputs. + pub total: u64, + /// Spendable-now total (mature, past any lock_height at the current tip). + pub unlocked: u64, + /// Number of unspent outputs. + pub count: u64, + /// How far the scanner has processed this account. + pub scanned_height: u64, + /// Current chain tip. + pub blockchain_height: u64, +} + +/// A single unspent output from `POST /get_unspent_outs`, WITH the recovered +/// derivation path so the wallet can spend directly. +#[derive(Debug, Deserialize)] +pub struct GrinLwsUnspentOut { + pub commit: String, + pub value: u64, + pub height: u64, + pub mmr_index: u64, + pub is_coinbase: bool, + pub lock_height: u64, + /// Spendable at the current tip (`lock_height <= blockchain_height`). + pub spendable: bool, + #[serde(default)] + pub key_id: Option, + #[serde(default)] + pub n_child: Option, +} + +/// Response from `POST /get_unspent_outs`. +#[derive(Debug, Deserialize)] +pub struct GrinLwsUnspentOuts { + #[serde(default)] + pub outputs: Vec, + pub blockchain_height: u64, +} diff --git a/src/infra/mod.rs b/src/infra/mod.rs index 90b34b2..2cc3187 100644 --- a/src/infra/mod.rs +++ b/src/infra/mod.rs @@ -5,6 +5,7 @@ pub mod chains; pub mod db; pub mod electrum; pub mod grin; +pub mod grin_lws; pub mod lws; pub mod payment; pub mod prices;