Skip to content

Add refresh and LNURL transfer to the protocol - #2

Merged
agustinkassis merged 1 commit into
mainfrom
feat/refresh-and-transfer
Aug 20, 2026
Merged

Add refresh and LNURL transfer to the protocol#2
agustinkassis merged 1 commit into
mainfrom
feat/refresh-and-transfer

Conversation

@agustinkassis

Copy link
Copy Markdown
Member

Two additions that together let a coupon change hands, plus the wire surface for both.

Refresh — POST /api/coupons/refresh

Burns a nonce and mints its replacement in one transaction, carrying the same benefit snapshot and the same expiry. Because the nonce is the credential, re-minting it is the transfer. No accounts, no holder field, no new identity concept anywhere in the protocol — which is the point: anything that looked like ownership would be an access control the claim path bypasses.

New page /docs/protocol/refresh, new Refresh tag, refreshCoupon operation.

Transfer — LUD-16 as the transport

A wallet advertises allowVouchers: true on its payRequest; a sender POSTs the voucher to the same callback it would pay; the receiver refreshes the nonce, stores the replacement, then answers ACCEPTED. GET on that callback stays ordinary LNURL-pay — the method is the discriminator, and action in the body leaves room for the next one.

New page /docs/protocol/transfer.

Three decisions worth arguing with

Refresh requires an Idempotency-Key, against the standing "no idempotency on create/mint — reconcile instead of blind retry" invariant. That rule assumes the caller can reconcile. After a refresh it cannot: the old nonce is burned, the preview must not reveal its successor, and a bearer cannot list mints (owner-authenticated). There is nothing left to reconcile with, so the key is the only recovery path from a dropped response. It's spelled out as a derivation, not a carve-out.

The key must also be required — "no key, so return the existing replacement" hands the same new nonce to two racing callers, turning a clean rejection into a double-issue.

The old nonce previews as refreshed, not voided. voided means the merchant killed the value; refreshed means it moved and still exists. A bearer who didn't authorise the swap has no other channel to learn it was taken. Costs two enum additions (MintJson.status, ClaimPreview.status); the voucher phase tag is deliberately untouched, since refresh signs a brand-new 20402 and the old event is immutable and already distributed.

refreshUrl lands in v2 discovery content, not a v3. The spec says a non-2 version is "discarded whole", so a bump kills every existing reader — whereas an unknown key is ignored harmlessly.

Stated, not buried

The transfer page says plainly what no ordering can fix: the receiver must be able to refresh before it can promise anything, so a dishonest receiver can take the coupon and answer ERROR. The mirror protocol is the mirror scam. Senders are told to treat ERROR as go read the service, never as you still have it.

Also normative on the receiver side, because these are the ways to get it wrong:

  • Pin the service by pubkey and use your own stored URL — never take refreshUrl from a request body. A signature proves integrity, not authenticity; anyone can sign a flawless voucher for a shop they don't represent and host a service that swears it's valid.
  • Write the intent down before burning anything, and store the replacement before answering.
  • Don't preview before refreshing — advisory, and the gap is a race.

Not in this PR

The implementation in lacrypta/merchant. This repo is the protocol surface; the behaviour needs a companion PR there before any of it is live. A reference client is being built against it in lawalletio/lawallet-nwc, which is where the receiver algorithm above comes from.

Checks

npm run build passes; 34 static pages, including the three new ones (/docs/protocol/refresh, /docs/protocol/transfer, and the generated /docs/api/refresh/refreshCoupon).

🤖 Generated with Claude Code

Two additions that together let a coupon change hands.

Refresh burns a nonce and mints its replacement in one transaction, carrying
the same benefit snapshot and the same expiry. Because the nonce is the
credential, re-minting it *is* the transfer — no accounts, no holder field, no
new identity concept anywhere in the protocol.

Transfer is the transport: a wallet advertises `allowVouchers` on its LUD-16
payRequest, a sender POSTs the voucher to the same callback it would pay, and
the receiver refreshes the nonce before answering. GET on that callback stays
ordinary LNURL-pay; the method is the discriminator and `action` leaves room
for the next one.

Three decisions worth arguing with:

- Refresh requires an `Idempotency-Key`, against the standing "no idempotency,
  reconcile instead" rule. That rule assumes the caller can reconcile; after a
  refresh it cannot. The old nonce is burned, the preview must not reveal its
  successor, and a bearer cannot list mints. There is nothing left to
  reconcile with, so the key is the only recovery path.
- The old nonce previews as `refreshed`, not `voided`. Voided means the
  merchant killed the value; refreshed means it moved and still exists. A
  bearer who did not authorise the swap has no other way to learn it was taken.
- `refreshUrl` goes in `v2` discovery content rather than a `v3`. A bump
  discards the document whole for every existing reader; an unknown key is
  ignored harmlessly.

The transfer page states plainly what cannot be fixed by ordering: a receiver
must be able to refresh before it can promise anything, so a dishonest one can
take the coupon and answer ERROR.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 20, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
coupons Ready Ready Preview Aug 20, 2026 2:39am

Request Review

@coderabbitai

coderabbitai Bot commented Aug 20, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 991052f6-9b63-4627-8b44-2ffc1e1c0586


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

agustinkassis added a commit to lawalletio/lawallet-nwc that referenced this pull request Aug 20, 2026
Vouchers can now move. A recipient's LUD-16 payRequest advertises
`allowVouchers`, the sender POSTs the signed voucher to that address's
callback, and the recipient swaps the nonce at the coupon service to take it.
Because the nonce is the credential, the swap *is* the change of ownership —
no holder field, no accounts. Spec: lacrypta/coupons#2.

The callback is now dispatched by action, per the house pattern at
`app/api/cards/[id]/scan/cb/`: GET is LNURL-pay, extracted unchanged into
`cb/actions/pay.ts`, and POST routes on `action` to one file per action.
`tests/integration/api/lud16.test.ts` guards that the payment path is
untouched.

The design decisions that carry weight, all of them things that go wrong
quietly if you get them backwards:

- **The receiver never dials a URL from the request.** A 20402 signature
  proves integrity, not authenticity: anyone can sign a flawless voucher for a
  shop they don't represent and host a service that swears it's valid. So the
  service is pinned by pubkey and the refresh URL comes from a row we already
  hold. Closes the fake-CMS surface and SSRF-via-transfer in one rule. Cost is
  real and intended: a service this instance has never seen is refused.
- **Intent is written before anything burns.** The swap is irreversible, so a
  failed insert must mean we never called refresh — a database outage costs
  nobody their coupon. `VoucherTransfer` is unique on
  `(servicePubkey, oldNonce)`, so a retried delivery replays without touching
  the service.
- **The sender claims the send with a conditional update**, and on refusal
  re-reads the *service* rather than believing the answer: a recipient can
  swap the nonce and then reply ERROR, and no ordering fixes that. The row is
  never deleted — it's the only record of where the coupon went.
- **`allowVouchers` defaults off**, and ALLOWLIST owners are refused outright.
  An LNURL sender is anonymous, so honouring a self-declared pubkey would make
  every allowlisted npub spoofable — strictly worse than nothing.

Two bugs found by running it rather than by reading it:

- The scheme was chosen from `NODE_ENV`, so a dev build forced http and met
  Cloudflare's https redirect — the body came back as a redirect page and
  parsed as "invalid JSON". Scheme now follows the host (`isLocalHost`),
  which is what the rest of the codebase already does.
- The recipient was shown the coupon's name from a *sibling row* of the same
  service, so a transfer arrived under the wrong name. Presentation now comes
  from the mint-shaped refresh response — the pinned service's own account of
  what it just minted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@agustinkassis
agustinkassis merged commit 5f65ed7 into main Aug 20, 2026
3 checks passed
@agustinkassis
agustinkassis deleted the feat/refresh-and-transfer branch August 20, 2026 16:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant