feat(grin): proxy scans through grin-lws (default) with grin-wallet fallback

Add a GrinLwsClient (a faithful mirror of the XMR LwsClient) + GrinLwsConfig
(enabled when GRIN_LWS_URL is set). /wallet/grin/scan now defaults to grin-lws
when configured and returns derivation paths (key_id/n_child) + spendability,
letting the wallet skip its client-side output-identify search.

Money-safety: the restore-depth/PoW gate runs once and is terminal (never
masked by fallback); grin-lws is trusted only when provably synced
(scanned_height + margin >= tip), otherwise the scan falls back to the
authoritative grin-wallet scan_rewind_hash; a grin-lws transport error also
falls back; grin-wallet errors propagate as 503. No path ever synthesizes an
empty/zero balance.

Off unless GRIN_LWS_URL is configured (prod grin behavior unchanged until
pointed at a grin-lws instance). Capabilities advertise `grin_lws`.
openapi.json regenerated.
This commit is contained in:
jwinterm
2026-07-09 16:26:23 -04:00
parent a7c4bbd238
commit 4d1d9006bc
10 changed files with 596 additions and 3 deletions
+10
View File
@@ -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
+114
View File
@@ -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",
+11
View File
@@ -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
+112 -3
View File
@@ -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<String>,
/// Recovered child index — populated only on the grin-lws path.
#[serde(skip_serializing_if = "Option::is_none")]
pub n_child: Option<u32>,
/// Spendable at the current tip — populated only on the grin-lws path.
#[serde(skip_serializing_if = "Option::is_none")]
pub spendable: Option<bool>,
}
/// 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<u64>,
/// 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<u64>,
}
#[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<u64, AppError> {
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<Option<GrinScanResponse>, 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),
}))
}
+22
View File
@@ -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<String>,
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,
+7
View File
@@ -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<LwsClient>,
pub wow: Option<LwsClient>,
pub grin: Option<Arc<GrinClient>>,
/// 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<GrinLwsClient>,
}
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()?,
})
}
}
+219
View File
@@ -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<String>,
#[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<Self, AppError> {
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<u64>,
) -> Result<GrinLwsRegister, AppError> {
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<GrinLwsBalance, AppError> {
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<GrinLwsUnspentOuts, AppError> {
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<u64, AppError> {
#[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<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!("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::<R>(&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<R>(&self, url: String, label: &'static str) -> Result<R, AppError>
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::<R>(&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<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("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"));
}
}
+14
View File
@@ -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};
+86
View File
@@ -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<u64>,
}
/// 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<String>,
#[serde(default)]
pub n_child: Option<u32>,
}
/// Response from `POST /get_unspent_outs`.
#[derive(Debug, Deserialize)]
pub struct GrinLwsUnspentOuts {
#[serde(default)]
pub outputs: Vec<GrinLwsUnspentOut>,
pub blockchain_height: u64,
}
+1
View File
@@ -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;