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

@ -0,0 +1,125 @@
import { describe, it, expect, vi } from 'vitest'
import { openCardSession, scanUrlToSessionUrl } from './boltcard-session'
const LNURLW =
'lnurlw://lnbits.l484.com/boltcards/api/v1/scan/abc123?p=DEADBEEFDEADBEEFDEADBEEFDEADBEEF&c=1122334455667788'
/** Mock fetch returning the given JSON bodies per call, in order (status 200). */
function mockFetch(bodies: unknown[], status = 200) {
const calls: string[] = []
const impl = vi.fn(async (url: string | URL) => {
calls.push(url.toString())
const body = bodies[calls.length - 1]
return { status, json: async () => body } as Response
})
return { impl: impl as unknown as typeof fetch, calls }
}
const SESSION = {
authenticated: true,
external_id: 'abc123',
card_name: 'Alice',
balance_msat: 123_456_789,
currency: 'usd',
fiat: 98.76,
withdraw: {
callback: 'https://lnbits.l484.com/boltcards/api/v1/lnurl/cb/hit1',
k1: 'hit1',
minWithdrawable: 1000,
maxWithdrawable: 50_000_000,
},
withdraw_blocked_reason: null,
pay: {
callback: 'https://lnbits.l484.com/boltcards/api/v1/pay/cb/hit1',
minSendable: 1000,
maxSendable: 50_000_000,
metadata: '[["text/plain","Bolt Card top-up"]]',
},
}
describe('scanUrlToSessionUrl', () => {
it('rewrites /scan/ to /session/ and preserves p + c', () => {
const u = scanUrlToSessionUrl(LNURLW)
expect(u).toContain('https://lnbits.l484.com/boltcards/api/v1/session/abc123')
expect(u).toContain('p=DEADBEEFDEADBEEFDEADBEEFDEADBEEF')
expect(u).toContain('c=1122334455667788')
})
it('returns null for a non-scan URL', () => {
expect(scanUrlToSessionUrl('lnurlw://host/somethingelse?p=1&c=2')).toBeNull()
expect(scanUrlToSessionUrl('http://host/boltcards/api/v1/scan/x')).toBeNull()
})
})
describe('openCardSession', () => {
it('opens a session: balance in sats, upper-cased currency, both steps', async () => {
const f = mockFetch([SESSION])
const out = await openCardSession(LNURLW, { fetchImpl: f.impl })
expect(f.calls).toHaveLength(1)
expect(f.calls[0]).toContain('/session/abc123')
expect(out).toEqual({
ok: true,
session: {
externalId: 'abc123',
cardName: 'Alice',
balanceSats: 123_456,
currency: 'USD',
fiat: 98.76,
withdraw: SESSION.withdraw,
withdrawBlockedReason: null,
pay: SESSION.pay,
},
})
})
it('carries a withheld withdraw step with its reason', async () => {
const f = mockFetch([
{ ...SESSION, withdraw: null, withdraw_blocked_reason: 'Max daily limit spent.' },
])
const out = await openCardSession(LNURLW, { fetchImpl: f.impl })
expect(out.ok).toBe(true)
if (!out.ok) return
expect(out.session.withdraw).toBeNull()
expect(out.session.withdrawBlockedReason).toBe('Max daily limit spent.')
expect(out.session.pay.callback).toBe(SESSION.pay.callback)
})
it('has no fiat when the server sent no currency', async () => {
const f = mockFetch([{ ...SESSION, currency: null, fiat: null }])
const out = await openCardSession(LNURLW, { fetchImpl: f.impl })
expect(out.ok && out.session.currency).toBeNull()
expect(out.ok && out.session.fiat).toBeNull()
})
it('surfaces the server reason on a rejected tap', async () => {
const f = mockFetch([{ authenticated: false, reason: 'This link is already used.' }])
const out = await openCardSession(LNURLW, { fetchImpl: f.impl })
expect(out).toEqual({ ok: false, reason: 'This link is already used.' })
})
it('rejects an incomplete session (no pay step)', async () => {
const f = mockFetch([{ ...SESSION, pay: undefined }])
const out = await openCardSession(LNURLW, { fetchImpl: f.impl })
expect(out).toEqual({ ok: false, reason: 'card server returned an incomplete session' })
})
it('names an old card server that has no /session', async () => {
const f = mockFetch([{ detail: 'Not Found' }], 404)
const out = await openCardSession(LNURLW, { fetchImpl: f.impl })
expect(out).toEqual({ ok: false, reason: 'card server does not support sessions' })
})
it('rejects a non-card tag without a network call', async () => {
const f = mockFetch([])
const out = await openCardSession('https://host/not/a/card', { fetchImpl: f.impl })
expect(out.ok).toBe(false)
expect(f.calls).toHaveLength(0)
})
it('reports an unreachable card server', async () => {
const impl = vi.fn(async () => {
throw new TypeError('fetch failed')
}) as unknown as typeof fetch
const out = await openCardSession(LNURLW, { fetchImpl: impl })
expect(out).toEqual({ ok: false, reason: 'could not reach the card: fetch failed' })
})
})

