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
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue