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>
167 lines
5.8 KiB
TypeScript
167 lines
5.8 KiB
TypeScript
/**
|
|
* 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,
|
|
},
|
|
}
|
|
}
|