mirror of
https://github.com/Such-Software/grin-lws.git
synced 2026-10-17 07:31:34 +02:00
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:
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
/target
|
||||
.env
|
||||
*.log
|
||||
Cargo.lock
|
||||
+51
@@ -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"
|
||||
@@ -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.
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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");
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user