bitspire/docs/boltcard-session.md
Padreug 8e11c41f62 perf(access): keep the rate lookup off the unlock path, log entry timing
Tap → unlock took ~3 s on sintra. The card server's /session now fills
fiat only from its warm rate cache (aiolabs/boltcards
fix/session-fiat-from-cache); when it returns a currency with fiat null,
the store prices the balance in that currency from the ATM's own rate
source after the unlock, so the chip still shows the wallet's currency.
Log how long the session call took and whether the server priced it, so
the next latency question can be answered from the journal.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 14:24:13 +02:00

5 KiB

Bolt Card session — one verified tap for a terminal visit

Wire contract for the /session endpoint the ATM's access gate (ADR-003 tap-to-enter) uses on the LNbits boltcards extension. Implemented in the aiolabs fork (git.atitlan.io/aiolabs/boltcards, v1.1.1-aio.3+); consumed by apps/machine/electron/boltcard-session.ts.

Why a session

A Bolt Card tap yields a single-use SUN p/c: the card server verifies it and advances the card's read counter, so any endpoint that checks it — /scan, /pay, /verify — spends it. The gate wants two things from one tap:

  1. Verify at entry — a genuine, non-replayed card unlocks the terminal and we can show the holder their balance.
  2. Complete without a second tap — the buy or sell later in the visit moves sats with the same card.

/session does the verification once and hands back the second steps of both LNURL flows, keyed by a single-use server-side hit — the same bearer /scan (as k1) and /pay already issue. The terminal holds no p/c afterwards.

Endpoint

GET /boltcards/api/v1/session/{external_id}?p={p}&c={c}

Same URL shape as /scan/{external_id}?p=&c= with scan → session; the ATM derives it by string substitution on the tapped lnurlw (scanUrlToSessionUrl()). SUN verification is byte-for-byte /scan's (shared helper in the fork): unknown / disabled card, UID mismatch, bad CMAC, replayed counter all reject with /scan's reasons. On success the counter advances and one hit is recorded.

Response

{
  "authenticated": true,
  "external_id": "abc123",
  "card_name": "Alice",
  "balance_msat": 123456000,
  "currency": "USD",
  "fiat": 98.76,
  "withdraw": {
    "callback": "https://lnbits.l484.com/boltcards/api/v1/lnurl/cb/<hit>",
    "k1": "<hit>",
    "minWithdrawable": 1000,
    "maxWithdrawable": 50000000
  },
  "withdraw_blocked_reason": null,
  "pay": {
    "callback": "https://lnbits.l484.com/boltcards/api/v1/pay/cb/<hit>",
    "minSendable": 1000,
    "maxSendable": 50000000,
    "metadata": "[[\"text/plain\",\"Bolt Card top-up\"]]"
  }
}
  • balance_msat — the card wallet's balance. Display only.
  • currency / fiat — the balance priced the way the LNbits wallet page does it: the wallet's own currency (per-wallet setting) first, else the instance's default accounting currency. fiat is filled only from the server's already-warm rate cache — this response gates the unlock, and a cold rate lookup queries external exchanges (~1 s). On a cache miss it is null and the ATM prices the sats itself: in currency from its own rate source, else in its own fiat at its display rate. No rate lookup ever blocks the session.
  • withdraw — the LUD-03 second step. The ATM calls callback?k1=<hit>&pr=<bolt11> at cash-out Complete. null with withdraw_blocked_reason set when /scan would have refused (daily limit spent); cash-in stays possible.
  • pay — the LUD-06 second step. The ATM calls callback?amount=<msat> at cash-in Complete and pays the returned BOLT11 over its own nostr transport.
  • Limits are the card's tx_limit, as on /scan and /pay.

Rejection:

{ "authenticated": false, "reason": "This link is already used." }

reason is surfaced verbatim on the locked screen — terse, non-sensitive.

Semantics of the hit

  • The first withdraw that uses the hit spends it (spent = true), exactly as after a /scan; a second withdraw is refused with "Payment already claimed."
  • A top-up does not mark the hit spent (as /pay today).
  • The ATM treats the whole session as single-shot regardless: after the first Complete attempt, accepted or declined, it drops the session and asks for a re-tap (completeWithCard in stores/atm.ts).
  • Hits do not expire server-side. The ATM's session security (60 s idle, 10 min cap, End Session) bounds how long one is held.

Flow

locked ── tap ─▶ GET /session/<id>?p=&c=  (spends the SUN)
   ├─ authenticated:false → stay locked, show reason
   └─ authenticated:true  → authorize(external_id) → idle, session held
                            CardChip: "Alice · ••c123  ••••••  [eye]"
sell: pick amount → Complete Sale     → withdraw.callback?k1&pr   → PAYMENT_RECEIVED → dispense
buy:  insert cash → Complete Purchase → pay.callback?amount → BOLT11 → ATM pays → complete
… re-lock (End Session / idle / complete) drops the session

Trust boundary (read this)

The ATM derives the session URL from the card's own lnurlw host. With openEnrollment on, a forged NDEF tag pointing at an attacker's server that answers {"authenticated": true, …} still unlocks the terminal. Money is not at risk — a fake server can only make the ATM pay an invoice the holder chose (cash-in) or accept a pull it never honours (cash-out never dispenses without PAYMENT_RECEIVED) — but the gate is only as trustworthy as the host it was told to ask. Closing that means pinning the card-server host(s) the gate accepts; tracked in aiolabs/bitspire#91.