From 457761719f034c151b4dca6372d7e98f2792c70b Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Wed, 5 Aug 2026 04:24:00 +0200 Subject: [PATCH] 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