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,5 @@
|
|||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { executeLnurlWithdraw, lnurlwToHttps } from './lnurl-withdraw'
|
||||
import { executeLnurlWithdraw, executeWithdrawCallback, lnurlwToHttps } from './lnurl-withdraw'
|
||||
|
||||
const BOLT11 = 'lnbc10u1p3xyz...'
|
||||
const LNURLW =
|
||||
|
|
@ -101,3 +101,44 @@ describe('executeLnurlWithdraw', () => {
|
|||
expect(res.reason).toMatch(/could not reach the card/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('executeWithdrawCallback (session second step, no tap)', () => {
|
||||
const step = {
|
||||
callback: 'https://lnbits.l484.com/boltcards/api/v1/lnurl/cb/hit1',
|
||||
k1: 'hit1',
|
||||
maxWithdrawable: 5_000_000,
|
||||
}
|
||||
|
||||
it('hands the invoice straight to the callback with k1', async () => {
|
||||
const f = mockFetch([{ status: 'OK' }])
|
||||
const out = await executeWithdrawCallback(step, BOLT11, { fetchImpl: f.impl })
|
||||
expect(out).toEqual({ ok: true })
|
||||
expect(f.calls).toHaveLength(1)
|
||||
expect(f.calls[0]).toContain('k1=hit1')
|
||||
expect(f.calls[0]).toContain('pr=' + BOLT11)
|
||||
})
|
||||
|
||||
it('refuses an amount above the step limit without calling out', async () => {
|
||||
const f = mockFetch([])
|
||||
const out = await executeWithdrawCallback(step, BOLT11, {
|
||||
fetchImpl: f.impl,
|
||||
amountMsat: 6_000_000,
|
||||
})
|
||||
expect(out).toEqual({ ok: false, reason: 'card limit is below this amount' })
|
||||
expect(f.calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('surfaces a callback decline', async () => {
|
||||
const f = mockFetch([{ status: 'ERROR', reason: 'Payment already claimed.' }])
|
||||
const out = await executeWithdrawCallback(step, BOLT11, { fetchImpl: f.impl })
|
||||
expect(out).toEqual({ ok: false, reason: 'Payment already claimed.' })
|
||||
})
|
||||
|
||||
it('rejects a missing invoice', async () => {
|
||||
const f = mockFetch([])
|
||||
expect(await executeWithdrawCallback(step, '', { fetchImpl: f.impl })).toEqual({
|
||||
ok: false,
|
||||
reason: 'no invoice to charge',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue