docs/boltcard-session.md is the /session wire contract (sibling of boltcard-receive-resolver.md), including the trust boundary: the session URL is derived from the card's own host, so open enrollment is still not a security boundary (#91). ADR-003's amendment now records verified entry via /session and the hidden-by-default balance display. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
4.6 KiB
Bolt Card tap-to-receive — LNbits resolver endpoint
Spec for the small custom endpoint the ATM needs on the LNbits boltcards
extension to support tap-to-receive (the cash-in / buy flow). The ATM side
(apps/machine/electron/lnurl-pay.ts) is already built against this contract;
this document is what to implement in the omni-private LNbits fork.
Why a new endpoint
A Bolt Card only ever emits its lnurlw://…/scan/<external_id>?p=&c= voucher —
a withdraw (spend) credential. You cannot push sats into the card with it.
To deposit to the card's wallet, the ATM uses the same tap as an authenticated
identity (the external_id + the SUN p/c, verified exactly as /scan
does) and needs the wallet's pay target back. Stock boltcards is
withdraw-only, so we add a pay sibling of scan.
The card is not re-written — same NDEF, same keys, same external_id. Only
the server learns a new way to answer the same tap.
Endpoint
GET /boltcards/api/v1/pay/{external_id}?p={p}&c={c}
- Same URL shape as
GET /boltcards/api/v1/scan/{external_id}?p=&c=, with the path segmentscan→pay. The ATM derives it by string-substitution on the tappedlnurlw(scanUrlToResolver()inlnurl-pay.ts). - Verify
p/cexactly like/scan: decrypt the PICC (p) with the card'sk1, recompute the CMAC (c) withk2, check the read counter is fresh (monotonic). Reject replays. Reuse the boltcards SUN verification path — do not fork it. A validp/cis the authorization: it proves card possession and prevents a cloned UID from misdirecting a deposit. - No auth key/header — like
/scan, this is a public LNURL-style endpoint gated solely by the SUN.
Response
Return one of the following JSON shapes (the ATM accepts all three). Since each card wallet has a Lightning Address, either of the first two is simplest.
(a) Lightning Address (recommended)
{ "lightningAddress": "cardname@l484.com" }
The ATM resolves it via LUD-16 (/.well-known/lnurlp/cardname) → LUD-06 pay.
(b) LUD-06 payRequest, inline
{
"tag": "payRequest",
"callback": "https://lnbits.l484.com/lnurlp/api/v1/lnurl/<id>",
"minSendable": 1000,
"maxSendable": 100000000,
"metadata": "[[\"text/plain\",\"bolt card top-up\"]]"
}
Hand back the card wallet's existing lnurlp payRequest directly (no extra
round-trip for the ATM).
(c) lnurlp pointer
{ "lnurlp": "https://lnbits.l484.com/lnurlp/<id>" }
An https:// (or lnurl://) URL the ATM will fetch to get the payRequest.
Error
{ "status": "ERROR", "reason": "invalid card" }
Use for a failed SUN check, a disabled/unknown card, or a wallet with no pay
target. reason is surfaced verbatim on the ATM screen, so keep it terse and
non-sensitive.
Flow, end to end
customer inserts cash → ATM owes N sats → customer taps Bolt Card
→ ATM reads lnurlw (external_id + fresh p/c)
→ GET /boltcards/api/v1/pay/<external_id>?p=&c= ← THIS ENDPOINT
→ { lightningAddress | payRequest | lnurlp }
→ ATM: LUD-16/LUD-06 → GET callback?amount=<N*1000 msat> → BOLT11
→ ATM pays the BOLT11 over its own nostr transport → card wallet credited
→ PAYMENT_RECEIVED → cash-in completes
Amounts are in millisatoshis on the LUD-06 callback (amount=<msat>), per
spec. Make sure each card wallet's minSendable/maxSendable span the ATM's
payout range or the tap will be declined with "amount is above/below the card
wallet …".
Notes
-
Tap-to-enter uses
/sessioninstead. When the access gate is on, the card was already verified at entry and the ATM holds the LUD-06 second step from/session(seeboltcard-session.md), so Complete callspay.callbackdirectly and never touches/pay./payremains the path for a card tapped directly on the cash-in screen (gate off, or a second card). -
Double-payout: the cash-in screen still shows the LNURL-withdraw QR as a fallback (customer pulls). A tap pays instead. The ATM gates re-entry while a tap is in flight and leaves
displayingQRon success; the withdraw link isuses:1. A customer would have to both tap and pull near-simultaneously to double-collect — acceptable for now, revisit if it bites. -
Future nostr transport:
resolveCardPayTarget(the/payGET) is the one HTTPS-today / nostr-tomorrow seam. A nostr-native boltcard would answer the sameexternal_id + SUNidentity over the ATM's existing nostr connection, dropping the clearnet HTTPS call. The rest (standard LNURL-pay) is unchanged.