From 64582e7fe65a1775abdca4b15e84bcfa9fb15526 Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Thu, 6 Aug 2026 00:30:13 +0200 Subject: [PATCH] feat(machine): Bolt Card (NFC) tap-to-receive on cash-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tap-to-receive for the buy flow, the receive counterpart to #83's cash-out tap-to-pay. A Bolt Card only emits an lnurlw withdraw voucher (wrong direction to deposit into it), so the tap is used as an authenticated identity (external_id + SUN p/c) to resolve the card wallet's lnurlp/Lightning Address; the ATM then pays an invoice for the payout over its existing nostr transport. - electron/lnurl-pay.ts: resolveCardInvoice() — resolve card -> pay target -> LUD-16/LUD-06 -> BOLT11. scanUrlToResolver() is the single HTTPS-today / nostr-tomorrow transport seam. 13 tests. - IPC lnurl:pay-card (main-process HTTPS to dodge renderer CORS) + preload/electron.d.ts surface. - stores/atm.ts: handleBoltCardReceive() settles via the existing payInvoice -> PAYMENT_RECEIVED path; the one NFC listener now routes the same tap by flow (cash-out pulls, cash-in receives). - CashInView.vue: NFC status + dev tap input. - docs/boltcard-receive-resolver.md: spec for the custom LNbits /boltcards/api/v1/pay/ resolver endpoint (omni-private side). Card issuance is unchanged — same NDEF/keys/external_id; receive is a server-side reading of the same tap. Co-Authored-By: Claude Opus 4.8 --- apps/machine/electron/lnurl-pay.test.ts | 120 +++++++++++++ apps/machine/electron/lnurl-pay.ts | 226 ++++++++++++++++++++++++ apps/machine/electron/main.ts | 23 ++- apps/machine/electron/preload.ts | 11 ++ apps/machine/src/stores/atm.ts | 86 +++++++-- apps/machine/src/types/electron.d.ts | 5 + apps/machine/src/views/CashInView.vue | 55 +++++- docs/boltcard-receive-resolver.md | 110 ++++++++++++ 8 files changed, 605 insertions(+), 31 deletions(-) create mode 100644 apps/machine/electron/lnurl-pay.test.ts create mode 100644 apps/machine/electron/lnurl-pay.ts create mode 100644 docs/boltcard-receive-resolver.md diff --git a/apps/machine/electron/lnurl-pay.test.ts b/apps/machine/electron/lnurl-pay.test.ts new file mode 100644 index 0000000..165688f --- /dev/null +++ b/apps/machine/electron/lnurl-pay.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, vi } from 'vitest' +import { resolveCardInvoice, scanUrlToResolver, lnAddressToLnurlp } from './lnurl-pay' + +const LNURLW = + 'lnurlw://lnbits.l484.com/boltcards/api/v1/scan/abc123?p=DEADBEEFDEADBEEFDEADBEEFDEADBEEF&c=1122334455667788' +const BOLT11 = 'lnbc10u1p3xyz...' + +/** 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('scanUrlToResolver', () => { + it('rewrites /scan/ to /pay/ and preserves p + c', () => { + const r = scanUrlToResolver(LNURLW) + expect(r).toContain('https://lnbits.l484.com/boltcards/api/v1/pay/abc123') + expect(r).toContain('p=DEADBEEFDEADBEEFDEADBEEFDEADBEEF') + expect(r).toContain('c=1122334455667788') + }) + it('returns null for a non-scan URL', () => { + expect(scanUrlToResolver('lnurlw://host/somethingelse?p=1&c=2')).toBeNull() + expect(scanUrlToResolver('http://host/boltcards/api/v1/scan/x')).toBeNull() + }) +}) + +describe('lnAddressToLnurlp', () => { + it('maps name@host to the well-known lnurlp URL', () => { + expect(lnAddressToLnurlp('cardname@l484.com')).toBe( + 'https://l484.com/.well-known/lnurlp/cardname' + ) + }) + it('rejects non-addresses', () => { + expect(lnAddressToLnurlp('not-an-address')).toBeNull() + expect(lnAddressToLnurlp('')).toBeNull() + }) +}) + +describe('resolveCardInvoice', () => { + const payReq = { + tag: 'payRequest', + callback: 'https://lnbits.l484.com/lnurlp/api/v1/lnurl/cb', + minSendable: 1000, + maxSendable: 100_000_000, + metadata: '[["text/plain","bolt card top-up"]]', + } + + it('resolver returns a payRequest inline → fetches the invoice', async () => { + const { impl, calls } = mockFetch([payReq, { pr: BOLT11 }]) + const res = await resolveCardInvoice(LNURLW, 21_000, { fetchImpl: impl }) + expect(res).toEqual({ ok: true, bolt11: BOLT11 }) + // 1st call = the /pay resolver; 2nd = the callback with amount in msat. + expect(calls[0]).toContain('/boltcards/api/v1/pay/abc123') + expect(calls[1]).toContain('amount=21000') + }) + + it('resolver returns a Lightning Address → LUD-16 → invoice', async () => { + const { impl, calls } = mockFetch([ + { lightningAddress: 'cardname@l484.com' }, + payReq, + { pr: BOLT11 }, + ]) + const res = await resolveCardInvoice(LNURLW, 21_000, { fetchImpl: impl }) + expect(res).toEqual({ ok: true, bolt11: BOLT11 }) + expect(calls[1]).toBe('https://l484.com/.well-known/lnurlp/cardname') + expect(calls[2]).toContain('amount=21000') + }) + + it('rejects a non-lnurlw tag', async () => { + const { impl } = mockFetch([]) + const res = await resolveCardInvoice('http://nope', 21_000, { fetchImpl: impl }) + expect(res).toMatchObject({ ok: false }) + expect(res.reason).toMatch(/not a valid Bolt Card/i) + }) + + it('rejects a zero amount', async () => { + const { impl } = mockFetch([]) + const res = await resolveCardInvoice(LNURLW, 0, { fetchImpl: impl }) + expect(res).toMatchObject({ ok: false, reason: 'no amount to send' }) + }) + + it('surfaces an ERROR from the resolver (bad SUN)', async () => { + const { impl } = mockFetch([{ status: 'ERROR', reason: 'invalid card' }]) + const res = await resolveCardInvoice(LNURLW, 21_000, { fetchImpl: impl }) + expect(res).toMatchObject({ ok: false, reason: 'invalid card' }) + }) + + it('rejects (without calling the callback) when the amount exceeds maxSendable', async () => { + const { impl, calls } = mockFetch([{ ...payReq, maxSendable: 5000 }]) + const res = await resolveCardInvoice(LNURLW, 21_000, { fetchImpl: impl }) + expect(res).toMatchObject({ ok: false, reason: 'amount is above the card wallet maximum' }) + expect(calls).toHaveLength(1) // callback never hit + }) + + it('surfaces an ERROR from the pay callback', async () => { + const { impl } = mockFetch([payReq, { status: 'ERROR', reason: 'wallet frozen' }]) + const res = await resolveCardInvoice(LNURLW, 21_000, { fetchImpl: impl }) + expect(res).toMatchObject({ ok: false, reason: 'wallet frozen' }) + }) + + it('rejects when the card wallet has no receive address', async () => { + const { impl } = mockFetch([{ foo: 'bar' }]) + const res = await resolveCardInvoice(LNURLW, 21_000, { fetchImpl: impl }) + expect(res).toMatchObject({ ok: false, reason: 'card wallet has no receive address' }) + }) + + it('handles a network failure gracefully', async () => { + const impl = vi.fn(async () => { + throw new Error('ECONNREFUSED') + }) as unknown as typeof fetch + const res = await resolveCardInvoice(LNURLW, 21_000, { fetchImpl: impl }) + expect(res.ok).toBe(false) + expect(res.reason).toMatch(/could not reach the card/i) + }) +}) diff --git a/apps/machine/electron/lnurl-pay.ts b/apps/machine/electron/lnurl-pay.ts new file mode 100644 index 0000000..91da90c --- /dev/null +++ b/apps/machine/electron/lnurl-pay.ts @@ -0,0 +1,226 @@ +/** + * LNURL-pay resolver (LUD-06 / LUD-16) — the ATM as the *paying* party. + * + * Bolt Card tap-to-RECEIVE for the cash-in (buy) flow. A Bolt Card only ever + * emits its `lnurlw://…?p=…&c=…` voucher — a *withdraw* (spend) credential — so + * we can't push sats into it directly. Instead the tap is used as an + * authenticated identity (external_id + SUN p/c) to look up the card wallet's + * *pay* target, then the ATM fetches an invoice for the payout amount: + * 1. resolveCardPayTarget — GET the boltcards `/pay/?p=&c=` resolver + * (a sibling of `/scan`); it verifies the same SUN and returns the card + * wallet's Lightning Address / lnurlp (or a LUD-06 payRequest directly). + * 2. toPayRequest → LUD-16 (Lightning Address) or LUD-06 fetch → payRequest. + * 3. requestInvoice — GET `callback?amount=` → a BOLT11 for the amount. + * The returned BOLT11 is handed back to the renderer, which pays it over the + * ATM's existing LNbits/nostr transport (stores/atm.ts `payInvoice`), so + * settlement + PAYMENT_RECEIVED reuse the tested cash-in completion path. + * + * Runs in the MAIN process (Node fetch) to avoid renderer CORS, exactly like + * lnurl-withdraw.ts. + * + * Transport seam: `resolveCardPayTarget()` is the single HTTPS-today / + * Nostr-tomorrow swap point. The rest is standard LNURL-pay against whatever + * pay target it returns and is transport-independent. + */ + +import { lnurlwToHttps } from './lnurl-withdraw.js' + +export interface ResolveCardInvoiceResult { + ok: boolean + /** BOLT11 to pay when ok; the renderer settles it over the nostr transport. */ + bolt11?: string + /** Human-readable reason when ok is false (safe to surface on-screen). */ + reason?: string +} + +type FetchLike = typeof fetch + +export interface ResolveCardInvoiceOptions { + /** Injected for tests; defaults to global fetch. */ + fetchImpl?: FetchLike + /** Per-request timeout (default 15s). */ + timeoutMs?: number +} + +/** Resolver response — any of these shapes is accepted (see the spec doc). */ +interface CardPayTarget { + status?: string + reason?: string + // (a) a LUD-06 payRequest, inline + tag?: string + callback?: string + minSendable?: number + maxSendable?: number + metadata?: string + // (b) a Lightning Address, e.g. "cardname@l484.com" + lightningAddress?: string + // (c) an lnurlp pointer (https or lnurl://) + lnurlp?: string + lnurl?: string +} + +/** LUD-06 payRequest (subset) + error shape. */ +interface PayRequest { + tag?: string + callback?: string + minSendable?: number + maxSendable?: number + metadata?: string + status?: string + reason?: string +} + +/** LUD-06 second-response (the callback body). */ +interface PayValues { + pr?: string + status?: string + reason?: string +} + +interface Ctx { + doFetch: FetchLike + timeoutMs: number +} + +function errMsg(e: unknown): string { + if (e instanceof Error) + return e.name === 'TimeoutError' || e.name === 'AbortError' ? 'timed out' : e.message + return String(e) +} + +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() +} + +/** + * Derive the boltcards *pay* resolver URL from a tapped card's `lnurlw`. + * The card presents `…/boltcards/api/v1/scan/?p=&c=` (a withdraw voucher); + * the receive resolver is its sibling `…/boltcards/api/v1/pay/?p=&c=`, + * carrying the same SUN p/c. This is the HTTPS transport seam — a future + * nostr-native card would resolve the same identity over nostr instead. + */ +export function scanUrlToResolver(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/', '/pay/') + return u.toString() +} + +/** LUD-16: map a Lightning Address `name@host` to its lnurlp URL. */ +export function lnAddressToLnurlp(addr: string): string | null { + const m = addr.trim().match(/^([a-z0-9._%+-]+)@([a-z0-9.-]+)$/i) + if (!m) return null + return `https://${m[2]}/.well-known/lnurlp/${m[1]}` +} + +async function fetchPayRequest( + url: string, + ctx: Ctx +): Promise<{ ok: true; payRequest: PayRequest } | { ok: false; reason: string }> { + let body: PayRequest + try { + const res = await ctx.doFetch(url, { signal: AbortSignal.timeout(ctx.timeoutMs) }) + body = (await res.json()) as PayRequest + } catch (e) { + return { ok: false, reason: `could not reach the card wallet: ${errMsg(e)}` } + } + if (body.status === 'ERROR') { + return { ok: false, reason: body.reason || 'card wallet rejected the request' } + } + if (body.tag !== 'payRequest' || !body.callback) { + return { ok: false, reason: 'card wallet did not return a pay request' } + } + return { ok: true, payRequest: body } +} + +/** Turn a resolver response into a LUD-06 payRequest (fetching if needed). */ +async function toPayRequest( + target: CardPayTarget, + ctx: Ctx +): Promise<{ ok: true; payRequest: PayRequest } | { ok: false; reason: string }> { + // (a) resolver returned a LUD-06 payRequest inline. + if (target.tag === 'payRequest' && target.callback) { + return { ok: true, payRequest: target } + } + // (b) resolver returned a Lightning Address (the common case here). + if (typeof target.lightningAddress === 'string') { + const url = lnAddressToLnurlp(target.lightningAddress) + if (!url) return { ok: false, reason: 'card wallet address is invalid' } + return fetchPayRequest(url, ctx) + } + // (c) resolver returned an lnurlp pointer. + const pointer = target.lnurlp ?? target.lnurl + if (typeof pointer === 'string') { + const url = lnurlwToHttps(pointer) + if (!url) return { ok: false, reason: 'card wallet lnurlp is invalid' } + return fetchPayRequest(url, ctx) + } + return { ok: false, reason: 'card wallet has no receive address' } +} + +async function requestInvoice( + pr: PayRequest, + amountMsat: number, + ctx: Ctx +): Promise { + 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) }) + let vals: PayValues + try { + const res = await ctx.doFetch(cbUrl, { signal: AbortSignal.timeout(ctx.timeoutMs) }) + vals = (await res.json()) as PayValues + } catch (e) { + return { ok: false, reason: `could not fetch the invoice: ${errMsg(e)}` } + } + if (vals.status === 'ERROR') { + return { ok: false, reason: vals.reason || 'card wallet declined' } + } + if (!vals.pr || !/^ln[a-z0-9]/i.test(vals.pr.trim())) { + return { ok: false, reason: 'card wallet returned no invoice' } + } + return { ok: true, bolt11: vals.pr.trim() } +} + +/** + * Resolve a tapped Bolt Card + a payout amount to a BOLT11 the ATM can pay. + * Never throws — every failure returns `{ ok: false, reason }`. + */ +export async function resolveCardInvoice( + lnurlw: string, + amountMsat: number, + opts: ResolveCardInvoiceOptions = {} +): Promise { + const ctx: Ctx = { doFetch: opts.fetchImpl ?? fetch, timeoutMs: opts.timeoutMs ?? 15_000 } + + const resolverUrl = scanUrlToResolver(lnurlw) + if (!resolverUrl) return { ok: false, reason: 'not a valid Bolt Card (lnurlw) tag' } + if (!(amountMsat > 0)) return { ok: false, reason: 'no amount to send' } + + // 1) Resolve card → pay target (the transport seam: HTTPS today). + let target: CardPayTarget + try { + const res = await ctx.doFetch(resolverUrl, { signal: AbortSignal.timeout(ctx.timeoutMs) }) + target = (await res.json()) as CardPayTarget + } catch (e) { + return { ok: false, reason: `could not reach the card: ${errMsg(e)}` } + } + if (target.status === 'ERROR') { + return { ok: false, reason: target.reason || 'card rejected the tap' } + } + + // 2) Normalize to a LUD-06 payRequest. + const pr = await toPayRequest(target, ctx) + if (!pr.ok) return pr + + // 3) Ask for an invoice for the payout amount. + return requestInvoice(pr.payRequest, amountMsat, ctx) +} diff --git a/apps/machine/electron/main.ts b/apps/machine/electron/main.ts index 81676cf..6cfcb20 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 { resolveCardInvoice } from './lnurl-pay.js' import { startNfcReader, type NfcStatus } from './nfc-service.js' // ESM equivalent of __dirname @@ -120,9 +121,7 @@ function loadBranding(): BrandingConfig | null { if (Object.keys(colors).length > 0) customColors = colors if (dark && typeof dark === 'object') { const darkColors = Object.fromEntries( - Object.entries(dark as Record).filter( - ([, v]) => typeof v === 'string' - ) + Object.entries(dark as Record).filter(([, v]) => typeof v === 'string') ) as Record if (Object.keys(darkColors).length > 0) customColorsDark = darkColors } @@ -431,6 +430,20 @@ ipcMain.handle( } ) +// Bolt Card cash-in (receive): resolve a tapped card + payout amount to a +// BOLT11 on the card wallet, which the renderer then pays over the nostr +// transport (stores/atm.ts payInvoice). HTTPS to the card host runs here in the +// main process to dodge renderer CORS. See lnurl-pay.ts. +ipcMain.handle( + 'lnurl:pay-card', + async ( + _event, + args: { lnurlw: string; amountMsat: number } + ): Promise<{ ok: boolean; bolt11?: string; reason?: string }> => { + return resolveCardInvoice(args.lnurlw, args.amountMsat) + } +) + // State persistence IPC handlers ipcMain.handle('state:load-cassettes', () => loadCassettes()) ipcMain.handle('state:set-cassettes', (_event, cassettes) => setCassettes(cassettes)) @@ -447,9 +460,7 @@ ipcMain.handle('state:remediate-transaction', (_event, txid: string, remediatedB ipcMain.handle('state:get-last-known-config-created-at', (): number => getLastKnownConfigCreatedAt() ) -ipcMain.handle('state:get-bootstrap-published-at', (): number | null => - getBootstrapPublishedAt() -) +ipcMain.handle('state:get-bootstrap-published-at', (): number | null => getBootstrapPublishedAt()) ipcMain.handle('state:mark-bootstrap-published', (_event, unixTimestamp: number): void => { markBootstrapPublished(unixTimestamp) }) diff --git a/apps/machine/electron/preload.ts b/apps/machine/electron/preload.ts index 8be0ed8..8ced2d5 100644 --- a/apps/machine/electron/preload.ts +++ b/apps/machine/electron/preload.ts @@ -134,6 +134,13 @@ contextBridge.exposeInMainWorld('electronAPI', { amountMsat?: number }): Promise<{ ok: boolean; reason?: string }> => ipcRenderer.invoke('lnurl:withdraw', args), + // Bolt Card cash-in: resolve a tapped card + amount to a BOLT11 to pay. + resolveCardInvoice: (args: { + lnurlw: string + amountMsat: number + }): Promise<{ ok: boolean; bolt11?: string; reason?: string }> => + ipcRenderer.invoke('lnurl:pay-card', args), + applyOperatorCassettesConfig: ( payload: { positions: Record @@ -272,6 +279,10 @@ declare global { bolt11: string amountMsat?: number }) => Promise<{ ok: boolean; reason?: string }> + resolveCardInvoice: (args: { + lnurlw: string + amountMsat: number + }) => Promise<{ ok: boolean; bolt11?: string; reason?: string }> applyOperatorCassettesConfig: ( payload: { positions: Record }, eventCreatedAt: number diff --git a/apps/machine/src/stores/atm.ts b/apps/machine/src/stores/atm.ts index 06dea22..db1a124 100644 --- a/apps/machine/src/stores/atm.ts +++ b/apps/machine/src/stores/atm.ts @@ -11,14 +11,8 @@ import { } from '@bitSpire/state-machine' import { initializeLightningServices, fetchBtcPrice } from '@/services/lightning' import { classifyInitError } from '@/services/init-error' -import { - startOperatorConfigService, - type OperatorConfigService, -} from '@/services/operator-config' -import { - startOperatorFeesService, - type OperatorFeesService, -} from '@/services/operator-fees' +import { startOperatorConfigService, type OperatorConfigService } from '@/services/operator-config' +import { startOperatorFeesService, type OperatorFeesService } from '@/services/operator-fees' import type { HalConfig, HalServices } from '@/services/hal' import type { MachineModel } from '@/config' import type { LightningBackend } from '@/services/lightning' @@ -52,7 +46,8 @@ function computeFeeSats(ctx: ATMContext, isCashIn: boolean): number { `Unit fraction expected (0.05 = 5%), not a percentage.` ) } - const principalSats = ctx.exchangeRate > 0 ? Math.floor((ctx.fiatCents / 100) * ctx.exchangeRate) : 0 + const principalSats = + ctx.exchangeRate > 0 ? Math.floor((ctx.fiatCents / 100) * ctx.exchangeRate) : 0 const feeSats = isCashIn ? principalSats - ctx.satsAmount // cash-in: customer receives less than principal : ctx.satsAmount - principalSats // cash-out: customer pays more than principal @@ -567,9 +562,13 @@ 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') { + // Clear Bolt Card state whenever we leave a tap screen — the cash-out + // invoice ('displayingInvoice') or the cash-in QR ('displayingQR') — so a + // stale "processing"/error can't linger into the next flow. + const leftBoltCardScreen = + (prevNestedState === 'displayingInvoice' && currentNested !== 'displayingInvoice') || + (prevNestedState === 'displayingQR' && currentNested !== 'displayingQR') + if (leftBoltCardScreen) { boltCardProcessing.value = false nfcStatus.value = null } @@ -614,26 +613,76 @@ export const useAtmStore = defineStore('atm', () => { } } + /** + * A tapped Bolt Card during the cash-in QR screen: RECEIVE sats to the card. + * The card's lnurlw is only a spend voucher, so we resolve it to the card + * wallet's lnurlp (main process), fetch an invoice for the payout, and pay it + * over the nostr transport via the normal `payInvoice` → PAYMENT_RECEIVED + * path. Settlement + completion reuse the tested cash-in flow. + */ + async function handleBoltCardReceive(lnurlw: string) { + if (!(isCashIn.value && nestedState.value === 'displayingQR')) return + const amountSats = context.value?.satsAmount ?? 0 + if (amountSats <= 0) return + if (boltCardProcessing.value) return // one at a time + boltCardProcessing.value = true + nfcStatus.value = { state: 'processing', message: 'Reading card…' } + try { + const amountMsat = amountSats * 1000 + const res = await window.electronAPI!.resolveCardInvoice({ lnurlw, amountMsat }) + if (!res.ok || !res.bolt11) { + boltCardProcessing.value = false + nfcStatus.value = { state: 'declined', message: res.reason ?? 'Card could not receive' } + return + } + nfcStatus.value = { state: 'accepted', message: 'Card found — sending sats…' } + const paid = await payInvoice(res.bolt11) + if (!paid) { + boltCardProcessing.value = false + nfcStatus.value = { state: 'error', message: paymentError.value ?? 'Payment failed' } + } + // On success payInvoice fires PAYMENT_RECEIVED; state leaves displayingQR + // and the subscribe-cleanup above resets nfcStatus/boltCardProcessing. + } catch (e) { + console.warn('[ATM] Bolt Card receive 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) + // Route the same physical tap by flow: cash-out pulls, cash-in receives. + if (isCashOut.value && nestedState.value === 'displayingInvoice') { + void handleBoltCardTap(lnurlw) + } else if (isCashIn.value && nestedState.value === 'displayingQR') { + void handleBoltCardReceive(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) { + // Only surface reader status on a tap screen, and don't clobber an + // in-flight tap's message. + const onTapScreen = + (isCashOut.value && nestedState.value === 'displayingInvoice') || + (isCashIn.value && nestedState.value === 'displayingQR') + if (onTapScreen && !boltCardProcessing.value) { nfcStatus.value = status } }) } - /** Dev/mock: simulate a tap with a pasted lnurlw (test without a card). */ + /** Dev/mock: simulate a cash-out tap with a pasted lnurlw (test without a card). */ function simulateBoltCardTap(lnurlw: string) { void handleBoltCardTap(lnurlw) } + /** Dev/mock: simulate a cash-in (receive) tap with a pasted lnurlw. */ + function simulateBoltCardReceive(lnurlw: string) { + void handleBoltCardReceive(lnurlw) + } + /** * Group an array of inserted bill denominations into { denomination, count } pairs. */ @@ -1586,10 +1635,11 @@ export const useAtmStore = defineStore('atm', () => { isCashOut, nestedState, - // Bolt Card cash-out (NFC) + // Bolt Card (NFC): cash-out pulls, cash-in receives nfcStatus, boltCardProcessing, simulateBoltCardTap, + simulateBoltCardReceive, // Actions initialize, diff --git a/apps/machine/src/types/electron.d.ts b/apps/machine/src/types/electron.d.ts index d391944..0e5846d 100644 --- a/apps/machine/src/types/electron.d.ts +++ b/apps/machine/src/types/electron.d.ts @@ -109,6 +109,11 @@ declare global { bolt11: string amountMsat?: number }) => Promise<{ ok: boolean; reason?: string }> + /** Bolt Card cash-in: resolve a tapped card + amount to a BOLT11 to pay. */ + resolveCardInvoice: (args: { + lnurlw: string + 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 5210c86..915c113 100644 --- a/apps/machine/src/views/CashInView.vue +++ b/apps/machine/src/views/CashInView.vue @@ -48,6 +48,9 @@ const showCancelButton = computed(() => { // Invoice input for manual payment const invoiceInput = ref('') +// Dev: paste an lnurlw to simulate a Bolt Card tap-to-receive +const mockLnurlw = ref('') + // Copy state for ndebit URI const copied = ref(false) @@ -279,7 +282,9 @@ const isProcessing = computed(() => atmStore.isPayingInvoice) +
+ + +
diff --git a/docs/boltcard-receive-resolver.md b/docs/boltcard-receive-resolver.md new file mode 100644 index 0000000..d505d3c --- /dev/null +++ b/docs/boltcard-receive-resolver.md @@ -0,0 +1,110 @@ +# Bolt Card tap-to-receive — LNbits resolver endpoint + +Spec for the small **custom endpoint** the ATM needs on the LNbits `boltcards` +extension to support **tap-to-receive** (the cash-in / buy flow). The ATM side +(`apps/machine/electron/lnurl-pay.ts`) is already built against this contract; +this document is what to implement in the `omni-private` LNbits fork. + +## Why a new endpoint + +A Bolt Card only ever emits its `lnurlw://…/scan/?p=&c=` voucher — +a **withdraw** (spend) credential. You cannot push sats _into_ the card with it. +To deposit to the card's wallet, the ATM uses the same tap as an **authenticated +identity** (the `external_id` + the SUN `p`/`c`, verified exactly as `/scan` +does) and needs the wallet's **pay** target back. Stock `boltcards` is +withdraw-only, so we add a `pay` sibling of `scan`. + +The card is **not re-written** — same NDEF, same keys, same `external_id`. Only +the server learns a new way to answer the same tap. + +## Endpoint + +``` +GET /boltcards/api/v1/pay/{external_id}?p={p}&c={c} +``` + +- Same URL shape as `GET /boltcards/api/v1/scan/{external_id}?p=&c=`, with the + path segment `scan` → `pay`. The ATM derives it by string-substitution on the + tapped `lnurlw` (`scanUrlToResolver()` in `lnurl-pay.ts`). +- **Verify `p`/`c` exactly like `/scan`**: decrypt the PICC (`p`) with the + card's `k1`, recompute the CMAC (`c`) with `k2`, check the read counter is + fresh (monotonic). Reject replays. Reuse the boltcards SUN verification path — + do not fork it. A valid `p`/`c` is the authorization: it proves card + possession and prevents a cloned UID from misdirecting a deposit. +- No auth key/header — like `/scan`, this is a public LNURL-style endpoint + gated solely by the SUN. + +## Response + +Return **one** of the following JSON shapes (the ATM accepts all three). Since +each card wallet has a Lightning Address, either of the first two is simplest. + +### (a) Lightning Address (recommended) + +```json +{ "lightningAddress": "cardname@l484.com" } +``` + +The ATM resolves it via LUD-16 (`/.well-known/lnurlp/cardname`) → LUD-06 pay. + +### (b) LUD-06 payRequest, inline + +```json +{ + "tag": "payRequest", + "callback": "https://lnbits.l484.com/lnurlp/api/v1/lnurl/", + "minSendable": 1000, + "maxSendable": 100000000, + "metadata": "[[\"text/plain\",\"bolt card top-up\"]]" +} +``` + +Hand back the card wallet's existing `lnurlp` payRequest directly (no extra +round-trip for the ATM). + +### (c) lnurlp pointer + +```json +{ "lnurlp": "https://lnbits.l484.com/lnurlp/" } +``` + +An `https://` (or `lnurl://`) URL the ATM will fetch to get the payRequest. + +### Error + +```json +{ "status": "ERROR", "reason": "invalid card" } +``` + +Use for a failed SUN check, a disabled/unknown card, or a wallet with no pay +target. `reason` is surfaced verbatim on the ATM screen, so keep it terse and +non-sensitive. + +## Flow, end to end + +``` +customer inserts cash → ATM owes N sats → customer taps Bolt Card + → ATM reads lnurlw (external_id + fresh p/c) + → GET /boltcards/api/v1/pay/?p=&c= ← THIS ENDPOINT + → { lightningAddress | payRequest | lnurlp } + → ATM: LUD-16/LUD-06 → GET callback?amount= → BOLT11 + → ATM pays the BOLT11 over its own nostr transport → card wallet credited + → PAYMENT_RECEIVED → cash-in completes +``` + +Amounts are in **millisatoshis** on the LUD-06 callback (`amount=`), per +spec. Make sure each card wallet's `minSendable`/`maxSendable` span the ATM's +payout range or the tap will be declined with "amount is above/below the card +wallet …". + +## Notes + +- **Double-payout:** the cash-in screen still shows the LNURL-withdraw QR as a + fallback (customer _pulls_). A tap _pays_ instead. The ATM gates re-entry + while a tap is in flight and leaves `displayingQR` on success; the withdraw + link is `uses:1`. A customer would have to both tap and pull near-simultaneously + to double-collect — acceptable for now, revisit if it bites. +- **Future nostr transport:** `resolveCardPayTarget` (the `/pay` GET) is the one + HTTPS-today / nostr-tomorrow seam. A nostr-native boltcard would answer the + same `external_id + SUN` identity over the ATM's existing nostr connection, + dropping the clearnet HTTPS call. The rest (standard LNURL-pay) is unchanged. -- 2.55.0