Tap-to-receive for the buy flow, the receive counterpart to #83's cash-out tap-to-pay. A Bolt Card only emits an lnurlw withdraw voucher (wrong direction to deposit into it), so the tap is used as an authenticated identity (external_id + SUN p/c) to resolve the card wallet's lnurlp/Lightning Address; the ATM then pays an invoice for the payout over its existing nostr transport. - electron/lnurl-pay.ts: resolveCardInvoice() — resolve card -> pay target -> LUD-16/LUD-06 -> BOLT11. scanUrlToResolver() is the single HTTPS-today / nostr-tomorrow transport seam. 13 tests. - IPC lnurl:pay-card (main-process HTTPS to dodge renderer CORS) + preload/electron.d.ts surface. - stores/atm.ts: handleBoltCardReceive() settles via the existing payInvoice -> PAYMENT_RECEIVED path; the one NFC listener now routes the same tap by flow (cash-out pulls, cash-in receives). - CashInView.vue: NFC status + dev tap input. - docs/boltcard-receive-resolver.md: spec for the custom LNbits /boltcards/api/v1/pay/<id> resolver endpoint (omni-private side). Card issuance is unchanged — same NDEF/keys/external_id; receive is a server-side reading of the same tap. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4.2 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
- 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.