mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +02:00
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:
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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),
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user