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>
103 lines
4.3 KiB
TypeScript
103 lines
4.3 KiB
TypeScript
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)
|
|
})
|
|
})
|