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>
165 lines
6.4 KiB
TypeScript
165 lines
6.4 KiB
TypeScript
import { describe, it, expect, vi } from 'vitest'
|
|
import {
|
|
resolveCardInvoice,
|
|
resolveInvoiceFromPayStep,
|
|
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)
|
|
})
|
|
})
|
|
|
|
describe('resolveInvoiceFromPayStep (session second step, no tap)', () => {
|
|
const step = {
|
|
callback: 'https://lnbits.l484.com/boltcards/api/v1/pay/cb/hit1',
|
|
minSendable: 1000,
|
|
maxSendable: 50_000_000,
|
|
metadata: '[["text/plain","Bolt Card top-up"]]',
|
|
}
|
|
|
|
it('fetches an invoice for the amount from the callback', async () => {
|
|
const f = mockFetch([{ pr: BOLT11 }])
|
|
const out = await resolveInvoiceFromPayStep(step, 25_000, { fetchImpl: f.impl })
|
|
expect(out).toEqual({ ok: true, bolt11: BOLT11 })
|
|
expect(f.calls).toHaveLength(1)
|
|
expect(f.calls[0]).toContain('amount=25000')
|
|
})
|
|
|
|
it('enforces the step bounds without calling out', async () => {
|
|
const f = mockFetch([])
|
|
expect(await resolveInvoiceFromPayStep(step, 500, { fetchImpl: f.impl })).toEqual({
|
|
ok: false,
|
|
reason: 'amount is below the card wallet minimum',
|
|
})
|
|
expect(await resolveInvoiceFromPayStep(step, 60_000_000, { fetchImpl: f.impl })).toEqual({
|
|
ok: false,
|
|
reason: 'amount is above the card wallet maximum',
|
|
})
|
|
expect(await resolveInvoiceFromPayStep(step, 0, { fetchImpl: f.impl })).toEqual({
|
|
ok: false,
|
|
reason: 'no amount to send',
|
|
})
|
|
expect(f.calls).toHaveLength(0)
|
|
})
|
|
|
|
it('surfaces a callback decline', async () => {
|
|
const f = mockFetch([{ status: 'ERROR', reason: 'Card is disabled.' }])
|
|
const out = await resolveInvoiceFromPayStep(step, 25_000, { fetchImpl: f.impl })
|
|
expect(out).toEqual({ ok: false, reason: 'Card is disabled.' })
|
|
})
|
|
})
|