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>
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:
- Verify at entry — a genuine, non-replayed card unlocks the terminal and we can show the holder their balance.
- 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.fiatis 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 isnulland the ATM prices the sats itself: incurrencyfrom 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 callscallback?k1=<hit>&pr=<bolt11>at cash-out Complete.nullwithwithdraw_blocked_reasonset when/scanwould have refused (daily limit spent); cash-in stays possible.pay— the LUD-06 second step. The ATM callscallback?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/scanand/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
/paytoday). - 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 (
completeWithCardinstores/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.