Skip to content

LUD-25: LNURLcash, Bearer assets - #301

Open
dni wants to merge 18 commits into
ludsfrom
lnurlcash
Open

LUD-25: LNURLcash, Bearer assets#301
dni wants to merge 18 commits into
ludsfrom
lnurlcash

Conversation

@dni

@dni dni commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

LNURL-withdraw (LUD-03) links are redeemable by anyone who holds a valid k1. This document formalizes that property into a bearer-asset scheme: a k1 is no longer only a one-time ticket that gets exchanged for a BOLT-11 invoice, it can itself be the asset. A k1 that a SERVICE has credited with value can be copied, printed, NFC-tapped or handed over exactly like a banknote, and can be re-issued ("melted and re-minted"), merged with other bearer notes, or split into change, all without the holder needing to be online or to have a route back to SERVICE at handoff time.

Because a bearer k1 is nothing more than a normal withdrawRequest k1, LNURLcash requires no new endpoint and no new encoding. A SERVICE that implements it is still speaking plain LUD-03; a WALLET that does not know about LNURLcash still sees a normal withdraw link and can cash it out to a BOLT-11 invoice as usual. LNURLcash is therefore fully optional and backward-compatible.

projects

i am currently working on a lnurl-wallet and lnurl-mint. passed alpha now in beta testing

initial commit

add security considerations

new version

lud21

fix signature byte order

manual

remove pdf
@dni
dni requested review from Kukks, andrerfneves and hsjoberg August 6, 2026 06:38
@andrerfneves andrerfneves added ready Author considers it final, needs review/merge decision new-lud Brand new protocol extension proposal labels Aug 6, 2026
@dni dni changed the title LUD-XX: LNURLcash, Bearer assets LUD-25: LNURLcash, Bearer assets Aug 10, 2026
@dni
dni requested a review from fiatjaf August 10, 2026 08:03
TheCryptoDonkey added a commit to TheCryptoDonkey/lnurl-vault that referenced this pull request Aug 17, 2026
Closes dni#18.

WHAT WAS WRONG

The README said browser-side integration into lnurl-wallet "is a
deliberate follow-up, not part of this repo yet", and listed "No
lnurl-wallet integration yet" under known limitations. It exists:
device.ts, deviceQueue.ts, deviceOrchestration.ts, DeviceContext.tsx and
pages/Vault.tsx, with device.test.ts and deviceOrchestration.test.ts.
Checked each file against dni/lnurl-wallet rather than taking the issue's
word for it.

sdkconfig.defaults opened by saying it had been authored without a working
ESP-IDF install, and pointed at a README section called "Status:
unverified by compilation" that no longer exists. CI builds both
environments on every push and both have run on hardware. The useful half
of that note -- that option names drift between IDF releases -- is kept,
now next to the fact that platformio.ini pins the platform so it does not
happen by surprise.

The nvs_keys note said the partition was "now-unused". It is not in the
repo at all, and the sentence stopped short of the thing a reader most
needs: NVS encryption is off entirely, so a physical flash dump recovers
every secret and physical possession is the protection model. Said plainly
now.

Reported build sizes were 30.1% RAM / 25.6% flash. A real build of this
commit is 36.3% / 27.2%.

Two claims that hardware has since contradicted: a "What's still
unverified" paragraph listing display rendering, button timing, BLE and
NVS persistence as open questions, and a line calling export_secret's
approval gate "unverified on hardware". All of those have been exercised.
That paragraph now defers to docs/HARDWARE-TEST-CHECKLIST.md, which
carries dates, boards and firmware versions per area and marks anything
unrun as NOT YET BENCH-RUN -- a file is a better authority for this than a
prose paragraph that goes stale silently.

LINKS THAT DID NOT RESOLVE

Seven links pointed at ../luds/25.md or ../../luds/25.md, plus
../lnurl-wallet and ../lnurl-mint. Those only work inside a monorepo
checkout; on GitHub they 404. LUD-25 is not a merged file anywhere -- it is
an open PR, lnurl/luds#301 -- so the links now point at the proposal, and
the two sibling projects at their own repositories.

