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>
119 lines
5 KiB
Markdown
119 lines
5 KiB
Markdown
# 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
|
|
|
|
```json
|
|
{
|
|
"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:
|
|
|
|
```json
|
|
{ "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.
|