From 1dabd1513c4df24e8ad4453010a4c5474461b0a4 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Thu, 9 Jul 2026 04:47:13 -0400 Subject: [PATCH] =?UTF-8?q?scaffold:=20grin-lws=20=E2=80=94=20Grin=20light?= =?UTF-8?q?-wallet-server=20(view-key=20scan=20+=20DB=20store)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .env.example | 32 ++++ .gitignore | 4 + Cargo.toml | 51 +++++++ README.md | 230 ++++++++++++++++++++++++++++ migrations/0001_init.sql | 58 +++++++ src/api.rs | 316 +++++++++++++++++++++++++++++++++++++++ src/config.rs | 68 +++++++++ src/db.rs | 235 +++++++++++++++++++++++++++++ src/error.rs | 64 ++++++++ src/grin.rs | 197 ++++++++++++++++++++++++ src/main.rs | 102 +++++++++++++ src/scanner.rs | 77 ++++++++++ src/secret.rs | 36 +++++ 13 files changed, 1470 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 migrations/0001_init.sql create mode 100644 src/api.rs create mode 100644 src/config.rs create mode 100644 src/db.rs create mode 100644 src/error.rs create mode 100644 src/grin.rs create mode 100644 src/main.rs create mode 100644 src/scanner.rs create mode 100644 src/secret.rs diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a9dea69 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e3a21e5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/target +.env +*.log +Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..0b18bee --- /dev/null +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md new file mode 100644 index 0000000..34c85d1 --- /dev/null +++ b/README.md @@ -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. diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql new file mode 100644 index 0000000..800a0e5 --- /dev/null +++ b/migrations/0001_init.sql @@ -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 +); diff --git a/src/api.rs b/src/api.rs new file mode 100644 index 0000000..6b6ebf2 --- /dev/null +++ b/src/api.rs @@ -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, +} + +#[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, + pub n_child: Option, +} + +#[derive(Serialize)] +pub struct UnspentOutsResponse { + pub outputs: Vec, + 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, +} + +#[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>, + Json(req): Json, +) -> Result> { + 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>, + Json(req): Json, +) -> Result> { + 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>, + Json(req): Json, +) -> Result> { + 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>, + Json(req): Json, +) -> Result> { + state.node.push_transaction(&req.tx).await?; + Ok(Json(SubmitRawTxResponse { ok: true })) +} + +/// `GET /height` — current chain tip. +pub async fn height(State(state): State>) -> Result> { + 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 { + 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>) -> Result> { + 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>, + headers: HeaderMap, +) -> Result> { + require_admin(&state, &headers)?; + let accounts = db::list_account_hashes(&state.pool).await?; + Ok(Json(ListAccountsResponse { accounts })) +} + +pub async fn rescan( + State(state): State>, + headers: HeaderMap, + Json(req): Json, +) -> Result> { + 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) -> 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) +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..6ce40e2 --- /dev/null +++ b/src/config.rs @@ -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() + } +} diff --git a/src/db.rs b/src/db.rs new file mode 100644 index 0000000..f420715 --- /dev/null +++ b/src/db.rs @@ -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, + pub n_child: Option, +} + +/// 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 { + // 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 { + 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> { + let rows = sqlx::query("SELECT rewind_hash FROM accounts") + .fetch_all(pool) + .await?; + Ok(rows.iter().map(|r| r.get::("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> { + 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> { + 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::("scan_height").max(0) as u64)) +} + +fn row_to_output(r: &sqlx::any::AnyRow) -> StoredOutput { + StoredOutput { + commit: r.get::("commit"), + value: r.get::("value").max(0) as u64, + height: r.get::("height").max(0) as u64, + mmr_index: r.get::("mmr_index").max(0) as u64, + is_coinbase: r.get::("is_coinbase"), + lock_height: r.get::("lock_height").max(0) as u64, + key_id: r.try_get::, _>("key_id").ok().flatten(), + n_child: r + .try_get::, _>("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> { + 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::("height").max(0) as u64, + block_hash: r.get::("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(()) +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..39dd5e6 --- /dev/null +++ b/src/error.rs @@ -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 = std::result::Result; + +/// 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"))) + } +} diff --git a/src/grin.rs b/src/grin.rs new file mode 100644 index 0000000..8163f53 --- /dev/null +++ b/src/grin.rs @@ -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, + /// Outputs created in this block (candidates for rewind). + pub outputs: Vec, +} + +#[derive(Clone)] +pub struct GrinNode { + foreign_api_url: String, + api_secret: String, + http: reqwest::Client, +} + +impl GrinNode { + pub fn new(cfg: &Config) -> Result { + 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 { + #[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 { + 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( + &self, + method: &'static str, + params: serde_json::Value, + ) -> Result { + #[derive(Deserialize)] + struct RpcResp { + result: Option>, + } + #[derive(Deserialize)] + struct RpcOk { + #[serde(rename = "Ok")] + ok: Option, + } + + 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 = 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> { + use futures::StreamExt; + let mut stream = resp.bytes_stream(); + let mut buf: Vec = Vec::new(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|_| 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 { + // TODO: compute nonce = f(rewind_hash, commit); rewind bulletproof; parse + // proof message -> (value, key_id, n_child). Returns None on non-match. + None +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..c7a8afb --- /dev/null +++ b/src/main.rs @@ -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"); +} diff --git a/src/scanner.rs b/src/scanner.rs new file mode 100644 index 0000000..d588bee --- /dev/null +++ b/src/scanner.rs @@ -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(()) +} diff --git a/src/secret.rs b/src/secret.rs new file mode 100644 index 0000000..c367701 --- /dev/null +++ b/src/secret.rs @@ -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) -> 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"); + } +}