NOT CHANGED

The web installer's github.com/dni/lnurl-vault source links. The issue
lists them as drift, but they point at the canonical upstream and are
correct as written. Deriving them from the Pages host would add JavaScript
to a page whose job is flashing firmware, and would not survive a custom
domain. Left alone deliberately rather than churned.

Verified: 257/257 native assertions; the S3 target builds (the source of
the corrected size figures); the LUD-25 PR and all five lnurl-wallet files
confirmed to exist via the GitHub API.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RpMeqfvv9KqifKGVM2GwA5
dni pushed a commit to dni/lnurl-vault that referenced this pull request Aug 17, 2026
…#18) (#37)

Closes #18. **Stacked on #36** — the README now points at
`docs/HARDWARE-TEST-CHECKLIST.md`, which that PR adds, so stacking keeps
the link from dangling at any point. This PR's own diff is the last
commit.

## What was wrong

**The wallet integration exists.** The README said it "is a deliberate
follow-up, not part of this repo yet" and listed "No `lnurl-wallet`
integration yet" under known limitations. `device.ts`, `deviceQueue.ts`,
`deviceOrchestration.ts`, `DeviceContext.tsx` and `pages/Vault.tsx` are
all there, with `device.test.ts` and `deviceOrchestration.test.ts`. I
checked each file against `dni/lnurl-wallet` via the API rather than
taking the issue's word for it.

**`sdkconfig.defaults` disowned itself.** It opened by saying it had
been authored without a working ESP-IDF install, and pointed at a README
section called *"Status: unverified by compilation"* that no longer
exists. CI builds both environments on every push and both have run on
hardware. The useful half of that note — option names drift between IDF
releases — is kept, now sitting next to the fact that `platformio.ini`
pins the platform so it doesn't happen by surprise.

**The `nvs_keys` note undersold the consequence.** It called the
partition "now-unused"; it isn't in the repo at all. And it stopped
short of what a reader most needs to know: NVS encryption is off
entirely, so **a physical flash dump recovers every secret** and
physical possession is the protection model. Said plainly now.

**Stale numbers.** Reported sizes were 30.1% RAM / 25.6% flash. A real
build of this commit is 36.3% / 27.2%.

**Two claims hardware has since contradicted:** a "What's still
unverified" paragraph listing display rendering, button timing, BLE and
NVS persistence as open questions, and a line calling `export_secret`'s
approval gate "unverified on hardware". All exercised now. That
paragraph defers to the checklist instead — a file with dates, boards
and firmware versions per area, which marks anything unrun as `NOT YET
BENCH-RUN`, is a better authority than a prose paragraph that goes stale
silently.

## Links that did not resolve

Seven links pointed at `../luds/25.md` or `../../luds/25.md`, plus
`../lnurl-wallet` and `../lnurl-mint`. Those only work inside a monorepo
checkout — **on GitHub they 404.**

