An account that registered while the initial full scan was still running got marked caught-up without ever being scanned, then stayed at a 0 balance forever (and the backend synced-gate would trust that 0). The advance step used a blanket UPDATE-by-height; on the first tick prev_tip is 0, so it matched any account, including one registered mid-scan. Advance only the caught_up set snapshotted before the scan; an account not in it falls through to the backfill phase on a later tick. Found by live-node validation against a funded wallet.
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: a wallet backend proxies to it exactly the way a backend proxies to the Monero LWS — forward a view credential, serve the stored results back to the wallet.
Status: FUNCTIONAL. The view-only rangeproof rewind, the PMMR-index node
reads, and the background scanner (discover / spend-reconcile / reorg / backfill)
are all implemented and tested. The rewind is cross-validated against grin's own
reference ProofBuilder and passed an adversarial money-safety review; an
end-to-end test drives the real scanner against a mock grin node (modeled on the
Foreign API v2) over both SQLite and Postgres. Remaining before production: a
smoke test against a live grin node with a funded wallet. See
Build order.
Why this exists (and why it needs a DB)
A common wallet-backend approach is an on-demand, stateless grin scan: 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
- Registration —
POST /register {rewind_hash, start_height?}inserts anaccountsrow that the scanner will follow. Idempotent (the monero-lwsadd_accountanalog, but self-service and view-only, so no spend risk). - 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-safechain_cursor(roll back rows above the fork on a reorg). - 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:
{ "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_hashes |
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. It runs unchanged on both PostgreSQL and
SQLite (verified in a test). 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_childare recovered during rewind — the direct-spend enabler.outputs.spent/spent_heightare set when a stored output's commitment appears as a block input.chain_cursoris the single-row global resume point; on a reorg the scanner rolls back rows above the fork and reseeks from it.
How a wallet backend proxies to it
Identical wiring to a Monero LWS client — forward the view credential, serve the stored results:
wallet ──JWT──▶ wallet backend ──private──▶ grin-lws ──▶ grin node
/wallet/grin/scan /get_unspent_outs (Foreign API v2)
- Add a grin-lws client config (
lws_url, optionaladmin_url+admin_key) alongside your existing Monero-LWS config. - Add an HTTP client mirroring your Monero-LWS client (shared
reqwest::Client, timeouts, size-capped hostile-response reads, aSecret-wrapped admin key). - Behind a feature flag, have your grin scan endpoint forward the
rewind_hashto grin-lws/get_unspent_outs(returning outputs with paths) instead of driving grin-walletscan_rewind_hashon-demand. - Register at balance-fetch time by forwarding
rewind_hashto grin-lws/register(idempotent), exactly as you await LWS registration for Monero.
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.
Once grin-lws is wired in, the wallet client can drop any client-side
output-identify 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.
- Scaffold. ✅ Routes, DTOs, config, DB layer, migrations, health/readiness.
- Rangeproof rewind (the core milestone). ✅
grin::rewind_output: compute the rewind nonce fromrewind_hash+ commitment, rewind the Bulletproof, and parse the proof message to recover(value, key_id, n_child)— VIEW-ONLY, the server-side analog ofgrin_recover_output. Cross-validated against grin's referenceProofBuilder; adversarial money-safety review passed. (v3 outputs only — legacy needs the master secret, same as grin-wallet's view-only scan.) - Grin node reads. ✅
get_unspent_outputs(PMMR-index, with proofs),get_outputs(spend-by-absence),get_pmmr_indices,get_header,get_tip,push_transaction— all against the Foreign API v2, defensively parsed. - Scanner. ✅ The
tickloop: page the UTXO set from the cursor, rewind per caught-up account,insert_outputon matches, spend-reconcile by absence, advance the cursor + account scan heights, coinbase-maturity/lock read path. - Reorg + backfill. ✅ Detect via header-hash mismatch at the checkpoint,
bounded
rollback_to, reseek; backfill a new account from its birthday index. - Live-node smoke test. ⏳ Point at a real grin node with a funded wallet and
confirm balance/outputs match grin-wallet's
scan_rewind_hash. - Backend proxy. ⏳ Land a
GrinLwsClientin a wallet backend behind a feature flag; parity-check against the on-demand path; retire the embedded grin-wallet coupling.
Running
# 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
# 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
/height, /submit_raw_tx, and /ready require a reachable grin node (they
return 502 otherwise). /register, /get_balance, and /get_unspent_outs
read from the DB store, which the background scanner fills once it has a grin
node to page the output set from. /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.
License & provenance
MIT — see LICENSE.
grin-lws was extracted from the Smirk wallet's backend and generalized into a standalone service that any wallet backend can run.