feat(access): verified Bolt Card session at entry + hidden-by-default balance #93
12 changed files with 639 additions and 63 deletions
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>
commit
735132b032
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 }>
|
||||
|
|
|
|||
|
|
@ -115,16 +115,15 @@ describe('access authorize (ADR-003)', () => {
|
|||
|
||||
describe('boltcard credential (tap-to-enter)', () => {
|
||||
it('open-enrollment grants any card as user', async () => {
|
||||
const out = await authorize(
|
||||
{ kind: 'boltcard', externalId: 'abc123', lnurlw: 'lnurlw://h/scan/abc123?p=1&c=2' },
|
||||
[],
|
||||
{ salt: SALT, openEnrollment: true }
|
||||
)
|
||||
const out = await authorize({ kind: 'boltcard', externalId: 'abc123' }, [], {
|
||||
salt: SALT,
|
||||
openEnrollment: true,
|
||||
})
|
||||
expect(out).toMatchObject({ status: 'granted', role: 'user' })
|
||||
})
|
||||
|
||||
it('rejects a card with no external_id', async () => {
|
||||
const out = await authorize({ kind: 'boltcard', externalId: '', lnurlw: '' }, [], {
|
||||
const out = await authorize({ kind: 'boltcard', externalId: '' }, [], {
|
||||
salt: SALT,
|
||||
openEnrollment: true,
|
||||
})
|
||||
|
|
@ -134,11 +133,10 @@ describe('access authorize (ADR-003)', () => {
|
|||
it('allow-list matches by external_id hash', async () => {
|
||||
const idHash = await hashId('abc123', SALT)
|
||||
const list: AllowListEntry[] = [{ idHash, role: 'operator' }]
|
||||
const out = await authorize(
|
||||
{ kind: 'boltcard', externalId: 'abc123', lnurlw: 'lnurlw://h/scan/abc123?p=1&c=2' },
|
||||
list,
|
||||
{ salt: SALT, openEnrollment: false }
|
||||
)
|
||||
const out = await authorize({ kind: 'boltcard', externalId: 'abc123' }, list, {
|
||||
salt: SALT,
|
||||
openEnrollment: false,
|
||||
})
|
||||
expect(out).toMatchObject({ status: 'granted', role: 'operator' })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -14,12 +14,11 @@ export type { AccessRole }
|
|||
|
||||
/**
|
||||
* A raw credential. Discriminated union so new factors are additive:
|
||||
* - `boltcard` — what ships: a tapped Bolt Card. `externalId` is the
|
||||
* identity (from the lnurlw path); `lnurlw` is the full
|
||||
* voucher (single-use SUN p/c intact) the session presents
|
||||
* once, at Complete, to move sats. Only `externalId` is
|
||||
* ever hashed/authorized — the p/c never enter the
|
||||
* authorize layer.
|
||||
* - `boltcard` — what ships: a tapped Bolt Card, already verified by the
|
||||
* card server's `/session` (the tap's SUN was spent there).
|
||||
* `externalId` is the identity as the server returned it;
|
||||
* it is the only thing hashed/authorized. The session's
|
||||
* payment steps stay in the store, never in this layer.
|
||||
* - `npub` — a Nostr pubkey (bare npub, `nostr:` URI or nprofile).
|
||||
* No reader emits it today; kept, with the PIN second
|
||||
* factor, for a future non-card credential.
|
||||
|
|
@ -27,6 +26,6 @@ export type { AccessRole }
|
|||
* authorized.
|
||||
*/
|
||||
export type AccessScan =
|
||||
| { kind: 'boltcard'; externalId: string; lnurlw: string }
|
||||
| { kind: 'boltcard'; externalId: string }
|
||||
| { kind: 'npub'; npub: string }
|
||||
| { kind: 'challenge'; pubkey: string; nonce: string; sig: string }
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
type ATMMachine,
|
||||
type AccessRole,
|
||||
} from '@bitSpire/state-machine'
|
||||
import type { AccessControlConfig } from '@/types/electron'
|
||||
import type { AccessControlConfig, CardSession } from '@/types/electron'
|
||||
import { initializeLightningServices, fetchBtcPrice } from '@/services/lightning'
|
||||
import { classifyInitError } from '@/services/init-error'
|
||||
import { startOperatorConfigService, type OperatorConfigService } from '@/services/operator-config'
|
||||
|
|
@ -302,6 +302,10 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
// boltcards server bumps the SUN counter on the first GET, so
|
||||
// treat the voucher as spent even when the failure was ours.
|
||||
type BoltCardOutcome = 'skipped' | 'accepted' | 'declined'
|
||||
// Where a Complete gets its voucher: a card tapped right now on the cash
|
||||
// screen (raw lnurlw, spent by this call) or the session opened at entry
|
||||
// (hit-keyed steps, no p/c).
|
||||
type BoltCardSource = { lnurlw: string } | { session: CardSession }
|
||||
// Access-control gate config (ADR-003). Defaults disabled → the machine's
|
||||
// `locked` state bypasses straight to `idle` (behaviour identical to no gate).
|
||||
// Populated from RuntimeConfig.accessControl in initializeForProduction.
|
||||
|
|
@ -314,11 +318,12 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
})
|
||||
// Build/dev bypass — opens the gate even when enabled (browser dev / CI).
|
||||
const accessBypassFlag = import.meta.env.VITE_SKIP_ACCESS_GATE === 'true'
|
||||
// Tap-to-enter (ADR-003): the Bolt Card tapped at the locked screen is held
|
||||
// here for the whole session so buy/sell just need "Complete" — no second tap.
|
||||
// Carries the full lnurlw (single-use SUN p/c intact; spent only at payment).
|
||||
// Cleared when the session ends (machine re-locks). Never logged.
|
||||
const loadedBoltCard = ref<{ externalId: string; lnurlw: string } | null>(null)
|
||||
// Tap-to-enter (ADR-003): the Bolt Card session opened at the locked screen
|
||||
// is held here for the whole visit so buy/sell just need "Complete" — no
|
||||
// second tap. The tap's SUN was spent opening it; what we hold are the
|
||||
// hit-keyed withdraw/pay steps plus balance + fiat for display. Cleared when
|
||||
// the machine re-locks. Never logged.
|
||||
const loadedBoltCard = ref<CardSession | null>(null)
|
||||
const fiatCode = ref('USD')
|
||||
// Defaults are 0 — the operator's fee config (received via Nostr
|
||||
// kind-30078 `bitspire-fees:<atm_pubkey>` envelope from satmachineadmin)
|
||||
|
|
@ -631,7 +636,7 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
* arrives through the invoice watcher → PAYMENT_RECEIVED → dispensingCash;
|
||||
* ok here only means the card accepted the pull.
|
||||
*/
|
||||
async function handleBoltCardTap(lnurlw: string): Promise<BoltCardOutcome> {
|
||||
async function handleBoltCardTap(source: BoltCardSource): Promise<BoltCardOutcome> {
|
||||
if (nestedState.value !== 'displayingInvoice') return 'skipped'
|
||||
const invoice = context.value?.invoice
|
||||
if (!invoice) return 'skipped'
|
||||
|
|
@ -640,7 +645,17 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
nfcStatus.value = { state: 'processing', message: 'Reading card…' }
|
||||
try {
|
||||
const amountMsat = (context.value?.satsAmount ?? 0) * 1000
|
||||
const res = await window.electronAPI!.lnurlWithdraw({ lnurlw, bolt11: invoice, amountMsat })
|
||||
const api = window.electronAPI!
|
||||
const res =
|
||||
'session' in source
|
||||
? source.session.withdraw
|
||||
? await api.withdrawWithSession({
|
||||
withdraw: source.session.withdraw,
|
||||
bolt11: invoice,
|
||||
amountMsat,
|
||||
})
|
||||
: { ok: false, reason: source.session.withdrawBlockedReason ?? 'card cannot pay now' }
|
||||
: await api.lnurlWithdraw({ lnurlw: source.lnurlw, bolt11: invoice, amountMsat })
|
||||
if (res.ok) {
|
||||
nfcStatus.value = { state: 'accepted', message: 'Card accepted — confirming payment…' }
|
||||
return 'accepted'
|
||||
|
|
@ -663,7 +678,7 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
* over the nostr transport via the normal `payInvoice` → PAYMENT_RECEIVED
|
||||
* path. Settlement + completion reuse the tested cash-in flow.
|
||||
*/
|
||||
async function handleBoltCardReceive(lnurlw: string): Promise<BoltCardOutcome> {
|
||||
async function handleBoltCardReceive(source: BoltCardSource): Promise<BoltCardOutcome> {
|
||||
if (!(isCashIn.value && nestedState.value === 'displayingQR')) return 'skipped'
|
||||
const amountSats = context.value?.satsAmount ?? 0
|
||||
if (amountSats <= 0) return 'skipped'
|
||||
|
|
@ -672,7 +687,11 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
nfcStatus.value = { state: 'processing', message: 'Reading card…' }
|
||||
try {
|
||||
const amountMsat = amountSats * 1000
|
||||
const res = await window.electronAPI!.resolveCardInvoice({ lnurlw, amountMsat })
|
||||
const api = window.electronAPI!
|
||||
const res =
|
||||
'session' in source
|
||||
? await api.resolveSessionInvoice({ pay: source.session.pay, amountMsat })
|
||||
: await api.resolveCardInvoice({ lnurlw: source.lnurlw, amountMsat })
|
||||
if (!res.ok || !res.bolt11) {
|
||||
boltCardProcessing.value = false
|
||||
nfcStatus.value = { state: 'declined', message: res.reason ?? 'Card could not receive' }
|
||||
|
|
@ -697,31 +716,43 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
}
|
||||
|
||||
/**
|
||||
* A tapped Bolt Card at the locked screen (ADR-003 tap-to-enter). Soft entry:
|
||||
* parse the external_id LOCALLY (no server call, so the single-use SUN p/c stay
|
||||
* valid), authorize (open-enrollment or allow-list), then hold the full lnurlw
|
||||
* for the session. The cryptographic check happens later, at Complete, when the
|
||||
* stored lnurlw actually moves sats.
|
||||
* A tapped Bolt Card at the locked screen (ADR-003 tap-to-enter). Verified
|
||||
* entry: the tap's single-use SUN is spent ONCE, on the card server's
|
||||
* `/session` (main process), which proves a genuine, non-replayed card and
|
||||
* returns the wallet balance plus the hit-keyed withdraw/pay steps. Then the
|
||||
* card's external_id is authorized locally (open-enrollment or allow-list)
|
||||
* and the session is held for the visit — Complete needs no second tap.
|
||||
*/
|
||||
async function handleBoltCardEntry(lnurlw: string) {
|
||||
if (!isLocked.value) return
|
||||
if (boltCardProcessing.value) return
|
||||
const parsed = parseBoltcardLnurlw(lnurlw)
|
||||
if (!parsed) {
|
||||
// Reject a non-card tag before spending anything or calling anyone.
|
||||
if (!parseBoltcardLnurlw(lnurlw)) {
|
||||
nfcStatus.value = { state: 'declined', message: 'Not a Bolt Card' }
|
||||
denyAccess('not a Bolt Card')
|
||||
return
|
||||
}
|
||||
if (!isElectron || !window.electronAPI?.openCardSession) {
|
||||
nfcStatus.value = { state: 'declined', message: 'Card sessions need the machine build' }
|
||||
denyAccess('card sessions need the machine build')
|
||||
return
|
||||
}
|
||||
boltCardProcessing.value = true
|
||||
nfcStatus.value = { state: 'processing', message: 'Reading card…' }
|
||||
nfcStatus.value = { state: 'processing', message: 'Verifying card…' }
|
||||
try {
|
||||
const scan: AccessScan = { kind: 'boltcard', externalId: parsed.externalId, lnurlw }
|
||||
const opened = await window.electronAPI.openCardSession({ lnurlw })
|
||||
if (!opened.ok) {
|
||||
nfcStatus.value = { state: 'declined', message: opened.reason }
|
||||
denyAccess(opened.reason)
|
||||
return
|
||||
}
|
||||
const scan: AccessScan = { kind: 'boltcard', externalId: opened.session.externalId }
|
||||
const outcome = await authorize(scan, accessControl.value.allowList, {
|
||||
salt: accessControl.value.salt,
|
||||
openEnrollment: accessControl.value.openEnrollment,
|
||||
})
|
||||
if (outcome.status === 'granted') {
|
||||
loadedBoltCard.value = { externalId: parsed.externalId, lnurlw }
|
||||
loadedBoltCard.value = opened.session
|
||||
nfcStatus.value = { state: 'accepted', message: 'Card accepted' }
|
||||
grantAccess(outcome.role, outcome.credentialIdHash)
|
||||
} else {
|
||||
|
|
@ -730,6 +761,10 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
nfcStatus.value = { state: 'declined', message: reason }
|
||||
denyAccess(reason, outcome.credentialIdHash)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[ATM] Bolt Card session failed:', e)
|
||||
nfcStatus.value = { state: 'error', message: 'Card verification failed' }
|
||||
denyAccess('card verification failed')
|
||||
} finally {
|
||||
boltCardProcessing.value = false
|
||||
}
|
||||
|
|
@ -740,19 +775,20 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
* Cash-out pulls via the stored lnurlw; cash-in resolves it to the card
|
||||
* wallet's lnurlp and pays. Reuses the tap handlers verbatim.
|
||||
*
|
||||
* The loaded card is SINGLE-SHOT: its lnurlw carries one SUN p/c pair and the
|
||||
* boltcards server consumes it on the first GET, so once the voucher has been
|
||||
* presented — accepted or declined — it can never succeed again. Drop it after
|
||||
* the first real attempt and tell the customer to re-tap; a fresh tap on the
|
||||
* cash screen goes straight through the normal tap-to-pay/receive path.
|
||||
* A 'skipped' outcome (guard bounced it, no server call) keeps the card.
|
||||
* The loaded session is SINGLE-SHOT: its withdraw/pay steps are keyed by one
|
||||
* server-side hit that the first use spends, so once a step has been
|
||||
* presented — accepted or declined — it can never succeed again. Drop the
|
||||
* session after the first real attempt and tell the customer to re-tap; a
|
||||
* fresh tap on the cash screen goes straight through the normal
|
||||
* tap-to-pay/receive path. A 'skipped' outcome (guard bounced it, no server
|
||||
* call) keeps the session.
|
||||
*/
|
||||
async function completeWithCard() {
|
||||
const card = loadedBoltCard.value
|
||||
if (!card) return
|
||||
let outcome: BoltCardOutcome = 'skipped'
|
||||
if (isCashOut.value) outcome = await handleBoltCardTap(card.lnurlw)
|
||||
else if (isCashIn.value) outcome = await handleBoltCardReceive(card.lnurlw)
|
||||
if (isCashOut.value) outcome = await handleBoltCardTap({ session: card })
|
||||
else if (isCashIn.value) outcome = await handleBoltCardReceive({ session: card })
|
||||
if (outcome === 'skipped') return
|
||||
loadedBoltCard.value = null
|
||||
if (outcome === 'declined') {
|
||||
|
|
@ -773,9 +809,9 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
if (isLocked.value) {
|
||||
void handleBoltCardEntry(lnurlw)
|
||||
} else if (isCashOut.value && nestedState.value === 'displayingInvoice') {
|
||||
void handleBoltCardTap(lnurlw)
|
||||
void handleBoltCardTap({ lnurlw })
|
||||
} else if (isCashIn.value && nestedState.value === 'displayingQR') {
|
||||
void handleBoltCardReceive(lnurlw)
|
||||
void handleBoltCardReceive({ lnurlw })
|
||||
}
|
||||
})
|
||||
window.electronAPI.onNfcStatus?.((status) => {
|
||||
|
|
@ -793,12 +829,12 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
|
||||
/** Dev/mock: simulate a cash-out tap with a pasted lnurlw (test without a card). */
|
||||
function simulateBoltCardTap(lnurlw: string) {
|
||||
void handleBoltCardTap(lnurlw)
|
||||
void handleBoltCardTap({ lnurlw })
|
||||
}
|
||||
|
||||
/** Dev/mock: simulate a cash-in (receive) tap with a pasted lnurlw. */
|
||||
function simulateBoltCardReceive(lnurlw: string) {
|
||||
void handleBoltCardReceive(lnurlw)
|
||||
void handleBoltCardReceive({ lnurlw })
|
||||
}
|
||||
|
||||
/** Dev/mock: simulate tapping a card at the locked screen (tap-to-enter). */
|
||||
|
|
@ -1831,7 +1867,7 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
boltCardProcessing,
|
||||
simulateBoltCardTap,
|
||||
simulateBoltCardReceive,
|
||||
// Tap-to-enter: card loaded at the locked screen, reused at Complete
|
||||
// Tap-to-enter: card session opened at the locked screen, reused at Complete
|
||||
loadedBoltCard,
|
||||
completeWithCard,
|
||||
simulateBoltCardEntry,
|
||||
|
|
|
|||
44
apps/machine/src/types/electron.d.ts
vendored
44
apps/machine/src/types/electron.d.ts
vendored
|
|
@ -22,6 +22,37 @@ export interface AccessControlConfig {
|
|||
allowList: AllowListEntry[]
|
||||
}
|
||||
|
||||
/**
|
||||
* A Bolt Card session opened at tap-to-enter (ADR-003). Wire payload from the
|
||||
* main process (electron/boltcard-session.ts) — mirrored here rather than
|
||||
* imported to avoid a cross-project electron↔renderer import. The withdraw and
|
||||
* pay steps are keyed by a single-use server-side hit; no card secret is held.
|
||||
*/
|
||||
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: {
|
||||
callback: string
|
||||
k1: string
|
||||
minWithdrawable?: number
|
||||
maxWithdrawable?: number
|
||||
} | 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: { callback: string; minSendable?: number; maxSendable?: number; metadata?: string }
|
||||
}
|
||||
|
||||
export type OpenCardSessionResult =
|
||||
| { ok: true; session: CardSession }
|
||||
| { ok: false; reason: string }
|
||||
|
||||
export interface RuntimeConfig {
|
||||
relayUrl: string
|
||||
/** LNbits nostr-transport server pubkey (hex, 64 chars). */
|
||||
|
|
@ -136,6 +167,19 @@ declare global {
|
|||
lnurlw: string
|
||||
amountMsat: number
|
||||
}) => Promise<{ ok: boolean; bolt11?: string; reason?: string }>
|
||||
/** Bolt Card tap-to-enter: open one verified session for a tapped card (spends the SUN). */
|
||||
openCardSession: (args: { lnurlw: string }) => Promise<OpenCardSessionResult>
|
||||
/** Cash-out via a session's withdraw step (no tap). */
|
||||
withdrawWithSession: (args: {
|
||||
withdraw: NonNullable<CardSession['withdraw']>
|
||||
bolt11: string
|
||||
amountMsat?: number
|
||||
}) => Promise<{ ok: boolean; reason?: string }>
|
||||
/** Cash-in via a session's pay step (no tap): a BOLT11 to pay. */
|
||||
resolveSessionInvoice: (args: {
|
||||
pay: CardSession['pay']
|
||||
amountMsat: number
|
||||
}) => Promise<{ ok: boolean; bolt11?: string; reason?: string }>
|
||||
applyOperatorCassettesConfig: (
|
||||
payload: { positions: Record<string, { denomination: number; count: number }> },
|
||||
eventCreatedAt: number
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue