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>
116 lines
4.6 KiB
Markdown
116 lines
4.6 KiB
Markdown
# 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.
|
|
|
|
### (a) Lightning Address (recommended)
|
|
|
|
```json
|
|
{ "lightningAddress": "cardname@l484.com" }
|
|
```
|
|
|
|
The ATM resolves it via LUD-16 (`/.well-known/lnurlp/cardname`) → LUD-06 pay.
|
|
|
|
### (b) LUD-06 payRequest, inline
|
|
|
|
```json
|
|
{
|
|
"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
|
|
|
|
```json
|
|
{ "lnurlp": "https://lnbits.l484.com/lnurlp/<id>" }
|
|
```
|
|
|
|
An `https://` (or `lnurl://`) URL the ATM will fetch to get the payRequest.
|
|
|
|
### Error
|
|
|
|
```json
|
|
{ "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.
|