diff --git a/apps/machine/electron/boltcard-session.test.ts b/apps/machine/electron/boltcard-session.test.ts new file mode 100644 index 0000000..58e30f4 --- /dev/null +++ b/apps/machine/electron/boltcard-session.test.ts @@ -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' }) + }) +}) diff --git a/apps/machine/electron/boltcard-session.ts b/apps/machine/electron/boltcard-session.ts new file mode 100644 index 0000000..a799178 --- /dev/null +++ b/apps/machine/electron/boltcard-session.ts @@ -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/?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/?p=&c=`; the session endpoint is its sibling + * `…/boltcards/api/v1/session/?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 => 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 { + 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, + }, + } +} diff --git a/apps/machine/electron/lnurl-pay.test.ts b/apps/machine/electron/lnurl-pay.test.ts index 165688f..4dbe150 100644 --- a/apps/machine/electron/lnurl-pay.test.ts +++ b/apps/machine/electron/lnurl-pay.test.ts @@ -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.' }) + }) +}) diff --git a/apps/machine/electron/lnurl-pay.ts b/apps/machine/electron/lnurl-pay.ts index 91da90c..cb408e6 100644 --- a/apps/machine/electron/lnurl-pay.ts +++ b/apps/machine/electron/lnurl-pay.ts @@ -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 { + 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 { + const ctx: Ctx = { doFetch: opts.fetchImpl ?? fetch, timeoutMs: opts.timeoutMs ?? 15_000 } + return requestInvoice(step, amountMsat, ctx) } diff --git a/apps/machine/electron/lnurl-withdraw.test.ts b/apps/machine/electron/lnurl-withdraw.test.ts index 4d14611..cceda64 100644 --- a/apps/machine/electron/lnurl-withdraw.test.ts +++ b/apps/machine/electron/lnurl-withdraw.test.ts @@ -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', + }) + }) +}) diff --git a/apps/machine/electron/lnurl-withdraw.ts b/apps/machine/electron/lnurl-withdraw.ts index eb93c08..35429ce 100644 --- a/apps/machine/electron/lnurl-withdraw.ts +++ b/apps/machine/electron/lnurl-withdraw.ts @@ -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 { } 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 { + 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) }) diff --git a/apps/machine/electron/main.ts b/apps/machine/electron/main.ts index 018cef6..0331392 100644 --- a/apps/machine/electron/main.ts +++ b/apps/machine/electron/main.ts @@ -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 => { + 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)) diff --git a/apps/machine/electron/preload.ts b/apps/machine/electron/preload.ts index 8ced2d5..df4ba0b 100644 --- a/apps/machine/electron/preload.ts +++ b/apps/machine/electron/preload.ts @@ -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 => + 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 diff --git a/apps/machine/src/components/CardChip.vue b/apps/machine/src/components/CardChip.vue new file mode 100644 index 0000000..c59060c --- /dev/null +++ b/apps/machine/src/components/CardChip.vue @@ -0,0 +1,78 @@ + + + diff --git a/apps/machine/src/services/access/__tests__/authorize.test.ts b/apps/machine/src/services/access/__tests__/authorize.test.ts index 521cfcb..9fc0c22 100644 --- a/apps/machine/src/services/access/__tests__/authorize.test.ts +++ b/apps/machine/src/services/access/__tests__/authorize.test.ts @@ -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' }) }) }) diff --git a/apps/machine/src/services/access/types.ts b/apps/machine/src/services/access/types.ts index 26ae8d5..4c7a4e3 100644 --- a/apps/machine/src/services/access/types.ts +++ b/apps/machine/src/services/access/types.ts @@ -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 } diff --git a/apps/machine/src/stores/atm.ts b/apps/machine/src/stores/atm.ts index 07eee0f..fd00df2 100644 --- a/apps/machine/src/stores/atm.ts +++ b/apps/machine/src/stores/atm.ts @@ -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,15 @@ 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(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') // Defaults are 0 — the operator's fee config (received via Nostr // kind-30078 `bitspire-fees:` 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. 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 state = snapshot.value?.value 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. if (state === 'locked' && loadedBoltCard.value) { loadedBoltCard.value = null + cardBalanceRevealed.value = false } // Detect network from first invoice we see @@ -631,7 +659,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 { + async function handleBoltCardTap(source: BoltCardSource): Promise { if (nestedState.value !== 'displayingInvoice') return 'skipped' const invoice = context.value?.invoice if (!invoice) return 'skipped' @@ -640,7 +668,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 +701,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 { + async function handleBoltCardReceive(source: BoltCardSource): Promise { if (!(isCashIn.value && nestedState.value === 'displayingQR')) return 'skipped' const amountSats = context.value?.satsAmount ?? 0 if (amountSats <= 0) return 'skipped' @@ -672,7 +710,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 +739,44 @@ 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 + cardBalanceRevealed.value = false nfcStatus.value = { state: 'accepted', message: 'Card accepted' } grantAccess(outcome.role, outcome.credentialIdHash) } else { @@ -730,6 +785,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 +799,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 +833,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 +853,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,8 +1891,11 @@ 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, + cardBalanceRevealed, + loadedCardFiat, + toggleCardBalance, completeWithCard, simulateBoltCardEntry, diff --git a/apps/machine/src/types/electron.d.ts b/apps/machine/src/types/electron.d.ts index 95425f6..1ab609c 100644 --- a/apps/machine/src/types/electron.d.ts +++ b/apps/machine/src/types/electron.d.ts @@ -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 + /** Cash-out via a session's withdraw step (no tap). */ + withdrawWithSession: (args: { + withdraw: NonNullable + 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 }, eventCreatedAt: number diff --git a/apps/machine/src/views/CashInView.vue b/apps/machine/src/views/CashInView.vue index b75a2a4..a86acd0 100644 --- a/apps/machine/src/views/CashInView.vue +++ b/apps/machine/src/views/CashInView.vue @@ -3,6 +3,7 @@ import { watch, computed, ref } from 'vue' import { useRouter } from 'vue-router' import { useAtmStore } from '@/stores/atm' import { Button } from '@/components/ui/button' +import CardChip from '@/components/CardChip.vue' import { Alert, AlertDescription } from '@/components/ui/alert' import { Input } from '@/components/ui/input' import QRCode from '@/components/QRCode.vue' @@ -368,9 +369,7 @@ const isProcessing = computed(() => atmStore.isPayingInvoice) v-if="atmStore.loadedBoltCard" class="flex w-full max-w-md flex-col items-center gap-2 pt-2" > -

- Card ••{{ atmStore.loadedBoltCard.externalId.slice(-4) }} -

+