Merge pull request 'feat(access): verified Bolt Card session at entry + hidden-by-default balance' (#93) from feat/boltcard-session-balance into dev

Reviewed-on: #93
This commit is contained in:
padreug 2026-09-20 15:16:41 +00:00
commit 3efbdf164b
19 changed files with 876 additions and 71 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 { describe, it, expect, vi } from 'vitest'
import { resolveCardInvoice, scanUrlToResolver, lnAddressToLnurlp } from './lnurl-pay' import {
resolveCardInvoice,
resolveInvoiceFromPayStep,
scanUrlToResolver,
lnAddressToLnurlp,
} from './lnurl-pay'
const LNURLW = const LNURLW =
'lnurlw://lnbits.l484.com/boltcards/api/v1/scan/abc123?p=DEADBEEFDEADBEEFDEADBEEFDEADBEEF&c=1122334455667788' '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) 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 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. */ /** LUD-06 payRequest (subset) + error shape. */
interface PayRequest { interface PayRequest {
tag?: string tag?: string
@ -163,17 +174,18 @@ async function toPayRequest(
} }
async function requestInvoice( async function requestInvoice(
pr: PayRequest, pr: PayStep,
amountMsat: number, amountMsat: number,
ctx: Ctx ctx: Ctx
): Promise<ResolveCardInvoiceResult> { ): Promise<ResolveCardInvoiceResult> {
if (!(amountMsat > 0)) return { ok: false, reason: 'no amount to send' }
if (typeof pr.minSendable === 'number' && amountMsat < pr.minSendable) { if (typeof pr.minSendable === 'number' && amountMsat < pr.minSendable) {
return { ok: false, reason: 'amount is below the card wallet minimum' } return { ok: false, reason: 'amount is below the card wallet minimum' }
} }
if (typeof pr.maxSendable === 'number' && amountMsat > pr.maxSendable) { if (typeof pr.maxSendable === 'number' && amountMsat > pr.maxSendable) {
return { ok: false, reason: 'amount is above the card wallet maximum' } 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 let vals: PayValues
try { try {
const res = await ctx.doFetch(cbUrl, { signal: AbortSignal.timeout(ctx.timeoutMs) }) const res = await ctx.doFetch(cbUrl, { signal: AbortSignal.timeout(ctx.timeoutMs) })
@ -222,5 +234,19 @@ export async function resolveCardInvoice(
if (!pr.ok) return pr if (!pr.ok) return pr
// 3) Ask for an invoice for the payout amount. // 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 { describe, it, expect, vi } from 'vitest'
import { executeLnurlWithdraw, lnurlwToHttps } from './lnurl-withdraw' import { executeLnurlWithdraw, executeWithdrawCallback, lnurlwToHttps } from './lnurl-withdraw'
const BOLT11 = 'lnbc10u1p3xyz...' const BOLT11 = 'lnbc10u1p3xyz...'
const LNURLW = const LNURLW =
@ -101,3 +101,44 @@ describe('executeLnurlWithdraw', () => {
expect(res.reason).toMatch(/could not reach the card/i) 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 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 { export interface ExecuteLnurlWithdrawOptions {
/** Injected for tests; defaults to global fetch. */ /** Injected for tests; defaults to global fetch. */
fetchImpl?: FetchLike fetchImpl?: FetchLike
@ -73,7 +84,8 @@ function appendQuery(url: string, params: Record<string, string>): string {
} }
function errMsg(e: unknown): 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) return String(e)
} }
@ -105,16 +117,46 @@ export async function executeLnurlWithdraw(
if (params.tag !== 'withdrawRequest' || !params.callback || !params.k1) { if (params.tag !== 'withdrawRequest' || !params.callback || !params.k1) {
return { ok: false, reason: 'card did not return a withdraw voucher' } 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 ( if (
opts.amountMsat != null && opts.amountMsat != null &&
typeof params.maxWithdrawable === 'number' && typeof step.maxWithdrawable === 'number' &&
opts.amountMsat > params.maxWithdrawable opts.amountMsat > step.maxWithdrawable
) { ) {
return { ok: false, reason: 'card limit is below this amount' } 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(step.callback, { k1: step.k1, pr: bolt11.trim() })
const cbUrl = appendQuery(params.callback, { k1: params.k1, pr: bolt11.trim() })
let cb: { status?: string; reason?: string } let cb: { status?: string; reason?: string }
try { try {
const res = await doFetch(cbUrl, { signal: AbortSignal.timeout(timeoutMs) }) const res = await doFetch(cbUrl, { signal: AbortSignal.timeout(timeoutMs) })

View file

@ -42,8 +42,13 @@ import {
type StoredBunkerBinding, type StoredBunkerBinding,
} from './state-store.js' } from './state-store.js'
import { initializeHal, type HalInstance } from './hal-service.js' import { initializeHal, type HalInstance } from './hal-service.js'
import { executeLnurlWithdraw } from './lnurl-withdraw.js' import {
import { resolveCardInvoice } from './lnurl-pay.js' 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' import { startNfcReader, type NfcStatus } from './nfc-service.js'
// ESM equivalent of __dirname // 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 // State persistence IPC handlers
ipcMain.handle('state:load-cassettes', () => loadCassettes()) ipcMain.handle('state:load-cassettes', () => loadCassettes())
ipcMain.handle('state:set-cassettes', (_event, cassettes) => setCassettes(cassettes)) 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 }> => }): Promise<{ ok: boolean; bolt11?: string; reason?: string }> =>
ipcRenderer.invoke('lnurl:pay-card', args), 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: ( applyOperatorCassettesConfig: (
payload: { payload: {
positions: Record<string, { denomination: number; count: number }> positions: Record<string, { denomination: number; count: number }>

View file

@ -0,0 +1,78 @@
<script setup lang="ts">
/**
* The Bolt Card loaded for this session (ADR-003 tap-to-enter): card label,
* and the card wallet's balance HIDDEN BY DEFAULT behind an eye toggle — a
* kiosk in a public space must not show a stranger's balance unasked. The
* revealed line mirrors the LNbits wallet page: sats, then the fiat
* equivalent in the wallet's own currency (Intl currency formatting), falling
* back to the ATM's fiat at its display rate when the card server priced
* nothing. Reveal state lives in the store and resets on re-lock.
*/
import { computed } from 'vue'
import { useAtmStore } from '@/stores/atm'
import { Button } from '@/components/ui/button'
import { Nfc, Eye, EyeOff } from 'lucide-vue-next'
const atmStore = useAtmStore()
const card = computed(() => atmStore.loadedBoltCard)
const label = computed(() => {
const c = card.value
if (!c) return ''
return c.cardName
? `${c.cardName} · ••${c.externalId.slice(-4)}`
: `Card ••${c.externalId.slice(-4)}`
})
const sats = computed(() =>
card.value ? new Intl.NumberFormat().format(card.value.balanceSats) : ''
)
const fiat = computed(() => {
const f = atmStore.loadedCardFiat
if (!f) return null
try {
return new Intl.NumberFormat(undefined, { style: 'currency', currency: f.currency }).format(
f.amount
)
} catch {
return `${f.amount.toFixed(2)} ${f.currency}`
}
})
</script>
<template>
<div
v-if="card"
class="flex items-center gap-3 rounded-xl border border-border bg-card px-4 py-2 text-left lg:gap-4 lg:px-6 lg:py-3"
>
<Nfc class="size-6 shrink-0 text-primary lg:size-8" />
<div class="flex min-w-0 flex-col leading-tight">
<span class="truncate text-xs uppercase tracking-wide text-muted-foreground lg:text-sm">
{{ label }}
</span>
<span
v-if="atmStore.cardBalanceRevealed"
class="text-base font-semibold text-foreground lg:text-2xl"
>
{{ sats }} sats
<span v-if="fiat" class="ml-2 font-normal text-muted-foreground">≈ {{ fiat }}</span>
</span>
<span
v-else
class="text-base font-semibold tracking-widest text-muted-foreground lg:text-2xl"
aria-label="Balance hidden"
>
••••••
</span>
</div>
<Button
variant="ghost"
size="icon"
class="ml-auto h-10 w-10 shrink-0 rounded-full lg:h-14 lg:w-14"
:aria-label="atmStore.cardBalanceRevealed ? 'Hide balance' : 'Show balance'"
@click="atmStore.toggleCardBalance()"
>
<EyeOff v-if="atmStore.cardBalanceRevealed" class="size-5 lg:size-7" />
<Eye v-else class="size-5 lg:size-7" />
</Button>
</div>
</template>

View file

@ -115,16 +115,15 @@ describe('access authorize (ADR-003)', () => {
describe('boltcard credential (tap-to-enter)', () => { describe('boltcard credential (tap-to-enter)', () => {
it('open-enrollment grants any card as user', async () => { it('open-enrollment grants any card as user', async () => {
const out = await authorize( const out = await authorize({ kind: 'boltcard', externalId: 'abc123' }, [], {
{ kind: 'boltcard', externalId: 'abc123', lnurlw: 'lnurlw://h/scan/abc123?p=1&c=2' }, salt: SALT,
[], openEnrollment: true,
{ salt: SALT, openEnrollment: true } })
)
expect(out).toMatchObject({ status: 'granted', role: 'user' }) expect(out).toMatchObject({ status: 'granted', role: 'user' })
}) })
it('rejects a card with no external_id', async () => { 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, salt: SALT,
openEnrollment: true, openEnrollment: true,
}) })
@ -134,11 +133,10 @@ describe('access authorize (ADR-003)', () => {
it('allow-list matches by external_id hash', async () => { it('allow-list matches by external_id hash', async () => {
const idHash = await hashId('abc123', SALT) const idHash = await hashId('abc123', SALT)
const list: AllowListEntry[] = [{ idHash, role: 'operator' }] const list: AllowListEntry[] = [{ idHash, role: 'operator' }]
const out = await authorize( const out = await authorize({ kind: 'boltcard', externalId: 'abc123' }, list, {
{ kind: 'boltcard', externalId: 'abc123', lnurlw: 'lnurlw://h/scan/abc123?p=1&c=2' }, salt: SALT,
list, openEnrollment: false,
{ salt: SALT, openEnrollment: false } })
)
expect(out).toMatchObject({ status: 'granted', role: 'operator' }) 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: * A raw credential. Discriminated union so new factors are additive:
* - `boltcard` — what ships: a tapped Bolt Card. `externalId` is the * - `boltcard` — what ships: a tapped Bolt Card, already verified by the
* identity (from the lnurlw path); `lnurlw` is the full * card server's `/session` (the tap's SUN was spent there).
* voucher (single-use SUN p/c intact) the session presents * `externalId` is the identity as the server returned it;
* once, at Complete, to move sats. Only `externalId` is * it is the only thing hashed/authorized. The session's
* ever hashed/authorized — the p/c never enter the * payment steps stay in the store, never in this layer.
* authorize layer.
* - `npub` — a Nostr pubkey (bare npub, `nostr:` URI or nprofile). * - `npub` — a Nostr pubkey (bare npub, `nostr:` URI or nprofile).
* No reader emits it today; kept, with the PIN second * No reader emits it today; kept, with the PIN second
* factor, for a future non-card credential. * factor, for a future non-card credential.
@ -27,6 +26,6 @@ export type { AccessRole }
* authorized. * authorized.
*/ */
export type AccessScan = export type AccessScan =
| { kind: 'boltcard'; externalId: string; lnurlw: string } | { kind: 'boltcard'; externalId: string }
| { kind: 'npub'; npub: string } | { kind: 'npub'; npub: string }
| { kind: 'challenge'; pubkey: string; nonce: string; sig: string } | { kind: 'challenge'; pubkey: string; nonce: string; sig: string }

View file

@ -10,7 +10,7 @@ import {
type ATMMachine, type ATMMachine,
type AccessRole, type AccessRole,
} from '@bitSpire/state-machine' } from '@bitSpire/state-machine'
import type { AccessControlConfig } from '@/types/electron' import type { AccessControlConfig, CardSession } from '@/types/electron'
import { initializeLightningServices, fetchBtcPrice } from '@/services/lightning' import { initializeLightningServices, fetchBtcPrice } from '@/services/lightning'
import { classifyInitError } from '@/services/init-error' import { classifyInitError } from '@/services/init-error'
import { startOperatorConfigService, type OperatorConfigService } from '@/services/operator-config' 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 // boltcards server bumps the SUN counter on the first GET, so
// treat the voucher as spent even when the failure was ours. // treat the voucher as spent even when the failure was ours.
type BoltCardOutcome = 'skipped' | 'accepted' | 'declined' 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 // Access-control gate config (ADR-003). Defaults disabled → the machine's
// `locked` state bypasses straight to `idle` (behaviour identical to no gate). // `locked` state bypasses straight to `idle` (behaviour identical to no gate).
// Populated from RuntimeConfig.accessControl in initializeForProduction. // Populated from RuntimeConfig.accessControl in initializeForProduction.
@ -314,11 +318,15 @@ export const useAtmStore = defineStore('atm', () => {
}) })
// Build/dev bypass — opens the gate even when enabled (browser dev / CI). // Build/dev bypass — opens the gate even when enabled (browser dev / CI).
const accessBypassFlag = import.meta.env.VITE_SKIP_ACCESS_GATE === 'true' 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 // Tap-to-enter (ADR-003): the Bolt Card session opened at the locked screen
// here for the whole session so buy/sell just need "Complete" — no second tap. // is held here for the whole visit so buy/sell just need "Complete" — no
// Carries the full lnurlw (single-use SUN p/c intact; spent only at payment). // second tap. The tap's SUN was spent opening it; what we hold are the
// Cleared when the session ends (machine re-locks). Never logged. // hit-keyed withdraw/pay steps plus balance + fiat for display. Cleared when
const loadedBoltCard = ref<{ externalId: string; lnurlw: string } | null>(null) // the machine re-locks. Never logged.
const loadedBoltCard = ref<CardSession | null>(null)
// The card balance is hidden by default on the public screen; the holder
// reveals it with the eye toggle. Resets on re-lock.
const cardBalanceRevealed = ref(false)
const fiatCode = ref('USD') const fiatCode = ref('USD')
// Defaults are 0 — the operator's fee config (received via Nostr // Defaults are 0 — the operator's fee config (received via Nostr
// kind-30078 `bitspire-fees:<atm_pubkey>` envelope from satmachineadmin) // kind-30078 `bitspire-fees:<atm_pubkey>` envelope from satmachineadmin)
@ -454,6 +462,25 @@ export const useAtmStore = defineStore('atm', () => {
// start), so App.vue's LockedView branch never renders on a non-access machine. // start), so App.vue's LockedView branch never renders on a non-access machine.
const isLocked = computed(() => currentState.value === 'locked') const isLocked = computed(() => currentState.value === 'locked')
/**
* Fiat view of the loaded card's balance, the way the LNbits wallet page
* prices it: the card server's own currency and rate first (the wallet's
* currency, else the instance default); when it priced nothing, the ATM's
* fiat at its display rate. Null when neither is available.
*/
const loadedCardFiat = computed<{ amount: number; currency: string } | null>(() => {
const card = loadedBoltCard.value
if (!card) return null
if (card.currency && card.fiat !== null) return { amount: card.fiat, currency: card.currency }
if (btcPrice.value && btcPrice.value > 0) {
return { amount: (card.balanceSats / 1e8) * btcPrice.value, currency: fiatCode.value }
}
return null
})
function toggleCardBalance() {
cardBalanceRevealed.value = !cardBalanceRevealed.value
}
const isCashIn = computed(() => { const isCashIn = computed(() => {
const state = snapshot.value?.value const state = snapshot.value?.value
return typeof state === 'object' && 'cashIn' in state return typeof state === 'object' && 'cashIn' in state
@ -512,6 +539,7 @@ export const useAtmStore = defineStore('atm', () => {
// re-locks) so the next customer starts fresh — never carry a card over. // re-locks) so the next customer starts fresh — never carry a card over.
if (state === 'locked' && loadedBoltCard.value) { if (state === 'locked' && loadedBoltCard.value) {
loadedBoltCard.value = null loadedBoltCard.value = null
cardBalanceRevealed.value = false
} }
// Detect network from first invoice we see // Detect network from first invoice we see
@ -631,7 +659,7 @@ export const useAtmStore = defineStore('atm', () => {
* arrives through the invoice watcher → PAYMENT_RECEIVED → dispensingCash; * arrives through the invoice watcher → PAYMENT_RECEIVED → dispensingCash;
* ok here only means the card accepted the pull. * 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' if (nestedState.value !== 'displayingInvoice') return 'skipped'
const invoice = context.value?.invoice const invoice = context.value?.invoice
if (!invoice) return 'skipped' if (!invoice) return 'skipped'
@ -640,7 +668,17 @@ export const useAtmStore = defineStore('atm', () => {
nfcStatus.value = { state: 'processing', message: 'Reading card…' } nfcStatus.value = { state: 'processing', message: 'Reading card…' }
try { try {
const amountMsat = (context.value?.satsAmount ?? 0) * 1000 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) { if (res.ok) {
nfcStatus.value = { state: 'accepted', message: 'Card accepted — confirming payment…' } nfcStatus.value = { state: 'accepted', message: 'Card accepted — confirming payment…' }
return 'accepted' return 'accepted'
@ -663,7 +701,7 @@ export const useAtmStore = defineStore('atm', () => {
* over the nostr transport via the normal `payInvoice` → PAYMENT_RECEIVED * over the nostr transport via the normal `payInvoice` → PAYMENT_RECEIVED
* path. Settlement + completion reuse the tested cash-in flow. * 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' if (!(isCashIn.value && nestedState.value === 'displayingQR')) return 'skipped'
const amountSats = context.value?.satsAmount ?? 0 const amountSats = context.value?.satsAmount ?? 0
if (amountSats <= 0) return 'skipped' if (amountSats <= 0) return 'skipped'
@ -672,7 +710,11 @@ export const useAtmStore = defineStore('atm', () => {
nfcStatus.value = { state: 'processing', message: 'Reading card…' } nfcStatus.value = { state: 'processing', message: 'Reading card…' }
try { try {
const amountMsat = amountSats * 1000 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) { if (!res.ok || !res.bolt11) {
boltCardProcessing.value = false boltCardProcessing.value = false
nfcStatus.value = { state: 'declined', message: res.reason ?? 'Card could not receive' } nfcStatus.value = { state: 'declined', message: res.reason ?? 'Card could not receive' }
@ -697,31 +739,44 @@ export const useAtmStore = defineStore('atm', () => {
} }
/** /**
* A tapped Bolt Card at the locked screen (ADR-003 tap-to-enter). Soft entry: * A tapped Bolt Card at the locked screen (ADR-003 tap-to-enter). Verified
* parse the external_id LOCALLY (no server call, so the single-use SUN p/c stay * entry: the tap's single-use SUN is spent ONCE, on the card server's
* valid), authorize (open-enrollment or allow-list), then hold the full lnurlw * `/session` (main process), which proves a genuine, non-replayed card and
* for the session. The cryptographic check happens later, at Complete, when the * returns the wallet balance plus the hit-keyed withdraw/pay steps. Then the
* stored lnurlw actually moves sats. * 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) { async function handleBoltCardEntry(lnurlw: string) {
if (!isLocked.value) return if (!isLocked.value) return
if (boltCardProcessing.value) return if (boltCardProcessing.value) return
const parsed = parseBoltcardLnurlw(lnurlw) // Reject a non-card tag before spending anything or calling anyone.
if (!parsed) { if (!parseBoltcardLnurlw(lnurlw)) {
nfcStatus.value = { state: 'declined', message: 'Not a Bolt Card' } nfcStatus.value = { state: 'declined', message: 'Not a Bolt Card' }
denyAccess('not a Bolt Card') denyAccess('not a Bolt Card')
return 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 boltCardProcessing.value = true
nfcStatus.value = { state: 'processing', message: 'Reading card…' } nfcStatus.value = { state: 'processing', message: 'Verifying card…' }
try { 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, { const outcome = await authorize(scan, accessControl.value.allowList, {
salt: accessControl.value.salt, salt: accessControl.value.salt,
openEnrollment: accessControl.value.openEnrollment, openEnrollment: accessControl.value.openEnrollment,
}) })
if (outcome.status === 'granted') { if (outcome.status === 'granted') {
loadedBoltCard.value = { externalId: parsed.externalId, lnurlw } loadedBoltCard.value = opened.session
cardBalanceRevealed.value = false
nfcStatus.value = { state: 'accepted', message: 'Card accepted' } nfcStatus.value = { state: 'accepted', message: 'Card accepted' }
grantAccess(outcome.role, outcome.credentialIdHash) grantAccess(outcome.role, outcome.credentialIdHash)
} else { } else {
@ -730,6 +785,10 @@ export const useAtmStore = defineStore('atm', () => {
nfcStatus.value = { state: 'declined', message: reason } nfcStatus.value = { state: 'declined', message: reason }
denyAccess(reason, outcome.credentialIdHash) 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 { } finally {
boltCardProcessing.value = false boltCardProcessing.value = false
} }
@ -740,19 +799,20 @@ export const useAtmStore = defineStore('atm', () => {
* Cash-out pulls via the stored lnurlw; cash-in resolves it to the card * Cash-out pulls via the stored lnurlw; cash-in resolves it to the card
* wallet's lnurlp and pays. Reuses the tap handlers verbatim. * 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 * The loaded session is SINGLE-SHOT: its withdraw/pay steps are keyed by one
* boltcards server consumes it on the first GET, so once the voucher has been * server-side hit that the first use spends, so once a step has been
* presented — accepted or declined — it can never succeed again. Drop it after * presented — accepted or declined — it can never succeed again. Drop the
* the first real attempt and tell the customer to re-tap; a fresh tap on the * session after the first real attempt and tell the customer to re-tap; a
* cash screen goes straight through the normal tap-to-pay/receive path. * fresh tap on the cash screen goes straight through the normal
* A 'skipped' outcome (guard bounced it, no server call) keeps the card. * tap-to-pay/receive path. A 'skipped' outcome (guard bounced it, no server
* call) keeps the session.
*/ */
async function completeWithCard() { async function completeWithCard() {
const card = loadedBoltCard.value const card = loadedBoltCard.value
if (!card) return if (!card) return
let outcome: BoltCardOutcome = 'skipped' let outcome: BoltCardOutcome = 'skipped'
if (isCashOut.value) outcome = await handleBoltCardTap(card.lnurlw) if (isCashOut.value) outcome = await handleBoltCardTap({ session: card })
else if (isCashIn.value) outcome = await handleBoltCardReceive(card.lnurlw) else if (isCashIn.value) outcome = await handleBoltCardReceive({ session: card })
if (outcome === 'skipped') return if (outcome === 'skipped') return
loadedBoltCard.value = null loadedBoltCard.value = null
if (outcome === 'declined') { if (outcome === 'declined') {
@ -773,9 +833,9 @@ export const useAtmStore = defineStore('atm', () => {
if (isLocked.value) { if (isLocked.value) {
void handleBoltCardEntry(lnurlw) void handleBoltCardEntry(lnurlw)
} else if (isCashOut.value && nestedState.value === 'displayingInvoice') { } else if (isCashOut.value && nestedState.value === 'displayingInvoice') {
void handleBoltCardTap(lnurlw) void handleBoltCardTap({ lnurlw })
} else if (isCashIn.value && nestedState.value === 'displayingQR') { } else if (isCashIn.value && nestedState.value === 'displayingQR') {
void handleBoltCardReceive(lnurlw) void handleBoltCardReceive({ lnurlw })
} }
}) })
window.electronAPI.onNfcStatus?.((status) => { window.electronAPI.onNfcStatus?.((status) => {
@ -793,12 +853,12 @@ export const useAtmStore = defineStore('atm', () => {
/** Dev/mock: simulate a cash-out tap with a pasted lnurlw (test without a card). */ /** Dev/mock: simulate a cash-out tap with a pasted lnurlw (test without a card). */
function simulateBoltCardTap(lnurlw: string) { function simulateBoltCardTap(lnurlw: string) {
void handleBoltCardTap(lnurlw) void handleBoltCardTap({ lnurlw })
} }
/** Dev/mock: simulate a cash-in (receive) tap with a pasted lnurlw. */ /** Dev/mock: simulate a cash-in (receive) tap with a pasted lnurlw. */
function simulateBoltCardReceive(lnurlw: string) { function simulateBoltCardReceive(lnurlw: string) {
void handleBoltCardReceive(lnurlw) void handleBoltCardReceive({ lnurlw })
} }
/** Dev/mock: simulate tapping a card at the locked screen (tap-to-enter). */ /** Dev/mock: simulate tapping a card at the locked screen (tap-to-enter). */
@ -1831,8 +1891,11 @@ export const useAtmStore = defineStore('atm', () => {
boltCardProcessing, boltCardProcessing,
simulateBoltCardTap, simulateBoltCardTap,
simulateBoltCardReceive, 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, loadedBoltCard,
cardBalanceRevealed,
loadedCardFiat,
toggleCardBalance,
completeWithCard, completeWithCard,
simulateBoltCardEntry, simulateBoltCardEntry,

View file

@ -22,6 +22,37 @@ export interface AccessControlConfig {
allowList: AllowListEntry[] 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 { export interface RuntimeConfig {
relayUrl: string relayUrl: string
/** LNbits nostr-transport server pubkey (hex, 64 chars). */ /** LNbits nostr-transport server pubkey (hex, 64 chars). */
@ -136,6 +167,19 @@ declare global {
lnurlw: string lnurlw: string
amountMsat: number amountMsat: number
}) => Promise<{ ok: boolean; bolt11?: string; reason?: string }> }) => 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: ( applyOperatorCassettesConfig: (
payload: { positions: Record<string, { denomination: number; count: number }> }, payload: { positions: Record<string, { denomination: number; count: number }> },
eventCreatedAt: number eventCreatedAt: number

View file

@ -3,6 +3,7 @@ import { watch, computed, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useAtmStore } from '@/stores/atm' import { useAtmStore } from '@/stores/atm'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import CardChip from '@/components/CardChip.vue'
import { Alert, AlertDescription } from '@/components/ui/alert' import { Alert, AlertDescription } from '@/components/ui/alert'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import QRCode from '@/components/QRCode.vue' import QRCode from '@/components/QRCode.vue'
@ -368,9 +369,7 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)
v-if="atmStore.loadedBoltCard" v-if="atmStore.loadedBoltCard"
class="flex w-full max-w-md flex-col items-center gap-2 pt-2" class="flex w-full max-w-md flex-col items-center gap-2 pt-2"
> >
<p class="text-xs uppercase tracking-wide text-muted-foreground"> <CardChip class="w-full" />
Card ••{{ atmStore.loadedBoltCard.externalId.slice(-4) }}
</p>
<Button <Button
class="w-full bg-success text-success-foreground" class="w-full bg-success text-success-foreground"
size="kiosk-lg" size="kiosk-lg"

View file

@ -3,6 +3,7 @@ import { watch, computed, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useAtmStore } from '@/stores/atm' import { useAtmStore } from '@/stores/atm'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import CardChip from '@/components/CardChip.vue'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Alert, AlertDescription } from '@/components/ui/alert' import { Alert, AlertDescription } from '@/components/ui/alert'
import QRCode from '@/components/QRCode.vue' import QRCode from '@/components/QRCode.vue'
@ -333,9 +334,7 @@ function formatFiat(cents: number): string {
v-if="atmStore.loadedBoltCard" v-if="atmStore.loadedBoltCard"
class="flex w-full max-w-md flex-col items-center gap-2 pt-2" class="flex w-full max-w-md flex-col items-center gap-2 pt-2"
> >
<p class="text-xs uppercase tracking-wide text-muted-foreground"> <CardChip class="w-full" />
Card ••{{ atmStore.loadedBoltCard.externalId.slice(-4) }}
</p>
<Button <Button
class="w-full bg-success text-success-foreground" class="w-full bg-success text-success-foreground"
size="kiosk-lg" size="kiosk-lg"

View file

@ -5,6 +5,7 @@ import { useAtmStore } from '@/stores/atm'
import { useBranding } from '@/composables/useBranding' import { useBranding } from '@/composables/useBranding'
import { initialContext } from '@bitSpire/state-machine' import { initialContext } from '@bitSpire/state-machine'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import CardChip from '@/components/CardChip.vue'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import BitcoinIcon from '@/components/BitcoinIcon.vue' import BitcoinIcon from '@/components/BitcoinIcon.vue'
import QRCode from '@/components/QRCode.vue' import QRCode from '@/components/QRCode.vue'
@ -104,6 +105,8 @@ function handleCashOut() {
> >
</span> </span>
</div> </div>
<!-- Tap-to-enter: the holder's card session, balance hidden until revealed -->
<CardChip v-if="atmStore.loadedBoltCard" class="mt-1 w-full max-w-md" />
</div> </div>
<!-- Touch Zones --> <!-- Touch Zones -->

View file

@ -10,8 +10,8 @@ The gate landed in aiolabs/bitspire#86 as **Bolt Card tap-to-enter**, not the np
- **Reader.** The batm3 / upboard reader is a USB CCID contactless reader (Feitian KP382, OMNIKEY 5022) driven by `pcscd` + `nfc-pcsc` in the **main process** (`electron/nfc-service.ts`, from #83), not the serial `/dev/ttyNFC` device or Web NFC. Taps reach the renderer over the existing `nfc:card-tapped` IPC and the store routes them by state (`locked` → enter; the cash screens → pay / receive). The renderer-side `AccessReader` abstraction, the camera npub-QR reader and the mock reader were never wired to anything and have been removed; `services/access/` now holds only `authorize`, the Bolt Card parser and the credential types. - **Reader.** The batm3 / upboard reader is a USB CCID contactless reader (Feitian KP382, OMNIKEY 5022) driven by `pcscd` + `nfc-pcsc` in the **main process** (`electron/nfc-service.ts`, from #83), not the serial `/dev/ttyNFC` device or Web NFC. Taps reach the renderer over the existing `nfc:card-tapped` IPC and the store routes them by state (`locked` → enter; the cash screens → pay / receive). The renderer-side `AccessReader` abstraction, the camera npub-QR reader and the mock reader were never wired to anything and have been removed; `services/access/` now holds only `authorize`, the Bolt Card parser and the credential types.
- **Credential.** Identity is the card's boltcards `external_id`, parsed **locally** from the tapped `lnurlw` (`AccessScan` kind `boltcard`), not the NFC UID. Only `hashId(external_id, salt)` is compared or logged. The `npub` variant (with its PIN second factor) stays in `authorize()` and its tests for a future non-card credential; `challenge` remains the v2 seam; the planned `uid` variant is gone. - **Credential.** Identity is the card's boltcards `external_id`, parsed **locally** from the tapped `lnurlw` (`AccessScan` kind `boltcard`), not the NFC UID. Only `hashId(external_id, salt)` is compared or logged. The `npub` variant (with its PIN second factor) stays in `authorize()` and its tests for a future non-card credential; `challenge` remains the v2 seam; the planned `uid` variant is gone.
- **Soft entry, verify-at-payment.** A tap yields a single-use SUN `p`/`c`. Verifying it at entry would spend the voucher we want to reuse at Complete, so entry makes **no server call**. The stored `lnurlw` is presented once, at Complete, through the #83 / #84 payment paths, and that is where the cryptographic check happens. Consequence: the loaded card is single-shot — after the first Complete attempt, accepted or declined, the store drops it and the customer re-taps. - **Verified entry via `/session`** (superseding #86's soft entry). A tap yields a single-use SUN `p`/`c`, and verifying it at entry would have spent the voucher Complete needed, so #86 made no server call at entry. The fork now exposes `/session/<id>?p=&c=` (`docs/boltcard-session.md`): it spends the SUN **once**, proves a genuine non-replayed card, returns the card wallet's balance + fiat, and hands back the hit-keyed LUD-03 / LUD-06 second steps so Complete still needs no second tap. The session is single-shot — after the first Complete attempt, accepted or declined, the store drops it and the customer re-taps. The balance is shown on the idle menu and cash screens **hidden by default** behind an eye toggle (`CardChip.vue`), priced the way the LNbits wallet page prices it (wallet currency, else instance default, else the ATM's fiat at its own rate).
- **Open enrollment is not a security boundary.** With `openEnrollment` on (the current posture on every gated machine), any NDEF tag whose URL contains `/scan/<id>` unlocks the terminal. The gate keeps casual users off the menu; money still only moves on a valid SUN. Closing this — a provisioned allow-list, or a verify-at-entry variant that spends one tap — is tracked in aiolabs/bitspire#91. - **Open enrollment is still not a security boundary.** `/session` proves the card is genuine *to the server the card names*: the session URL is derived from the tapped `lnurlw`'s host, so with `openEnrollment` on a forged NDEF tag pointing at a server that answers `authenticated: true` still unlocks the terminal. Money is unaffected (cash-out never dispenses without `PAYMENT_RECEIVED`; cash-in pays where the holder pointed). Closing the gate means pinning the accepted card-server host(s) and/or a provisioned allow-list — tracked in aiolabs/bitspire#91.
- **Session semantics.** One tap = one session; every transaction terminal state returns to `locked`. Inactivity (60 s on the idle menu) and the absolute cap (10 min) are measured at the DOM layer (`useSessionSecurity`) because an XState `after` cannot observe touches. Both send `END_SESSION`, which the machine accepts **from `idle` only**, so a timer can never abandon stacked bills or an in-flight dispense. An explicit End Session button re-locks immediately. - **Session semantics.** One tap = one session; every transaction terminal state returns to `locked`. Inactivity (60 s on the idle menu) and the absolute cap (10 min) are measured at the DOM layer (`useSessionSecurity`) because an XState `after` cannot observe touches. Both send `END_SESSION`, which the machine accepts **from `idle` only**, so a timer can never abandon stacked bills or an in-flight dispense. An explicit End Session button re-locks immediately.
- **Config.** `ACCESS_CONTROL_ENABLED` / `ACCESS_OPEN_ENROLLMENT` / `ACCESS_DEV_UNLOCK` / `ACCESS_SALT` from env, overridden by `/var/lib/bitspire/access.json` (pushed with `deploy/nixos/provision-access.sh`, no rebuild). `devUnlock` defaults **off**. - **Config.** `ACCESS_CONTROL_ENABLED` / `ACCESS_OPEN_ENROLLMENT` / `ACCESS_DEV_UNLOCK` / `ACCESS_SALT` from env, overridden by `/var/lib/bitspire/access.json` (pushed with `deploy/nixos/provision-access.sh`, no rebuild). `devUnlock` defaults **off**.
- **Audit** is still a console stub; the state.db write promised in decision 7 is tracked in aiolabs/bitspire#90. - **Audit** is still a console stub; the state.db write promised in decision 7 is tracked in aiolabs/bitspire#90.

View file

@ -99,6 +99,12 @@ wallet …".
## Notes ## Notes
- **Tap-to-enter uses `/session` instead.** When the access gate is on, the
card was already verified at entry and the ATM holds the LUD-06 second step
from `/session` (see `boltcard-session.md`), so Complete calls `pay.callback`
directly and never touches `/pay`. `/pay` remains the path for a card tapped
directly on the cash-in screen (gate off, or a second card).
- **Double-payout:** the cash-in screen still shows the LNURL-withdraw QR as a - **Double-payout:** the cash-in screen still shows the LNURL-withdraw QR as a
fallback (customer _pulls_). A tap _pays_ instead. The ATM gates re-entry fallback (customer _pulls_). A tap _pays_ instead. The ATM gates re-entry
while a tap is in flight and leaves `displayingQR` on success; the withdraw while a tap is in flight and leaves `displayingQR` on success; the withdraw

117
docs/boltcard-session.md Normal file
View file

@ -0,0 +1,117 @@
# 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, at the server's rate. `null` when the server has
no currency or the rate lookup failed; the ATM then prices the sats itself in
its own fiat at its display rate. A rate failure never fails 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.