bitspire/apps/machine/electron/lnurl-withdraw.ts
Padreug 735132b032 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>
2026-09-20 17:07:16 +02:00

169 lines
5.8 KiB
TypeScript

/**
* LNURL-withdraw executor (LUD-03) — the ATM as the *withdrawing* party.
*
* Bolt Card tap-to-pay for the cash-out flow: a Bolt Card presents an
* `lnurlw://…?p=…&c=…` voucher (NTAG424 SUN — fresh p/c per tap). The ATM has
* already generated its cash-out BOLT11; here it asks the card's wallet to pay
* that invoice:
* 1. GET the lnurlw URL → a `withdrawRequest` (callback, k1, max/min).
* 2. GET `callback?k1=…&pr=<our bolt11>` → the card's wallet pays it.
* Settlement itself is observed elsewhere (the existing invoice watcher over
* nostr), so a returned `{ ok: true }` means "the card accepted the pull", not
* "cash dispensed" — the state machine still waits for PAYMENT_RECEIVED.
*
* Runs in the MAIN process (Node fetch) to avoid renderer CORS: LNURL
* endpoints don't send CORS headers, so a renderer fetch to the card's host
* would be blocked.
*/
export interface LnurlWithdrawResult {
ok: boolean
/** Human-readable reason when ok is false (safe to surface on-screen). */
reason?: string
}
/** LUD-03 withdrawRequest (subset we consume) + LUD-06 error shape. */
interface WithdrawRequest {
tag?: string
callback?: string
k1?: string
minWithdrawable?: number
maxWithdrawable?: number
defaultDescription?: string
status?: string
reason?: string
}
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
/**
* Our invoice amount in millisats. When set, we reject early if it exceeds
* the voucher's maxWithdrawable (defensive; the callback would reject anyway).
*/
amountMsat?: number
/** Per-request timeout (default 15s). */
timeoutMs?: number
}
/**
* Normalize a Bolt Card / LNURL-withdraw pointer to an https URL.
* Bolt Cards emit `lnurlw://host/path?query`; we also accept `lnurl://` and a
* bare `https://`. Bech32 `LNURL1…` is intentionally unsupported (Bolt Cards
* never use it) and rejected with a clear reason.
*/
export function lnurlwToHttps(raw: string): string | null {
let s = raw.trim()
if (!s) return null
if (s.toLowerCase().startsWith('lightning:')) s = s.slice('lightning:'.length)
const lower = s.toLowerCase()
if (lower.startsWith('lnurlw://')) return 'https://' + s.slice('lnurlw://'.length)
if (lower.startsWith('lnurl://')) return 'https://' + s.slice('lnurl://'.length)
if (lower.startsWith('https://')) return s
// Reject http:// (must be TLS) and bech32 lnurl1… (not a Bolt Card).
return null
}
function appendQuery(url: string, params: Record<string, string>): string {
const u = new URL(url)
for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v)
return u.toString()
}
function errMsg(e: unknown): string {
if (e instanceof Error)
return e.name === 'TimeoutError' || e.name === 'AbortError' ? 'timed out' : e.message
return String(e)
}
export async function executeLnurlWithdraw(
lnurlw: string,
bolt11: string,
opts: ExecuteLnurlWithdrawOptions = {}
): Promise<LnurlWithdrawResult> {
const doFetch = opts.fetchImpl ?? fetch
const timeoutMs = opts.timeoutMs ?? 15_000
const paramsUrl = lnurlwToHttps(lnurlw)
if (!paramsUrl) return { ok: false, reason: 'not a valid Bolt Card (lnurlw) tag' }
if (!bolt11 || !/^ln[a-z0-9]/i.test(bolt11.trim())) {
return { ok: false, reason: 'no invoice to charge' }
}
// 1) Fetch the withdraw request.
let params: WithdrawRequest
try {
const res = await doFetch(paramsUrl, { signal: AbortSignal.timeout(timeoutMs) })
params = (await res.json()) as WithdrawRequest
} catch (e) {
return { ok: false, reason: `could not reach the card: ${errMsg(e)}` }
}
if (params.status === 'ERROR') {
return { ok: false, reason: params.reason || 'card rejected the tap' }
}
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 step.maxWithdrawable === 'number' &&
opts.amountMsat > step.maxWithdrawable
) {
return { ok: false, reason: 'card limit is below this amount' }
}
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) })
cb = (await res.json()) as { status?: string; reason?: string }
} catch (e) {
return { ok: false, reason: `card payment failed: ${errMsg(e)}` }
}
if (cb.status === 'OK') return { ok: true }
return { ok: false, reason: cb.reason || 'card declined the payment' }
}