LUD-25 is not a merged file anywhere; it is an open PR,
[lnurl/luds#301](lnurl/luds#301). The links now
point at the proposal, and the two sibling projects at their own
repositories.

## Not changed, deliberately

The installer's `github.com/dni/lnurl-vault` source links. The issue
lists them as drift, but they point at the canonical upstream and are
correct as written. Deriving them from the Pages host would add
JavaScript to a page whose job is flashing firmware, and would not
survive a custom domain. Say the word if you'd rather they were
fork-aware.

## Verification

- 257/257 native assertions
- the S3 target builds — that build is where the corrected size figures
come from
- the LUD-25 PR and all five `lnurl-wallet` files confirmed to exist via
the GitHub API

---------

Co-authored-by: TheCryptoDonkey <TheCryptoDonkey@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@felixfelix-bot

felixfelix-bot commented Aug 17, 2026

Copy link
Copy Markdown

Implementer use case: TollGate — captive-portal internet access on OpenWrt

We're building TollGate — internet-access gateways on OpenWrt routers. Guests arrive with no internet and no wallet: you can't route a Lightning payment to a device with no network, and you can't hand a Cashu token to someone with no wallet.

Bearer notes solve this entirely on frozen LUD-03 — zero draft-spec features needed to ship.

Flow (a guest paying with a note — could be an LNbits voucher, a boltcard withdraw link, or any LUD-03 lnurlw:// link shared via QR or NFC):

  1. Gate parses the lnurlw link, does the informational GET, reads maxWithdrawable.
  2. Gate produces a BOLT11 invoice it can receive on — any Lightning node or wallet works; no working mint is required to accept a note. Our gate happens to source its invoice from a Cashu mint quote (its treasury is ecash), but that's an implementation detail, not a requirement of this scheme.
  3. Gate submits that invoice as the note's pr to the issuer's LUD-03 callback: GET callback?k1=K&pr=<invoice>.
  4. Issuer pays the invoice; gate detects settlement; session granted. Guest is online.

The notes are also directly spendable: they melt straight to any BOLT11 invoice — no e-cash intermediate, no conversion step. A holder can pay any Lightning invoice with a note the same way TollGate does; nothing in the flow above forces creating ecash first.

Every step is existing, frozen spec: LUD-03 withdraw callback, issuer pays an invoice. Works today.

Where LUD-25 would later help (optional, gated on draft maturity): rotate closes the race window between paste and callback atomically; offline sig verifies paper vouchers by scanning without phoning home; split/merge enables a reseller economy. None of that is on the critical path.

We'd be implementer #1 on the consuming side. A few things we hit while reading the draft:

(a) PENDING-TTL — no timeout on pending k1s

The spec marks k1 pending on melt and restores on failure, but specifies no timeout. If the issuer's node crashes or the network partitions mid-melt, the note is frozen forever — no recovery path for the holder.

Suggest: SERVICE MAY set a pending TTL after which it auto-restores the k1. Restoring while a slow HTLC may still settle risks the issuer self-double-paying, so the TTL should restore only on definitive failure — but at minimum the spec should acknowledge the stuck state rather than leave it implicit.

(b) CSPRNG — k1 entropy is unspecified

k1 is the money — a guessable k1 is a stealable note. The draft says wallets generate "a fresh random 32-byte preimage" for rotate/split/merge (disclosing only h), but says nothing about how.

Suggest: a one-line MUST — wallet-generated preimages MUST be 32 bytes from a CSPRNG, not user-derived entropy. Worth stating for issuer-side minting too (payment preimages already are, by Lightning's own rules, but the draft should note it).

(c) MERGE CAP — GET query string has practical length limits

Multi-k1 merge passes every k1 in the query string: ~68 chars per note (64-char hex + k1=&), practical URL limits ~2 KB → merges cap out around 20–30 notes before something in the chain truncates.

Suggest: SERVICE MAY accept a POST body with the same parameters for large merges — backward-compatible transport alternative, no new endpoint.


Happy to stress-test any of these in a reference implementation. We're building the consuming side now (pure LUD-03 composition) and tracking the draft for the issuer-side features we'd adopt later. The backward-compatible design is what makes this viable for us — thanks for the work on it.

@dni

dni commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator Author

hey wow very cool, i though about most of this myself. expiry would be cool and there should be a hardcap on urls. thank you very valid review.

@TheCryptoDonkey

This comment was marked as resolved.

@TheCryptoDonkey

This comment was marked as resolved.

@TheCryptoDonkey

This comment was marked as resolved.

@TheCryptoDonkey

This comment was marked as resolved.

TheCryptoDonkey added a commit to forgesworn/moneyer that referenced this pull request Aug 21, 2026
LUD-25's wording admits both https://host/w and lnurlw://host/w; the
reference stack emits the plain form and that is the intended one. A
wallet that fetches the field without LUD-17 translation now works.
Raised on lnurl/luds#301.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XHMhjbXkEnnffa1ihqJMFe
TheCryptoDonkey added a commit to TheCryptoDonkey/lnurlcash-kit that referenced this pull request Aug 21, 2026
LUD-25 states the mint fee as base_fee_msat plus a ppm cut and says
nothing about rounding. Both live implementations read it differently and
both are defensible:

  - dni's lnurl-mint, the reference, and what every public mint on the
    awesome list except moneyer runs, ceilings the fee to a whole sat on
    purpose so the mint is "never short a sat". _mint_fee_msat ends
    `return -(-fee_msat // 1000) * 1000`.
  - moneyer withholds the msat-exact amount.

So applyMintFee is right about exactly one of them, and a wallet that
compares against it warns spuriously against the other. Measured on real
sats today: 40_000 msat at mint.forgesworn.dev with a 1000 + 1000ppm fee
credited 38_000, not the 38_960 the formula gives, and notecase called
that a discrepancy when the mint had done exactly what it documents.

mintFeeBand gives the range instead: the formula is the most a holder can
be credited, the sat-ceilinged fee the least. withinMintFeeBand is what a
caller should compare a credited note against, rather than an equality
against applyMintFee - an exact match is the best case, not the only
compliant one.

Nothing here decides which reading is correct. That is a question for
lnurl/luds#301; until it settles, both are accepted and anything outside
the band is still wrong.

Co-authored-by: TheCryptoDonkey <TheCryptoDonkey@users.noreply.github.com>
dni pushed a commit to dni/lnurl-mint that referenced this pull request Aug 25, 2026
…22)

## What this is

An adversarial threat-suite for the bearer-note transport/exposure
options currently debated for
[LUD-25](lnurl/luds#301) — one executable
scenario per scorecard row, so candidate fixes get measured against the
same attacks instead of argued about in the abstract.

**This PR is documentation, not a fix.** It takes no position on which
option wins; it gives the debate a shared, runnable baseline. Fix PRs
come later, one per chosen option, each flipping its marked tests.

**Language-neutral twin:** the same T1–T11 scorecard now also exists as
conformance vectors at [`lnurlcash-conformance` →
`vectors/threat-suite.json`](https://github.com/TheCryptoDonkey/lnurlcash-conformance/blob/main/vectors/threat-suite.json),
loadable from any implementation's test suite.

## Options under test

- **A** — status quo (lnurl/luds#301 as drafted)
- **B** — A + comment-secret: WALLET attaches a secret encrypted to the
mint on the payRequest (LUD-12 comment); the note's `k1` becomes
`<secret>:<preimage>`, and the public LUD-21 preimage alone no longer
redeems anything
- **C** — "`?p=` everywhere": every `k1` replaced in transport by that
`k1` encrypted to the mint
- **D** — A + hash-keyed informational GET (poll `/w` by `sha256(k1)`,
never `k1`)
- **E** — blinded signatures (chaumian model)
- **F** — B + D
- **G** — locked notes: a second *asset class*, not a bearer variant.
The `k1` stays a short plaintext secret, but redemption requires an
LUD-04 signature from the LUD-05/LUD-13 `linkingKey` registered at
mint/rotate time. Signature-gated — no ciphertext in URLs anywhere.
Scored separately below, since the trades differ by note type.

## Scorecard

(✓ = attack fails / property holds, ✗ = attack succeeds)

| scenario | A | B | C | D | E | F | covered by |
|---|:-:|:-:|:-:|:-:|:-:|:-:|---|
| T1 verify race | ✗ | ✓ | ✗ | ✗ | ✗ | ✓ |
`tests/test_poc_verify_race.py` (existing) |
| T2 routing-node race (no verify needed) | ✗ | ✓ | ✗ | ✗ | ✗ | ✓ |
`test_t2_...` (this PR) |
| T3 poll-log replay | ✗ | ✗ | ✗ | ✓ | ✗ | ✓ | `test_t3_...` (this PR) |
| T4 callback-log replay (control) | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
`test_t4_...` (this PR) |
| T5 note at rest (bearer axiom, control) | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ |
`test_t5_...` (this PR) |
| T6 operator correlation | ✗ | ✗ | ✗ | ✗ | ✓ | ✗ | `test_t6_...` (this
PR) |
| T7 legacy LUD-03 melt | ✓ | ✓ | ✗ | ✓ | ✓ | ✓ | melt tests in
`test_lnurlcash.py` / `test_verify.py` |
| T8 first-contact offline verify | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | analytical —
needs `mintPubkey` on record; spec-level gap, no endpoint to hit |
| T9 comment silently ignored today | ✗ | ✓ | ✗ | ✗ | ✗ | ✓ |
`test_t9_...` (this PR) |
| T10 merge URL budget | ✓ | ✓ | ✗ | ✓ | ✓ | ✓ | `test_t10_...` (this
PR) |
| T11 offline handoff | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | structural — no
endpoint; the bearer property itself (the spec's Offline circulation
section) |

## Option G (locked notes) — where it wins, what it costs

- **T1/T2 ✓** — the wallet authenticates at `/p/cb`, so the note is
locked to its `linkingKey` from birth; a racer holding only `P` cannot
redeem. No comment-secret needed for these notes.
- **T3/T4 ✓** — a logged `k1` is useless without the key.
- **T5 ✓** — the *only* option that beats the at-rest axiom, precisely
because it gives up the property the axiom protects.
- **T6 ✗** — the operator knows exactly which key owns which notes.
- **T7 ✗** — a plain LUD-03 wallet cannot lnurl-auth; no legacy story.
- **T11 ✗** — offline handoff dies; transfer requires an online re-lock
via the mint.

That is the whole price: locked notes are registered claims, not cash.
Bearer core (B/D) and locked notes (G) are complements, not competitors
— ship the bearer-side race fixes now, spec G as the extension for
claim-check use cases.

**Why C loses T1/T2/T3:** encrypting to the mint is a *public* operation
(`mintPubkey` is advertised), so a racer wraps a leaked preimage himself
and replays it; and a logged `p` redeems exactly like a logged `k1` —
the mint honors the ciphertext, so the ciphertext *is* the note.
Re-encrypting a bearer credential to the party that redeems it never
shrinks its exposure set; the only encryption that helps is encrypting
to the *holder*, which kills bearer-ness (G takes that trade
deliberately, via signatures rather than ciphertext).

**Why T2 matters as much as T1:** the race exists even with
`VERIFY_ENABLED=false` — every routing node on the mint payment's path
learns the preimage as the HTLC settles. Verify only makes the race
*public*; the comment-secret is what closes it in both forms.

## Seed-recoverable notes (needs no protocol change beyond D)

A WALLET that derives its note secrets deterministically from its seed
(BIP32 — reusing LUD-05's own `m/138'`/HMAC(domain) path trick, plus a
counter) can restore outstanding notes from the seed alone: re-derive
candidates, hash them, look them up by `sha256` — option D doubles as
the restore API. Freshly minted notes (`k1` = the mint-generated
preimage) are never seed-derived; they live in the wallet's Lightning
payment history until rotate-on-receipt converts them into seed-derived
ones — the security rule and the backup rule are the same act. **Restore
covers device loss, not theft**: anyone who copied a circulating note
may have spent it long before the restore runs. (Cashu's NUT-13 already
does deterministic secrets — this is parity, not invention.) Pin ONE
derivation convention in the spec, or wallets fragment and restores
silently miss notes.

## Red/green policy

Same convention as `test_melt_restore_double_payout_poc.py`: tests
documenting an attack that succeeds **today** assert the current
vulnerable behavior and carry `INVERTS WHEN` in their docstring — the PR
landing the named option flips them red, forcing the assertions to be
rewritten against the fixed behavior. Control tests (T4, T5) pin
behavior that must never change. All 223 tests pass as of this PR.

## Implementation notes for the option-B PR (found while writing this
suite)

- `router._resolve_note`'s `HEX32_PATTERN` rejects any `k1` that isn't
64 lowercase hex — a composite `<secret>:<preimage>` `k1` dies at the
door. That PR must relax the pattern (NoteStore ids stay `sha256` over
the composite's raw bytes, so payment-hash-keyed mints keep working).
- `router.py`'s own docstrings already cite "the spec's Security
considerations" for the rotate-immediately rule — a section LUD-25 does
not yet contain. This suite is that section's executable skeleton.
- Encryption key management, resolved in discussion: the mint derives
its encryption keypair from the `signmessage` RPC it already uses
(LUD-13's trick: a canonical, never-signable-on-request phrase,
`enc_priv = sha256(sig)`), so no raw-ECDH RPC (LND `DeriveSharedKey` /
CLN `getsharedsecret`) is needed. The derived pubkey ≠ node id, so it
must be published at payRequest time (next to `withdrawLink`) — the
comment rides the callback *request*, before any invoice exists. Decrypt
at `/p/cb` time, store only `sha256(secret:P)`, never the ciphertext.


## One unrelated change, called out rather than buried

`flake.nix` drops `-q` from three `curl | grep` checks in the NixOS VM
test. Nothing to do with bearer notes, and happy to split it out if you
would rather.

`grep -q` exits on the first match and closes the pipe, `curl` then dies
of `SIGPIPE` (exit 23), and the driver's `pipefail` turns a *passing*
match into a failing step. It only bites when the response is large
enough that curl is still writing when grep exits, which is why it reads
as an intermittent CI flake rather than a broken assertion. Dropping
`-q` costs a line of log output and makes the check deterministic.

---------

Co-authored-by: TheCryptoDonkey <TheCryptoDonkey@users.noreply.github.com>
@dni dni mentioned this pull request Aug 26, 2026
@TheCryptoDonkey

Copy link
Copy Markdown

Re-read the document at fc1296c against my comments above, since text has landed since I wrote them. Where each stands:

Addressed — closing from my side:

  • Burn permanence. Adopted verbatim in Redeeming a bearer note ("A burn is permanent: once a k1 is burned, SERVICE MUST NOT return it to the outstanding set…"). Nothing further needed on that one.
  • The dangling "see Security considerations" references. The section exists now, covering the no-comment fallback / verify exposure and the log-hygiene point.

Still open:

  1. Retry rule for mutation GETs. The ERROR sentence in Redeeming a bearer note still mandates answering a retried rotate/split/merge as a definitive failure. The proposed rule — an identical replay (same k1s, same h/h2, same amount) returns the original {"status": "OK"}, with the same sig/sig2, rather than an error — hasn't landed. This remains the one I'd most want pinned before merge: two mainstream HTTP stacks retry GETs on their own initiative, and without the rule a transport hiccup is reported to the wallet as "already spent", so it discards the only copy of a note SERVICE already minted.
  2. Security-bundle items 1–5: wallets MUST NOT auto-retry callbacks and probe the informational GET on any uncertain outcome (plus Cache-Control: no-store and refusing non-GET requests); a redirect policy for k1-bearing URLs; a callback refusal proves nothing about a k1 (the informational GET is the only authoritative probe); a shared funding source between mints (dedupe by payment hash at the node, refuse to pay self-issued invoices); and one operator sentence on fee-free mints still paying routing on every melt. None of these are in the text yet. (The new split/merge fee rules do cover the dust-splitting side of the griefing concern.)
  3. withdrawLink spelling. Still "a raw, non bech32-encoded URL as described in LUD-17", and LUD-17 names both the plain URL and the lnurlw:// scheme — both readings are live in real mints today. My vote stays the plain form with a SHOULD-accept for lnurlw://, but either pin beats the ambiguity.

Separately, a couple of items on the seed-derivation section and the h lookup that I'll raise directly rather than in this thread.

@TheCryptoDonkey

This comment was marked as resolved.

@dni

dni commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator Author

@TheCryptoDonkey updated to lud and addresses most of your issues.

@bitkarrot

Copy link
Copy Markdown

LFG!!!!

@dni

dni commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator Author

added @TheCryptoDonkey retry rule for mutation requests

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

new-lud Brand new protocol extension proposal ready Author considers it final, needs review/merge decision

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants