Add refresh and LNURL transfer to the protocol - #2
Merged
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 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. Comment |
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two additions that together let a coupon change hands, plus the wire surface for both.
Refresh —
POST /api/coupons/refreshBurns 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, newRefreshtag,refreshCouponoperation.Transfer — LUD-16 as the transport
A wallet advertises
allowVouchers: trueon its payRequest; a sender POSTs the voucher to the same callback it would pay; the receiver refreshes the nonce, stores the replacement, then answersACCEPTED.GETon that callback stays ordinary LNURL-pay — the method is the discriminator, andactionin 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, notvoided.voidedmeans the merchant killed the value;refreshedmeans 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 voucherphasetag is deliberately untouched, since refresh signs a brand-new 20402 and the old event is immutable and already distributed.refreshUrllands inv2discovery content, not av3. The spec says a non-2version 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 treatERRORas 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:
refreshUrlfrom 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.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 inlawalletio/lawallet-nwc, which is where the receiver algorithm above comes from.Checks
npm run buildpasses; 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