feat(grin): proxy scans through grin-lws (default) with grin-wallet fallback

Add a GrinLwsClient (a faithful mirror of the XMR LwsClient) + GrinLwsConfig
(enabled when GRIN_LWS_URL is set). /wallet/grin/scan now defaults to grin-lws
when configured and returns derivation paths (key_id/n_child) + spendability,
letting the wallet skip its client-side output-identify search.

Money-safety: the restore-depth/PoW gate runs once and is terminal (never
masked by fallback); grin-lws is trusted only when provably synced
(scanned_height + margin >= tip), otherwise the scan falls back to the
authoritative grin-wallet scan_rewind_hash; a grin-lws transport error also
falls back; grin-wallet errors propagate as 503. No path ever synthesizes an
empty/zero balance.

Off unless GRIN_LWS_URL is configured (prod grin behavior unchanged until
pointed at a grin-lws instance). Capabilities advertise `grin_lws`.
openapi.json regenerated.
This commit is contained in:
jwinterm
2026-07-09 16:26:23 -04:00
parent a7c4bbd238
commit 4d1d9006bc
10 changed files with 596 additions and 3 deletions
+114
View File
@@ -895,6 +895,47 @@
}
}
},
"/wallet/grin/address/{addr}/user": {
"get": {
"tags": [
"grin"
],
"summary": "Resolve a bare Grin address to its owning user's Nostr routing identity.",
"description": "The address→npub bridge (see [`GrinAddressUserResponse`]). JWT-gated: only\nauthenticated senders resolve routing. An address unknown to this backend\nreturns the constant-shape `registered: false` response — the caller then\nfalls back to manual clipboard or the same-instance relay.",
"operationId": "address_user",
"parameters": [
{
"name": "addr",
"in": "path",
"description": "Grin address (registered public key)",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Address resolution result",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GrinAddressUserResponse"
}
}
}
},
"401": {
"description": "Missing or invalid token"
}
},
"security": [
{
"bearer_auth": []
}
]
}
},
"/wallet/grin/broadcast": {
"post": {
"tags": [
@@ -2467,6 +2508,7 @@
"type": "object",
"required": [
"grin_relay",
"grin_lws",
"prices",
"nostr_identity",
"nostr_native_auth",
@@ -2480,6 +2522,10 @@
"type": "boolean",
"description": "Public curated Nostr feed for this instance. See `feed`."
},
"grin_lws": {
"type": "boolean",
"description": "Grin light-wallet-server (grin-lws) scan path is available — fast balance\nscans via grin-lws, with the authoritative grin-wallet scan as fallback."
},
"grin_relay": {
"type": "boolean",
"description": "Grin async slatepack relay mailbox."
@@ -2612,6 +2658,33 @@
}
}
},
"GrinAddressUserResponse": {
"type": "object",
"description": "Resolution of a bare Grin address to its owning user's routing identity.\n\nThe address→npub **bridge**: given a `grin1…` slatepack address, report\nwhether it belongs to a user registered on *this* backend and, if so, that\nuser's linked Nostr pubkey so a bare-address send can be upgraded to a\nfederated Nostr gift-wrap instead of the same-instance relay.\n\nNOTE (federation): this is a **same-instance convenience**. It can only\nresolve addresses whose owner registered their Grin key here (via `POST\n/keys`). It is *not* a federated directory — a wallet on another backend\nwon't be found. `registered: false` (a constant-shape miss, mirroring\n`GET /users/by-username`) means \"unknown to this instance\", at which point\nthe sender falls back to manual clipboard / the backend relay.",
"required": [
"registered"
],
"properties": {
"npub": {
"type": [
"string",
"null"
],
"description": "The owning user's linked Nostr pubkey (x-only hex), when they have one.\nThis is what the sender feeds the Nostr gift-wrap channel to route the\nslate over Nostr. `None` when the user registered no Nostr identity."
},
"registered": {
"type": "boolean",
"description": "Whether the address resolved to a user on this backend."
},
"user_id": {
"type": [
"string",
"null"
],
"description": "The owning user's id (UUID), when `registered`."
}
}
},
"GrinBroadcastRequest": {
"type": "object",
"description": "Broadcast a finalized, signed Grin transaction (built + signed by the wallet).",
@@ -2672,6 +2745,13 @@
"is_coinbase": {
"type": "boolean"
},
"key_id": {
"type": [
"string",
"null"
],
"description": "Recovered derivation key id — populated only on the grin-lws path."
},
"lock_height": {
"type": "integer",
"format": "int64",
@@ -2682,6 +2762,22 @@
"format": "int64",
"minimum": 0
},
"n_child": {
"type": [
"integer",
"null"
],
"format": "int32",
"description": "Recovered child index — populated only on the grin-lws path.",
"minimum": 0
},
"spendable": {
"type": [
"boolean",
"null"
],
"description": "Spendable at the current tip — populated only on the grin-lws path."
},
"value": {
"type": "integer",
"format": "int64",
@@ -2729,6 +2825,15 @@
"last_pmmr_index"
],
"properties": {
"blockchain_height": {
"type": [
"integer",
"null"
],
"format": "int64",
"description": "Chain tip observed by grin-lws at scan time — present only on the grin-lws\npath.",
"minimum": 0
},
"last_pmmr_index": {
"type": "integer",
"format": "int64",
@@ -2741,6 +2846,15 @@
"$ref": "#/components/schemas/GrinOutput"
}
},
"scanned_height": {
"type": [
"integer",
"null"
],
"format": "int64",
"description": "How far grin-lws has scanned this account — present only on the grin-lws\npath (the authoritative grin-wallet path leaves it null).",
"minimum": 0
},
"total_balance": {
"type": "integer",
"format": "int64",