bitspire/docs/boltcard-receive-resolver.md
Padreug ec2b15c08f docs: Bolt Card session contract, ADR-003 amendment for verified entry
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>
2026-09-20 17:07:17 +02:00

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 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

  • Tap-to-enter uses /session instead. When the access gate is on, the card was already verified at entry and the ATM holds the LUD-06 second step from /session (see boltcard-session.md), so Complete calls pay.callback directly and never touches /pay. /pay remains 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 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.