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

@ -42,8 +42,13 @@ import {
type StoredBunkerBinding,
} from './state-store.js'
import { initializeHal, type HalInstance } from './hal-service.js'
import { executeLnurlWithdraw } from './lnurl-withdraw.js'
import { resolveCardInvoice } from './lnurl-pay.js'
import {
executeLnurlWithdraw,
executeWithdrawCallback,
type WithdrawStep,
} from './lnurl-withdraw.js'
import { resolveCardInvoice, resolveInvoiceFromPayStep, type PayStep } from './lnurl-pay.js'
import { openCardSession, type OpenCardSessionResult } from './boltcard-session.js'
import { startNfcReader, type NfcStatus } from './nfc-service.js'
// ESM equivalent of __dirname
@ -503,6 +508,37 @@ ipcMain.handle(
}
)
// Bolt Card tap-to-enter (ADR-003): open a verified session for a tapped card.
// Spends the tap's SUN once and returns balance + fiat + the withdraw/pay
// second steps the session reuses at Complete. See boltcard-session.ts.
ipcMain.handle(
'lnurl:open-card-session',
async (_event, args: { lnurlw: string }): Promise<OpenCardSessionResult> => {
return openCardSession(args.lnurlw)
}
)
// Session variants of the two Complete paths: no tap, no p/c — just the
// hit-keyed second step the session already holds.
ipcMain.handle(
'lnurl:withdraw-session',
async (
_event,
args: { withdraw: WithdrawStep; bolt11: string; amountMsat?: number }
): Promise<{ ok: boolean; reason?: string }> => {
return executeWithdrawCallback(args.withdraw, args.bolt11, { amountMsat: args.amountMsat })
}
)
ipcMain.handle(
'lnurl:pay-session',
async (
_event,
args: { pay: PayStep; amountMsat: number }
): Promise<{ ok: boolean; bolt11?: string; reason?: string }> => {
return resolveInvoiceFromPayStep(args.pay, args.amountMsat)
}
)
// State persistence IPC handlers
ipcMain.handle('state:load-cassettes', () => loadCassettes())
ipcMain.handle('state:set-cassettes', (_event, cassettes) => setCassettes(cassettes))