feat(machine): open a verified Bolt Card session at tap-to-enter

Entry now spends the tap's single-use SUN once, on the card server's new
/session endpoint (aiolabs/boltcards feat/card-session-endpoint), instead
of parsing the lnurlw locally and deferring every check to Complete. The
server proves a genuine, non-replayed card and returns the wallet balance
plus the hit-keyed LUD-03 withdraw and LUD-06 pay second steps — the same
single-use bearer /scan and /pay hand out — so Complete still needs no
second tap and the ATM holds no p/c for the visit.

- electron/boltcard-session.ts: /scan → /session URL derivation, response
  parsing, 404 → 'card server does not support sessions'.
- lnurl-withdraw / lnurl-pay: the second steps are now callable on their
  own (executeWithdrawCallback, resolveInvoiceFromPayStep); the tap paths
  are unchanged and reuse them.
- IPC: lnurl:open-card-session, lnurl:withdraw-session, lnurl:pay-session.
- store: handleBoltCardEntry opens the session then authorizes the
  server-returned external_id; the payment handlers take a source (raw
  tap or session); a withheld withdraw step declines with the server's
  reason. The boltcard AccessScan no longer carries the lnurlw.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-09-20 17:07:16 +02:00
commit 735132b032
12 changed files with 639 additions and 63 deletions

View file

@ -115,16 +115,15 @@ describe('access authorize (ADR-003)', () => {
describe('boltcard credential (tap-to-enter)', () => {
it('open-enrollment grants any card as user', async () => {
const out = await authorize(
{ kind: 'boltcard', externalId: 'abc123', lnurlw: 'lnurlw://h/scan/abc123?p=1&c=2' },
[],
{ salt: SALT, openEnrollment: true }
)
const out = await authorize({ kind: 'boltcard', externalId: 'abc123' }, [], {
salt: SALT,
openEnrollment: true,
})
expect(out).toMatchObject({ status: 'granted', role: 'user' })
})
it('rejects a card with no external_id', async () => {
const out = await authorize({ kind: 'boltcard', externalId: '', lnurlw: '' }, [], {
const out = await authorize({ kind: 'boltcard', externalId: '' }, [], {
salt: SALT,
openEnrollment: true,
})
@ -134,11 +133,10 @@ describe('access authorize (ADR-003)', () => {
it('allow-list matches by external_id hash', async () => {
const idHash = await hashId('abc123', SALT)
const list: AllowListEntry[] = [{ idHash, role: 'operator' }]
const out = await authorize(
{ kind: 'boltcard', externalId: 'abc123', lnurlw: 'lnurlw://h/scan/abc123?p=1&c=2' },
list,
{ salt: SALT, openEnrollment: false }
)
const out = await authorize({ kind: 'boltcard', externalId: 'abc123' }, list, {
salt: SALT,
openEnrollment: false,
})
expect(out).toMatchObject({ status: 'granted', role: 'operator' })
})
})

View file

@ -14,12 +14,11 @@ export type { AccessRole }
/**
* A raw credential. Discriminated union so new factors are additive:
* - `boltcard` — what ships: a tapped Bolt Card. `externalId` is the
* identity (from the lnurlw path); `lnurlw` is the full
* voucher (single-use SUN p/c intact) the session presents
* once, at Complete, to move sats. Only `externalId` is
* ever hashed/authorized — the p/c never enter the
* authorize layer.
* - `boltcard` — what ships: a tapped Bolt Card, already verified by the
* card server's `/session` (the tap's SUN was spent there).
* `externalId` is the identity as the server returned it;
* it is the only thing hashed/authorized. The session's
* payment steps stay in the store, never in this layer.
* - `npub` — a Nostr pubkey (bare npub, `nostr:` URI or nprofile).
* No reader emits it today; kept, with the PIN second
* factor, for a future non-card credential.
@ -27,6 +26,6 @@ export type { AccessRole }
* authorized.
*/
export type AccessScan =
| { kind: 'boltcard'; externalId: string; lnurlw: string }
| { kind: 'boltcard'; externalId: string }
| { kind: 'npub'; npub: string }
| { kind: 'challenge'; pubkey: string; nonce: string; sig: string }

View file

@ -10,7 +10,7 @@ import {
type ATMMachine,
type AccessRole,
} from '@bitSpire/state-machine'
import type { AccessControlConfig } from '@/types/electron'
import type { AccessControlConfig, CardSession } from '@/types/electron'
import { initializeLightningServices, fetchBtcPrice } from '@/services/lightning'
import { classifyInitError } from '@/services/init-error'
import { startOperatorConfigService, type OperatorConfigService } from '@/services/operator-config'
@ -302,6 +302,10 @@ export const useAtmStore = defineStore('atm', () => {
// boltcards server bumps the SUN counter on the first GET, so
// treat the voucher as spent even when the failure was ours.
type BoltCardOutcome = 'skipped' | 'accepted' | 'declined'
// Where a Complete gets its voucher: a card tapped right now on the cash
// screen (raw lnurlw, spent by this call) or the session opened at entry
// (hit-keyed steps, no p/c).
type BoltCardSource = { lnurlw: string } | { session: CardSession }
// Access-control gate config (ADR-003). Defaults disabled → the machine's
// `locked` state bypasses straight to `idle` (behaviour identical to no gate).
// Populated from RuntimeConfig.accessControl in initializeForProduction.
@ -314,11 +318,12 @@ export const useAtmStore = defineStore('atm', () => {
})
// Build/dev bypass — opens the gate even when enabled (browser dev / CI).
const accessBypassFlag = import.meta.env.VITE_SKIP_ACCESS_GATE === 'true'
// Tap-to-enter (ADR-003): the Bolt Card tapped at the locked screen is held
// here for the whole session so buy/sell just need "Complete" — no second tap.
// Carries the full lnurlw (single-use SUN p/c intact; spent only at payment).
// Cleared when the session ends (machine re-locks). Never logged.
const loadedBoltCard = ref<{ externalId: string; lnurlw: string } | null>(null)
// Tap-to-enter (ADR-003): the Bolt Card session opened at the locked screen
// is held here for the whole visit so buy/sell just need "Complete" — no
// second tap. The tap's SUN was spent opening it; what we hold are the
// hit-keyed withdraw/pay steps plus balance + fiat for display. Cleared when
// the machine re-locks. Never logged.
const loadedBoltCard = ref<CardSession | null>(null)
const fiatCode = ref('USD')
// Defaults are 0 — the operator's fee config (received via Nostr
// kind-30078 `bitspire-fees:<atm_pubkey>` envelope from satmachineadmin)
@ -631,7 +636,7 @@ export const useAtmStore = defineStore('atm', () => {
* arrives through the invoice watcher → PAYMENT_RECEIVED → dispensingCash;
* ok here only means the card accepted the pull.
*/
async function handleBoltCardTap(lnurlw: string): Promise<BoltCardOutcome> {
async function handleBoltCardTap(source: BoltCardSource): Promise<BoltCardOutcome> {
if (nestedState.value !== 'displayingInvoice') return 'skipped'
const invoice = context.value?.invoice
if (!invoice) return 'skipped'
@ -640,7 +645,17 @@ export const useAtmStore = defineStore('atm', () => {
nfcStatus.value = { state: 'processing', message: 'Reading card…' }
try {
const amountMsat = (context.value?.satsAmount ?? 0) * 1000
const res = await window.electronAPI!.lnurlWithdraw({ lnurlw, bolt11: invoice, amountMsat })
const api = window.electronAPI!
const res =
'session' in source
? source.session.withdraw
? await api.withdrawWithSession({
withdraw: source.session.withdraw,
bolt11: invoice,
amountMsat,
})
: { ok: false, reason: source.session.withdrawBlockedReason ?? 'card cannot pay now' }
: await api.lnurlWithdraw({ lnurlw: source.lnurlw, bolt11: invoice, amountMsat })
if (res.ok) {
nfcStatus.value = { state: 'accepted', message: 'Card accepted — confirming payment…' }
return 'accepted'
@ -663,7 +678,7 @@ export const useAtmStore = defineStore('atm', () => {
* over the nostr transport via the normal `payInvoice` → PAYMENT_RECEIVED
* path. Settlement + completion reuse the tested cash-in flow.
*/
async function handleBoltCardReceive(lnurlw: string): Promise<BoltCardOutcome> {
async function handleBoltCardReceive(source: BoltCardSource): Promise<BoltCardOutcome> {
if (!(isCashIn.value && nestedState.value === 'displayingQR')) return 'skipped'
const amountSats = context.value?.satsAmount ?? 0
if (amountSats <= 0) return 'skipped'
@ -672,7 +687,11 @@ export const useAtmStore = defineStore('atm', () => {
nfcStatus.value = { state: 'processing', message: 'Reading card…' }
try {
const amountMsat = amountSats * 1000
const res = await window.electronAPI!.resolveCardInvoice({ lnurlw, amountMsat })
const api = window.electronAPI!
const res =
'session' in source
? await api.resolveSessionInvoice({ pay: source.session.pay, amountMsat })
: await api.resolveCardInvoice({ lnurlw: source.lnurlw, amountMsat })
if (!res.ok || !res.bolt11) {
boltCardProcessing.value = false
nfcStatus.value = { state: 'declined', message: res.reason ?? 'Card could not receive' }
@ -697,31 +716,43 @@ export const useAtmStore = defineStore('atm', () => {
}
/**
* A tapped Bolt Card at the locked screen (ADR-003 tap-to-enter). Soft entry:
* parse the external_id LOCALLY (no server call, so the single-use SUN p/c stay
* valid), authorize (open-enrollment or allow-list), then hold the full lnurlw
* for the session. The cryptographic check happens later, at Complete, when the
* stored lnurlw actually moves sats.
* A tapped Bolt Card at the locked screen (ADR-003 tap-to-enter). Verified
* entry: the tap's single-use SUN is spent ONCE, on the card server's
* `/session` (main process), which proves a genuine, non-replayed card and
* returns the wallet balance plus the hit-keyed withdraw/pay steps. Then the
* card's external_id is authorized locally (open-enrollment or allow-list)
* and the session is held for the visit — Complete needs no second tap.
*/
async function handleBoltCardEntry(lnurlw: string) {
if (!isLocked.value) return
if (boltCardProcessing.value) return
const parsed = parseBoltcardLnurlw(lnurlw)
if (!parsed) {
// Reject a non-card tag before spending anything or calling anyone.
if (!parseBoltcardLnurlw(lnurlw)) {
nfcStatus.value = { state: 'declined', message: 'Not a Bolt Card' }
denyAccess('not a Bolt Card')
return
}
if (!isElectron || !window.electronAPI?.openCardSession) {
nfcStatus.value = { state: 'declined', message: 'Card sessions need the machine build' }
denyAccess('card sessions need the machine build')
return
}
boltCardProcessing.value = true
nfcStatus.value = { state: 'processing', message: 'Reading card…' }
nfcStatus.value = { state: 'processing', message: 'Verifying card…' }
try {
const scan: AccessScan = { kind: 'boltcard', externalId: parsed.externalId, lnurlw }
const opened = await window.electronAPI.openCardSession({ lnurlw })
if (!opened.ok) {
nfcStatus.value = { state: 'declined', message: opened.reason }
denyAccess(opened.reason)
return
}
const scan: AccessScan = { kind: 'boltcard', externalId: opened.session.externalId }
const outcome = await authorize(scan, accessControl.value.allowList, {
salt: accessControl.value.salt,
openEnrollment: accessControl.value.openEnrollment,
})
if (outcome.status === 'granted') {
loadedBoltCard.value = { externalId: parsed.externalId, lnurlw }
loadedBoltCard.value = opened.session
nfcStatus.value = { state: 'accepted', message: 'Card accepted' }
grantAccess(outcome.role, outcome.credentialIdHash)
} else {
@ -730,6 +761,10 @@ export const useAtmStore = defineStore('atm', () => {
nfcStatus.value = { state: 'declined', message: reason }
denyAccess(reason, outcome.credentialIdHash)
}
} catch (e) {
console.warn('[ATM] Bolt Card session failed:', e)
nfcStatus.value = { state: 'error', message: 'Card verification failed' }
denyAccess('card verification failed')
} finally {
boltCardProcessing.value = false
}
@ -740,19 +775,20 @@ export const useAtmStore = defineStore('atm', () => {
* Cash-out pulls via the stored lnurlw; cash-in resolves it to the card
* wallet's lnurlp and pays. Reuses the tap handlers verbatim.
*
* The loaded card is SINGLE-SHOT: its lnurlw carries one SUN p/c pair and the
* boltcards server consumes it on the first GET, so once the voucher has been
* presented — accepted or declined — it can never succeed again. Drop it after
* the first real attempt and tell the customer to re-tap; a fresh tap on the
* cash screen goes straight through the normal tap-to-pay/receive path.
* A 'skipped' outcome (guard bounced it, no server call) keeps the card.
* The loaded session is SINGLE-SHOT: its withdraw/pay steps are keyed by one
* server-side hit that the first use spends, so once a step has been
* presented — accepted or declined — it can never succeed again. Drop the
* session after the first real attempt and tell the customer to re-tap; a
* fresh tap on the cash screen goes straight through the normal
* tap-to-pay/receive path. A 'skipped' outcome (guard bounced it, no server
* call) keeps the session.
*/
async function completeWithCard() {
const card = loadedBoltCard.value
if (!card) return
let outcome: BoltCardOutcome = 'skipped'
if (isCashOut.value) outcome = await handleBoltCardTap(card.lnurlw)
else if (isCashIn.value) outcome = await handleBoltCardReceive(card.lnurlw)
if (isCashOut.value) outcome = await handleBoltCardTap({ session: card })
else if (isCashIn.value) outcome = await handleBoltCardReceive({ session: card })
if (outcome === 'skipped') return
loadedBoltCard.value = null
if (outcome === 'declined') {
@ -773,9 +809,9 @@ export const useAtmStore = defineStore('atm', () => {
if (isLocked.value) {
void handleBoltCardEntry(lnurlw)
} else if (isCashOut.value && nestedState.value === 'displayingInvoice') {
void handleBoltCardTap(lnurlw)
void handleBoltCardTap({ lnurlw })
} else if (isCashIn.value && nestedState.value === 'displayingQR') {
void handleBoltCardReceive(lnurlw)
void handleBoltCardReceive({ lnurlw })
}
})
window.electronAPI.onNfcStatus?.((status) => {
@ -793,12 +829,12 @@ export const useAtmStore = defineStore('atm', () => {
/** Dev/mock: simulate a cash-out tap with a pasted lnurlw (test without a card). */
function simulateBoltCardTap(lnurlw: string) {
void handleBoltCardTap(lnurlw)
void handleBoltCardTap({ lnurlw })
}
/** Dev/mock: simulate a cash-in (receive) tap with a pasted lnurlw. */
function simulateBoltCardReceive(lnurlw: string) {
void handleBoltCardReceive(lnurlw)
void handleBoltCardReceive({ lnurlw })
}
/** Dev/mock: simulate tapping a card at the locked screen (tap-to-enter). */
@ -1831,7 +1867,7 @@ export const useAtmStore = defineStore('atm', () => {
boltCardProcessing,
simulateBoltCardTap,
simulateBoltCardReceive,
// Tap-to-enter: card loaded at the locked screen, reused at Complete
// Tap-to-enter: card session opened at the locked screen, reused at Complete
loadedBoltCard,
completeWithCard,
simulateBoltCardEntry,

View file

@ -22,6 +22,37 @@ export interface AccessControlConfig {
allowList: AllowListEntry[]
}
/**
* A Bolt Card session opened at tap-to-enter (ADR-003). Wire payload from the
* main process (electron/boltcard-session.ts) — mirrored here rather than
* imported to avoid a cross-project electron↔renderer import. The withdraw and
* pay steps are keyed by a single-use server-side hit; no card secret is held.
*/
export interface CardSession {
externalId: string
cardName: string
balanceSats: number
/** ISO currency the card server priced the balance in; null → no fiat. */
currency: string | null
/** Balance in `currency` at the card server's rate; null when unknown. */
fiat: number | null
/** LUD-03 second step, or null when the card server withheld it. */
withdraw: {
callback: string
k1: string
minWithdrawable?: number
maxWithdrawable?: number
} | null
/** Why `withdraw` is null (e.g. daily limit spent); safe to show on-screen. */
withdrawBlockedReason: string | null
/** LUD-06 second step for topping the card wallet up. */
pay: { callback: string; minSendable?: number; maxSendable?: number; metadata?: string }
}
export type OpenCardSessionResult =
| { ok: true; session: CardSession }
| { ok: false; reason: string }
export interface RuntimeConfig {
relayUrl: string
/** LNbits nostr-transport server pubkey (hex, 64 chars). */
@ -136,6 +167,19 @@ declare global {
lnurlw: string
amountMsat: number
}) => Promise<{ ok: boolean; bolt11?: string; reason?: string }>
/** Bolt Card tap-to-enter: open one verified session for a tapped card (spends the SUN). */
openCardSession: (args: { lnurlw: string }) => Promise<OpenCardSessionResult>
/** Cash-out via a session's withdraw step (no tap). */
withdrawWithSession: (args: {
withdraw: NonNullable<CardSession['withdraw']>
bolt11: string
amountMsat?: number
}) => Promise<{ ok: boolean; reason?: string }>
/** Cash-in via a session's pay step (no tap): a BOLT11 to pay. */
resolveSessionInvoice: (args: {
pay: CardSession['pay']
amountMsat: number
}) => Promise<{ ok: boolean; bolt11?: string; reason?: string }>
applyOperatorCassettesConfig: (
payload: { positions: Record<string, { denomination: number; count: number }> },
eventCreatedAt: number