Skip to content

Repository files navigation

LNURLwallet

A serverless wallet for LNURLcash bearer notes (LUD-XX draft). It is a single static page - no backend, no database, no accounts - deployed straight to GitHub Pages. Everything it holds lives encrypted in your browser's local storage, and every network request goes directly from your browser to the LNURLcash service that issued a note.

Built with the same stack as lnurl_server's frontend: Vite, SolidJS, TypeScript, sass, @scure/@noble crypto, solid-qr-code, solid-toast, solid-icons. Works against any spec-compliant service, e.g. lnurl-mint.

LNURLcash (LUD-XX)

A bearer note is an ordinary LUD-03 withdrawRequest link whose k1 is the asset:

lnurlw://mint.example/withdraw?k1=<secret>&amount=<msat>

Whoever knows the k1 controls the sats behind it - like a banknote. The amount alongside it is only a claim by whoever encoded the note (untrusted until confirmed online, or backed by a signature - see below); the authoritative value is always maxWithdrawable from an informational GET, which the service ignores the URL's own amount for. No new endpoint, no new encoding: a wallet that doesn't know LNURLcash sees a normal withdraw link and can cash it out to a BOLT-11 invoice. That informational GET never burns; all mutating operations go to the callback from that withdrawRequest JSON:

Request Result
callback?k1=X&pr=<bolt11> melt: X burned once pr (of exactly its value) settles
callback?k1=X rotate: X burned, {"status":"OK","k1":X'} same value
callback?k1=X&amount=<msat> split: X burned, response carries k1 (amount) + change
callback?k1=X&k1=Y merge: all burned, one note worth the sum returned

Melt only ever takes a single k1 - merge several notes first to melt them together in one payment. {"status":"OK"} from a melt only means the payment is now on its way, not that the note is confirmed spent - the service pays it out asynchronously and only finalizes the burn once that settles, restoring the note if it fails instead (rejecting any other callback naming that k1 with {"status":"ERROR","reason":"pending"} in the meantime). This wallet leaves a just-melted note in place rather than assume success; refresh confirms it once the note is actually gone.

Minting: a LUD-06 payRequest advertising withdrawLink mints notes - the payment preimage of its paid invoice becomes a valid k1 at that endpoint. This wallet has no Lightning node of its own, so you pay the invoice with any wallet and paste back the preimage it reveals; the wallet verifies it with the service, then immediately rotates it - that verifying GET already put the preimage on the wire - before storing the note, same as it does for a scanned or pasted note.

Offline verification (optional): a service MAY publish a mintPubkey on its withdrawRequest response and sign each fresh secret it hands out (on rotate/split/merge), letting a holder verify issuer and amount without a network round trip. The signature is made the same way LUD-13 signs its auth seed phrase - a Lightning node's own signmessage:

message = "LNURLcash:" || amount_msat (decimal) || ":" || hex(sha256(k1))
digest  = sha256(sha256("Lightning Signed Message:" || message))

and travels as one more query param, &sig=<hex>, ignored by wallets that don't check it. This wallet verifies it whenever it already knows a note's service pubkey and shows a "signed" badge on a match - tolerating both the spec text's r ‖ s ‖ recovery-id (trailing) wire layout and the recovery-id-leading layout at least one real implementation (lnurl-mint, as of this writing) sends instead, trying both rather than hard-failing real notes over a byte-order mismatch.

The wallet follows the spec's security guidance:

  • Received (scanned/pasted) notes are rotated immediately after the informational GET that verifies them - that GET necessarily puts the old secret on the wire, so the previous holder's copy needs burning regardless of who they are. (An earlier draft of the spec let this be avoided via an ?id=sha256(k1) hash lookup; that option was since removed.)
  • One wallet holds notes from any number of independent mints side by side, grouped per service; combine (merge) works across selected same-mint notes in a single request.

Security model: encrypted with your linking key, in your local storage

  • At setup a 12-word BIP39 seed phrase is generated in your browser. It is never stored - write it down; it is the only way to recover the wallet on another device.
  • From the seed a linking key is derived using the LUD-05 derivation (same scheme as lnurl_server) against the fixed domain lnurlwallet, so the identity is independent of where this page is hosted.
  • Every bearer note is AES-256-GCM encrypted with a key derived from the linking key before it is written to local storage. Plaintext secrets never touch disk.
  • The linking key itself is stored encrypted as well: during setup you are asked for a password and the key is saved as AES-GCM ciphertext under a PBKDF2 (210k iterations, SHA-256) stretch of that password. Unlocking decrypts it into memory only. Opting out is possible but leaves the key readable to anyone using the browser profile.
  • The wallet sends nothing anywhere except the note operations you trigger, straight to the issuing service.

Backup & restore

Backup downloads a single JSON file with all your bearer notes exactly as stored - still encrypted. If the linking key is password-encrypted, its ciphertext is included too (backup + password restores everything on a new device); a plaintext-stored linking key is never exported, the seed phrase is its recovery path.

Restore merges a backup file's notes into local storage, skipping ones already present. Ciphertexts become readable once the same seed (hence the same linking key) is active - restore the seed first or the file first, either order works.

A backup protects against a lost device, not against theft of the note itself: the service settles for whoever presents a k1 first. Rotation (the transfer action, and the automatic rotate-on-receive) is the tool against stale copies - after restoring an old backup, rotate what you hold.

Note on lnurl-mint

lnurl-mint tracks this spec closely and implements offline verification when a funding source is configured (mintPubkey + signing via the node's own signmessage), but its sign_note currently sends the recovery-id-leading byte layout rather than the spec text's trailing one - see Offline verification above for how this wallet tolerates both. Its melt is also synchronous (the HTTP response doesn't return until the outgoing payment has already settled or confirmably failed) rather than exposing the spec's async pending window, which is a stricter special case of it, not a violation.

Development

npm install
npm run dev     # dev server on :3000
npm test        # vitest (codec + crypto round-trips)
npm run tsc     # typecheck
npm run build   # static build in dist/

For an end-to-end local loop, run lnurl-mint (uv run fastapi dev lnurl_mint/server.py) and point the Mint page at localhost:8000 - insecure hosts (localhost, 127.0.0.1, .onion) are resolved as http automatically.

Deployment

Pushing to main runs tests and deploys dist/ to GitHub Pages via .github/workflows/deploy.yml (enable Pages -> "GitHub Actions" as the source in the repo settings). The build uses relative asset paths and a hash router, so it works from any subpath - no server configuration needed.

License

MIT

About

LNURLwallet is a serverless wallet utilizing LNURLcash bearer tokens.

Resources

Stars

3 stars

Watchers

0 watching

Forks

Contributors

Languages