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>
252 lines
8.9 KiB
TypeScript
252 lines
8.9 KiB
TypeScript
/**
|
|
* LNURL-pay resolver (LUD-06 / LUD-16) — the ATM as the *paying* party.
|
|
*
|
|
* Bolt Card tap-to-RECEIVE for the cash-in (buy) flow. A Bolt Card only ever
|
|
* emits its `lnurlw://…?p=…&c=…` voucher — a *withdraw* (spend) credential — so
|
|
* we can't push sats into it directly. Instead the tap is used as an
|
|
* authenticated identity (external_id + SUN p/c) to look up the card wallet's
|
|
* *pay* target, then the ATM fetches an invoice for the payout amount:
|
|
* 1. resolveCardPayTarget — GET the boltcards `/pay/<id>?p=&c=` resolver
|
|
* (a sibling of `/scan`); it verifies the same SUN and returns the card
|
|
* wallet's Lightning Address / lnurlp (or a LUD-06 payRequest directly).
|
|
* 2. toPayRequest → LUD-16 (Lightning Address) or LUD-06 fetch → payRequest.
|
|
* 3. requestInvoice — GET `callback?amount=<msat>` → a BOLT11 for the amount.
|
|
* The returned BOLT11 is handed back to the renderer, which pays it over the
|
|
* ATM's existing LNbits/nostr transport (stores/atm.ts `payInvoice`), so
|
|
* settlement + PAYMENT_RECEIVED reuse the tested cash-in completion path.
|
|
*
|
|
* Runs in the MAIN process (Node fetch) to avoid renderer CORS, exactly like
|
|
* lnurl-withdraw.ts.
|
|
*
|
|
* Transport seam: `resolveCardPayTarget()` is the single HTTPS-today /
|
|
* Nostr-tomorrow swap point. The rest is standard LNURL-pay against whatever
|
|
* pay target it returns and is transport-independent.
|
|
*/
|
|
|
|
import { lnurlwToHttps } from './lnurl-withdraw.js'
|
|
|
|
export interface ResolveCardInvoiceResult {
|
|
ok: boolean
|
|
/** BOLT11 to pay when ok; the renderer settles it over the nostr transport. */
|
|
bolt11?: string
|
|
/** Human-readable reason when ok is false (safe to surface on-screen). */
|
|
reason?: string
|
|
}
|
|
|
|
type FetchLike = typeof fetch
|
|
|
|
export interface ResolveCardInvoiceOptions {
|
|
/** Injected for tests; defaults to global fetch. */
|
|
fetchImpl?: FetchLike
|
|
/** Per-request timeout (default 15s). */
|
|
timeoutMs?: number
|
|
}
|
|
|
|
/** Resolver response — any of these shapes is accepted (see the spec doc). */
|
|
interface CardPayTarget {
|
|
status?: string
|
|
reason?: string
|
|
// (a) a LUD-06 payRequest, inline
|
|
tag?: string
|
|
callback?: string
|
|
minSendable?: number
|
|
maxSendable?: number
|
|
metadata?: string
|
|
// (b) a Lightning Address, e.g. "cardname@l484.com"
|
|
lightningAddress?: string
|
|
// (c) an lnurlp pointer (https or lnurl://)
|
|
lnurlp?: string
|
|
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
|
|
callback?: string
|
|
minSendable?: number
|
|
maxSendable?: number
|
|
metadata?: string
|
|
status?: string
|
|
reason?: string
|
|
}
|
|
|
|
/** LUD-06 second-response (the callback body). */
|
|
interface PayValues {
|
|
pr?: string
|
|
status?: string
|
|
reason?: string
|
|
}
|
|
|
|
interface Ctx {
|
|
doFetch: FetchLike
|
|
timeoutMs: number
|
|
}
|
|
|
|
function errMsg(e: unknown): string {
|
|
if (e instanceof Error)
|
|
return e.name === 'TimeoutError' || e.name === 'AbortError' ? 'timed out' : e.message
|
|
return String(e)
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
/**
|
|
* Derive the boltcards *pay* resolver URL from a tapped card's `lnurlw`.
|
|
* The card presents `…/boltcards/api/v1/scan/<id>?p=&c=` (a withdraw voucher);
|
|
* the receive resolver is its sibling `…/boltcards/api/v1/pay/<id>?p=&c=`,
|
|
* carrying the same SUN p/c. This is the HTTPS transport seam — a future
|
|
* nostr-native card would resolve the same identity over nostr instead.
|
|
*/
|
|
export function scanUrlToResolver(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/', '/pay/')
|
|
return u.toString()
|
|
}
|
|
|
|
/** LUD-16: map a Lightning Address `name@host` to its lnurlp URL. */
|
|
export function lnAddressToLnurlp(addr: string): string | null {
|
|
const m = addr.trim().match(/^([a-z0-9._%+-]+)@([a-z0-9.-]+)$/i)
|
|
if (!m) return null
|
|
return `https://${m[2]}/.well-known/lnurlp/${m[1]}`
|
|
}
|
|
|
|
async function fetchPayRequest(
|
|
url: string,
|
|
ctx: Ctx
|
|
): Promise<{ ok: true; payRequest: PayRequest } | { ok: false; reason: string }> {
|
|
let body: PayRequest
|
|
try {
|
|
const res = await ctx.doFetch(url, { signal: AbortSignal.timeout(ctx.timeoutMs) })
|
|
body = (await res.json()) as PayRequest
|
|
} catch (e) {
|
|
return { ok: false, reason: `could not reach the card wallet: ${errMsg(e)}` }
|
|
}
|
|
if (body.status === 'ERROR') {
|
|
return { ok: false, reason: body.reason || 'card wallet rejected the request' }
|
|
}
|
|
if (body.tag !== 'payRequest' || !body.callback) {
|
|
return { ok: false, reason: 'card wallet did not return a pay request' }
|
|
}
|
|
return { ok: true, payRequest: body }
|
|
}
|
|
|
|
/** Turn a resolver response into a LUD-06 payRequest (fetching if needed). */
|
|
async function toPayRequest(
|
|
target: CardPayTarget,
|
|
ctx: Ctx
|
|
): Promise<{ ok: true; payRequest: PayRequest } | { ok: false; reason: string }> {
|
|
// (a) resolver returned a LUD-06 payRequest inline.
|
|
if (target.tag === 'payRequest' && target.callback) {
|
|
return { ok: true, payRequest: target }
|
|
}
|
|
// (b) resolver returned a Lightning Address (the common case here).
|
|
if (typeof target.lightningAddress === 'string') {
|
|
const url = lnAddressToLnurlp(target.lightningAddress)
|
|
if (!url) return { ok: false, reason: 'card wallet address is invalid' }
|
|
return fetchPayRequest(url, ctx)
|
|
}
|
|
// (c) resolver returned an lnurlp pointer.
|
|
const pointer = target.lnurlp ?? target.lnurl
|
|
if (typeof pointer === 'string') {
|
|
const url = lnurlwToHttps(pointer)
|
|
if (!url) return { ok: false, reason: 'card wallet lnurlp is invalid' }
|
|
return fetchPayRequest(url, ctx)
|
|
}
|
|
return { ok: false, reason: 'card wallet has no receive address' }
|
|
}
|
|
|
|
async function requestInvoice(
|
|
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) })
|
|
let vals: PayValues
|
|
try {
|
|
const res = await ctx.doFetch(cbUrl, { signal: AbortSignal.timeout(ctx.timeoutMs) })
|
|
vals = (await res.json()) as PayValues
|
|
} catch (e) {
|
|
return { ok: false, reason: `could not fetch the invoice: ${errMsg(e)}` }
|
|
}
|
|
if (vals.status === 'ERROR') {
|
|
return { ok: false, reason: vals.reason || 'card wallet declined' }
|
|
}
|
|
if (!vals.pr || !/^ln[a-z0-9]/i.test(vals.pr.trim())) {
|
|
return { ok: false, reason: 'card wallet returned no invoice' }
|
|
}
|
|
return { ok: true, bolt11: vals.pr.trim() }
|
|
}
|
|
|
|
/**
|
|
* Resolve a tapped Bolt Card + a payout amount to a BOLT11 the ATM can pay.
|
|
* Never throws — every failure returns `{ ok: false, reason }`.
|
|
*/
|
|
export async function resolveCardInvoice(
|
|
lnurlw: string,
|
|
amountMsat: number,
|
|
opts: ResolveCardInvoiceOptions = {}
|
|
): Promise<ResolveCardInvoiceResult> {
|
|
const ctx: Ctx = { doFetch: opts.fetchImpl ?? fetch, timeoutMs: opts.timeoutMs ?? 15_000 }
|
|
|
|
const resolverUrl = scanUrlToResolver(lnurlw)
|
|
if (!resolverUrl) return { ok: false, reason: 'not a valid Bolt Card (lnurlw) tag' }
|
|
if (!(amountMsat > 0)) return { ok: false, reason: 'no amount to send' }
|
|
|
|
// 1) Resolve card → pay target (the transport seam: HTTPS today).
|
|
let target: CardPayTarget
|
|
try {
|
|
const res = await ctx.doFetch(resolverUrl, { signal: AbortSignal.timeout(ctx.timeoutMs) })
|
|
target = (await res.json()) as CardPayTarget
|
|
} catch (e) {
|
|
return { ok: false, reason: `could not reach the card: ${errMsg(e)}` }
|
|
}
|
|
if (target.status === 'ERROR') {
|
|
return { ok: false, reason: target.reason || 'card rejected the tap' }
|
|
}
|
|
|
|
// 2) Normalize to a LUD-06 payRequest.
|
|
const pr = await toPayRequest(target, ctx)
|
|
if (!pr.ok) return pr
|
|
|
|
// 3) Ask for an invoice for the payout amount.
|
|
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)
|
|
}
|