View file

@ -0,0 +1,167 @@
/**
* Bolt Card session (tap-to-enter) — one tap, one verified visit.
*
* A Bolt Card tap yields a single-use SUN `p`/`c`; anything that verifies it
* spends it. The access gate (ADR-003) wants to verify the card at entry AND
* let the holder finish a buy or sell later without tapping again, so the
* aiolabs `boltcards` fork exposes `/session/<external_id>?p=&c=` — a sibling
* of `/scan` and `/pay` that verifies once, records one hit, and returns:
* - the card wallet's balance and fiat equivalent (display only),
* - the LUD-03 second step (withdraw callback + k1 = hit) for cash-out,
* - the LUD-06 second step (pay callback) for cash-in.
* Both callbacks are keyed by the hit — the same single-use bearer `/scan`
* and `/pay` hand out — so the ATM holds no p/c for the rest of the visit.
* The withdraw step is withheld (with a reason) once the card's daily limit is
* spent, exactly as `/scan` would refuse.
*
* Runs in the MAIN process (Node fetch) to avoid renderer CORS, like the other
* LNURL modules. See docs/boltcard-session.md for the wire contract.
*/
import { lnurlwToHttps, type WithdrawStep } from './lnurl-withdraw.js'
import type { PayStep } from './lnurl-pay.js'
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: WithdrawStep | 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: PayStep
}
export type OpenCardSessionResult =
| { ok: true; session: CardSession }
| { ok: false; reason: string }
type FetchLike = typeof fetch
export interface OpenCardSessionOptions {
/** Injected for tests; defaults to global fetch. */
fetchImpl?: FetchLike
/** Per-request timeout (default 15s). */
timeoutMs?: number
}
/**
* Derive the session URL from a tapped card's `lnurlw`: the card presents
* `…/boltcards/api/v1/scan/<id>?p=&c=`; the session endpoint is its sibling
* `…/boltcards/api/v1/session/<id>?p=&c=` with the same SUN.
*/
export function scanUrlToSessionUrl(lnurlw: string): string | null {
const https = lnurlwToHttps(lnurlw)
if (!https) return null
const u = new URL(https)
if (!u.pathname.includes('/scan/')) return null
u.pathname = u.pathname.replace('/scan/', '/session/')
return u.toString()
}
/** Wire shape of a `/session` reply (any of the fields may be missing/odd). */
interface SessionWire {
authenticated?: unknown
reason?: unknown
external_id?: unknown
card_name?: unknown
balance_msat?: unknown
currency?: unknown
fiat?: unknown
withdraw?: unknown
withdraw_blocked_reason?: unknown
pay?: unknown
}
const isObj = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null
const optNum = (v: unknown): number | undefined => (typeof v === 'number' ? v : undefined)
const optStr = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
function parseWithdraw(v: unknown): WithdrawStep | null {
if (!isObj(v)) return null
const callback = optStr(v.callback)
const k1 = optStr(v.k1)
if (!callback || !k1) return null
return {
callback,
k1,
minWithdrawable: optNum(v.minWithdrawable),
maxWithdrawable: optNum(v.maxWithdrawable),
}
}
function parsePay(v: unknown): PayStep | null {
if (!isObj(v)) return null
const callback = optStr(v.callback)
if (!callback) return null
return {
callback,
minSendable: optNum(v.minSendable),
maxSendable: optNum(v.maxSendable),
metadata: optStr(v.metadata),
}
}
function errMsg(e: unknown): string {
if (e instanceof Error)
return e.name === 'TimeoutError' || e.name === 'AbortError' ? 'timed out' : e.message
return String(e)
}
/**
* Open a session for a tapped card. Spends the tap's SUN. Never throws —
* every failure returns `{ ok: false, reason }` (reasons come from the card
* server verbatim and are safe to show).
*/
export async function openCardSession(
lnurlw: string,
opts: OpenCardSessionOptions = {}
): Promise<OpenCardSessionResult> {
const doFetch = opts.fetchImpl ?? fetch
const timeoutMs = opts.timeoutMs ?? 15_000
const url = scanUrlToSessionUrl(lnurlw)
if (!url) return { ok: false, reason: 'not a valid Bolt Card (lnurlw) tag' }
let wire: SessionWire
try {
const res = await doFetch(url, { signal: AbortSignal.timeout(timeoutMs) })
if (res.status === 404) {
// Older fork without /session — say so rather than "card rejected".
return { ok: false, reason: 'card server does not support sessions' }
}
wire = (await res.json()) as SessionWire
} catch (e) {
return { ok: false, reason: `could not reach the card: ${errMsg(e)}` }
}
if (wire.authenticated !== true) {
return { ok: false, reason: optStr(wire.reason) || 'card rejected the tap' }
}
const externalId = optStr(wire.external_id)
const pay = parsePay(wire.pay)
if (!externalId || !pay) {
return { ok: false, reason: 'card server returned an incomplete session' }
}
const balanceMsat = optNum(wire.balance_msat) ?? 0
const currency = optStr(wire.currency)?.toUpperCase() ?? null
const fiat = optNum(wire.fiat)
return {
ok: true,
session: {
externalId,
cardName: optStr(wire.card_name) ?? '',
balanceSats: Math.floor(balanceMsat / 1000),
currency,
fiat: currency && fiat !== undefined ? fiat : null,
withdraw: parseWithdraw(wire.withdraw),
withdrawBlockedReason: optStr(wire.withdraw_blocked_reason) ?? null,
pay,
},
}
}

View file

@ -1,5 +1,10 @@
import { describe, it, expect, vi } from 'vitest'
import { resolveCardInvoice, scanUrlToResolver, lnAddressToLnurlp } from './lnurl-pay'
import {
resolveCardInvoice,
resolveInvoiceFromPayStep,
scanUrlToResolver,
lnAddressToLnurlp,
} from './lnurl-pay'
const LNURLW =
'lnurlw://lnbits.l484.com/boltcards/api/v1/scan/abc123?p=DEADBEEFDEADBEEFDEADBEEFDEADBEEF&c=1122334455667788'
@ -118,3 +123,43 @@ describe('resolveCardInvoice', () => {
expect(res.reason).toMatch(/could not reach the card/i)
})
})
describe('resolveInvoiceFromPayStep (session second step, no tap)', () => {
const step = {
callback: 'https://lnbits.l484.com/boltcards/api/v1/pay/cb/hit1',
minSendable: 1000,
maxSendable: 50_000_000,
metadata: '[["text/plain","Bolt Card top-up"]]',
}
it('fetches an invoice for the amount from the callback', async () => {
const f = mockFetch([{ pr: BOLT11 }])
const out = await resolveInvoiceFromPayStep(step, 25_000, { fetchImpl: f.impl })
expect(out).toEqual({ ok: true, bolt11: BOLT11 })
expect(f.calls).toHaveLength(1)
expect(f.calls[0]).toContain('amount=25000')
})
it('enforces the step bounds without calling out', async () => {
const f = mockFetch([])
expect(await resolveInvoiceFromPayStep(step, 500, { fetchImpl: f.impl })).toEqual({
ok: false,
reason: 'amount is below the card wallet minimum',
})
expect(await resolveInvoiceFromPayStep(step, 60_000_000, { fetchImpl: f.impl })).toEqual({
ok: false,
reason: 'amount is above the card wallet maximum',
})
expect(await resolveInvoiceFromPayStep(step, 0, { fetchImpl: f.impl })).toEqual({
ok: false,
reason: 'no amount to send',
})
expect(f.calls).toHaveLength(0)
})
it('surfaces a callback decline', async () => {
const f = mockFetch([{ status: 'ERROR', reason: 'Card is disabled.' }])
const out = await resolveInvoiceFromPayStep(step, 25_000, { fetchImpl: f.impl })
expect(out).toEqual({ ok: false, reason: 'Card is disabled.' })
})
})

View file

