feat(machine): LNURL-withdraw executor for Bolt Card cash-out (LUD-03)
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=<invoice> 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 <noreply@anthropic.com>
This commit is contained in:
parent
c36c2fb1c4
commit
457761719f
5 changed files with 263 additions and 0 deletions
103
apps/machine/electron/lnurl-withdraw.test.ts
Normal file
103
apps/machine/electron/lnurl-withdraw.test.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
127
apps/machine/electron/lnurl-withdraw.ts
Normal file
127
apps/machine/electron/lnurl-withdraw.ts
Normal file
|
|
@ -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=<our bolt11>` → 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, string>): 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<LnurlWithdrawResult> {
|
||||
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' }
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -127,6 +127,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||
// Reload the renderer to re-attempt initialization (connectivity recovery).
|
||||
recoverApp: (): Promise<void> => 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<string, { denomination: number; count: number }>
|
||||
|
|
@ -246,6 +253,11 @@ declare global {
|
|||
saveSpireSeed: (seed: string) => Promise<void>
|
||||
relaunchApp: () => Promise<void>
|
||||
recoverApp: () => Promise<void>
|
||||
lnurlWithdraw: (args: {
|
||||
lnurlw: string
|
||||
bolt11: string
|
||||
amountMsat?: number
|
||||
}) => Promise<{ ok: boolean; reason?: string }>
|
||||
applyOperatorCassettesConfig: (
|
||||
payload: { positions: Record<string, { denomination: number; count: number }> },
|
||||
eventCreatedAt: number
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue