scaffold: grin-lws — Grin light-wallet-server (view-key scan + DB store)

A standalone, self-hostable Grin light-wallet-server mirroring the Monero LWS:
register a view/rewind credential, run a background chain scanner, rewind outputs
against registered keys, store per-account matches in a DB, and serve balance /
outputs / broadcast / height fast (vs the current on-demand stateless scan proxy).
This is the scaffold — README (architecture + HTTP surface + DB schema + how a
wallet backend proxies to it), Cargo skeleton, migrations sketch, and route/scanner
stubs. Public-clean: no operator-specific infra, hosts, or secrets.
This commit is contained in:
jwinterm
2026-07-09 04:47:13 -04:00
commit 1dabd1513c
13 changed files with 1470 additions and 0 deletions
+32
View File
@@ -0,0 +1,32 @@
# grin-lws configuration. Copy to .env and fill in.
#
# PUBLIC-CLEAN: every value below is generic. There are no hostnames, operator
# IPs, or secrets baked into this repo — point it at your own grin node + DB.
# HTTP bind address. Bind to loopback / a private network: the user API is not
# authenticated at this layer (the proxying backend does user auth).
GRINLWS_BIND=127.0.0.1:3480
# Database. Postgres for scale, or a SQLite file for a single operator.
# DATABASE_URL=postgres://grinlws:grinlws@127.0.0.1:5432/grinlws
DATABASE_URL=sqlite://grin-lws.db
# grin node Foreign API v2 (JSON-RPC): tip, block reads, and broadcast.
GRIN_NODE_URL=http://127.0.0.1:3413/v2/foreign
# Optional basic-auth secret for the node Foreign API (empty = none).
GRIN_NODE_API_SECRET=
# Background scanner tuning.
GRINLWS_SCAN_POLL_SECS=15
GRINLWS_SCAN_BATCH_BLOCKS=100
# Restore-depth bound: how many days behind the tip a newly registered account
# may start scanning from. 0 = unbounded.
GRINLWS_RESTORE_MAX_DEPTH_DAYS=0
# Optional shared bearer that gates the admin API (/list_accounts, /rescan).
# Empty = admin API disabled.
GRINLWS_ADMIN_KEY=
# Log filter.
RUST_LOG=grin_lws=info
+4
View File
@@ -0,0 +1,4 @@
/target
.env
*.log
Cargo.lock
+51
View File
@@ -0,0 +1,51 @@
[package]
name = "grin-lws"
version = "0.1.0"
edition = "2021"
rust-version = "1.75"
description = "Grin light-wallet-server: register a view credential, scan the chain in the background, store recognized outputs in a DB, and serve balance/outputs/broadcast/height."
license = "MIT"
publish = false
[[bin]]
name = "grin-lws"
path = "src/main.rs"
[dependencies]
# HTTP server + client.
tokio = { version = "1.35", features = ["full"] }
axum = { version = "0.7", features = ["macros"] }
reqwest = { version = "0.11", features = ["json", "rustls-tls", "stream"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
futures = "0.3"
hex = "0.4"
dotenvy = "0.15"
thiserror = "1.0"
# Persistence. This is a REAL light-wallet-server: it maintains a per-account
# output store and a reorg-safe chain cursor, so it NEEDS a database. Postgres is
# the default for scale; SQLite works for single-operator deployments (enable the
# `sqlite` feature and point DATABASE_URL at a file). Runtime queries only (no
# compile-time `query!` macros) so the crate builds without a live DB.
sqlx = { version = "0.7", default-features = false, features = [
"runtime-tokio-rustls",
"any",
"postgres",
"sqlite",
"macros",
"migrate",
"chrono",
] }
chrono = { version = "0.4", features = ["serde"] }
# ECDH (secp256k1) + AES-256-GCM for the grin-wallet Owner API v3 secure channel,
# IF this service drives a grin-wallet as its scan/rewind engine instead of
# rewinding rangeproofs itself. Uncomment + pin when the scanner lands.
# secp256k1 = "0.28"
# aes-gcm = "0.10"
[dev-dependencies]
tokio-test = "0.4"
+230
View File
@@ -0,0 +1,230 @@
# grin-lws — Grin light-wallet-server
A **real** light-wallet-server for Grin, mirroring [monero-lws]: register a
view credential, run a **background scanner** over the Grin chain, **store**
recognized outputs (with recovered derivation paths) in a **database**, and
serve balance / unspent-outputs / broadcast / height fast from that store.
It is the Grin analogue of monero-lws, and lets any backend proxy to it exactly
the way `smirk-backend-core`'s `xmr_wow.rs` proxies to the Monero LWS via
`infra/lws/client.rs`.
**Status: SCAFFOLD.** The HTTP surface, DTOs, config, database layer, and the
scanner loop *structure* are real and build. The two chain-crypto seams — block
parsing and rangeproof rewind — are stubbed; wiring them up is the main remaining
milestone (see [Build order](#build-order)).
[monero-lws]: https://github.com/vtnerd/monero-lws
---
## Why this exists (and why it needs a DB)
`smirk-backend-core`'s current `POST /wallet/grin/scan` is an **on-demand,
stateless proxy**: each request forwards a `rewind_hash` to grin-wallet's
`scan_rewind_hash`, which rescans the chain and returns matches, **storing
nothing**. That works for Grin's small UTXO set, but it is *not* a
light-wallet-server — every read re-pays the scan cost, and it has no output
store to recover derivation paths from.
A real LWS — like monero-lws — keeps a **per-account output store** maintained by
a **background scanner**, so client reads are O(rows) not O(chain), and it can
return each output's **derivation path**. That store is a database. Hence this
service, and hence the three tables in `migrations/`.
The payoff for the wallet: `get_unspent_outs` returns outputs *with* `key_id` /
`n_child`, so the client spends directly with **no client-side identify search**
(it closes the path-identification gap that on-demand scan leaves open).
**Non-custodial, always.** An account is a `rewind_hash` — a *view credential*
derived from the wallet's public root key (Grin has had view/spend separation
since 2021). It can recognize outputs and read amounts but **cannot spend**. This
service never stores a private or spend key.
---
## Architecture
Three parts, matching monero-lws's user-api + admin-api + scanner:
```
┌──────────────────────────────────────────┐
wallet ──JWT──▶ backend ─┤ grin-lws │
/wallet/grin/* proxy │ │
│ 1. REGISTRATION POST /register │
│ rewind_hash -> accounts row │
│ │
│ 2. SCANNER (background task) │
│ follow tip -> per block: │
│ rewind outputs vs each account, │
│ store matches + spend detection, │
│ reorg-safe chain_cursor │
│ │ │
│ ▼ │
│ 3. USER API ┌─────────┐ │
│ read from ───▶│ DB │◀── writes │
│ the store └─────────┘ (scanner) │
└───────────────────────┬──────────────────┘
│
grin node (Foreign API v2)
get_tip / get_block / push_transaction
```
1. **Registration** — `POST /register {rewind_hash, start_height?}` inserts an
`accounts` row that the scanner will follow. Idempotent (the monero-lws
`add_account` analog, but self-service and view-only, so no spend risk).
2. **Background scanner** (the hard part; its own long-lived task) — follows the
chain tip. For each new block: rewind each output's rangeproof against every
registered `rewind_hash`; on a match, store the output **and** the derivation
path recovered from the proof message; detect spends by matching input
commitments against stored outputs; maintain a reorg-safe `chain_cursor`
(roll back rows above the fork on a reorg).
3. **User API** — reads served from the DB store (fast), plus broadcast + tip.
---
## HTTP surface
The **user API** is unauthenticated at this layer — grin-lws binds to loopback /
a private network and trusts the proxying backend (which does JWT auth) as its
sole caller. The **admin API** is gated by a shared bearer (`GRINLWS_ADMIN_KEY`)
and disabled when that is unset.
### User API (mirrors monero-lws user endpoints)
| Route | Body | Returns |
|-------|------|---------|
| `POST /register` | `{rewind_hash, start_height?}` | `{registered, new_account, scan_height, start_height}` |
| `POST /get_balance` | `{rewind_hash}` | `{total, count, scanned_height, blockchain_height}` |
| `POST /get_unspent_outs` | `{rewind_hash}` | `{outputs[], blockchain_height}` |
| `POST /submit_raw_tx` | `{tx}` | `{ok}` |
| `GET /height` | — | `{height}` |
| `GET /health` | — | liveness (always 200 if up) |
| `GET /ready` | — | readiness (503 if the node is unreachable) |
`get_unspent_outs` outputs — **crucially with the recovered path**:
```jsonc
{ "commit": "09…", "value": 1000000000, "height": 1851234,
"mmr_index": 987654, "is_coinbase": false, "lock_height": 0,
"key_id": "0300000000…", "n_child": 7 } // ← direct-spend, no client identify
```
### Admin API (bearer-gated)
| Route | Body | Purpose |
|-------|------|---------|
| `POST /list_accounts` | — | enumerate registered `rewind_hash`es |
| `POST /rescan` | `{rewind_hash, height}` | **backwards-only** re-derive from `height` |
`/rescan` is backwards-only (mirrors the monero-lws rescan invariant): it clears
stored outputs at/above `height` and lowers `scan_height`, so the scanner
re-derives them. A rescan to a height `>=` the current scan height is rejected as
a no-op.
---
## Database schema
See `migrations/0001_init.sql`. Postgres flavored; a SQLite single-operator build
swaps the type spellings (noted inline). **Never** stores a private/spend key.
```
accounts(rewind_hash PK, start_height, scan_height, created_at)
outputs("commit" PK, rewind_hash → accounts, value, height, mmr_index,
is_coinbase, lock_height, key_id, n_child, spent, spent_height)
INDEX(rewind_hash, spent), INDEX("commit")
chain_cursor(id=1 PK, height, block_hash) -- reorg-safe resume
```
* `outputs.key_id` / `n_child` are recovered during rewind — the direct-spend
enabler.
* `outputs.spent` / `spent_height` are set when a stored output's commitment
appears as a block input.
* `chain_cursor` is the single-row global resume point; on a reorg the scanner
rolls back rows above the fork and reseeks from it.
---
## How `smirk-backend-core` proxies to it
Identical wiring to the XMR/WOW LWS (`infra/lws/client.rs`):
```
wallet ──JWT──▶ smirk-backend-core ──private──▶ grin-lws ──▶ grin node
/api/v1/wallet/grin/scan /get_unspent_outs (Foreign API v2)
```
1. Add `GrinLwsConfig { lws_url, admin_url?, admin_key? }` in `config.rs`, next
to `LwsConfig`.
2. Add a `GrinLwsClient` in `infra/` mirroring `infra/lws/client.rs` (shared
`reqwest::Client`, timeouts, size-capped hostile-response reads, `Secret`-
wrapped admin key).
3. Behind a `features.chains.grin_lws` flag, `POST /wallet/grin/scan` forwards
the `rewind_hash` to grin-lws `/get_unspent_outs` (returning outputs **with
paths**) instead of driving grin-wallet `scan_rewind_hash` on-demand.
4. Register at balance-fetch time by forwarding `rewind_hash` to grin-lws
`/register` (idempotent), exactly as `fetchLwsBalance` awaits `registerLws`
for XMR/WOW.
**Non-custodial guarantee preserved end to end:** the backend forwards only the
`rewind_hash` and stores nothing; grin-lws holds the `rewind_hash` for scanning
just as monero-lws holds the view key. No spend key ever leaves the wallet.
When grin-lws ships, the client can **drop** its `grin_identify_output` helper —
`/get_unspent_outs` returns paths directly.
---
## Build order
Ship in stages; the deployed on-demand `/wallet/grin/scan` keeps serving balance
throughout, so nothing here blocks the client refactor.
1. **Scaffold (this repo).** ✅ Routes, DTOs, config, DB layer, scanner loop
structure, migrations, health/readiness. User-API reads already work against
the DB; the store is just empty until the scanner fills it.
2. **Grin node reads.** Implement `grin::GrinNode::get_block` and
`get_tip_height` against the Foreign API v2, and `push_transaction` for
`/submit_raw_tx`. After this, `/height` and broadcast are live.
3. **Rangeproof rewind (the core milestone).** Implement `grin::rewind_output`:
compute the rewind nonce from `rewind_hash` + commitment, rewind the
Bulletproof, and parse the proof message to recover `(value, key_id,
n_child)` — the server-side analog of `grin_recover_output`. Add the
`secp256k1` / bulletproof crypto deps (commented in `Cargo.toml`).
4. **Scanner fill + spend detection.** Wire the `tick` loop: page blocks, rewind
per account, `insert_output` on matches, `mark_spent` on input commitments,
advance `chain_cursor`.
5. **Reorg handling + backfill.** Detect `prev_hash` mismatch, `rollback_to` the
fork, reseek. Backfill from each account's `start_height`.
6. **Backend proxy.** Land `GrinLwsConfig` + `GrinLwsClient` behind the
`grin_lws` feature flag; parity-check against the on-demand path; then retire
the embedded grin-wallet coupling.
Steps 3–5 are the bulk of the work and are why this is its own project.
---
## Running
```sh
# 1. Provision a DB and run the migration.
# Postgres: createdb grinlws && psql grinlws < migrations/0001_init.sql
# SQLite: sqlite3 grin-lws.db < migrations/0001_init.sql (after type-swap)
# 2. Configure.
cp .env.example .env # edit DATABASE_URL + GRIN_NODE_URL
# 3. Run.
cargo run # binds GRINLWS_BIND (default 127.0.0.1:3480)
curl localhost:3480/health
```
In the scaffold, `/height`, `/submit_raw_tx`, and `/ready` require a reachable
grin node (they return `502` otherwise); `/register`, `/get_balance`, and
`/get_unspent_outs` work against the DB but return empty until the scanner is
implemented; `/health` always works.
## Deployment
Run as a private service alongside a grin node and its database. Bind to
loopback / a private network; expose it only to the proxying backend. Add a
readiness probe on `/ready`. Everything is env-configured — no code changes to
deploy.
+58
View File
@@ -0,0 +1,58 @@
-- grin-lws initial schema.
--
-- This service is a REAL light-wallet-server: it holds a per-account output
-- store keyed by each account's `rewind_hash` (a view credential) and a
-- reorg-safe chain cursor. It NEVER stores a private/spend key — `rewind_hash`
-- is view-only (it can recognize a wallet's outputs and read amounts, but
-- cannot spend).
--
-- Dialect note: written for PostgreSQL. For a single-operator SQLite build,
-- replace BIGINT -> INTEGER, BOOLEAN -> INTEGER (0/1), TIMESTAMPTZ ->
-- TEXT/INTEGER, and drop `DEFAULT now()`. `commit` is a reserved word in SQL,
-- hence it is always quoted as "commit".
-- An account = one registered view credential.
CREATE TABLE IF NOT EXISTS accounts (
-- The wallet's rewind_hash (64 hex). View-only; safe to persist.
rewind_hash TEXT PRIMARY KEY,
-- The height the scanner should (re)start from when this account was added
-- (a wallet birthday). Never advances backwards on its own.
start_height BIGINT NOT NULL,
-- How far the scanner has processed this account. Advances toward the tip;
-- lowered only by an explicit backwards rescan.
scan_height BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Outputs the scanner recognized for a registered account (rewound rangeproof
-- matched). Crucially stores the recovered derivation path (`key_id` /
-- `n_child`) so a client can spend WITHOUT a client-side identify search.
CREATE TABLE IF NOT EXISTS outputs (
"commit" TEXT PRIMARY KEY,
rewind_hash TEXT NOT NULL REFERENCES accounts(rewind_hash) ON DELETE CASCADE,
value BIGINT NOT NULL,
height BIGINT NOT NULL,
mmr_index BIGINT NOT NULL,
is_coinbase BOOLEAN NOT NULL DEFAULT false,
lock_height BIGINT NOT NULL DEFAULT 0,
-- Recovered from the proof message during rewind (grin_recover_output
-- equivalent, server-side). Enables direct spend by the client.
key_id TEXT,
n_child INTEGER,
-- Spend tracking: set when this output's commitment appears as a tx input.
spent BOOLEAN NOT NULL DEFAULT false,
spent_height BIGINT
);
-- Balance / unspent-set queries filter by (account, spent).
CREATE INDEX IF NOT EXISTS idx_outputs_account_spent ON outputs (rewind_hash, spent);
-- Spend detection matches an on-chain input commitment against stored outputs.
CREATE INDEX IF NOT EXISTS idx_outputs_commit ON outputs ("commit");
-- Single-row chain cursor for reorg-safe resume. On a reorg, roll back rows
-- above the fork height and reset the cursor to the fork point.
CREATE TABLE IF NOT EXISTS chain_cursor (
id INTEGER PRIMARY KEY CHECK (id = 1),
height BIGINT NOT NULL,
block_hash TEXT NOT NULL
);
+316
View File
@@ -0,0 +1,316 @@
//! HTTP surface — mirrors the monero-lws user API so smirk-backend-core can
//! proxy to it exactly as it proxies the XMR/WOW LWS (see `infra/lws/client.rs`).
//!
//! AUTH: the user API is unauthenticated at THIS layer — grin-lws binds to
//! loopback / a private network and trusts smirk-backend-core (which does the
//! JWT auth) as its only caller. The admin API (`/list_accounts`, `/rescan`) is
//! gated by a shared bearer (`GRINLWS_ADMIN_KEY`) and disabled when unset.
//!
//! The `rewind_hash` in every user request is a VIEW credential (public-key
//! derived — it cannot spend). Its request DTOs omit `Debug` so it is never
//! logged.
use std::sync::Arc;
use axum::{
extract::State,
http::HeaderMap,
routing::{get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
use crate::db;
use crate::error::{validate_hex64, Error, Result};
use crate::AppState;
// ── user DTOs ──────────────────────────────────────────────────────────────────
/// Register an account (a view credential) for background scanning. Idempotent.
#[derive(Deserialize)]
pub struct RegisterRequest {
pub rewind_hash: String,
/// Wallet birthday to scan from. Omit to start at the current tip.
#[serde(default)]
pub start_height: Option<u64>,
}
#[derive(Serialize)]
pub struct RegisterResponse {
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,
}
/// Carries a view credential — omit `Debug`, never log.
#[derive(Deserialize)]
pub struct RewindHashRequest {
pub rewind_hash: String,
}
#[derive(Serialize)]
pub struct BalanceResponse {
/// Total unspent (nanogrin).
pub total: 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,
}
/// An unspent output served to the client — WITH the recovered derivation path,
/// so the client spends directly (no client-side identify search).
#[derive(Serialize)]
pub struct UnspentOut {
pub commit: String,
pub value: u64,
pub height: u64,
pub mmr_index: u64,
pub is_coinbase: bool,
pub lock_height: u64,
pub key_id: Option<String>,
pub n_child: Option<u32>,
}
#[derive(Serialize)]
pub struct UnspentOutsResponse {
pub outputs: Vec<UnspentOut>,
pub blockchain_height: u64,
}
#[derive(Deserialize)]
pub struct SubmitRawTxRequest {
/// The finalized transaction object (grin node `push_transaction` input).
pub tx: serde_json::Value,
}
#[derive(Serialize)]
pub struct SubmitRawTxResponse {
pub ok: bool,
}
#[derive(Serialize)]
pub struct HeightResponse {
pub height: u64,
}
#[derive(Serialize)]
pub struct HealthResponse {
pub status: &'static str,
pub service: &'static str,
}
// ── admin DTOs ─────────────────────────────────────────────────────────────────
#[derive(Serialize)]
pub struct ListAccountsResponse {
pub accounts: Vec<String>,
}
#[derive(Deserialize)]
pub struct RescanRequest {
pub rewind_hash: String,
/// Backwards-only target height (must be below the account's current
/// scan_height); the scanner re-derives outputs from here.
pub height: u64,
}
// ── user handlers ──────────────────────────────────────────────────────────────
/// `POST /register` — add a view credential to the scan set (idempotent). Analog
/// of monero-lws admin `add_account`, but self-service (view-only, no spend risk).
pub async fn register(
State(state): State<Arc<AppState>>,
Json(req): Json<RegisterRequest>,
) -> Result<Json<RegisterResponse>> {
validate_hex64(&req.rewind_hash, "rewind_hash")?;
// Default new accounts to the current tip; a restore supplies an earlier
// birthday, gated by the restore-depth policy.
let tip = state.node.get_tip_height().await?;
let start = match req.start_height {
Some(h) => {
enforce_restore_depth(&state, h, tip)?;
h
}
None => tip,
};
let new_account = db::register_account(&state.pool, &req.rewind_hash, start as i64).await?;
let scan_height = db::account_scan_height(&state.pool, &req.rewind_hash)
.await?
.unwrap_or(start);
Ok(Json(RegisterResponse {
registered: true,
new_account,
scan_height,
start_height: start,
}))
}
/// `POST /get_balance` — the account's unspent total + scan progress.
pub async fn get_balance(
State(state): State<Arc<AppState>>,
Json(req): Json<RewindHashRequest>,
) -> Result<Json<BalanceResponse>> {
validate_hex64(&req.rewind_hash, "rewind_hash")?;
let (total, count) = db::account_totals(&state.pool, &req.rewind_hash).await?;
let scanned_height = db::account_scan_height(&state.pool, &req.rewind_hash)
.await?
.unwrap_or(0);
let blockchain_height = state.node.get_tip_height().await?;
Ok(Json(BalanceResponse {
total,
count,
scanned_height,
blockchain_height,
}))
}
/// `POST /get_unspent_outs` — the spendable set WITH derivation paths.
pub async fn get_unspent_outs(
State(state): State<Arc<AppState>>,
Json(req): Json<RewindHashRequest>,
) -> Result<Json<UnspentOutsResponse>> {
validate_hex64(&req.rewind_hash, "rewind_hash")?;
let stored = db::unspent_outputs(&state.pool, &req.rewind_hash).await?;
let blockchain_height = state.node.get_tip_height().await?;
let outputs = stored
.into_iter()
.map(|o| UnspentOut {
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,
})
.collect();
Ok(Json(UnspentOutsResponse {
outputs,
blockchain_height,
}))
}
/// `POST /submit_raw_tx` — relay a finalized tx to the node.
pub async fn submit_raw_tx(
State(state): State<Arc<AppState>>,
Json(req): Json<SubmitRawTxRequest>,
) -> Result<Json<SubmitRawTxResponse>> {
state.node.push_transaction(&req.tx).await?;
Ok(Json(SubmitRawTxResponse { ok: true }))
}
/// `GET /height` — current chain tip.
pub async fn height(State(state): State<Arc<AppState>>) -> Result<Json<HeightResponse>> {
let height = state.node.get_tip_height().await?;
Ok(Json(HeightResponse { height }))
}
// ── health ─────────────────────────────────────────────────────────────────────
/// Liveness — 200 if the process is up. No upstream I/O.
pub async fn health() -> Json<HealthResponse> {
Json(HealthResponse {
status: "ok",
service: "grin-lws",
})
}
/// Readiness — reaches the grin node (503 when the node is down).
pub async fn ready(State(state): State<Arc<AppState>>) -> Result<Json<HealthResponse>> {
state.node.health_check().await?;
Ok(Json(HealthResponse {
status: "ready",
service: "grin-lws",
}))
}
// ── admin handlers (bearer-gated) ──────────────────────────────────────────────
pub async fn list_accounts(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<Json<ListAccountsResponse>> {
require_admin(&state, &headers)?;
let accounts = db::list_account_hashes(&state.pool).await?;
Ok(Json(ListAccountsResponse { accounts }))
}
pub async fn rescan(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(req): Json<RescanRequest>,
) -> Result<Json<HealthResponse>> {
require_admin(&state, &headers)?;
validate_hex64(&req.rewind_hash, "rewind_hash")?;
db::rescan_account(&state.pool, &req.rewind_hash, req.height as i64).await?;
Ok(Json(HealthResponse {
status: "ok",
service: "grin-lws",
}))
}
// ── helpers ────────────────────────────────────────────────────────────────────
/// Enforce the restore-depth bound: reject a `start_height` more than
/// `restore_max_depth_days` worth of blocks behind the tip (0 = unbounded).
/// Grin targets ~60s blocks ⇒ 1440 blocks/day.
fn enforce_restore_depth(state: &AppState, start_height: u64, tip: u64) -> Result<()> {
let max_days = state.config.restore_max_depth_days;
if max_days == 0 {
return Ok(());
}
let max_depth = (max_days as u64).saturating_mul(1440);
if tip.saturating_sub(start_height) > max_depth {
return Err(Error::Validation(format!(
"start_height too far behind tip (max {max_days} days)"
)));
}
Ok(())
}
/// Gate the admin API on the shared bearer. Disabled (401) when no admin key is
/// configured, so admin routes are never open by default.
fn require_admin(state: &AppState, headers: &HeaderMap) -> Result<()> {
if !state.config.admin_enabled() {
return Err(Error::Unauthorized);
}
let expected = format!("Bearer {}", state.config.admin_key.expose());
let ok = headers
.get(axum::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.map(|v| v == expected)
.unwrap_or(false);
if ok {
Ok(())
} else {
Err(Error::Unauthorized)
}
}
// ── router ─────────────────────────────────────────────────────────────────────
pub fn router(state: Arc<AppState>) -> Router {
Router::new()
// health
.route("/health", get(health))
.route("/ready", get(ready))
// user API (mirrors monero-lws user endpoints)
.route("/register", post(register))
.route("/get_balance", post(get_balance))
.route("/get_unspent_outs", post(get_unspent_outs))
.route("/submit_raw_tx", post(submit_raw_tx))
.route("/height", get(height))
// admin API (bearer-gated)
.route("/list_accounts", post(list_accounts))
.route("/rescan", post(rescan))
.with_state(state)
}
+68
View File
@@ -0,0 +1,68 @@
//! Service configuration, loaded entirely from the environment.
//!
//! PUBLIC-CLEAN: every value is an env var with a generic loopback default. No
//! hostnames, no operator IPs, no secrets baked in. Anyone can point this at
//! their own grin node + database and run it. See `.env.example`.
use std::net::SocketAddr;
use crate::secret::Secret;
#[derive(Clone)]
pub struct Config {
/// Address to bind the HTTP server to (loopback / a private network).
pub bind_addr: SocketAddr,
/// Database connection URL. `postgres://…` for scale, or `sqlite://path.db`
/// for a single-operator deployment.
pub database_url: Secret,
/// grin node Foreign API base URL (v2 JSON-RPC): tip, block/output reads,
/// and `push_transaction` for broadcast.
pub node_foreign_api_url: String,
/// Optional basic-auth secret for the node Foreign API (empty = none).
pub node_foreign_api_secret: Secret,
/// How often the background scanner polls for new blocks.
pub scan_poll_secs: u64,
/// How many blocks the scanner processes per batch (bounds memory / a single
/// transaction's size).
pub scan_batch_blocks: u64,
/// How far behind the tip a newly-registered account may start scanning from
/// (restore-depth bound). `0` = unbounded.
pub restore_max_depth_days: u32,
/// Optional shared bearer that gates the admin API (`/list_accounts`,
/// `/rescan`). Empty = admin API disabled.
pub admin_key: Secret,
}
impl Config {
pub fn from_env() -> Self {
let env_or = |k: &str, d: &str| std::env::var(k).unwrap_or_else(|_| d.to_string());
let parse_or = |k: &str, d: u64| env_or(k, &d.to_string()).parse().unwrap_or(d);
let bind_addr = env_or("GRINLWS_BIND", "127.0.0.1:3480")
.parse()
.expect("GRINLWS_BIND must be a valid socket address");
Config {
bind_addr,
database_url: Secret::new(env_or("DATABASE_URL", "sqlite://grin-lws.db")),
node_foreign_api_url: env_or(
"GRIN_NODE_URL",
"http://127.0.0.1:3413/v2/foreign",
),
node_foreign_api_secret: Secret::new(env_or("GRIN_NODE_API_SECRET", "")),
scan_poll_secs: parse_or("GRINLWS_SCAN_POLL_SECS", 15),
scan_batch_blocks: parse_or("GRINLWS_SCAN_BATCH_BLOCKS", 100),
restore_max_depth_days: parse_or("GRINLWS_RESTORE_MAX_DEPTH_DAYS", 0) as u32,
admin_key: Secret::new(env_or("GRINLWS_ADMIN_KEY", "")),
}
}
/// Whether the admin API is enabled (an admin key was configured).
pub fn admin_enabled(&self) -> bool {
!self.admin_key.expose().is_empty()
}
}
+235
View File
@@ -0,0 +1,235 @@
//! Persistence layer: the per-account output store, accounts table, and the
//! reorg-safe chain cursor.
//!
//! Uses `sqlx::Any` so a single build serves both Postgres (scale) and SQLite
//! (single operator) — the driver is chosen at runtime from `DATABASE_URL`.
//!
//! NOTE (dialect): the SQL below uses Postgres `$N` placeholders. sqlx's `Any`
//! backend forwards placeholders verbatim, so a SQLite deployment should run the
//! SQLite-flavored migration (see `migrations/`) and, if it hits placeholder
//! incompatibility, swap `$N` for `?`. This scaffold keeps one dialect for
//! readability; pick your target DB before landing the scanner.
//!
//! SECURITY: only view-only data is ever stored. No spend key, no private key.
// Several write helpers (insert_output, mark_spent, set_cursor, rollback_to) are
// exercised only by the scanner, which is a deferred milestone in this scaffold.
// They are real and tested-shaped; allow them to be unused until the scanner
// loop is wired up.
#![allow(dead_code)]
use sqlx::any::AnyPoolOptions;
use sqlx::{AnyPool, Row};
use crate::error::{Error, Result};
/// A stored output as served to the client. Includes the recovered derivation
/// path (`key_id` / `n_child`) so the client can spend directly — the whole
/// point of a real LWS over an on-demand scan.
#[derive(Debug, Clone, serde::Serialize)]
pub struct StoredOutput {
pub commit: String,
pub value: u64,
pub height: u64,
pub mmr_index: u64,
pub is_coinbase: bool,
pub lock_height: u64,
pub key_id: Option<String>,
pub n_child: Option<u32>,
}
/// The scanner's resume point.
#[derive(Debug, Clone)]
pub struct ChainCursor {
pub height: u64,
pub block_hash: String,
}
/// Connect (lazily) to the database. Does not require a live DB at construction;
/// the first query establishes the connection. Run `migrations/` before serving.
pub async fn connect(database_url: &str) -> Result<AnyPool> {
// Registers the compiled-in drivers (postgres / sqlite) for the `Any` pool.
sqlx::any::install_default_drivers();
let pool = AnyPoolOptions::new()
.max_connections(8)
.connect_lazy(database_url)
.map_err(Error::Db)?;
Ok(pool)
}
// ── accounts ──────────────────────────────────────────────────────────────────
/// Register an account (idempotent). A new account starts scanning at
/// `start_height`; an existing one is left untouched (its scan progress and view
/// key are preserved). Returns `true` if a new row was inserted.
pub async fn register_account(pool: &AnyPool, rewind_hash: &str, start_height: i64) -> Result<bool> {
let res = sqlx::query(
"INSERT INTO accounts (rewind_hash, start_height, scan_height) \
VALUES ($1, $2, $2) ON CONFLICT (rewind_hash) DO NOTHING",
)
.bind(rewind_hash)
.bind(start_height)
.execute(pool)
.await?;
Ok(res.rows_affected() > 0)
}
/// All registered accounts' rewind_hashes (the scanner iterates these per block).
pub async fn list_account_hashes(pool: &AnyPool) -> Result<Vec<String>> {
let rows = sqlx::query("SELECT rewind_hash FROM accounts")
.fetch_all(pool)
.await?;
Ok(rows.iter().map(|r| r.get::<String, _>("rewind_hash")).collect())
}
/// Lower an account's scan progress to `height` for a backwards rescan (also
/// clears its stored outputs at/above `height` so they are re-derived). SAFETY:
/// backwards-only, mirroring the monero-lws rescan invariant.
pub async fn rescan_account(pool: &AnyPool, rewind_hash: &str, height: i64) -> Result<()> {
let mut tx = pool.begin().await?;
sqlx::query("DELETE FROM outputs WHERE rewind_hash = $1 AND height >= $2")
.bind(rewind_hash)
.bind(height)
.execute(&mut *tx)
.await?;
sqlx::query(
"UPDATE accounts SET scan_height = $2 \
WHERE rewind_hash = $1 AND scan_height > $2",
)
.bind(rewind_hash)
.bind(height)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
// ── outputs ────────────────────────────────────────────────────────────────────
/// Persist a recognized output (scanner match). Idempotent on the commitment.
#[allow(clippy::too_many_arguments)]
pub async fn insert_output(pool: &AnyPool, out: &StoredOutput, rewind_hash: &str) -> Result<()> {
sqlx::query(
"INSERT INTO outputs \
(\"commit\", rewind_hash, value, height, mmr_index, is_coinbase, lock_height, key_id, n_child) \
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) ON CONFLICT (\"commit\") DO NOTHING",
)
.bind(&out.commit)
.bind(rewind_hash)
.bind(out.value as i64)
.bind(out.height as i64)
.bind(out.mmr_index as i64)
.bind(out.is_coinbase)
.bind(out.lock_height as i64)
.bind(&out.key_id)
.bind(out.n_child.map(|n| n as i32))
.execute(pool)
.await?;
Ok(())
}
/// Mark an output spent when its commitment appears as a tx input on-chain.
pub async fn mark_spent(pool: &AnyPool, commit: &str, spent_height: i64) -> Result<()> {
sqlx::query("UPDATE outputs SET spent = true, spent_height = $2 WHERE \"commit\" = $1")
.bind(commit)
.bind(spent_height)
.execute(pool)
.await?;
Ok(())
}
/// Unspent outputs for an account — the spendable set, WITH derivation paths.
pub async fn unspent_outputs(pool: &AnyPool, rewind_hash: &str) -> Result<Vec<StoredOutput>> {
let rows = sqlx::query(
"SELECT \"commit\", value, height, mmr_index, is_coinbase, lock_height, key_id, n_child \
FROM outputs WHERE rewind_hash = $1 AND spent = false ORDER BY height ASC",
)
.bind(rewind_hash)
.fetch_all(pool)
.await?;
Ok(rows.iter().map(row_to_output).collect())
}
/// (total unspent, count) for an account — the balance read.
pub async fn account_totals(pool: &AnyPool, rewind_hash: &str) -> Result<(u64, u64)> {
let row = sqlx::query(
"SELECT COALESCE(SUM(value),0) AS total, COUNT(*) AS n \
FROM outputs WHERE rewind_hash = $1 AND spent = false",
)
.bind(rewind_hash)
.fetch_one(pool)
.await?;
let total: i64 = row.try_get("total").unwrap_or(0);
let n: i64 = row.try_get("n").unwrap_or(0);
Ok((total.max(0) as u64, n.max(0) as u64))
}
/// The height at which the scanner last processed this account.
pub async fn account_scan_height(pool: &AnyPool, rewind_hash: &str) -> Result<Option<u64>> {
let row = sqlx::query("SELECT scan_height FROM accounts WHERE rewind_hash = $1")
.bind(rewind_hash)
.fetch_optional(pool)
.await?;
Ok(row.map(|r| r.get::<i64, _>("scan_height").max(0) as u64))
}
fn row_to_output(r: &sqlx::any::AnyRow) -> StoredOutput {
StoredOutput {
commit: r.get::<String, _>("commit"),
value: r.get::<i64, _>("value").max(0) as u64,
height: r.get::<i64, _>("height").max(0) as u64,
mmr_index: r.get::<i64, _>("mmr_index").max(0) as u64,
is_coinbase: r.get::<bool, _>("is_coinbase"),
lock_height: r.get::<i64, _>("lock_height").max(0) as u64,
key_id: r.try_get::<Option<String>, _>("key_id").ok().flatten(),
n_child: r
.try_get::<Option<i32>, _>("n_child")
.ok()
.flatten()
.map(|n| n.max(0) as u32),
}
}
// ── chain cursor (reorg-safe resume) ───────────────────────────────────────────
/// Read the scanner's global cursor (last block processed across all accounts).
pub async fn get_cursor(pool: &AnyPool) -> Result<Option<ChainCursor>> {
let row = sqlx::query("SELECT height, block_hash FROM chain_cursor WHERE id = 1")
.fetch_optional(pool)
.await?;
Ok(row.map(|r| ChainCursor {
height: r.get::<i64, _>("height").max(0) as u64,
block_hash: r.get::<String, _>("block_hash"),
}))
}
/// Advance (or initialize) the scanner cursor.
pub async fn set_cursor(pool: &AnyPool, height: i64, block_hash: &str) -> Result<()> {
sqlx::query(
"INSERT INTO chain_cursor (id, height, block_hash) VALUES (1, $1, $2) \
ON CONFLICT (id) DO UPDATE SET height = $1, block_hash = $2",
)
.bind(height)
.bind(block_hash)
.execute(pool)
.await?;
Ok(())
}
/// Roll back all stored state above `fork_height` after a detected reorg.
pub async fn rollback_to(pool: &AnyPool, fork_height: i64) -> Result<()> {
let mut tx = pool.begin().await?;
// Outputs created above the fork never happened on the new chain.
sqlx::query("DELETE FROM outputs WHERE height > $1")
.bind(fork_height)
.execute(&mut *tx)
.await?;
// Spends recorded above the fork are undone (the input may be unspent again).
sqlx::query("UPDATE outputs SET spent = false, spent_height = NULL WHERE spent_height > $1")
.bind(fork_height)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
+64
View File
@@ -0,0 +1,64 @@
//! Error type mapped to HTTP responses.
//!
//! Hostile-upstream hygiene (carried over from smirk-backend-core's LWS/grin
//! clients): a node/DB error NEVER interpolates an untrusted upstream response
//! body into the client-facing message.
use axum::{http::StatusCode, response::IntoResponse, Json};
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("validation: {0}")]
Validation(String),
#[error("not found")]
NotFound,
#[error("unauthorized")]
Unauthorized,
/// Upstream grin node failure. The `&'static str` label is safe to surface;
/// an untrusted node body is never included.
#[error("node error: {0}")]
Node(&'static str),
#[error("database error")]
Db(#[from] sqlx::Error),
/// A route that is deliberately stubbed in this scaffold.
#[error("{0} not implemented (scaffold)")]
NotImplemented(&'static str),
}
impl IntoResponse for Error {
fn into_response(self) -> axum::response::Response {
let status = match &self {
Error::Validation(_) => StatusCode::BAD_REQUEST,
Error::NotFound => StatusCode::NOT_FOUND,
Error::Unauthorized => StatusCode::UNAUTHORIZED,
Error::Node(_) => StatusCode::BAD_GATEWAY,
Error::Db(e) => {
// Log the detail privately; never surface DB internals.
tracing::error!(error = %e, "database error");
StatusCode::INTERNAL_SERVER_ERROR
}
Error::NotImplemented(_) => StatusCode::NOT_IMPLEMENTED,
};
let message = match &self {
Error::Db(_) => "internal error".to_string(),
other => other.to_string(),
};
(status, Json(serde_json::json!({ "error": message }))).into_response()
}
}
pub type Result<T> = std::result::Result<T, Error>;
/// Validate a 64-char hex string (a `rewind_hash`). Rejects anything else.
pub fn validate_hex64(s: &str, field: &str) -> Result<()> {
if s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit()) {
Ok(())
} else {
Err(Error::Validation(format!("{field} must be 64 hex chars")))
}
}
+197
View File
@@ -0,0 +1,197 @@
//! Grin node access — the chain data the scanner and broadcast path need.
//!
//! Talks to a grin node's Foreign API v2 (JSON-RPC): read the tip, page through
//! block outputs (commit + rangeproof), and relay a finalized transaction. This
//! is the ONLY component that reaches the chain; everything else reads the DB.
//!
//! Hostile-upstream hygiene (carried from smirk-backend-core's node client):
//! per-request + connect timeouts, a streaming body-size cap, and errors that
//! never interpolate an untrusted node body.
//!
//! REWIND (the hard, deferred part): recognizing which of a block's outputs
//! belong to a registered account means rewinding each output's Bulletproof
//! rangeproof against that account's `rewind_hash`-derived nonce, then parsing
//! the proof message to recover the derivation path — exactly what
//! `grin_recover_output` does client-side, done here server-side. That requires
//! the grin crypto stack (secp256k1 / bulletproofs) and is stubbed below.
use std::time::Duration;
use serde::Deserialize;
use crate::config::Config;
use crate::error::{Error, Result};
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
/// Hard cap on any node response body (a full block page is the largest).
const MAX_BODY_BYTES: usize = 32 * 1024 * 1024;
/// A raw output as read from a block: the commitment and its rangeproof. The
/// rangeproof is what the scanner rewinds to test account ownership.
#[derive(Debug, Clone)]
pub struct ChainOutput {
pub commit: String,
pub proof: String,
pub is_coinbase: bool,
pub height: u64,
pub mmr_index: u64,
pub lock_height: u64,
}
/// A block's spend/create surface: inputs consume outputs (by commitment),
/// outputs create them.
#[derive(Debug, Clone)]
pub struct BlockView {
pub height: u64,
pub hash: String,
pub prev_hash: String,
/// Input commitments spent in this block (for spend detection).
pub input_commits: Vec<String>,
/// Outputs created in this block (candidates for rewind).
pub outputs: Vec<ChainOutput>,
}
#[derive(Clone)]
pub struct GrinNode {
foreign_api_url: String,
api_secret: String,
http: reqwest::Client,
}
impl GrinNode {
pub fn new(cfg: &Config) -> Result<Self> {
let http = reqwest::Client::builder()
.timeout(REQUEST_TIMEOUT)
.connect_timeout(CONNECT_TIMEOUT)
.build()
.map_err(|_| Error::Node("failed to build HTTP client"))?;
Ok(Self {
foreign_api_url: cfg.node_foreign_api_url.clone(),
api_secret: cfg.node_foreign_api_secret.expose().to_string(),
http,
})
}
/// Current chain tip height (Foreign API `get_tip`).
pub async fn get_tip_height(&self) -> Result<u64> {
#[derive(Deserialize)]
struct Tip {
height: u64,
}
let tip: Tip = self.rpc("get_tip", serde_json::json!([])).await?;
Ok(tip.height)
}
/// Read a block at `height` as a [`BlockView`] (Foreign API `get_block`).
///
/// STUB: real impl parses the node's block JSON into inputs + outputs
/// (commit + rangeproof + features). Left unimplemented pending the scanner.
pub async fn get_block(&self, _height: u64) -> Result<BlockView> {
Err(Error::NotImplemented("grin get_block"))
}
/// Relay a finalized transaction to the node (Foreign API `push_transaction`).
///
/// STUB: real impl posts `push_transaction(tx, fluff=false)` and treats any
/// JSON-RPC error as [`Error::Node`].
pub async fn push_transaction(&self, _tx: &serde_json::Value) -> Result<()> {
Err(Error::NotImplemented("grin push_transaction"))
}
/// Liveness probe for `/ready` — a cheap tip read.
pub async fn health_check(&self) -> Result<()> {
self.get_tip_height().await.map(|_| ())
}
/// Minimal JSON-RPC helper with a size-capped response read. Non-2xx and
/// JSON-RPC `error` payloads map to a generic [`Error::Node`] (no body
/// interpolation).
async fn rpc<T: serde::de::DeserializeOwned>(
&self,
method: &'static str,
params: serde_json::Value,
) -> Result<T> {
#[derive(Deserialize)]
struct RpcResp<T> {
result: Option<RpcOk<T>>,
}
#[derive(Deserialize)]
struct RpcOk<T> {
#[serde(rename = "Ok")]
ok: Option<T>,
}
let body = serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": method,
"params": params,
});
let mut req = self.http.post(&self.foreign_api_url).json(&body);
if !self.api_secret.is_empty() {
req = req.basic_auth("grin", Some(&self.api_secret));
}
let resp = req.send().await.map_err(|_| Error::Node(method_label(method)))?;
if !resp.status().is_success() {
return Err(Error::Node(method_label(method)));
}
let bytes = read_capped(resp, MAX_BODY_BYTES).await?;
let parsed: RpcResp<T> = serde_json::from_slice(&bytes)
.map_err(|_| Error::Node(method_label(method)))?;
parsed
.result
.and_then(|r| r.ok)
.ok_or(Error::Node(method_label(method)))
}
}
/// Map a method to a static label so the untrusted node body is never surfaced.
fn method_label(method: &str) -> &'static str {
match method {
"get_tip" => "grin get_tip",
"get_block" => "grin get_block",
"push_transaction" => "grin push_transaction",
_ => "grin rpc",
}
}
/// Read a response body enforcing `cap` as bytes arrive (content-length is
/// attacker-asserted, so untrusted).
async fn read_capped(resp: reqwest::Response, cap: usize) -> Result<Vec<u8>> {
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(|_| Error::Node("grin read failed"))?;
if buf.len().saturating_add(chunk.len()) > cap {
return Err(Error::Node("grin response exceeded size limit"));
}
buf.extend_from_slice(&chunk);
}
Ok(buf)
}
/// Rewind a single output's rangeproof against an account's `rewind_hash`. On a
/// match, returns the recovered value + derivation path; `None` if the output
/// does not belong to the account.
///
/// STUB (the core deferred milestone): this is the server-side analog of
/// `grin_recover_output`. It needs the grin crypto stack (secp256k1 +
/// bulletproofs) to compute the rewind nonce from `rewind_hash` + commitment and
/// rewind the proof. Uncomment the `secp256k1` / `aes-gcm`-adjacent crypto deps
/// in Cargo.toml and port the rewind + proof-message parse when the scanner
/// lands.
#[allow(dead_code)]
pub struct RewoundOutput {
pub value: u64,
pub key_id: String,
pub n_child: u32,
}
#[allow(dead_code)]
pub fn rewind_output(_rewind_hash: &str, _out: &ChainOutput) -> Option<RewoundOutput> {
// TODO: compute nonce = f(rewind_hash, commit); rewind bulletproof; parse
// proof message -> (value, key_id, n_child). Returns None on non-match.
None
}
+102
View File
@@ -0,0 +1,102 @@
//! grin-lws — a Grin light-wallet-server.
//!
//! A REAL light-wallet-server mirroring [monero-lws]: register a view credential
//! (`rewind_hash`), run a BACKGROUND scanner over the grin chain, rewind block
//! outputs against registered credentials, STORE the matches (with recovered
//! derivation paths) in a database, and serve balance / unspent-outputs /
//! broadcast / height fast from that store.
//!
//! [monero-lws]: https://github.com/vtnerd/monero-lws
//!
//! WHY A DB
//! --------
//! smirk-backend-core's current `POST /wallet/grin/scan` is an ON-DEMAND,
//! STATELESS proxy: each request forwards a `rewind_hash` to grin-wallet's
//! `scan_rewind_hash`, which rescans and returns matches, storing NOTHING. That
//! is fine for grin's small UTXO set but it is NOT a light-wallet-server. A real
//! LWS — like monero-lws — keeps a per-account output store maintained by a
//! background scanner, so reads are O(rows) not O(chain). Hence this service
//! needs a database (the 3 tables in `migrations/`).
//!
//! HOW smirk-backend-core PROXIES TO IT
//! ------------------------------------
//! Identical wiring to the XMR/WOW LWS (`infra/lws/client.rs`): the backend adds
//! a `GrinLwsConfig { lws_url, admin_url?, admin_key? }` and a `GrinLwsClient`;
//! when the `grin_lws` feature is on, `POST /wallet/grin/scan` forwards the
//! `rewind_hash` to this service's `/get_unspent_outs` (and registers via
//! `/register` at balance-fetch time, idempotently, exactly as `fetchLwsBalance`
//! awaits `registerLws` for XMR/WOW). Non-custodial guarantee preserved: the
//! backend forwards only the `rewind_hash` and stores nothing; grin-lws holds
//! the `rewind_hash` for scanning just as monero-lws holds the view key. NEVER a
//! spend key.
//!
//! STATUS: SCAFFOLD. Routes, DTOs, config, DB layer, and the scanner loop
//! structure are real; the two chain-crypto seams — block parsing
//! (`grin::GrinNode::get_block`) and rangeproof rewind (`grin::rewind_output`) —
//! are stubbed. See README.md for the build-order plan.
//!
//! PUBLIC-CLEAN: all config is env-based with generic loopback defaults. No
//! hostnames, operator IPs, secrets, or deploy specifics anywhere in this repo.
mod api;
mod config;
mod db;
mod error;
mod grin;
mod scanner;
mod secret;
use std::sync::Arc;
use crate::config::Config;
use crate::grin::GrinNode;
/// Process-wide shared state.
pub struct AppState {
pub config: Config,
pub pool: sqlx::AnyPool,
pub node: GrinNode,
}
#[tokio::main]
async fn main() {
let _ = dotenvy::dotenv();
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "grin_lws=info,tower_http=info".into()),
)
.init();
let config = Config::from_env();
let bind_addr = config.bind_addr;
let pool = db::connect(config.database_url.expose())
.await
.expect("failed to build database pool");
// In production, run `sqlx migrate run` (or embed `sqlx::migrate!()`) against
// DATABASE_URL before serving. Left explicit here so the scaffold starts
// without a live DB.
let node = GrinNode::new(&config).expect("failed to build grin node client");
// The background scanner is what makes this a real LWS.
scanner::spawn(pool.clone(), node.clone(), config.clone());
let state = Arc::new(AppState {
config,
pool,
node,
});
let listener = tokio::net::TcpListener::bind(bind_addr)
.await
.expect("failed to bind grin-lws listener");
tracing::info!(
%bind_addr,
"grin-lws listening (SCAFFOLD — scanner block-parse + rewind stubbed)"
);
axum::serve(listener, api::router(state))
.await
.expect("grin-lws server error");
}
+77
View File
@@ -0,0 +1,77 @@
//! Background chain scanner — the heart of a real light-wallet-server, and the
//! bulk of the remaining work.
//!
//! It is a long-lived task that follows the grin chain tip and, for every new
//! block, does two things per registered account:
//! 1. CREATE: rewind each block output's rangeproof against the account's
//! `rewind_hash`. On a match, persist the output WITH its recovered
//! derivation path (so the client can spend directly — no client-side
//! identify search).
//! 2. SPEND: for each input commitment in the block, if it matches a stored
//! output, mark that output spent at this height.
//! It maintains a reorg-safe [`chain_cursor`](crate::db): on a reorg (a block
//! whose `prev_hash` does not match our cursor), roll back rows above the fork
//! and resume from the fork point.
//!
//! STATUS: SCAFFOLD. The loop structure, cursor handling, and DB seams are real;
//! the per-output rewind ([`crate::grin::rewind_output`]) and block parsing
//! ([`crate::grin::GrinNode::get_block`]) are stubbed. Until this lands, the
//! deployed on-demand `/wallet/grin/scan` proxy serves balance for grin's small
//! UTXO set and the client uses a client-side identify helper for spend paths.
use std::time::Duration;
use sqlx::AnyPool;
use crate::config::Config;
use crate::grin::GrinNode;
/// Spawn the background scanner. Returns immediately; the task runs until the
/// process exits.
pub fn spawn(pool: AnyPool, node: GrinNode, cfg: Config) {
tokio::spawn(async move {
let poll = Duration::from_secs(cfg.scan_poll_secs.max(1));
tracing::info!(
poll_secs = cfg.scan_poll_secs,
batch = cfg.scan_batch_blocks,
"grin-lws scanner started (SCAFFOLD — block parse + rewind stubbed)"
);
loop {
if let Err(e) = tick(&pool, &node, &cfg).await {
tracing::warn!(error = %e, "scanner tick failed; will retry");
}
tokio::time::sleep(poll).await;
}
});
}
/// One scan pass: catch up from the cursor toward the tip, a batch at a time.
async fn tick(pool: &AnyPool, node: &GrinNode, cfg: &Config) -> crate::error::Result<()> {
let tip = node.get_tip_height().await?;
let cursor = crate::db::get_cursor(pool).await?;
let from = cursor.as_ref().map(|c| c.height + 1).unwrap_or(0);
if from > tip {
return Ok(()); // caught up
}
let to = (from + cfg.scan_batch_blocks).min(tip);
tracing::debug!(from, to, tip, "scanner batch");
for height in from..=to {
// REAL IMPL (deferred):
// let block = node.get_block(height).await?;
// reorg check: if block.prev_hash != cursor.block_hash { rollback + reseek }
// let accounts = db::list_account_hashes(pool).await?;
// for out in &block.outputs {
// for rh in &accounts {
// if let Some(r) = grin::rewind_output(rh, out) {
// db::insert_output(pool, &to_stored(out, &r), rh).await?;
// }
// }
// }
// for commit in &block.input_commits { db::mark_spent(pool, commit, height).await?; }
// db::set_cursor(pool, height, &block.hash).await?;
let _ = height;
return Ok(()); // scaffold: stop after entering the loop; no chain reads yet.
}
Ok(())
}
+36
View File
@@ -0,0 +1,36 @@
//! A tiny redacting wrapper so secrets (DB URL, node auth, admin key) never leak
//! into `Debug` output or tracing spans. Mirrors smirk-backend-core's `Secret`.
#[derive(Clone)]
pub struct Secret(String);
impl Secret {
pub fn new(value: impl Into<String>) -> Self {
Secret(value.into())
}
/// Reveal the inner value. Call sites should keep the exposed value local and
/// never log it.
pub fn expose(&self) -> &str {
&self.0
}
}
impl std::fmt::Debug for Secret {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("Secret(***)")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn debug_is_redacted() {
let s = Secret::new("hunter2");
assert_eq!(format!("{s:?}"), "Secret(***)");
assert!(!format!("{s:?}").contains("hunter2"));
assert_eq!(s.expose(), "hunter2");
}
}