bitspire/docs/boltcard-receive-resolver.md
Patrick Mulligan 64582e7fe6 feat(machine): Bolt Card (NFC) tap-to-receive on cash-in
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>
2026-08-06 00:30:13 +02:00

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 segment scan → pay. The ATM derives it by string-substitution on the tapped lnurlw (scanUrlToResolver() in lnurl-pay.ts).
  • Verify p/c exactly like /scan: decrypt the PICC (p) with the card's k1, recompute the CMAC (c) with k2, check the read counter is fresh (monotonic). Reject replays. Reuse the boltcards SUN verification path — do not fork it. A valid p/c is 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.

{ "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 displayingQR on success; the withdraw link is uses: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 /pay GET) is the one HTTPS-today / nostr-tomorrow seam. A nostr-native boltcard would answer the same external_id + SUN identity over the ATM's existing nostr connection, dropping the clearnet HTTPS call. The rest (standard LNURL-pay) is unchanged.