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:
parent
ce4b5a5dc6
commit
735132b032
12 changed files with 639 additions and 63 deletions
|
|
@ -1,5 +1,10 @@
|
|||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { resolveCardInvoice, scanUrlToResolver, lnAddressToLnurlp } from './lnurl-pay'
|
||||
import {
|
||||
resolveCardInvoice,
|
||||
resolveInvoiceFromPayStep,
|
||||
scanUrlToResolver,
|
||||
lnAddressToLnurlp,
|
||||
} from './lnurl-pay'
|
||||
|
||||
const LNURLW =
|
||||
'lnurlw://lnbits.l484.com/boltcards/api/v1/scan/abc123?p=DEADBEEFDEADBEEFDEADBEEFDEADBEEF&c=1122334455667788'
|
||||
|
|
@ -118,3 +123,43 @@ describe('resolveCardInvoice', () => {
|
|||
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.' })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue