From 457761719f034c151b4dca6372d7e98f2792c70b Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Wed, 5 Aug 2026 04:24:00 +0200 Subject: [PATCH 1/9] feat(machine): LNURL-withdraw executor for Bolt Card cash-out (LUD-03) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First, hardware-independent piece of Bolt Card tap-to-pay on the cash-out flow. When a customer taps a Bolt Card, the ATM (which already has its cash-out BOLT11) becomes the LNURL-*withdrawing* party: GET the card's lnurlw voucher → GET callback?k1=…&pr= so the card's wallet pays the invoice. Settlement is still observed via the existing invoice watcher (a returned ok=true means "card accepted the pull", not "cash dispensed"). - electron/lnurl-withdraw.ts: executeLnurlWithdraw() + lnurlwToHttps(). Runs in the main process (Node fetch) to avoid renderer CORS, since LNURL endpoints send no CORS headers. Fully injectable fetch for testing. - electron/lnurl-withdraw.test.ts: 12 tests (scheme mapping, two-step happy path passing k1+pr, ERROR surfacing, non-withdraw tag, amount-over-limit short-circuit, callback decline, network failure). - IPC `lnurl:withdraw` (main) + preload + electron.d.ts. Next: pcscd + an nfc-pcsc reader driver (reads the NTAG424 NDEF lnurlw), then wire the tap into the cashOut displayingInvoice state + "tap or scan" UI. Co-Authored-By: Claude Opus 4.8 --- apps/machine/electron/lnurl-withdraw.test.ts | 103 +++++++++++++++ apps/machine/electron/lnurl-withdraw.ts | 127 +++++++++++++++++++ apps/machine/electron/main.ts | 15 +++ apps/machine/electron/preload.ts | 12 ++ apps/machine/src/types/electron.d.ts | 6 + 5 files changed, 263 insertions(+) create mode 100644 apps/machine/electron/lnurl-withdraw.test.ts create mode 100644 apps/machine/electron/lnurl-withdraw.ts diff --git a/apps/machine/electron/lnurl-withdraw.test.ts b/apps/machine/electron/lnurl-withdraw.test.ts new file mode 100644 index 0000000..4d14611 --- /dev/null +++ b/apps/machine/electron/lnurl-withdraw.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, vi } from 'vitest' +import { executeLnurlWithdraw, lnurlwToHttps } from './lnurl-withdraw' + +const BOLT11 = 'lnbc10u1p3xyz...' +const LNURLW = + 'lnurlw://lnbits.l484.com/boltcards/api/v1/scan/abc123?p=DEADBEEFDEADBEEFDEADBEEFDEADBEEF&c=1122334455667788' + +/** Build a mock fetch that returns the given JSON bodies per call, in order. */ +function mockFetch(bodies: unknown[]) { + const calls: string[] = [] + const impl = vi.fn(async (url: string | URL) => { + calls.push(url.toString()) + const body = bodies[calls.length - 1] + return { json: async () => body } as Response + }) + return { impl: impl as unknown as typeof fetch, calls } +} + +describe('lnurlwToHttps', () => { + it('maps lnurlw:// and lnurl:// to https://', () => { + expect(lnurlwToHttps('lnurlw://host/p?x=1')).toBe('https://host/p?x=1') + expect(lnurlwToHttps('lnurl://host/p')).toBe('https://host/p') + }) + it('strips a lightning: prefix', () => { + expect(lnurlwToHttps('lightning:lnurlw://host/p')).toBe('https://host/p') + }) + it('passes https:// through and trims', () => { + expect(lnurlwToHttps(' https://host/p ')).toBe('https://host/p') + }) + it('rejects http://, bech32 lnurl1…, and empty', () => { + expect(lnurlwToHttps('http://host/p')).toBeNull() + expect(lnurlwToHttps('LNURL1DP68GURN8GHJ7')).toBeNull() + expect(lnurlwToHttps('')).toBeNull() + }) +}) + +describe('executeLnurlWithdraw', () => { + const withdrawReq = { + tag: 'withdrawRequest', + callback: 'https://lnbits.l484.com/boltcards/api/v1/scan/cb', + k1: 'K1TOKEN', + minWithdrawable: 1000, + maxWithdrawable: 5_000_000, + } + + it('completes the two-step withdraw and passes k1 + pr to the callback', async () => { + const { impl, calls } = mockFetch([withdrawReq, { status: 'OK' }]) + const res = await executeLnurlWithdraw(LNURLW, BOLT11, { fetchImpl: impl }) + expect(res).toEqual({ ok: true }) + // First call = the lnurlw as https; second = callback with k1 + pr. + expect(calls[0]).toContain('https://lnbits.l484.com/boltcards/api/v1/scan/abc123') + expect(calls[1]).toContain('k1=K1TOKEN') + expect(calls[1]).toContain(`pr=${encodeURIComponent(BOLT11)}`) + }) + + it('rejects a non-lnurlw tag', async () => { + const { impl } = mockFetch([]) + const res = await executeLnurlWithdraw('http://nope', BOLT11, { fetchImpl: impl }) + expect(res.ok).toBe(false) + expect(res.reason).toMatch(/not a valid Bolt Card/i) + }) + + it('rejects when there is no invoice', async () => { + const { impl } = mockFetch([]) + const res = await executeLnurlWithdraw(LNURLW, '', { fetchImpl: impl }) + expect(res).toMatchObject({ ok: false, reason: 'no invoice to charge' }) + }) + + it('surfaces an ERROR from the withdraw request', async () => { + const { impl } = mockFetch([{ status: 'ERROR', reason: 'spent today limit' }]) + const res = await executeLnurlWithdraw(LNURLW, BOLT11, { fetchImpl: impl }) + expect(res).toMatchObject({ ok: false, reason: 'spent today limit' }) + }) + + it('rejects a response that is not a withdrawRequest', async () => { + const { impl } = mockFetch([{ tag: 'payRequest', callback: 'x' }]) + const res = await executeLnurlWithdraw(LNURLW, BOLT11, { fetchImpl: impl }) + expect(res).toMatchObject({ ok: false }) + expect(res.reason).toMatch(/withdraw voucher/i) + }) + + it('rejects (without calling the callback) when the amount exceeds the card limit', async () => { + const { impl, calls } = mockFetch([{ ...withdrawReq, maxWithdrawable: 2000 }]) + const res = await executeLnurlWithdraw(LNURLW, BOLT11, { fetchImpl: impl, amountMsat: 5000 }) + expect(res).toMatchObject({ ok: false, reason: 'card limit is below this amount' }) + expect(calls).toHaveLength(1) // callback never hit + }) + + it('surfaces an ERROR from the callback (card declined)', async () => { + const { impl } = mockFetch([withdrawReq, { status: 'ERROR', reason: 'insufficient funds' }]) + const res = await executeLnurlWithdraw(LNURLW, BOLT11, { fetchImpl: impl }) + expect(res).toMatchObject({ ok: false, reason: 'insufficient funds' }) + }) + + it('handles a network failure gracefully', async () => { + const impl = vi.fn(async () => { + throw new Error('ECONNREFUSED') + }) as unknown as typeof fetch + const res = await executeLnurlWithdraw(LNURLW, BOLT11, { fetchImpl: impl }) + expect(res.ok).toBe(false) + expect(res.reason).toMatch(/could not reach the card/i) + }) +}) diff --git a/apps/machine/electron/lnurl-withdraw.ts b/apps/machine/electron/lnurl-withdraw.ts new file mode 100644 index 0000000..eb93c08 --- /dev/null +++ b/apps/machine/electron/lnurl-withdraw.ts @@ -0,0 +1,127 @@ +/** + * LNURL-withdraw executor (LUD-03) — the ATM as the *withdrawing* party. + * + * Bolt Card tap-to-pay for the cash-out flow: a Bolt Card presents an + * `lnurlw://…?p=…&c=…` voucher (NTAG424 SUN — fresh p/c per tap). The ATM has + * already generated its cash-out BOLT11; here it asks the card's wallet to pay + * that invoice: + * 1. GET the lnurlw URL → a `withdrawRequest` (callback, k1, max/min). + * 2. GET `callback?k1=…&pr=` → the card's wallet pays it. + * Settlement itself is observed elsewhere (the existing invoice watcher over + * nostr), so a returned `{ ok: true }` means "the card accepted the pull", not + * "cash dispensed" — the state machine still waits for PAYMENT_RECEIVED. + * + * Runs in the MAIN process (Node fetch) to avoid renderer CORS: LNURL + * endpoints don't send CORS headers, so a renderer fetch to the card's host + * would be blocked. + */ + +export interface LnurlWithdrawResult { + ok: boolean + /** Human-readable reason when ok is false (safe to surface on-screen). */ + reason?: string +} + +/** LUD-03 withdrawRequest (subset we consume) + LUD-06 error shape. */ +interface WithdrawRequest { + tag?: string + callback?: string + k1?: string + minWithdrawable?: number + maxWithdrawable?: number + defaultDescription?: string + status?: string + reason?: string +} + +type FetchLike = typeof fetch + +export interface ExecuteLnurlWithdrawOptions { + /** Injected for tests; defaults to global fetch. */ + fetchImpl?: FetchLike + /** + * Our invoice amount in millisats. When set, we reject early if it exceeds + * the voucher's maxWithdrawable (defensive; the callback would reject anyway). + */ + amountMsat?: number + /** Per-request timeout (default 15s). */ + timeoutMs?: number +} + +/** + * Normalize a Bolt Card / LNURL-withdraw pointer to an https URL. + * Bolt Cards emit `lnurlw://host/path?query`; we also accept `lnurl://` and a + * bare `https://`. Bech32 `LNURL1…` is intentionally unsupported (Bolt Cards + * never use it) and rejected with a clear reason. + */ +export function lnurlwToHttps(raw: string): string | null { + let s = raw.trim() + if (!s) return null + if (s.toLowerCase().startsWith('lightning:')) s = s.slice('lightning:'.length) + const lower = s.toLowerCase() + if (lower.startsWith('lnurlw://')) return 'https://' + s.slice('lnurlw://'.length) + if (lower.startsWith('lnurl://')) return 'https://' + s.slice('lnurl://'.length) + if (lower.startsWith('https://')) return s + // Reject http:// (must be TLS) and bech32 lnurl1… (not a Bolt Card). + return null +} + +function appendQuery(url: string, params: Record): string { + const u = new URL(url) + for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v) + return u.toString() +} + +function errMsg(e: unknown): string { + if (e instanceof Error) return e.name === 'TimeoutError' || e.name === 'AbortError' ? 'timed out' : e.message + return String(e) +} + +export async function executeLnurlWithdraw( + lnurlw: string, + bolt11: string, + opts: ExecuteLnurlWithdrawOptions = {} +): Promise { + const doFetch = opts.fetchImpl ?? fetch + const timeoutMs = opts.timeoutMs ?? 15_000 + + const paramsUrl = lnurlwToHttps(lnurlw) + if (!paramsUrl) return { ok: false, reason: 'not a valid Bolt Card (lnurlw) tag' } + if (!bolt11 || !/^ln[a-z0-9]/i.test(bolt11.trim())) { + return { ok: false, reason: 'no invoice to charge' } + } + + // 1) Fetch the withdraw request. + let params: WithdrawRequest + try { + const res = await doFetch(paramsUrl, { signal: AbortSignal.timeout(timeoutMs) }) + params = (await res.json()) as WithdrawRequest + } catch (e) { + return { ok: false, reason: `could not reach the card: ${errMsg(e)}` } + } + if (params.status === 'ERROR') { + return { ok: false, reason: params.reason || 'card rejected the tap' } + } + if (params.tag !== 'withdrawRequest' || !params.callback || !params.k1) { + return { ok: false, reason: 'card did not return a withdraw voucher' } + } + if ( + opts.amountMsat != null && + typeof params.maxWithdrawable === 'number' && + opts.amountMsat > params.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() }) + let cb: { status?: string; reason?: string } + try { + const res = await doFetch(cbUrl, { signal: AbortSignal.timeout(timeoutMs) }) + cb = (await res.json()) as { status?: string; reason?: string } + } catch (e) { + return { ok: false, reason: `card payment failed: ${errMsg(e)}` } + } + if (cb.status === 'OK') return { ok: true } + return { ok: false, reason: cb.reason || 'card declined the payment' } +} diff --git a/apps/machine/electron/main.ts b/apps/machine/electron/main.ts index ac31a6a..9b4815d 100644 --- a/apps/machine/electron/main.ts +++ b/apps/machine/electron/main.ts @@ -42,6 +42,7 @@ import { type StoredBunkerBinding, } from './state-store.js' import { initializeHal, type HalInstance } from './hal-service.js' +import { executeLnurlWithdraw } from './lnurl-withdraw.js' // ESM equivalent of __dirname const __filename = fileURLToPath(import.meta.url) @@ -415,6 +416,20 @@ ipcMain.handle('app:recover', (): void => { reloadRenderer() }) +// Bolt Card cash-out: pull payment for the current invoice from a tapped card +// via LNURL-withdraw. Runs in the main process (Node fetch) to dodge renderer +// CORS. Returns once the card accepts; settlement arrives via the invoice +// watcher. See lnurl-withdraw.ts. +ipcMain.handle( + 'lnurl:withdraw', + async ( + _event, + args: { lnurlw: string; bolt11: string; amountMsat?: number } + ): Promise<{ ok: boolean; reason?: string }> => { + return executeLnurlWithdraw(args.lnurlw, args.bolt11, { amountMsat: 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 41c54df..d65cb80 100644 --- a/apps/machine/electron/preload.ts +++ b/apps/machine/electron/preload.ts @@ -127,6 +127,13 @@ contextBridge.exposeInMainWorld('electronAPI', { // Reload the renderer to re-attempt initialization (connectivity recovery). recoverApp: (): Promise => ipcRenderer.invoke('app:recover'), + // Bolt Card cash-out: pull payment for the current invoice from a tapped card. + lnurlWithdraw: (args: { + lnurlw: string + bolt11: string + amountMsat?: number + }): Promise<{ ok: boolean; reason?: string }> => ipcRenderer.invoke('lnurl:withdraw', args), + applyOperatorCassettesConfig: ( payload: { positions: Record @@ -246,6 +253,11 @@ declare global { saveSpireSeed: (seed: string) => Promise relaunchApp: () => Promise recoverApp: () => Promise + lnurlWithdraw: (args: { + lnurlw: string + bolt11: string + amountMsat?: number + }) => Promise<{ ok: boolean; reason?: string }> applyOperatorCassettesConfig: ( payload: { positions: Record }, eventCreatedAt: number diff --git a/apps/machine/src/types/electron.d.ts b/apps/machine/src/types/electron.d.ts index 4c8faa8..b77f850 100644 --- a/apps/machine/src/types/electron.d.ts +++ b/apps/machine/src/types/electron.d.ts @@ -103,6 +103,12 @@ declare global { relaunchApp: () => Promise /** Reload the renderer to re-attempt initialization (connectivity recovery). */ recoverApp: () => Promise + /** Bolt Card cash-out: pull payment for the current invoice from a tapped card. */ + lnurlWithdraw: (args: { + lnurlw: string + bolt11: string + amountMsat?: number + }) => Promise<{ ok: boolean; reason?: string }> applyOperatorCassettesConfig: ( payload: { positions: Record }, eventCreatedAt: number From 74c420fbd3112791e652cd763514c9a871872643 Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Wed, 5 Aug 2026 04:27:00 +0200 Subject: [PATCH 2/9] feat(deploy): enable pcscd on batm3 for the Bolt Card reader The Feitian KP382 (096e:0608) is a CCID contactless reader; PC/SC must be running for the CCID driver to bind it. The app will talk to pcscd's socket via nfc-pcsc. Idle/harmless when no reader is attached. Co-Authored-By: Claude Opus 4.8 --- deploy/nixos/hardware/batm3.nix | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/deploy/nixos/hardware/batm3.nix b/deploy/nixos/hardware/batm3.nix index a92159e..a03c830 100644 --- a/deploy/nixos/hardware/batm3.nix +++ b/deploy/nixos/hardware/batm3.nix @@ -85,6 +85,13 @@ cpuFreqGovernor = "performance"; }; + # PC/SC daemon for the Feitian KP382 contactless reader (096e:0608, a CCID + # smart-card reader) used for Bolt Card tap-to-pay on cash-out. Enabling it + # binds the CCID driver to the reader; the app talks to pcscd's socket (via + # nfc-pcsc) rather than the USB device directly. Harmless if no reader is + # attached — pcscd just idles. + services.pcscd.enable = true; + # Disable suspend/hibernate for kiosk systemd.targets = { sleep.enable = false; From 84d746a0dae8ddf2656b6b99d103f2388a60f86a Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Wed, 5 Aug 2026 04:42:07 +0200 Subject: [PATCH 3/9] feat(machine): NFC Bolt Card reader driver + IPC (main process) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Main-process driver over nfc-pcsc (PC/SC). On tap it reads the NTAG424 Type-4 NDEF file via ISO7816 APDUs (select NDEF app D2760000850101 → select file → ReadBinary NLEN + message) and extracts the lnurlw voucher (fresh SUN p/c per tap), forwarding it to the renderer on `nfc:card-tapped` (+ `nfc:status`). Lazy, guarded import — a missing reader/pcscd just reports 'unavailable', never breaking the cash-out QR path. Preload removeAllListeners guards against a double payment-trigger on renderer reload. - electron/nfc-service.ts: startNfcReader() + readNdefLnurlw()/extractLnurlw(). - electron/nfc-service.test.ts: 7 tests (NDEF URI extraction, Type-4 read sequence incl. AID select, empty-file + select-fail handling). - main.ts start + IPC forward; preload + electron.d.ts listeners. - add nfc-pcsc dep (native @pokusew/pcsclite; nix build handling next). Co-Authored-By: Claude Opus 4.8 --- apps/machine/electron/main.ts | 9 ++ apps/machine/electron/nfc-service.test.ts | 66 +++++++++ apps/machine/electron/nfc-service.ts | 157 ++++++++++++++++++++++ apps/machine/electron/preload.ts | 18 +++ apps/machine/package.json | 1 + apps/machine/src/types/electron.d.ts | 6 + pnpm-lock.yaml | 25 +++- 7 files changed, 281 insertions(+), 1 deletion(-) create mode 100644 apps/machine/electron/nfc-service.test.ts create mode 100644 apps/machine/electron/nfc-service.ts diff --git a/apps/machine/electron/main.ts b/apps/machine/electron/main.ts index 9b4815d..ac15f6e 100644 --- a/apps/machine/electron/main.ts +++ b/apps/machine/electron/main.ts @@ -43,6 +43,7 @@ import { } from './state-store.js' import { initializeHal, type HalInstance } from './hal-service.js' import { executeLnurlWithdraw } from './lnurl-withdraw.js' +import { startNfcReader, type NfcStatus } from './nfc-service.js' // ESM equivalent of __dirname const __filename = fileURLToPath(import.meta.url) @@ -828,6 +829,14 @@ app.whenReady().then(() => { startWatchdog() startCommandPoller() + // Bolt Card reader — forwards taps (lnurlw) + status to the renderer. Fully + // best-effort: if the reader/pcscd is absent it just reports 'unavailable' + // and the cash-out QR path is unaffected. + void startNfcReader( + (lnurlw) => mainWindow?.webContents.send('nfc:card-tapped', lnurlw), + (status) => mainWindow?.webContents.send('nfc:status', status) + ) + app.on('activate', () => { // macOS: re-create window when dock icon clicked if (BrowserWindow.getAllWindows().length === 0) { diff --git a/apps/machine/electron/nfc-service.test.ts b/apps/machine/electron/nfc-service.test.ts new file mode 100644 index 0000000..3023d36 --- /dev/null +++ b/apps/machine/electron/nfc-service.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, vi } from 'vitest' +import { extractLnurlw, readNdefLnurlw } from './nfc-service' + +const LNURLW = + 'lnurlw://lnbits.l484.com/boltcards/api/v1/scan/abc123?p=DEADBEEFDEADBEEFDEADBEEFDEADBEEF&c=1122334455667788' + +/** Build a Type-4 NDEF message with a single URI record carrying `uri`. */ +function ndefUriMessage(uri: string): Buffer { + const uriBytes = Buffer.from(uri, 'ascii') + const payload = Buffer.concat([Buffer.from([0x00]), uriBytes]) // 0x00 = no prefix + // D1 = MB|ME|SR, TNF=well-known; type length 1; payload length; 'U' + return Buffer.concat([Buffer.from([0xd1, 0x01, payload.length, 0x55]), payload]) +} + +describe('extractLnurlw', () => { + it('pulls an lnurlw:// URI out of an NDEF record', () => { + expect(extractLnurlw(ndefUriMessage(LNURLW))).toBe(LNURLW) + }) + it('pulls a boltcards https scan URL', () => { + const https = 'https://lnbits.l484.com/boltcards/api/v1/scan/x?p=aa&c=bb' + expect(extractLnurlw(ndefUriMessage(https))).toBe(https) + }) + it('stops at the record boundary (no trailing binary)', () => { + const msg = Buffer.concat([ndefUriMessage(LNURLW), Buffer.from([0x00, 0xfe, 0x01])]) + expect(extractLnurlw(msg)).toBe(LNURLW) + }) + it('returns null when there is no lnurl', () => { + expect(extractLnurlw(Buffer.from('just some text', 'ascii'))).toBeNull() + }) +}) + +describe('readNdefLnurlw', () => { + const SW_OK = Buffer.from([0x90, 0x00]) + + it('runs the Type-4 read sequence and returns the lnurlw', async () => { + const msg = ndefUriMessage(LNURLW) + const nlen = msg.length + const transmit = vi + .fn() + .mockResolvedValueOnce(SW_OK) // select NDEF app + .mockResolvedValueOnce(SW_OK) // select NDEF file + .mockResolvedValueOnce(Buffer.concat([Buffer.from([(nlen >> 8) & 0xff, nlen & 0xff]), SW_OK])) // NLEN + .mockResolvedValueOnce(Buffer.concat([msg, SW_OK])) // NDEF message + + const out = await readNdefLnurlw(transmit) + expect(out).toBe(LNURLW) + // First APDU selects the NDEF application (AID D2760000850101). + expect(Buffer.from((transmit.mock.calls[0][0] as Buffer)).toString('hex')).toContain( + 'd2760000850101' + ) + }) + + it('returns null if selecting the NDEF app fails', async () => { + const transmit = vi.fn().mockResolvedValue(Buffer.from([0x6a, 0x82])) // file not found SW + expect(await readNdefLnurlw(transmit)).toBeNull() + }) + + it('returns null on an empty NDEF file', async () => { + const transmit = vi + .fn() + .mockResolvedValueOnce(SW_OK) + .mockResolvedValueOnce(SW_OK) + .mockResolvedValueOnce(Buffer.concat([Buffer.from([0x00, 0x00]), SW_OK])) // NLEN = 0 + expect(await readNdefLnurlw(transmit)).toBeNull() + }) +}) diff --git a/apps/machine/electron/nfc-service.ts b/apps/machine/electron/nfc-service.ts new file mode 100644 index 0000000..e7e3f00 --- /dev/null +++ b/apps/machine/electron/nfc-service.ts @@ -0,0 +1,157 @@ +/** + * NFC reader driver (main process) for Bolt Card tap-to-pay. + * + * Wraps `nfc-pcsc` (PC/SC via the Feitian KP382 CCID reader). On each card + * tap it reads the NTAG424 Type-4 NDEF file over ISO7816 APDUs and extracts + * the `lnurlw://…?p=…&c=…` voucher (the card computes fresh SUN p/c per tap), + * then hands it to the renderer over IPC. The renderer, when showing a + * cash-out invoice, pays it via LNURL-withdraw (see lnurl-withdraw.ts). + * + * Everything here is best-effort and lazy: `nfc-pcsc` is a native addon, so it + * is dynamically imported and every failure is swallowed into a status + * callback. If the reader/library is absent, NFC is simply unavailable and the + * QR path keeps working — cash-out never depends on this. + */ + +export type NfcState = 'ready' | 'reading' | 'error' | 'card-removed' | 'unavailable' +export interface NfcStatus { + state: NfcState + reader?: string + message?: string +} + +type CardHandler = (lnurlw: string) => void +type StatusHandler = (status: NfcStatus) => void + +function errMsg(e: unknown): string { + return e instanceof Error ? e.message : String(e) +} + +/** Pull the lnurlw (or a boltcards https scan URL) out of a Type-4 NDEF blob. */ +export function extractLnurlw(ndef: Buffer): string | null { + // Robust to record framing: the URI record embeds the literal string; grab + // it directly, bounded to URL-safe characters so we stop at the record end. + const text = ndef.toString('latin1') + const urlChars = "[A-Za-z0-9._~:/?#\\[\\]@!$&'()*+,;=%-]+" + const m = + text.match(new RegExp('lnurlw://' + urlChars, 'i')) || + text.match(new RegExp('https://' + urlChars + '/boltcards/' + urlChars, 'i')) + return m ? m[0] : null +} + +/** + * Read the NDEF file of a Type-4 tag and return the extracted lnurlw, or null. + * `transmit(apdu, maxLen) => Buffer` including the trailing SW1 SW2. + */ +export async function readNdefLnurlw( + transmit: (apdu: Buffer, maxLen: number) => Promise +): Promise { + const send = (bytes: number[]) => transmit(Buffer.from(bytes), 256) + const ok = (r: Buffer) => r.length >= 2 && r[r.length - 2] === 0x90 && r[r.length - 1] === 0x00 + + // 1) Select the NDEF Tag Application (AID D2760000850101). + if (!ok(await send([0x00, 0xa4, 0x04, 0x00, 0x07, 0xd2, 0x76, 0x00, 0x00, 0x85, 0x01, 0x01, 0x00]))) { + return null + } + // 2) Select the NDEF file (EF 0x0004). + if (!ok(await send([0x00, 0xa4, 0x00, 0x0c, 0x02, 0x00, 0x04]))) return null + // 3) Read the 2-byte NLEN header. + const lenResp = await send([0x00, 0xb0, 0x00, 0x00, 0x02]) + if (!ok(lenResp)) return null + const nlen = (lenResp[0] << 8) | lenResp[1] + if (nlen <= 0 || nlen > 0x2000) return null + // 4) Read the NDEF message (starts at offset 2), in <=250-byte chunks. + const chunks: Buffer[] = [] + let offset = 2 + let remaining = nlen + while (remaining > 0) { + const toRead = Math.min(remaining, 0xfa) + const resp = await send([0x00, 0xb0, (offset >> 8) & 0xff, offset & 0xff, toRead]) + if (!ok(resp)) break + const data = resp.subarray(0, resp.length - 2) + if (data.length === 0) break + chunks.push(data) + offset += data.length + remaining -= data.length + } + return extractLnurlw(Buffer.concat(chunks)) +} + +let stopFn: (() => void) | null = null + +/** + * Start listening for Bolt Card taps. Idempotent. Returns a stop function. + * Never throws — failures surface via onStatus. + */ +export async function startNfcReader( + onCard: CardHandler, + onStatus: StatusHandler +): Promise<() => void> { + if (stopFn) return stopFn + + let mod: unknown + try { + // Non-literal specifier: nfc-pcsc ships no types; keep it `any` to tsc + // while resolving normally at runtime. + const pkg = 'nfc-pcsc' + mod = (await import(pkg)) as unknown + } catch (e) { + onStatus({ state: 'unavailable', message: `NFC library unavailable: ${errMsg(e)}` }) + return () => {} + } + const NFC = + (mod as { NFC?: unknown }).NFC ?? (mod as { default?: { NFC?: unknown } }).default?.NFC + if (typeof NFC !== 'function') { + onStatus({ state: 'unavailable', message: 'NFC library has no NFC export' }) + return () => {} + } + + let nfc: { on: (e: string, cb: (...a: unknown[]) => void) => void; close?: () => void } + try { + nfc = new (NFC as new () => typeof nfc)() + } catch (e) { + onStatus({ state: 'unavailable', message: `NFC init failed: ${errMsg(e)}` }) + return () => {} + } + + nfc.on('reader', (reader: unknown) => { + const r = reader as { + name?: string + reader?: { name?: string } + autoProcessing?: boolean + on: (e: string, cb: (...a: unknown[]) => void) => void + transmit: (data: Buffer, maxLen: number) => Promise + } + const name = r.name ?? r.reader?.name ?? 'reader' + // We do our own NDEF APDU read, not nfc-pcsc's UID auto-processing. + r.autoProcessing = false + onStatus({ state: 'ready', reader: name }) + + r.on('card', async () => { + onStatus({ state: 'reading', reader: name }) + try { + const lnurlw = await readNdefLnurlw((apdu, maxLen) => r.transmit(apdu, maxLen)) + if (lnurlw) onCard(lnurlw) + else onStatus({ state: 'error', reader: name, message: 'not a Bolt Card' }) + } catch (e) { + onStatus({ state: 'error', reader: name, message: errMsg(e) }) + } + }) + r.on('card.off', () => onStatus({ state: 'card-removed', reader: name })) + r.on('error', (err: unknown) => + onStatus({ state: 'error', reader: name, message: errMsg(err) }) + ) + r.on('end', () => onStatus({ state: 'unavailable', reader: name, message: 'reader disconnected' })) + }) + nfc.on('error', (err: unknown) => onStatus({ state: 'error', message: errMsg(err) })) + + stopFn = () => { + try { + nfc.close?.() + } catch { + /* idempotent */ + } + stopFn = null + } + return stopFn +} diff --git a/apps/machine/electron/preload.ts b/apps/machine/electron/preload.ts index d65cb80..8be0ed8 100644 --- a/apps/machine/electron/preload.ts +++ b/apps/machine/electron/preload.ts @@ -195,6 +195,20 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.on('hal:error', (_event, error) => callback(error)) }, + // Bolt Card reader (main process → renderer). removeAllListeners first: a + // renderer reload re-runs this, and a duplicated card-tap listener would + // trigger the LNURL-withdraw twice. + onNfcCardTapped: (callback: (lnurlw: string) => void) => { + ipcRenderer.removeAllListeners('nfc:card-tapped') + ipcRenderer.on('nfc:card-tapped', (_event, lnurlw) => callback(lnurlw)) + }, + onNfcStatus: ( + callback: (status: { state: string; reader?: string; message?: string }) => void + ) => { + ipcRenderer.removeAllListeners('nfc:status') + ipcRenderer.on('nfc:status', (_event, status) => callback(status)) + }, + // Watchdog heartbeat (main process → renderer → main process) onWatchdogPing: (callback: () => void) => { ipcRenderer.on('watchdog:ping', () => callback()) @@ -295,6 +309,10 @@ declare global { onHalBillInserted: (callback: (denomination: number) => void) => void onHalBillRejected: (callback: (reason: string) => void) => void onHalError: (callback: (error: string) => void) => void + onNfcCardTapped: (callback: (lnurlw: string) => void) => void + onNfcStatus: ( + callback: (status: { state: string; reader?: string; message?: string }) => void + ) => void onWatchdogPing: (callback: () => void) => void watchdogPong: () => Promise platform: NodeJS.Platform diff --git a/apps/machine/package.json b/apps/machine/package.json index de432cb..7f68db8 100644 --- a/apps/machine/package.json +++ b/apps/machine/package.json @@ -35,6 +35,7 @@ "clsx": "^2.1.1", "lucide-vue-next": "^0.563.0", "marked": "^17.0.5", + "nfc-pcsc": "^0.8.1", "nostr-tools": "^2.10.0", "pinia": "^2.2.0", "qr": "^0.6.0", diff --git a/apps/machine/src/types/electron.d.ts b/apps/machine/src/types/electron.d.ts index b77f850..d391944 100644 --- a/apps/machine/src/types/electron.d.ts +++ b/apps/machine/src/types/electron.d.ts @@ -146,6 +146,12 @@ declare global { onHalBillInserted: (callback: (denomination: number) => void) => void onHalBillRejected: (callback: (reason: string) => void) => void onHalError: (callback: (error: string) => void) => void + /** Bolt Card reader: a tapped card's lnurlw voucher. */ + onNfcCardTapped: (callback: (lnurlw: string) => void) => void + /** Bolt Card reader status (ready / reading / error / unavailable). */ + onNfcStatus: ( + callback: (status: { state: string; reader?: string; message?: string }) => void + ) => void onWatchdogPing: (callback: () => void) => void watchdogPong: () => Promise platform: NodeJS.Platform diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0fe2b33..3f91de1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,9 @@ importers: marked: specifier: ^17.0.5 version: 17.0.5 + nfc-pcsc: + specifier: ^0.8.1 + version: 0.8.1 nostr-tools: specifier: ^2.10.0 version: 2.19.4(typescript@5.9.3) @@ -897,6 +900,9 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@pokusew/pcsclite@0.6.0': + resolution: {integrity: sha512-jX7zRXM2Or5Pms1AFjNtawsXDjLiZOzOUo7Sf0put7Pnq/EKIR9g0KvTx62HtwdPpVP6hWHGydUTHgIi9PxodQ==} + '@rollup/rollup-android-arm-eabi@4.56.0': resolution: {integrity: sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw==} cpu: [arm] @@ -2484,6 +2490,9 @@ packages: muggle-string@0.4.1: resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + nan@2.28.0: + resolution: {integrity: sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -2496,6 +2505,9 @@ packages: resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} engines: {node: '>= 0.6'} + nfc-pcsc@0.8.1: + resolution: {integrity: sha512-wEfacG0dwPVZOG/WY28Mk3P4Q+yz6q7LnjpnZvdFddx3iXavEXiGhftRZXBtudr0NrzH1MrGWSkWq77tef7BMA==} + node-abi@3.87.0: resolution: {integrity: sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==} engines: {node: '>=10'} @@ -2966,7 +2978,7 @@ packages: tar@6.2.1: resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} engines: {node: '>=10'} - deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me temp-file@3.4.0: resolution: {integrity: sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==} @@ -3760,6 +3772,11 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@pokusew/pcsclite@0.6.0': + dependencies: + bindings: 1.5.0 + nan: 2.28.0 + '@rollup/rollup-android-arm-eabi@4.56.0': optional: true @@ -5506,12 +5523,18 @@ snapshots: muggle-string@0.4.1: {} + nan@2.28.0: {} + nanoid@3.3.11: {} napi-build-utils@2.0.0: {} negotiator@0.6.4: {} + nfc-pcsc@0.8.1: + dependencies: + '@pokusew/pcsclite': 0.6.0 + node-abi@3.87.0: dependencies: semver: 7.7.3 From 07978a8de12fba10f9cce2ba96e85700b5c76c35 Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Wed, 5 Aug 2026 04:45:51 +0200 Subject: [PATCH 4/9] feat(machine): wire Bolt Card tap into cash-out displayingInvoice + UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renderer side of tap-to-pay. The store subscribes to the main-process reader (onNfcCardTapped/onNfcStatus); a tap during displayingInvoice pulls payment for the shown invoice via lnurlWithdraw (amount in msats), guarded against double-taps. Settlement still flows through the existing invoice watcher → PAYMENT_RECEIVED → dispensingCash, so the state machine is unchanged. Bolt Card state clears when leaving the invoice screen. CashOutView: "Tap Card or Scan to Pay" + live reader/processing/declined status on the invoice screen, plus a dev input to simulate a tap with a pasted lnurlw. Exposes nfcStatus / boltCardProcessing / simulateBoltCardTap. Co-Authored-By: Claude Opus 4.8 --- apps/machine/src/stores/atm.ts | 69 ++++++++++++++++++++++++++ apps/machine/src/views/CashOutView.vue | 52 +++++++++++++++++-- 2 files changed, 116 insertions(+), 5 deletions(-) diff --git a/apps/machine/src/stores/atm.ts b/apps/machine/src/stores/atm.ts index 7119500..06dea22 100644 --- a/apps/machine/src/stores/atm.ts +++ b/apps/machine/src/stores/atm.ts @@ -291,6 +291,11 @@ export const useAtmStore = defineStore('atm', () => { const debugMode = ref(true) const allowMockFallback = ref(true) // default true for browser dev const initError = ref(null) // fatal error → maintenance screen + // Bolt Card cash-out (NFC tap-to-pay). nfcStatus surfaces reader state on the + // invoice screen; boltCardProcessing gates against double-taps while a pull + // is in flight (settlement still arrives via the normal invoice watcher). + const nfcStatus = ref<{ state: string; message?: string } | null>(null) + const boltCardProcessing = ref(false) const fiatCode = ref('USD') // Defaults are 0 — the operator's fee config (received via Nostr // kind-30078 `bitspire-fees:` envelope from satmachineadmin) @@ -562,14 +567,73 @@ export const useAtmStore = defineStore('atm', () => { } } + // Clear Bolt Card state whenever we leave the invoice screen (dispensed, + // timed out, or cancelled) so a stale "processing"/error can't linger. + if (currentNested !== 'displayingInvoice' && prevNestedState === 'displayingInvoice') { + boltCardProcessing.value = false + nfcStatus.value = null + } + prevNestedState = currentNested }) // Start the machine actor.value.start() + setupNfcListener() console.log('[ATM] State machine initialized') } + // ── Bolt Card cash-out (NFC tap-to-pay) ─────────────────────────────────── + + /** + * A tapped Bolt Card during the cash-out invoice screen: pull payment for + * the shown invoice via LNURL-withdraw (main process). Settlement still + * arrives through the invoice watcher → PAYMENT_RECEIVED → dispensingCash; + * ok here only means the card accepted the pull. + */ + async function handleBoltCardTap(lnurlw: string) { + if (nestedState.value !== 'displayingInvoice') return + const invoice = context.value?.invoice + if (!invoice) return + if (boltCardProcessing.value) return // one pull at a time + boltCardProcessing.value = true + 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 }) + if (res.ok) { + nfcStatus.value = { state: 'accepted', message: 'Card accepted — confirming payment…' } + } else { + boltCardProcessing.value = false + nfcStatus.value = { state: 'declined', message: res.reason ?? 'Card declined' } + } + } catch (e) { + console.warn('[ATM] Bolt Card withdraw failed:', e) + boltCardProcessing.value = false + nfcStatus.value = { state: 'error', message: 'Card payment failed' } + } + } + + /** Wire the main-process reader once (idempotent via preload removeAllListeners). */ + function setupNfcListener() { + if (!isElectron || !window.electronAPI?.onNfcCardTapped) return + window.electronAPI.onNfcCardTapped((lnurlw) => { + void handleBoltCardTap(lnurlw) + }) + window.electronAPI.onNfcStatus?.((status) => { + // Only surface reader status on the invoice screen, and don't clobber an + // in-flight pull's message. + if (nestedState.value === 'displayingInvoice' && !boltCardProcessing.value) { + nfcStatus.value = status + } + }) + } + + /** Dev/mock: simulate a tap with a pasted lnurlw (test without a card). */ + function simulateBoltCardTap(lnurlw: string) { + void handleBoltCardTap(lnurlw) + } + /** * Group an array of inserted bill denominations into { denomination, count } pairs. */ @@ -1522,6 +1586,11 @@ export const useAtmStore = defineStore('atm', () => { isCashOut, nestedState, + // Bolt Card cash-out (NFC) + nfcStatus, + boltCardProcessing, + simulateBoltCardTap, + // Actions initialize, initializeWithLightning, diff --git a/apps/machine/src/views/CashOutView.vue b/apps/machine/src/views/CashOutView.vue index 4b4a4d2..6257882 100644 --- a/apps/machine/src/views/CashOutView.vue +++ b/apps/machine/src/views/CashOutView.vue @@ -15,6 +15,10 @@ const isElectron = typeof window !== 'undefined' && window.electronAPI !== undef const cashOutSteps = ['Select', 'Pay', 'Collect'] +// Dev-only: paste a real card's lnurlw to exercise the Bolt Card pull without +// the reader (single-use, so a live tap each time). +const mockLnurlw = ref('') + const currentStepIndex = computed(() => { switch (nestedState.value) { case 'fetchingRate': @@ -284,7 +288,9 @@ function formatFiat(cents: number): string {
-

Scan to Pay

+

+ {{ isElectron ? 'Tap Card or Scan to Pay' : 'Scan to Pay' }} +

{{ context ? formatSats(context.satsAmount) : 0 }} sats

@@ -295,10 +301,31 @@ function formatFiat(cents: number): string {

- -
- -

Waiting for payment...

+ +
+
+ +

+ {{ + atmStore.boltCardProcessing + ? 'Processing card…' + : isElectron + ? 'Tap your card or scan the QR' + : 'Waiting for payment...' + }} +

+
+

+ {{ atmStore.nfcStatus.message }} +

@@ -322,6 +349,21 @@ function formatFiat(cents: number): string { > Simulate Payment +
+ + +
From 51dcf0d6f639163798ab0dc2e0bd1fce9bc95cfa Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Wed, 5 Aug 2026 04:55:30 +0200 Subject: [PATCH 5/9] feat(deploy): build nfc-pcsc's native pcsclite addon for Electron (mkAtmApp) Rebuild @pokusew/pcsclite (V8 C++ addon) against Electron headers like better-sqlite3, and package nfc-pcsc + @pokusew/pcsclite into the runtime node_modules. Its binding.gyp hardcodes Debian /usr/include/PCSC + /usr/lib, so point the compiler/linker at nixpkgs pcsclite via CPATH/LIBRARY_PATH (winscard.h lives under include/PCSC); pcsclite.lib in buildInputs lets autoPatchelf wire libpcsclite.so.1 into the .node RPATH. Bumps the pnpmDeps hash for the added nfc-pcsc dependency. Co-Authored-By: Claude Opus 4.8 --- nix/mkAtmApp.nix | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/nix/mkAtmApp.nix b/nix/mkAtmApp.nix index 630faf3..68a4c37 100644 --- a/nix/mkAtmApp.nix +++ b/nix/mkAtmApp.nix @@ -38,7 +38,7 @@ pkgs.stdenv.mkDerivation (finalAttrs: { inherit (finalAttrs) pname version src pnpmWorkspaces; inherit pnpm; fetcherVersion = 3; - hash = "sha256-03ANBQ7bHJwsqlX2ScA1+1LFuO8njiuU7O4VGOb6cMM="; + hash = "sha256-XqpQpFL3PqnFltb4ujAmmnnV0LOqTHKV/bo3riFu9pY="; }; nativeBuildInputs = [ @@ -57,6 +57,11 @@ pkgs.stdenv.mkDerivation (finalAttrs: { pkgs.sqlite.dev # better-sqlite3 pkgs.libudev-zero # serialport pkgs.stdenv.cc.cc.lib # libstdc++ + # @pokusew/pcsclite (nfc-pcsc): the `lib` output carries libpcsclite.so so + # autoPatchelf wires it into the .node RPATH at runtime. Compile/link paths + # are injected via CPATH/LIBRARY_PATH in buildPhase (its binding.gyp + # hardcodes Debian /usr paths instead of using pkg-config). + pkgs.pcsclite.lib ]; env = { @@ -83,6 +88,19 @@ pkgs.stdenv.mkDerivation (finalAttrs: { --arch=x64 popd + # @pokusew/pcsclite (nfc-pcsc's native addon) — also V8 C++ API, so it too + # must be rebuilt against Electron's headers. Its binding.gyp hardcodes + # /usr/include/PCSC + /usr/lib, so point the compiler/linker at nixpkgs' + # pcsclite explicitly (winscard.h lives under include/PCSC). + echo "=== Rebuilding @pokusew/pcsclite against Electron ${electron.version} headers ===" + pushd node_modules/.pnpm/@pokusew+pcsclite@*/node_modules/@pokusew/pcsclite + CPATH="${pkgs.pcsclite.dev}/include/PCSC''${CPATH:+:$CPATH}" \ + LIBRARY_PATH="${pkgs.pcsclite.lib}/lib''${LIBRARY_PATH:+:$LIBRARY_PATH}" \ + HOME=$TMPDIR ${nodejs}/bin/npx --yes node-gyp rebuild \ + --nodedir="$electron_nodedir" \ + --arch=x64 + popd + # Build the Electron app (turbo builds all workspace deps + app) pnpm --filter="@bitSpire/machine..." build @@ -95,7 +113,7 @@ pkgs.stdenv.mkDerivation (finalAttrs: { installPhase = '' runHook preInstall - mkdir -p $out/node_modules/{@lamassu,@serialport} + mkdir -p $out/node_modules/{@lamassu,@serialport,@pokusew} # Helper: find a package dir inside the pnpm virtual store. # pnpm store dirs look like: node_modules/.pnpm/@[_]/node_modules/ @@ -132,6 +150,12 @@ pkgs.stdenv.mkDerivation (finalAttrs: { copy_pnpm_pkg bindings $out/node_modules/bindings copy_pnpm_pkg file-uri-to-path $out/node_modules/file-uri-to-path + # nfc-pcsc + @pokusew/pcsclite (Bolt Card reader). The compiled + # pcsclite.node (from the rebuild above) rides along in the package dir and + # loads via `bindings` (already copied). autoPatchelf wires libpcsclite. + copy_pnpm_pkg nfc-pcsc $out/node_modules/nfc-pcsc + copy_pnpm_pkg @pokusew/pcsclite $out/node_modules/@pokusew/pcsclite + # @bitSpire/hal (workspace package, dynamically imported for hardware access) mkdir -p $out/node_modules/@bitSpire/hal/dist cp -rL packages/hal/dist/* $out/node_modules/@bitSpire/hal/dist/ From 0a3156855cf2a3f395343d93b9c33715a707fd7b Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Wed, 5 Aug 2026 05:01:08 +0200 Subject: [PATCH 6/9] feat(deploy): authorize bitspire for pcscd (polkit) + NFC diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pcscd gates clients via polkit; the sandboxed bitspire user was "Rejected unauthorized PC/SC client", so add a polkit rule granting it access_pcsc/access_card. Also log NFC reader status + taps from the main process to journald (value redacted — it carries the card's SUN p/c) so reader detection and taps are observable during testing. Co-Authored-By: Claude Opus 4.8 --- apps/machine/electron/main.ts | 13 +++++++++++-- deploy/nixos/hardware/batm3.nix | 13 +++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/apps/machine/electron/main.ts b/apps/machine/electron/main.ts index ac15f6e..81676cf 100644 --- a/apps/machine/electron/main.ts +++ b/apps/machine/electron/main.ts @@ -833,8 +833,17 @@ app.whenReady().then(() => { // best-effort: if the reader/pcscd is absent it just reports 'unavailable' // and the cash-out QR path is unaffected. void startNfcReader( - (lnurlw) => mainWindow?.webContents.send('nfc:card-tapped', lnurlw), - (status) => mainWindow?.webContents.send('nfc:status', status) + (lnurlw) => { + // Don't log the value — it carries the card's single-use SUN p/c. + console.log(`[NFC] card tapped — lnurlw (${lnurlw.length} chars) → renderer`) + mainWindow?.webContents.send('nfc:card-tapped', lnurlw) + }, + (status: NfcStatus) => { + console.log( + `[NFC] status=${status.state}${status.reader ? ` reader="${status.reader}"` : ''}${status.message ? ` — ${status.message}` : ''}` + ) + mainWindow?.webContents.send('nfc:status', status) + } ) app.on('activate', () => { diff --git a/deploy/nixos/hardware/batm3.nix b/deploy/nixos/hardware/batm3.nix index a03c830..40983c9 100644 --- a/deploy/nixos/hardware/batm3.nix +++ b/deploy/nixos/hardware/batm3.nix @@ -92,6 +92,19 @@ # attached — pcscd just idles. services.pcscd.enable = true; + # pcscd gates client access via polkit; without a rule the sandboxed + # `bitspire` service user is "Rejected unauthorized PC/SC client". Authorize + # it to talk to the daemon and the card. + security.polkit.extraConfig = '' + polkit.addRule(function(action, subject) { + if ((action.id == "org.debian.pcsc-lite.access_pcsc" || + action.id == "org.debian.pcsc-lite.access_card") && + subject.user == "bitspire") { + return polkit.Result.YES; + } + }); + ''; + # Disable suspend/hibernate for kiosk systemd.targets = { sleep.enable = false; From ea736dfab06365f777597e952256f58944d6a820 Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Wed, 5 Aug 2026 20:02:26 +0200 Subject: [PATCH 7/9] fix(machine): read NTAG424 NDEF via Capability Container (Bolt Card FileID) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First real-card tap read the NDEF file with id 0004 and got "not a Bolt Card" — NTAG424 (Bolt Cards) use FileID E104. Read the Capability Container (EF E103) after selecting the NDEF app to learn the advertised NDEF FileID, then read that file; fall back to E104/0004. Tolerates a transient transmit error (surfaced as a retryable status; the next tap re-reads). Co-Authored-By: Claude Opus 4.8 --- apps/machine/electron/nfc-service.test.ts | 50 +++++++++-------- apps/machine/electron/nfc-service.ts | 66 ++++++++++++++++------- 2 files changed, 76 insertions(+), 40 deletions(-) diff --git a/apps/machine/electron/nfc-service.test.ts b/apps/machine/electron/nfc-service.test.ts index 3023d36..f694758 100644 --- a/apps/machine/electron/nfc-service.test.ts +++ b/apps/machine/electron/nfc-service.test.ts @@ -31,36 +31,44 @@ describe('extractLnurlw', () => { describe('readNdefLnurlw', () => { const SW_OK = Buffer.from([0x90, 0x00]) + const SW_NOTFOUND = Buffer.from([0x6a, 0x82]) + // Capability Container advertising the NDEF file id E104 (TLV 04 06 at [7,8]). + const CC = Buffer.from([ + 0x00, 0x0f, 0x20, 0x00, 0x3b, 0x00, 0x34, 0x04, 0x06, 0xe1, 0x04, 0x00, 0xff, 0x00, 0xff, + ]) - it('runs the Type-4 read sequence and returns the lnurlw', async () => { - const msg = ndefUriMessage(LNURLW) + /** Route APDUs by content so the CC-read + fallback loop is exercised. */ + function cardMock(opts: { noApp?: boolean; nlen0?: boolean; uri?: string } = {}) { + const msg = ndefUriMessage(opts.uri ?? LNURLW) const nlen = msg.length - const transmit = vi - .fn() - .mockResolvedValueOnce(SW_OK) // select NDEF app - .mockResolvedValueOnce(SW_OK) // select NDEF file - .mockResolvedValueOnce(Buffer.concat([Buffer.from([(nlen >> 8) & 0xff, nlen & 0xff]), SW_OK])) // NLEN - .mockResolvedValueOnce(Buffer.concat([msg, SW_OK])) // NDEF message + return vi.fn(async (apdu: Buffer) => { + const hex = apdu.toString('hex') + if (hex.includes('d2760000850101')) return opts.noApp ? SW_NOTFOUND : SW_OK // select app + if (hex.startsWith('00a4000c02e103')) return SW_OK // select CC + if (hex.startsWith('00b000000f')) return Buffer.concat([CC, SW_OK]) // read CC + if (hex.startsWith('00a4000c02e104')) return SW_OK // select NDEF file (E104) + if (hex.startsWith('00a4000c020004')) return SW_NOTFOUND // fallback file id: absent + if (hex.startsWith('00b0000002')) + return opts.nlen0 + ? Buffer.concat([Buffer.from([0x00, 0x00]), SW_OK]) + : Buffer.concat([Buffer.from([(nlen >> 8) & 0xff, nlen & 0xff]), SW_OK]) // NLEN + if (hex.startsWith('00b0')) return Buffer.concat([msg, SW_OK]) // read message + return SW_NOTFOUND + }) + } - const out = await readNdefLnurlw(transmit) - expect(out).toBe(LNURLW) + it('reads CC → NDEF file (E104) and returns the lnurlw', async () => { + const transmit = cardMock() + expect(await readNdefLnurlw(transmit)).toBe(LNURLW) // First APDU selects the NDEF application (AID D2760000850101). - expect(Buffer.from((transmit.mock.calls[0][0] as Buffer)).toString('hex')).toContain( - 'd2760000850101' - ) + expect((transmit.mock.calls[0][0] as Buffer).toString('hex')).toContain('d2760000850101') }) it('returns null if selecting the NDEF app fails', async () => { - const transmit = vi.fn().mockResolvedValue(Buffer.from([0x6a, 0x82])) // file not found SW - expect(await readNdefLnurlw(transmit)).toBeNull() + expect(await readNdefLnurlw(cardMock({ noApp: true }))).toBeNull() }) it('returns null on an empty NDEF file', async () => { - const transmit = vi - .fn() - .mockResolvedValueOnce(SW_OK) - .mockResolvedValueOnce(SW_OK) - .mockResolvedValueOnce(Buffer.concat([Buffer.from([0x00, 0x00]), SW_OK])) // NLEN = 0 - expect(await readNdefLnurlw(transmit)).toBeNull() + expect(await readNdefLnurlw(cardMock({ nlen0: true }))).toBeNull() }) }) diff --git a/apps/machine/electron/nfc-service.ts b/apps/machine/electron/nfc-service.ts index e7e3f00..ecafc07 100644 --- a/apps/machine/electron/nfc-service.ts +++ b/apps/machine/electron/nfc-service.ts @@ -39,35 +39,27 @@ export function extractLnurlw(ndef: Buffer): string | null { return m ? m[0] : null } -/** - * Read the NDEF file of a Type-4 tag and return the extracted lnurlw, or null. - * `transmit(apdu, maxLen) => Buffer` including the trailing SW1 SW2. - */ -export async function readNdefLnurlw( - transmit: (apdu: Buffer, maxLen: number) => Promise -): Promise { - const send = (bytes: number[]) => transmit(Buffer.from(bytes), 256) - const ok = (r: Buffer) => r.length >= 2 && r[r.length - 2] === 0x90 && r[r.length - 1] === 0x00 +const swOk = (r: Buffer) => r.length >= 2 && r[r.length - 2] === 0x90 && r[r.length - 1] === 0x00 - // 1) Select the NDEF Tag Application (AID D2760000850101). - if (!ok(await send([0x00, 0xa4, 0x04, 0x00, 0x07, 0xd2, 0x76, 0x00, 0x00, 0x85, 0x01, 0x01, 0x00]))) { - return null - } - // 2) Select the NDEF file (EF 0x0004). - if (!ok(await send([0x00, 0xa4, 0x00, 0x0c, 0x02, 0x00, 0x04]))) return null - // 3) Read the 2-byte NLEN header. +/** Select an EF by its 2-byte file id and read + parse its NDEF message. */ +async function readNdefFile( + send: (bytes: number[]) => Promise, + fid: [number, number] +): Promise { + if (!swOk(await send([0x00, 0xa4, 0x00, 0x0c, 0x02, fid[0], fid[1]]))) return null + // 2-byte NLEN header at offset 0. const lenResp = await send([0x00, 0xb0, 0x00, 0x00, 0x02]) - if (!ok(lenResp)) return null + if (!swOk(lenResp)) return null const nlen = (lenResp[0] << 8) | lenResp[1] if (nlen <= 0 || nlen > 0x2000) return null - // 4) Read the NDEF message (starts at offset 2), in <=250-byte chunks. + // NDEF message starts at offset 2; read in <=250-byte chunks. const chunks: Buffer[] = [] let offset = 2 let remaining = nlen while (remaining > 0) { const toRead = Math.min(remaining, 0xfa) const resp = await send([0x00, 0xb0, (offset >> 8) & 0xff, offset & 0xff, toRead]) - if (!ok(resp)) break + if (!swOk(resp)) break const data = resp.subarray(0, resp.length - 2) if (data.length === 0) break chunks.push(data) @@ -77,6 +69,42 @@ export async function readNdefLnurlw( return extractLnurlw(Buffer.concat(chunks)) } +/** + * Read the NDEF of a Type-4 tag and return the extracted lnurlw, or null. + * `transmit(apdu, maxLen) => Buffer` including the trailing SW1 SW2. + * + * Select the NDEF Tag Application, read the Capability Container to learn the + * real NDEF FileID (NTAG424 Bolt Cards use E104, not the 0004 some tags use), + * then read that file. Falls back to E104/0004 if the CC read is unavailable. + */ +export async function readNdefLnurlw( + transmit: (apdu: Buffer, maxLen: number) => Promise +): Promise { + const send = (bytes: number[]) => transmit(Buffer.from(bytes), 256) + + // Select the NDEF Tag Application (AID D2760000850101). + if (!swOk(await send([0x00, 0xa4, 0x04, 0x00, 0x07, 0xd2, 0x76, 0x00, 0x00, 0x85, 0x01, 0x01, 0x00]))) { + return null + } + + // Prefer the FileID advertised by the Capability Container (EF E103). + const candidates: Array<[number, number]> = [] + if (swOk(await send([0x00, 0xa4, 0x00, 0x0c, 0x02, 0xe1, 0x03]))) { + const cc = await send([0x00, 0xb0, 0x00, 0x00, 0x0f]) + // CC layout: …[07]=TLV tag 0x04, [08]=len, [09..10]=NDEF FileID. + if (swOk(cc) && cc.length >= 13 && cc[7] === 0x04) candidates.push([cc[9], cc[10]]) + } + for (const fid of [[0xe1, 0x04] as [number, number], [0x00, 0x04] as [number, number]]) { + if (!candidates.some((c) => c[0] === fid[0] && c[1] === fid[1])) candidates.push(fid) + } + + for (const fid of candidates) { + const found = await readNdefFile(send, fid) + if (found) return found + } + return null +} + let stopFn: (() => void) | null = null /** From ed04c63b2fed6d973f4b5073e11757105524267c Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Wed, 5 Aug 2026 20:16:50 +0200 Subject: [PATCH 8/9] perf(machine): fewer NFC APDUs (E104-first) + single-attempt read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retrying a read hammered the cheap CCID reader into a stuck present↔empty loop (only cleared by a reboot), so drop the retry: a read is a single attempt and the user re-taps if the RF link drops mid-read. Also skip the Capability-Container round-trip in the common case — NTAG424 Bolt Cards use NDEF FileID E104, so try E104/0004 directly and only read the CC to discover the id if both fail. Fewer APDUs → a read completes inside a shorter stable window. Co-Authored-By: Claude Opus 4.8 --- apps/machine/electron/nfc-service.ts | 35 +++++++++++++++++----------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/apps/machine/electron/nfc-service.ts b/apps/machine/electron/nfc-service.ts index ecafc07..970d17d 100644 --- a/apps/machine/electron/nfc-service.ts +++ b/apps/machine/electron/nfc-service.ts @@ -87,20 +87,20 @@ export async function readNdefLnurlw( return null } - // Prefer the FileID advertised by the Capability Container (EF E103). - const candidates: Array<[number, number]> = [] + // NTAG424 Bolt Cards use NDEF FileID E104. Try it (and 0004) directly to + // minimise APDU round-trips over a flaky RF link; only fall back to reading + // the Capability Container to discover the id if both direct reads fail. + for (const fid of [[0xe1, 0x04] as [number, number], [0x00, 0x04] as [number, number]]) { + const found = await readNdefFile(send, fid) + if (found) return found + } if (swOk(await send([0x00, 0xa4, 0x00, 0x0c, 0x02, 0xe1, 0x03]))) { const cc = await send([0x00, 0xb0, 0x00, 0x00, 0x0f]) // CC layout: …[07]=TLV tag 0x04, [08]=len, [09..10]=NDEF FileID. - if (swOk(cc) && cc.length >= 13 && cc[7] === 0x04) candidates.push([cc[9], cc[10]]) - } - for (const fid of [[0xe1, 0x04] as [number, number], [0x00, 0x04] as [number, number]]) { - if (!candidates.some((c) => c[0] === fid[0] && c[1] === fid[1])) candidates.push(fid) - } - - for (const fid of candidates) { - const found = await readNdefFile(send, fid) - if (found) return found + if (swOk(cc) && cc.length >= 13 && cc[7] === 0x04) { + const found = await readNdefFile(send, [cc[9], cc[10]]) + if (found) return found + } } return null } @@ -157,12 +157,19 @@ export async function startNfcReader( r.on('card', async () => { onStatus({ state: 'reading', reader: name }) + // Single attempt: hammering a flaky RF link with retries wedges these + // cheap CCID readers. A read is a few APDU round-trips; if the card + // shifts mid-read the transmit fails and the user simply re-taps. try { const lnurlw = await readNdefLnurlw((apdu, maxLen) => r.transmit(apdu, maxLen)) - if (lnurlw) onCard(lnurlw) - else onStatus({ state: 'error', reader: name, message: 'not a Bolt Card' }) + if (lnurlw) { + onCard(lnurlw) + } else { + onStatus({ state: 'error', reader: name, message: 'not a Bolt Card' }) + } } catch (e) { - onStatus({ state: 'error', reader: name, message: errMsg(e) }) + onStatus({ state: 'error', reader: name, message: 'card read failed — hold steady & retap' }) + void e } }) r.on('card.off', () => onStatus({ state: 'card-removed', reader: name })) From 73376a6c68b79bdd7ba57ea19a1b399c159dcad2 Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Wed, 5 Aug 2026 20:46:18 +0200 Subject: [PATCH 9/9] fix(machine): cooldown after failed NFC read to prevent reader wedge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hammering a flaky CCID reader with rapid re-reads wedges it into a present↔empty storm (only a USB replug clears it). After a failed read, ignore card re-detections for 1.5s; successful reads don't cool down. Co-Authored-By: Claude Opus 4.8 --- apps/machine/electron/nfc-service.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/apps/machine/electron/nfc-service.ts b/apps/machine/electron/nfc-service.ts index 970d17d..ca9ace1 100644 --- a/apps/machine/electron/nfc-service.ts +++ b/apps/machine/electron/nfc-service.ts @@ -155,22 +155,28 @@ export async function startNfcReader( r.autoProcessing = false onStatus({ state: 'ready', reader: name }) + // Cooldown after a failed read: these cheap CCID readers can get wedged into + // a present↔empty storm when hammered, so ignore re-detections for a beat + // after a failure. Successful reads don't cool down. + let cooldownUntil = 0 r.on('card', async () => { + if (Date.now() < cooldownUntil) return onStatus({ state: 'reading', reader: name }) - // Single attempt: hammering a flaky RF link with retries wedges these - // cheap CCID readers. A read is a few APDU round-trips; if the card - // shifts mid-read the transmit fails and the user simply re-taps. + // Single attempt: retrying hammers a flaky RF link. A read is a few APDU + // round-trips; if the card shifts mid-read the transmit fails and the + // user simply re-taps. try { const lnurlw = await readNdefLnurlw((apdu, maxLen) => r.transmit(apdu, maxLen)) if (lnurlw) { onCard(lnurlw) - } else { - onStatus({ state: 'error', reader: name, message: 'not a Bolt Card' }) + return } + onStatus({ state: 'error', reader: name, message: 'not a Bolt Card' }) } catch (e) { onStatus({ state: 'error', reader: name, message: 'card read failed — hold steady & retap' }) void e } + cooldownUntil = Date.now() + 1500 }) r.on('card.off', () => onStatus({ state: 'card-removed', reader: name })) r.on('error', (err: unknown) =>