@ -59,6 +59,17 @@ interface CardPayTarget {
lnurl?: string
}
/**
* The LUD-06 second step on its own: what a `payRequest` (or a Bolt Card
* session, see boltcard-session.ts) hands us to fetch an invoice.
*/
export interface PayStep {
callback: string
minSendable?: number
maxSendable?: number
metadata?: string
}
/** LUD-06 payRequest (subset) + error shape. */
interface PayRequest {
tag?: string
@ -163,17 +174,18 @@ async function toPayRequest(
}
async function requestInvoice(
pr: PayRequest,
pr: PayStep,
amountMsat: number,
ctx: Ctx
): Promise<ResolveCardInvoiceResult> {
if (!(amountMsat > 0)) return { ok: false, reason: 'no amount to send' }
if (typeof pr.minSendable === 'number' && amountMsat < pr.minSendable) {
return { ok: false, reason: 'amount is below the card wallet minimum' }
}
if (typeof pr.maxSendable === 'number' && amountMsat > pr.maxSendable) {
return { ok: false, reason: 'amount is above the card wallet maximum' }
}
const cbUrl = appendQuery(pr.callback!, { amount: String(amountMsat) })
const cbUrl = appendQuery(pr.callback, { amount: String(amountMsat) })
let vals: PayValues
try {
const res = await ctx.doFetch(cbUrl, { signal: AbortSignal.timeout(ctx.timeoutMs) })
@ -222,5 +234,19 @@ export async function resolveCardInvoice(
if (!pr.ok) return pr
// 3) Ask for an invoice for the payout amount.
return requestInvoice(pr.payRequest, amountMsat, ctx)
return requestInvoice({ ...pr.payRequest, callback: pr.payRequest.callback! }, amountMsat, ctx)
}
/**
* The LUD-06 second step alone: fetch a BOLT11 for `amountMsat` from an
* already-obtained pay step (from a Bolt Card session opened at tap-to-enter).
* Never throws — every failure returns `{ ok: false, reason }`.
*/
export async function resolveInvoiceFromPayStep(
step: PayStep,
amountMsat: number,
opts: ResolveCardInvoiceOptions = {}
): Promise<ResolveCardInvoiceResult> {
const ctx: Ctx = { doFetch: opts.fetchImpl ?? fetch, timeoutMs: opts.timeoutMs ?? 15_000 }
return requestInvoice(step, amountMsat, ctx)
}

View file

@ -1,5 +1,5 @@
import { describe, it, expect, vi } from 'vitest'
import { executeLnurlWithdraw, lnurlwToHttps } from './lnurl-withdraw'
import { executeLnurlWithdraw, executeWithdrawCallback, lnurlwToHttps } from './lnurl-withdraw'
const BOLT11 = 'lnbc10u1p3xyz...'
const LNURLW =
@ -101,3 +101,44 @@ describe('executeLnurlWithdraw', () => {
expect(res.reason).toMatch(/could not reach the card/i)
})
})
describe('executeWithdrawCallback (session second step, no tap)', () => {
const step = {
callback: 'https://lnbits.l484.com/boltcards/api/v1/lnurl/cb/hit1',
k1: 'hit1',
maxWithdrawable: 5_000_000,
}
it('hands the invoice straight to the callback with k1', async () => {
const f = mockFetch([{ status: 'OK' }])
const out = await executeWithdrawCallback(step, BOLT11, { fetchImpl: f.impl })
expect(out).toEqual({ ok: true })
expect(f.calls).toHaveLength(1)
expect(f.calls[0]).toContain('k1=hit1')
expect(f.calls[0]).toContain('pr=' + BOLT11)
})
it('refuses an amount above the step limit without calling out', async () => {
const f = mockFetch([])
const out = await executeWithdrawCallback(step, BOLT11, {
fetchImpl: f.impl,
amountMsat: 6_000_000,
})
expect(out).toEqual({ ok: false, reason: 'card limit is below this amount' })
expect(f.calls).toHaveLength(0)
})
it('surfaces a callback decline', async () => {
const f = mockFetch([{ status: 'ERROR', reason: 'Payment already claimed.' }])
const out = await executeWithdrawCallback(step, BOLT11, { fetchImpl: f.impl })
expect(out).toEqual({ ok: false, reason: 'Payment already claimed.' })
})
it('rejects a missing invoice', async () => {
const f = mockFetch([])
expect(await executeWithdrawCallback(step, '', { fetchImpl: f.impl })).toEqual({
ok: false,
reason: 'no invoice to charge',
})
})
})

View file

@ -36,6 +36,17 @@ interface WithdrawRequest {
type FetchLike = typeof fetch
/**
* The LUD-03 second step on its own: what a `withdrawRequest` (or a Bolt Card
* session, see boltcard-session.ts) hands us to actually pull a payment.
*/
export interface WithdrawStep {
callback: string
k1: string
minWithdrawable?: number
maxWithdrawable?: number
}
export interface ExecuteLnurlWithdrawOptions {
/** Injected for tests; defaults to global fetch. */
fetchImpl?: FetchLike
@ -73,7 +84,8 @@ function appendQuery(url: string, params: Record<string, string>): string {
}
function errMsg(e: unknown): string {
if (e instanceof Error) return e.name === 'TimeoutError' || e.name === 'AbortError' ? 'timed out' : e.message
if (e instanceof Error)
return e.name === 'TimeoutError' || e.name === 'AbortError' ? 'timed out' : e.message
return String(e)
}
@ -105,16 +117,46 @@ export async function executeLnurlWithdraw(
if (params.tag !== 'withdrawRequest' || !params.callback || !params.k1) {
return { ok: false, reason: 'card did not return a withdraw voucher' }
}
// 2) Hand our invoice to the callback — the card's wallet pays it.
return executeWithdrawCallback(
{
callback: params.callback,
k1: params.k1,
minWithdrawable: params.minWithdrawable,
maxWithdrawable: params.maxWithdrawable,
},
bolt11,
opts
)
}
/**
* The LUD-03 second step alone: hand our invoice to an already-obtained
* withdraw step (from a `/scan` withdrawRequest, or from a Bolt Card session
* opened at tap-to-enter) — the card's wallet pays it. `{ ok: true }` means the
* card accepted the pull; settlement is observed by the invoice watcher.
*/
export async function executeWithdrawCallback(
step: WithdrawStep,
bolt11: string,
opts: ExecuteLnurlWithdrawOptions = {}
): Promise<LnurlWithdrawResult> {
const doFetch = opts.fetchImpl ?? fetch
const timeoutMs = opts.timeoutMs ?? 15_000
if (!bolt11 || !/^ln[a-z0-9]/i.test(bolt11.trim())) {
return { ok: false, reason: 'no invoice to charge' }
}
if (
opts.amountMsat != null &&
typeof params.maxWithdrawable === 'number' &&
opts.amountMsat > params.maxWithdrawable
typeof step.maxWithdrawable === 'number' &&
opts.amountMsat > step.maxWithdrawable
) {
return { ok: false, reason: 'card limit is below this amount' }
}
// 2) Hand our invoice to the callback — the card's wallet pays it.
const cbUrl = appendQuery(params.callback, { k1: params.k1, pr: bolt11.trim() })
const cbUrl = appendQuery(step.callback, { k1: step.k1, pr: bolt11.trim() })
let cb: { status?: string; reason?: string }
try {
const res = await doFetch(cbUrl, { signal: AbortSignal.timeout(timeoutMs) })

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))

View file

@ -141,6 +141,23 @@ contextBridge.exposeInMainWorld('electronAPI', {
}): Promise<{ ok: boolean; bolt11?: string; reason?: string }> =>
ipcRenderer.invoke('lnurl:pay-card', args),
// Bolt Card tap-to-enter: one verified session per tap (balance + fiat +
// the withdraw/pay second steps reused at Complete). Payload shapes are
// declared in src/types/electron.d.ts (CardSession).
openCardSession: (args: { lnurlw: string }): Promise<unknown> =>
ipcRenderer.invoke('lnurl:open-card-session', args),
withdrawWithSession: (args: {
withdraw: { callback: string; k1: string; minWithdrawable?: number; maxWithdrawable?: number }
bolt11: string
amountMsat?: number
}): Promise<{ ok: boolean; reason?: string }> =>
ipcRenderer.invoke('lnurl:withdraw-session', args),
resolveSessionInvoice: (args: {
pay: { callback: string; minSendable?: number; maxSendable?: number; metadata?: string }
amountMsat: number
}): Promise<{ ok: boolean; bolt11?: string; reason?: string }> =>
ipcRenderer.invoke('lnurl:pay-session', args),
applyOperatorCassettesConfig: (
payload: {
positions: Record<string, { denomination: number; count: number }>

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