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>
125 lines
4.6 KiB
TypeScript
125 lines
4.6 KiB
TypeScript
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' })
|
|
})
|
|
})
|