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>
This commit is contained in:
Patrick Mulligan 2026-08-06 00:30:13 +02:00
commit 64582e7fe6
8 changed files with 605 additions and 31 deletions

View file

@ -0,0 +1,110 @@
# 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
- **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.