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
125
apps/machine/electron/boltcard-session.test.ts
Normal file
125
apps/machine/electron/boltcard-session.test.ts
Normal 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' })
|
||||
})
|
||||
})
|
||||
167
apps/machine/electron/boltcard-session.ts
Normal file
167
apps/machine/electron/boltcard-session.ts
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
/**
|
||||
* Bolt Card session (tap-to-enter) — one tap, one verified visit.
|
||||
*
|
||||
* A Bolt Card tap yields a single-use SUN `p`/`c`; anything that verifies it
|
||||
* spends it. The access gate (ADR-003) wants to verify the card at entry AND
|
||||
* let the holder finish a buy or sell later without tapping again, so the
|
||||
* aiolabs `boltcards` fork exposes `/session/<external_id>?p=&c=` — a sibling
|
||||
* of `/scan` and `/pay` that verifies once, records one hit, and returns:
|
||||
* - the card wallet's balance and fiat equivalent (display only),
|
||||
* - the LUD-03 second step (withdraw callback + k1 = hit) for cash-out,
|
||||
* - the LUD-06 second step (pay callback) for cash-in.
|
||||
* Both callbacks are keyed by the hit — the same single-use bearer `/scan`
|
||||
* and `/pay` hand out — so the ATM holds no p/c for the rest of the visit.
|
||||
* The withdraw step is withheld (with a reason) once the card's daily limit is
|
||||
* spent, exactly as `/scan` would refuse.
|
||||
*
|
||||
* Runs in the MAIN process (Node fetch) to avoid renderer CORS, like the other
|
||||
* LNURL modules. See docs/boltcard-session.md for the wire contract.
|
||||
*/
|
||||
|
||||
import { lnurlwToHttps, type WithdrawStep } from './lnurl-withdraw.js'
|
||||
import type { PayStep } from './lnurl-pay.js'
|
||||
|
||||
export interface CardSession {
|
||||
externalId: string
|
||||
cardName: string
|
||||
balanceSats: number
|
||||
/** ISO currency the card server priced the balance in; null → no fiat. */
|
||||
currency: string | null
|
||||
/** Balance in `currency` at the card server's rate; null when unknown. */
|
||||
fiat: number | null
|
||||
/** LUD-03 second step, or null when the card server withheld it. */
|
||||
withdraw: WithdrawStep | null
|
||||
/** Why `withdraw` is null (e.g. daily limit spent); safe to show on-screen. */
|
||||
withdrawBlockedReason: string | null
|
||||
/** LUD-06 second step for topping the card wallet up. */
|
||||
pay: PayStep
|
||||
}
|
||||
|
||||
export type OpenCardSessionResult =
|
||||
| { ok: true; session: CardSession }
|
||||
| { ok: false; reason: string }
|
||||
|
||||
type FetchLike = typeof fetch
|
||||
|
||||
export interface OpenCardSessionOptions {
|
||||
/** Injected for tests; defaults to global fetch. */
|
||||
fetchImpl?: FetchLike
|
||||
/** Per-request timeout (default 15s). */
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the session URL from a tapped card's `lnurlw`: the card presents
|
||||
* `…/boltcards/api/v1/scan/<id>?p=&c=`; the session endpoint is its sibling
|
||||
* `…/boltcards/api/v1/session/<id>?p=&c=` with the same SUN.
|
||||
*/
|
||||
export function scanUrlToSessionUrl(lnurlw: string): string | null {
|
||||
const https = lnurlwToHttps(lnurlw)
|
||||
if (!https) return null
|
||||
const u = new URL(https)
|
||||
if (!u.pathname.includes('/scan/')) return null
|
||||
u.pathname = u.pathname.replace('/scan/', '/session/')
|
||||
return u.toString()
|
||||
}
|
||||
|
||||
/** Wire shape of a `/session` reply (any of the fields may be missing/odd). */
|
||||
interface SessionWire {
|
||||
authenticated?: unknown
|
||||
reason?: unknown
|
||||
external_id?: unknown
|
||||
card_name?: unknown
|
||||
balance_msat?: unknown
|
||||
currency?: unknown
|
||||
fiat?: unknown
|
||||
withdraw?: unknown
|
||||
withdraw_blocked_reason?: unknown
|
||||
pay?: unknown
|
||||
}
|
||||
|
||||
const isObj = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null
|
||||
const optNum = (v: unknown): number | undefined => (typeof v === 'number' ? v : undefined)
|
||||
const optStr = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
|
||||
|
||||
function parseWithdraw(v: unknown): WithdrawStep | null {
|
||||
if (!isObj(v)) return null
|
||||
const callback = optStr(v.callback)
|
||||
const k1 = optStr(v.k1)
|
||||
if (!callback || !k1) return null
|
||||
return {
|
||||
callback,
|
||||
k1,
|
||||
minWithdrawable: optNum(v.minWithdrawable),
|
||||
maxWithdrawable: optNum(v.maxWithdrawable),
|
||||
}
|
||||
}
|
||||
|
||||
function parsePay(v: unknown): PayStep | null {
|
||||
if (!isObj(v)) return null
|
||||
const callback = optStr(v.callback)
|
||||
if (!callback) return null
|
||||
return {
|
||||
callback,
|
||||
minSendable: optNum(v.minSendable),
|
||||
maxSendable: optNum(v.maxSendable),
|
||||
metadata: optStr(v.metadata),
|
||||
}
|
||||
}
|
||||
|
||||
function errMsg(e: unknown): string {
|
||||
if (e instanceof Error)
|
||||
return e.name === 'TimeoutError' || e.name === 'AbortError' ? 'timed out' : e.message
|
||||
return String(e)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a session for a tapped card. Spends the tap's SUN. Never throws —
|
||||
* every failure returns `{ ok: false, reason }` (reasons come from the card
|
||||
* server verbatim and are safe to show).
|
||||
*/
|
||||
export async function openCardSession(
|
||||
lnurlw: string,
|
||||
opts: OpenCardSessionOptions = {}
|
||||
): Promise<OpenCardSessionResult> {
|
||||
const doFetch = opts.fetchImpl ?? fetch
|
||||
const timeoutMs = opts.timeoutMs ?? 15_000
|
||||
|
||||
const url = scanUrlToSessionUrl(lnurlw)
|
||||
if (!url) return { ok: false, reason: 'not a valid Bolt Card (lnurlw) tag' }
|
||||
|
||||
let wire: SessionWire
|
||||
try {
|
||||
const res = await doFetch(url, { signal: AbortSignal.timeout(timeoutMs) })
|
||||
if (res.status === 404) {
|
||||
// Older fork without /session — say so rather than "card rejected".
|
||||
return { ok: false, reason: 'card server does not support sessions' }
|
||||
}
|
||||
wire = (await res.json()) as SessionWire
|
||||
} catch (e) {
|
||||
return { ok: false, reason: `could not reach the card: ${errMsg(e)}` }
|
||||
}
|
||||
|
||||
if (wire.authenticated !== true) {
|
||||
return { ok: false, reason: optStr(wire.reason) || 'card rejected the tap' }
|
||||
}
|
||||
const externalId = optStr(wire.external_id)
|
||||
const pay = parsePay(wire.pay)
|
||||
if (!externalId || !pay) {
|
||||
return { ok: false, reason: 'card server returned an incomplete session' }
|
||||
}
|
||||
const balanceMsat = optNum(wire.balance_msat) ?? 0
|
||||
const currency = optStr(wire.currency)?.toUpperCase() ?? null
|
||||
const fiat = optNum(wire.fiat)
|
||||
return {
|
||||
ok: true,
|
||||
session: {
|
||||
externalId,
|
||||
cardName: optStr(wire.card_name) ?? '',
|
||||
balanceSats: Math.floor(balanceMsat / 1000),
|
||||
currency,
|
||||
fiat: currency && fiat !== undefined ? fiat : null,
|
||||
withdraw: parseWithdraw(wire.withdraw),
|
||||
withdrawBlockedReason: optStr(wire.withdraw_blocked_reason) ?? null,
|
||||
pay,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -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.' })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -59,6 +59,17 @@ interface CardPayTarget {
|
|||
lnurl?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The LUD-06 second step on its own: what a `payRequest` (or a Bolt Card
|
||||
* session, see boltcard-session.ts) hands us to fetch an invoice.
|
||||
*/
|
||||
export interface PayStep {
|
||||
callback: string
|
||||
minSendable?: number
|
||||
maxSendable?: number
|
||||
metadata?: string
|
||||
}
|
||||
|
||||
/** LUD-06 payRequest (subset) + error shape. */
|
||||
interface PayRequest {
|
||||
tag?: string
|
||||
|
|
@ -163,17 +174,18 @@ async function toPayRequest(
|
|||
}
|
||||
|
||||
async function requestInvoice(
|
||||
pr: PayRequest,
|
||||
pr: PayStep,
|
||||
amountMsat: number,
|
||||
ctx: Ctx
|
||||
): Promise<ResolveCardInvoiceResult> {
|
||||
if (!(amountMsat > 0)) return { ok: false, reason: 'no amount to send' }
|
||||
if (typeof pr.minSendable === 'number' && amountMsat < pr.minSendable) {
|
||||
return { ok: false, reason: 'amount is below the card wallet minimum' }
|
||||
}
|
||||
if (typeof pr.maxSendable === 'number' && amountMsat > pr.maxSendable) {
|
||||
return { ok: false, reason: 'amount is above the card wallet maximum' }
|
||||
}
|
||||
const cbUrl = appendQuery(pr.callback!, { amount: String(amountMsat) })
|
||||
const cbUrl = appendQuery(pr.callback, { amount: String(amountMsat) })
|
||||
let vals: PayValues
|
||||
try {
|
||||
const res = await ctx.doFetch(cbUrl, { signal: AbortSignal.timeout(ctx.timeoutMs) })
|
||||
|
|
@ -222,5 +234,19 @@ export async function resolveCardInvoice(
|
|||
if (!pr.ok) return pr
|
||||
|
||||
// 3) Ask for an invoice for the payout amount.
|
||||
return requestInvoice(pr.payRequest, amountMsat, ctx)
|
||||
return requestInvoice({ ...pr.payRequest, callback: pr.payRequest.callback! }, amountMsat, ctx)
|
||||
}
|
||||
|
||||
/**
|
||||
* The LUD-06 second step alone: fetch a BOLT11 for `amountMsat` from an
|
||||
* already-obtained pay step (from a Bolt Card session opened at tap-to-enter).
|
||||
* Never throws — every failure returns `{ ok: false, reason }`.
|
||||
*/
|
||||
export async function resolveInvoiceFromPayStep(
|
||||
step: PayStep,
|
||||
amountMsat: number,
|
||||
opts: ResolveCardInvoiceOptions = {}
|
||||
): Promise<ResolveCardInvoiceResult> {
|
||||
const ctx: Ctx = { doFetch: opts.fetchImpl ?? fetch, timeoutMs: opts.timeoutMs ?? 15_000 }
|
||||
return requestInvoice(step, amountMsat, ctx)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -36,6 +36,17 @@ interface WithdrawRequest {
|
|||
|
||||
type FetchLike = typeof fetch
|
||||
|
||||
/**
|
||||
* The LUD-03 second step on its own: what a `withdrawRequest` (or a Bolt Card
|
||||
* session, see boltcard-session.ts) hands us to actually pull a payment.
|
||||
*/
|
||||
export interface WithdrawStep {
|
||||
callback: string
|
||||
k1: string
|
||||
minWithdrawable?: number
|
||||
maxWithdrawable?: number
|
||||
}
|
||||
|
||||
export interface ExecuteLnurlWithdrawOptions {
|
||||
/** Injected for tests; defaults to global fetch. */
|
||||
fetchImpl?: FetchLike
|
||||
|
|
@ -73,7 +84,8 @@ function appendQuery(url: string, params: Record<string, string>): string {
|
|||
}
|
||||
|
||||
function errMsg(e: unknown): string {
|
||||
if (e instanceof Error) return e.name === 'TimeoutError' || e.name === 'AbortError' ? 'timed out' : e.message
|
||||
if (e instanceof Error)
|
||||
return e.name === 'TimeoutError' || e.name === 'AbortError' ? 'timed out' : e.message
|
||||
return String(e)
|
||||
}
|
||||
|
||||
|
|
@ -105,16 +117,46 @@ export async function executeLnurlWithdraw(
|
|||
if (params.tag !== 'withdrawRequest' || !params.callback || !params.k1) {
|
||||
return { ok: false, reason: 'card did not return a withdraw voucher' }
|
||||
}
|
||||
|
||||
// 2) Hand our invoice to the callback — the card's wallet pays it.
|
||||
return executeWithdrawCallback(
|
||||
{
|
||||
callback: params.callback,
|
||||
k1: params.k1,
|
||||
minWithdrawable: params.minWithdrawable,
|
||||
maxWithdrawable: params.maxWithdrawable,
|
||||
},
|
||||
bolt11,
|
||||
opts
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The LUD-03 second step alone: hand our invoice to an already-obtained
|
||||
* withdraw step (from a `/scan` withdrawRequest, or from a Bolt Card session
|
||||
* opened at tap-to-enter) — the card's wallet pays it. `{ ok: true }` means the
|
||||
* card accepted the pull; settlement is observed by the invoice watcher.
|
||||
*/
|
||||
export async function executeWithdrawCallback(
|
||||
step: WithdrawStep,
|
||||
bolt11: string,
|
||||
opts: ExecuteLnurlWithdrawOptions = {}
|
||||
): Promise<LnurlWithdrawResult> {
|
||||
const doFetch = opts.fetchImpl ?? fetch
|
||||
const timeoutMs = opts.timeoutMs ?? 15_000
|
||||
|
||||
if (!bolt11 || !/^ln[a-z0-9]/i.test(bolt11.trim())) {
|
||||
return { ok: false, reason: 'no invoice to charge' }
|
||||
}
|
||||
if (
|
||||
opts.amountMsat != null &&
|
||||
typeof params.maxWithdrawable === 'number' &&
|
||||
opts.amountMsat > params.maxWithdrawable
|
||||
typeof step.maxWithdrawable === 'number' &&
|
||||
opts.amountMsat > step.maxWithdrawable
|
||||
) {
|
||||
return { ok: false, reason: 'card limit is below this amount' }
|
||||
}
|
||||
|
||||
// 2) Hand our invoice to the callback — the card's wallet pays it.
|
||||
const cbUrl = appendQuery(params.callback, { k1: params.k1, pr: bolt11.trim() })
|
||||
const cbUrl = appendQuery(step.callback, { k1: step.k1, pr: bolt11.trim() })
|
||||
let cb: { status?: string; reason?: string }
|
||||
try {
|
||||
const res = await doFetch(cbUrl, { signal: AbortSignal.timeout(timeoutMs) })
|
||||
|
|
|
|||
|
|
@ -42,8 +42,13 @@ import {
|
|||
type StoredBunkerBinding,
|
||||
} from './state-store.js'
|
||||
import { initializeHal, type HalInstance } from './hal-service.js'
|
||||
import { executeLnurlWithdraw } from './lnurl-withdraw.js'
|
||||
import { resolveCardInvoice } from './lnurl-pay.js'
|
||||
import {
|
||||
executeLnurlWithdraw,
|
||||
executeWithdrawCallback,
|
||||
type WithdrawStep,
|
||||
} from './lnurl-withdraw.js'
|
||||
import { resolveCardInvoice, resolveInvoiceFromPayStep, type PayStep } from './lnurl-pay.js'
|
||||
import { openCardSession, type OpenCardSessionResult } from './boltcard-session.js'
|
||||
import { startNfcReader, type NfcStatus } from './nfc-service.js'
|
||||
|
||||
// ESM equivalent of __dirname
|
||||
|
|
@ -503,6 +508,37 @@ ipcMain.handle(
|
|||
}
|
||||
)
|
||||
|
||||
// Bolt Card tap-to-enter (ADR-003): open a verified session for a tapped card.
|
||||
// Spends the tap's SUN once and returns balance + fiat + the withdraw/pay
|
||||
// second steps the session reuses at Complete. See boltcard-session.ts.
|
||||
ipcMain.handle(
|
||||
'lnurl:open-card-session',
|
||||
async (_event, args: { lnurlw: string }): Promise<OpenCardSessionResult> => {
|
||||
return openCardSession(args.lnurlw)
|
||||
}
|
||||
)
|
||||
|
||||
// Session variants of the two Complete paths: no tap, no p/c — just the
|
||||
// hit-keyed second step the session already holds.
|
||||
ipcMain.handle(
|
||||
'lnurl:withdraw-session',
|
||||
async (
|
||||
_event,
|
||||
args: { withdraw: WithdrawStep; bolt11: string; amountMsat?: number }
|
||||
): Promise<{ ok: boolean; reason?: string }> => {
|
||||
return executeWithdrawCallback(args.withdraw, args.bolt11, { amountMsat: args.amountMsat })
|
||||
}
|
||||
)
|
||||
ipcMain.handle(
|
||||
'lnurl:pay-session',
|
||||
async (
|
||||
_event,
|
||||
args: { pay: PayStep; amountMsat: number }
|
||||
): Promise<{ ok: boolean; bolt11?: string; reason?: string }> => {
|
||||
return resolveInvoiceFromPayStep(args.pay, args.amountMsat)
|
||||
}
|
||||
)
|
||||
|
||||
// State persistence IPC handlers
|
||||
ipcMain.handle('state:load-cassettes', () => loadCassettes())
|
||||
ipcMain.handle('state:set-cassettes', (_event, cassettes) => setCassettes(cassettes))
|
||||
|
|
|
|||
|
|
@ -141,6 +141,23 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||
}): Promise<{ ok: boolean; bolt11?: string; reason?: string }> =>
|
||||
ipcRenderer.invoke('lnurl:pay-card', args),
|
||||
|
||||
// Bolt Card tap-to-enter: one verified session per tap (balance + fiat +
|
||||
// the withdraw/pay second steps reused at Complete). Payload shapes are
|
||||
// declared in src/types/electron.d.ts (CardSession).
|
||||
openCardSession: (args: { lnurlw: string }): Promise<unknown> =>
|
||||
ipcRenderer.invoke('lnurl:open-card-session', args),
|
||||
withdrawWithSession: (args: {
|
||||
withdraw: { callback: string; k1: string; minWithdrawable?: number; maxWithdrawable?: number }
|
||||
bolt11: string
|
||||
amountMsat?: number
|
||||
}): Promise<{ ok: boolean; reason?: string }> =>
|
||||
ipcRenderer.invoke('lnurl:withdraw-session', args),
|
||||
resolveSessionInvoice: (args: {
|
||||
pay: { callback: string; minSendable?: number; maxSendable?: number; metadata?: string }
|
||||
amountMsat: number
|
||||
}): Promise<{ ok: boolean; bolt11?: string; reason?: string }> =>
|
||||
ipcRenderer.invoke('lnurl:pay-session', args),
|
||||
|
||||
applyOperatorCassettesConfig: (
|
||||
payload: {
|
||||
positions: Record<string, { denomination: number; count: number }>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue