feat(machine): open a verified Bolt Card session at tap-to-enter

Entry now spends the tap's single-use SUN once, on the card server's new
/session endpoint (aiolabs/boltcards feat/card-session-endpoint), instead
of parsing the lnurlw locally and deferring every check to Complete. The
server proves a genuine, non-replayed card and returns the wallet balance
plus the hit-keyed LUD-03 withdraw and LUD-06 pay second steps — the same
single-use bearer /scan and /pay hand out — so Complete still needs no
second tap and the ATM holds no p/c for the visit.

- electron/boltcard-session.ts: /scan → /session URL derivation, response
  parsing, 404 → 'card server does not support sessions'.
- lnurl-withdraw / lnurl-pay: the second steps are now callable on their
  own (executeWithdrawCallback, resolveInvoiceFromPayStep); the tap paths
  are unchanged and reuse them.
- IPC: lnurl:open-card-session, lnurl:withdraw-session, lnurl:pay-session.
- store: handleBoltCardEntry opens the session then authorizes the
  server-returned external_id; the payment handlers take a source (raw
  tap or session); a withheld withdraw step declines with the server's
  reason. The boltcard AccessScan no longer carries the lnurlw.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-09-20 17:07:16 +02:00
commit 735132b032
12 changed files with 639 additions and 63 deletions

View file

@ -0,0 +1,125 @@
import { describe, it, expect, vi } from 'vitest'
import { openCardSession, scanUrlToSessionUrl } from './boltcard-session'
const LNURLW =
'lnurlw://lnbits.l484.com/boltcards/api/v1/scan/abc123?p=DEADBEEFDEADBEEFDEADBEEFDEADBEEF&c=1122334455667788'
/** Mock fetch returning the given JSON bodies per call, in order (status 200). */
function mockFetch(bodies: unknown[], status = 200) {
const calls: string[] = []
const impl = vi.fn(async (url: string | URL) => {
calls.push(url.toString())
const body = bodies[calls.length - 1]
return { status, json: async () => body } as Response
})
return { impl: impl as unknown as typeof fetch, calls }
}
const SESSION = {
authenticated: true,
external_id: 'abc123',
card_name: 'Alice',
balance_msat: 123_456_789,
currency: 'usd',
fiat: 98.76,
withdraw: {
callback: 'https://lnbits.l484.com/boltcards/api/v1/lnurl/cb/hit1',
k1: 'hit1',
minWithdrawable: 1000,
maxWithdrawable: 50_000_000,
},
withdraw_blocked_reason: null,
pay: {
callback: 'https://lnbits.l484.com/boltcards/api/v1/pay/cb/hit1',
minSendable: 1000,
maxSendable: 50_000_000,
metadata: '[["text/plain","Bolt Card top-up"]]',
},
}
describe('scanUrlToSessionUrl', () => {
it('rewrites /scan/ to /session/ and preserves p + c', () => {
const u = scanUrlToSessionUrl(LNURLW)
expect(u).toContain('https://lnbits.l484.com/boltcards/api/v1/session/abc123')
expect(u).toContain('p=DEADBEEFDEADBEEFDEADBEEFDEADBEEF')
expect(u).toContain('c=1122334455667788')
})
it('returns null for a non-scan URL', () => {
expect(scanUrlToSessionUrl('lnurlw://host/somethingelse?p=1&c=2')).toBeNull()
expect(scanUrlToSessionUrl('http://host/boltcards/api/v1/scan/x')).toBeNull()
})
})
describe('openCardSession', () => {
it('opens a session: balance in sats, upper-cased currency, both steps', async () => {
const f = mockFetch([SESSION])
const out = await openCardSession(LNURLW, { fetchImpl: f.impl })
expect(f.calls).toHaveLength(1)
expect(f.calls[0]).toContain('/session/abc123')
expect(out).toEqual({
ok: true,
session: {
externalId: 'abc123',
cardName: 'Alice',
balanceSats: 123_456,
currency: 'USD',
fiat: 98.76,
withdraw: SESSION.withdraw,
withdrawBlockedReason: null,
pay: SESSION.pay,
},
})
})
it('carries a withheld withdraw step with its reason', async () => {
const f = mockFetch([
{ ...SESSION, withdraw: null, withdraw_blocked_reason: 'Max daily limit spent.' },
])
const out = await openCardSession(LNURLW, { fetchImpl: f.impl })
expect(out.ok).toBe(true)
if (!out.ok) return
expect(out.session.withdraw).toBeNull()
expect(out.session.withdrawBlockedReason).toBe('Max daily limit spent.')
expect(out.session.pay.callback).toBe(SESSION.pay.callback)
})
it('has no fiat when the server sent no currency', async () => {
const f = mockFetch([{ ...SESSION, currency: null, fiat: null }])
const out = await openCardSession(LNURLW, { fetchImpl: f.impl })
expect(out.ok && out.session.currency).toBeNull()
expect(out.ok && out.session.fiat).toBeNull()
})
it('surfaces the server reason on a rejected tap', async () => {
const f = mockFetch([{ authenticated: false, reason: 'This link is already used.' }])
const out = await openCardSession(LNURLW, { fetchImpl: f.impl })
expect(out).toEqual({ ok: false, reason: 'This link is already used.' })
})
it('rejects an incomplete session (no pay step)', async () => {
const f = mockFetch([{ ...SESSION, pay: undefined }])
const out = await openCardSession(LNURLW, { fetchImpl: f.impl })
expect(out).toEqual({ ok: false, reason: 'card server returned an incomplete session' })
})
it('names an old card server that has no /session', async () => {
const f = mockFetch([{ detail: 'Not Found' }], 404)
const out = await openCardSession(LNURLW, { fetchImpl: f.impl })
expect(out).toEqual({ ok: false, reason: 'card server does not support sessions' })
})
it('rejects a non-card tag without a network call', async () => {
const f = mockFetch([])
const out = await openCardSession('https://host/not/a/card', { fetchImpl: f.impl })
expect(out.ok).toBe(false)
expect(f.calls).toHaveLength(0)
})
it('reports an unreachable card server', async () => {
const impl = vi.fn(async () => {
throw new TypeError('fetch failed')
}) as unknown as typeof fetch
const out = await openCardSession(LNURLW, { fetchImpl: impl })
expect(out).toEqual({ ok: false, reason: 'could not reach the card: fetch failed' })
})
})