feat(access): verified Bolt Card session at entry + hidden-by-default balance #93
19 changed files with 876 additions and 71 deletions
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 }>
|
||||
|
|
|
|||
78
apps/machine/src/components/CardChip.vue
Normal file
78
apps/machine/src/components/CardChip.vue
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* The Bolt Card loaded for this session (ADR-003 tap-to-enter): card label,
|
||||
* and the card wallet's balance HIDDEN BY DEFAULT behind an eye toggle — a
|
||||
* kiosk in a public space must not show a stranger's balance unasked. The
|
||||
* revealed line mirrors the LNbits wallet page: sats, then the fiat
|
||||
* equivalent in the wallet's own currency (Intl currency formatting), falling
|
||||
* back to the ATM's fiat at its display rate when the card server priced
|
||||
* nothing. Reveal state lives in the store and resets on re-lock.
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
import { useAtmStore } from '@/stores/atm'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Nfc, Eye, EyeOff } from 'lucide-vue-next'
|
||||
|
||||
const atmStore = useAtmStore()
|
||||
const card = computed(() => atmStore.loadedBoltCard)
|
||||
|
||||
const label = computed(() => {
|
||||
const c = card.value
|
||||
if (!c) return ''
|
||||
return c.cardName
|
||||
? `${c.cardName} · ••${c.externalId.slice(-4)}`
|
||||
: `Card ••${c.externalId.slice(-4)}`
|
||||
})
|
||||
const sats = computed(() =>
|
||||
card.value ? new Intl.NumberFormat().format(card.value.balanceSats) : ''
|
||||
)
|
||||
const fiat = computed(() => {
|
||||
const f = atmStore.loadedCardFiat
|
||||
if (!f) return null
|
||||
try {
|
||||
return new Intl.NumberFormat(undefined, { style: 'currency', currency: f.currency }).format(
|
||||
f.amount
|
||||
)
|
||||
} catch {
|
||||
return `${f.amount.toFixed(2)} ${f.currency}`
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="card"
|
||||
class="flex items-center gap-3 rounded-xl border border-border bg-card px-4 py-2 text-left lg:gap-4 lg:px-6 lg:py-3"
|
||||
>
|
||||
<Nfc class="size-6 shrink-0 text-primary lg:size-8" />
|
||||
<div class="flex min-w-0 flex-col leading-tight">
|
||||
<span class="truncate text-xs uppercase tracking-wide text-muted-foreground lg:text-sm">
|
||||
{{ label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="atmStore.cardBalanceRevealed"
|
||||
class="text-base font-semibold text-foreground lg:text-2xl"
|
||||
>
|
||||
{{ sats }} sats
|
||||
<span v-if="fiat" class="ml-2 font-normal text-muted-foreground">≈ {{ fiat }}</span>
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="text-base font-semibold tracking-widest text-muted-foreground lg:text-2xl"
|
||||
aria-label="Balance hidden"
|
||||
>
|
||||
••••••
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="ml-auto h-10 w-10 shrink-0 rounded-full lg:h-14 lg:w-14"
|
||||
:aria-label="atmStore.cardBalanceRevealed ? 'Hide balance' : 'Show balance'"
|
||||
@click="atmStore.toggleCardBalance()"
|
||||
>
|
||||
<EyeOff v-if="atmStore.cardBalanceRevealed" class="size-5 lg:size-7" />
|
||||
<Eye v-else class="size-5 lg:size-7" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -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,15 @@ 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)
|
||||
// The card balance is hidden by default on the public screen; the holder
|
||||
// reveals it with the eye toggle. Resets on re-lock.
|
||||
const cardBalanceRevealed = ref(false)
|
||||
const fiatCode = ref('USD')
|
||||
// Defaults are 0 — the operator's fee config (received via Nostr
|
||||
// kind-30078 `bitspire-fees:<atm_pubkey>` envelope from satmachineadmin)
|
||||
|
|
@ -454,6 +462,25 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
// start), so App.vue's LockedView branch never renders on a non-access machine.
|
||||
const isLocked = computed(() => currentState.value === 'locked')
|
||||
|
||||
/**
|
||||
* Fiat view of the loaded card's balance, the way the LNbits wallet page
|
||||
* prices it: the card server's own currency and rate first (the wallet's
|
||||
* currency, else the instance default); when it priced nothing, the ATM's
|
||||
* fiat at its display rate. Null when neither is available.
|
||||
*/
|
||||
const loadedCardFiat = computed<{ amount: number; currency: string } | null>(() => {
|
||||
const card = loadedBoltCard.value
|
||||
if (!card) return null
|
||||
if (card.currency && card.fiat !== null) return { amount: card.fiat, currency: card.currency }
|
||||
if (btcPrice.value && btcPrice.value > 0) {
|
||||
return { amount: (card.balanceSats / 1e8) * btcPrice.value, currency: fiatCode.value }
|
||||
}
|
||||
return null
|
||||
})
|
||||
function toggleCardBalance() {
|
||||
cardBalanceRevealed.value = !cardBalanceRevealed.value
|
||||
}
|
||||
|
||||
const isCashIn = computed(() => {
|
||||
const state = snapshot.value?.value
|
||||
return typeof state === 'object' && 'cashIn' in state
|
||||
|
|
@ -512,6 +539,7 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
// re-locks) so the next customer starts fresh — never carry a card over.
|
||||
if (state === 'locked' && loadedBoltCard.value) {
|
||||
loadedBoltCard.value = null
|
||||
cardBalanceRevealed.value = false
|
||||
}
|
||||
|
||||
// Detect network from first invoice we see
|
||||
|
|
@ -631,7 +659,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 +668,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 +701,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 +710,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 +739,44 @@ 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
|
||||
cardBalanceRevealed.value = false
|
||||
nfcStatus.value = { state: 'accepted', message: 'Card accepted' }
|
||||
grantAccess(outcome.role, outcome.credentialIdHash)
|
||||
} else {
|
||||
|
|
@ -730,6 +785,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 +799,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 +833,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 +853,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,8 +1891,11 @@ 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,
|
||||
cardBalanceRevealed,
|
||||
loadedCardFiat,
|
||||
toggleCardBalance,
|
||||
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
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { watch, computed, ref } from 'vue'
|
|||
import { useRouter } from 'vue-router'
|
||||
import { useAtmStore } from '@/stores/atm'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import CardChip from '@/components/CardChip.vue'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import QRCode from '@/components/QRCode.vue'
|
||||
|
|
@ -368,9 +369,7 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)
|
|||
v-if="atmStore.loadedBoltCard"
|
||||
class="flex w-full max-w-md flex-col items-center gap-2 pt-2"
|
||||
>
|
||||
<p class="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Card ••{{ atmStore.loadedBoltCard.externalId.slice(-4) }}
|
||||
</p>
|
||||
<CardChip class="w-full" />
|
||||
<Button
|
||||
class="w-full bg-success text-success-foreground"
|
||||
size="kiosk-lg"
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { watch, computed, ref } from 'vue'
|
|||
import { useRouter } from 'vue-router'
|
||||
import { useAtmStore } from '@/stores/atm'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import CardChip from '@/components/CardChip.vue'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import QRCode from '@/components/QRCode.vue'
|
||||
|
|
@ -333,9 +334,7 @@ function formatFiat(cents: number): string {
|
|||
v-if="atmStore.loadedBoltCard"
|
||||
class="flex w-full max-w-md flex-col items-center gap-2 pt-2"
|
||||
>
|
||||
<p class="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Card ••{{ atmStore.loadedBoltCard.externalId.slice(-4) }}
|
||||
</p>
|
||||
<CardChip class="w-full" />
|
||||
<Button
|
||||
class="w-full bg-success text-success-foreground"
|
||||
size="kiosk-lg"
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { useAtmStore } from '@/stores/atm'
|
|||
import { useBranding } from '@/composables/useBranding'
|
||||
import { initialContext } from '@bitSpire/state-machine'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import CardChip from '@/components/CardChip.vue'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import BitcoinIcon from '@/components/BitcoinIcon.vue'
|
||||
import QRCode from '@/components/QRCode.vue'
|
||||
|
|
@ -104,6 +105,8 @@ function handleCashOut() {
|
|||
>
|
||||
</span>
|
||||
</div>
|
||||
<!-- Tap-to-enter: the holder's card session, balance hidden until revealed -->
|
||||
<CardChip v-if="atmStore.loadedBoltCard" class="mt-1 w-full max-w-md" />
|
||||
</div>
|
||||
|
||||
<!-- Touch Zones -->
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@ The gate landed in aiolabs/bitspire#86 as **Bolt Card tap-to-enter**, not the np
|
|||
|
||||
- **Reader.** The batm3 / upboard reader is a USB CCID contactless reader (Feitian KP382, OMNIKEY 5022) driven by `pcscd` + `nfc-pcsc` in the **main process** (`electron/nfc-service.ts`, from #83), not the serial `/dev/ttyNFC` device or Web NFC. Taps reach the renderer over the existing `nfc:card-tapped` IPC and the store routes them by state (`locked` → enter; the cash screens → pay / receive). The renderer-side `AccessReader` abstraction, the camera npub-QR reader and the mock reader were never wired to anything and have been removed; `services/access/` now holds only `authorize`, the Bolt Card parser and the credential types.
|
||||
- **Credential.** Identity is the card's boltcards `external_id`, parsed **locally** from the tapped `lnurlw` (`AccessScan` kind `boltcard`), not the NFC UID. Only `hashId(external_id, salt)` is compared or logged. The `npub` variant (with its PIN second factor) stays in `authorize()` and its tests for a future non-card credential; `challenge` remains the v2 seam; the planned `uid` variant is gone.
|
||||
- **Soft entry, verify-at-payment.** A tap yields a single-use SUN `p`/`c`. Verifying it at entry would spend the voucher we want to reuse at Complete, so entry makes **no server call**. The stored `lnurlw` is presented once, at Complete, through the #83 / #84 payment paths, and that is where the cryptographic check happens. Consequence: the loaded card is single-shot — after the first Complete attempt, accepted or declined, the store drops it and the customer re-taps.
|
||||
- **Open enrollment is not a security boundary.** With `openEnrollment` on (the current posture on every gated machine), any NDEF tag whose URL contains `/scan/<id>` unlocks the terminal. The gate keeps casual users off the menu; money still only moves on a valid SUN. Closing this — a provisioned allow-list, or a verify-at-entry variant that spends one tap — is tracked in aiolabs/bitspire#91.
|
||||
- **Verified entry via `/session`** (superseding #86's soft entry). A tap yields a single-use SUN `p`/`c`, and verifying it at entry would have spent the voucher Complete needed, so #86 made no server call at entry. The fork now exposes `/session/<id>?p=&c=` (`docs/boltcard-session.md`): it spends the SUN **once**, proves a genuine non-replayed card, returns the card wallet's balance + fiat, and hands back the hit-keyed LUD-03 / LUD-06 second steps so Complete still needs no second tap. The session is single-shot — after the first Complete attempt, accepted or declined, the store drops it and the customer re-taps. The balance is shown on the idle menu and cash screens **hidden by default** behind an eye toggle (`CardChip.vue`), priced the way the LNbits wallet page prices it (wallet currency, else instance default, else the ATM's fiat at its own rate).
|
||||
- **Open enrollment is still not a security boundary.** `/session` proves the card is genuine *to the server the card names*: the session URL is derived from the tapped `lnurlw`'s host, so with `openEnrollment` on a forged NDEF tag pointing at a server that answers `authenticated: true` still unlocks the terminal. Money is unaffected (cash-out never dispenses without `PAYMENT_RECEIVED`; cash-in pays where the holder pointed). Closing the gate means pinning the accepted card-server host(s) and/or a provisioned allow-list — tracked in aiolabs/bitspire#91.
|
||||
- **Session semantics.** One tap = one session; every transaction terminal state returns to `locked`. Inactivity (60 s on the idle menu) and the absolute cap (10 min) are measured at the DOM layer (`useSessionSecurity`) because an XState `after` cannot observe touches. Both send `END_SESSION`, which the machine accepts **from `idle` only**, so a timer can never abandon stacked bills or an in-flight dispense. An explicit End Session button re-locks immediately.
|
||||
- **Config.** `ACCESS_CONTROL_ENABLED` / `ACCESS_OPEN_ENROLLMENT` / `ACCESS_DEV_UNLOCK` / `ACCESS_SALT` from env, overridden by `/var/lib/bitspire/access.json` (pushed with `deploy/nixos/provision-access.sh`, no rebuild). `devUnlock` defaults **off**.
|
||||
- **Audit** is still a console stub; the state.db write promised in decision 7 is tracked in aiolabs/bitspire#90.
|
||||
|
|
|
|||
|
|
@ -99,6 +99,12 @@ wallet …".
|
|||
|
||||
## Notes
|
||||
|
||||
- **Tap-to-enter uses `/session` instead.** When the access gate is on, the
|
||||
card was already verified at entry and the ATM holds the LUD-06 second step
|
||||
from `/session` (see `boltcard-session.md`), so Complete calls `pay.callback`
|
||||
directly and never touches `/pay`. `/pay` remains the path for a card tapped
|
||||
directly on the cash-in screen (gate off, or a second card).
|
||||
|
||||
- **Double-payout:** the cash-in screen still shows the LNURL-withdraw QR as a
|
||||
fallback (customer _pulls_). A tap _pays_ instead. The ATM gates re-entry
|
||||
while a tap is in flight and leaves `displayingQR` on success; the withdraw
|
||||
|
|
|
|||
117
docs/boltcard-session.md
Normal file
117
docs/boltcard-session.md
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
# Bolt Card session — one verified tap for a terminal visit
|
||||
|
||||
Wire contract for the `/session` endpoint the ATM's access gate (ADR-003
|
||||
tap-to-enter) uses on the LNbits `boltcards` extension. Implemented in the
|
||||
aiolabs fork (`git.atitlan.io/aiolabs/boltcards`, `v1.1.1-aio.3`+); consumed by
|
||||
`apps/machine/electron/boltcard-session.ts`.
|
||||
|
||||
## Why a session
|
||||
|
||||
A Bolt Card tap yields a single-use SUN `p`/`c`: the card server verifies it
|
||||
and advances the card's read counter, so any endpoint that checks it — `/scan`,
|
||||
`/pay`, `/verify` — spends it. The gate wants two things from one tap:
|
||||
|
||||
1. **Verify at entry** — a genuine, non-replayed card unlocks the terminal and
|
||||
we can show the holder their balance.
|
||||
2. **Complete without a second tap** — the buy or sell later in the visit
|
||||
moves sats with the same card.
|
||||
|
||||
`/session` does the verification once and hands back the _second steps_ of
|
||||
both LNURL flows, keyed by a single-use server-side `hit` — the same bearer
|
||||
`/scan` (as `k1`) and `/pay` already issue. The terminal holds no `p`/`c`
|
||||
afterwards.
|
||||
|
||||
## Endpoint
|
||||
|
||||
```
|
||||
GET /boltcards/api/v1/session/{external_id}?p={p}&c={c}
|
||||
```
|
||||
|
||||
Same URL shape as `/scan/{external_id}?p=&c=` with `scan` → `session`; the ATM
|
||||
derives it by string substitution on the tapped `lnurlw`
|
||||
(`scanUrlToSessionUrl()`). SUN verification is byte-for-byte `/scan`'s (shared
|
||||
helper in the fork): unknown / disabled card, UID mismatch, bad CMAC, replayed
|
||||
counter all reject with `/scan`'s reasons. On success the counter advances and
|
||||
one `hit` is recorded.
|
||||
|
||||
## Response
|
||||
|
||||
```json
|
||||
{
|
||||
"authenticated": true,
|
||||
"external_id": "abc123",
|
||||
"card_name": "Alice",
|
||||
"balance_msat": 123456000,
|
||||
"currency": "USD",
|
||||
"fiat": 98.76,
|
||||
"withdraw": {
|
||||
"callback": "https://lnbits.l484.com/boltcards/api/v1/lnurl/cb/<hit>",
|
||||
"k1": "<hit>",
|
||||
"minWithdrawable": 1000,
|
||||
"maxWithdrawable": 50000000
|
||||
},
|
||||
"withdraw_blocked_reason": null,
|
||||
"pay": {
|
||||
"callback": "https://lnbits.l484.com/boltcards/api/v1/pay/cb/<hit>",
|
||||
"minSendable": 1000,
|
||||
"maxSendable": 50000000,
|
||||
"metadata": "[[\"text/plain\",\"Bolt Card top-up\"]]"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `balance_msat` — the card wallet's balance. Display only.
|
||||
- `currency` / `fiat` — the balance priced the way the LNbits wallet page does
|
||||
it: the wallet's own currency (per-wallet setting) first, else the instance's
|
||||
default accounting currency, at the server's rate. `null` when the server has
|
||||
no currency or the rate lookup failed; the ATM then prices the sats itself in
|
||||
its own fiat at its display rate. A rate failure never fails the session.
|
||||
- `withdraw` — the LUD-03 second step. The ATM calls
|
||||
`callback?k1=<hit>&pr=<bolt11>` at cash-out Complete. `null` with
|
||||
`withdraw_blocked_reason` set when `/scan` would have refused (daily limit
|
||||
spent); cash-in stays possible.
|
||||
- `pay` — the LUD-06 second step. The ATM calls `callback?amount=<msat>` at
|
||||
cash-in Complete and pays the returned BOLT11 over its own nostr transport.
|
||||
- Limits are the card's `tx_limit`, as on `/scan` and `/pay`.
|
||||
|
||||
Rejection:
|
||||
|
||||
```json
|
||||
{ "authenticated": false, "reason": "This link is already used." }
|
||||
```
|
||||
|
||||
`reason` is surfaced verbatim on the locked screen — terse, non-sensitive.
|
||||
|
||||
## Semantics of the hit
|
||||
|
||||
- The first withdraw that uses the hit spends it (`spent = true`), exactly as
|
||||
after a `/scan`; a second withdraw is refused with "Payment already claimed."
|
||||
- A top-up does not mark the hit spent (as `/pay` today).
|
||||
- The ATM treats the whole session as single-shot regardless: after the first
|
||||
Complete attempt, accepted or declined, it drops the session and asks for a
|
||||
re-tap (`completeWithCard` in `stores/atm.ts`).
|
||||
- Hits do not expire server-side. The ATM's session security (60 s idle,
|
||||
10 min cap, End Session) bounds how long one is held.
|
||||
|
||||
## Flow
|
||||
|
||||
```
|
||||
locked ── tap ─▶ GET /session/<id>?p=&c= (spends the SUN)
|
||||
├─ authenticated:false → stay locked, show reason
|
||||
└─ authenticated:true → authorize(external_id) → idle, session held
|
||||
CardChip: "Alice · ••c123 •••••• [eye]"
|
||||
sell: pick amount → Complete Sale → withdraw.callback?k1&pr → PAYMENT_RECEIVED → dispense
|
||||
buy: insert cash → Complete Purchase → pay.callback?amount → BOLT11 → ATM pays → complete
|
||||
… re-lock (End Session / idle / complete) drops the session
|
||||
```
|
||||
|
||||
## Trust boundary (read this)
|
||||
|
||||
The ATM derives the session URL from the **card's own `lnurlw` host**. With
|
||||
`openEnrollment` on, a forged NDEF tag pointing at an attacker's server that
|
||||
answers `{"authenticated": true, …}` still unlocks the terminal. Money is not
|
||||
at risk — a fake server can only make the ATM pay an invoice the holder chose
|
||||
(cash-in) or accept a pull it never honours (cash-out never dispenses without
|
||||
`PAYMENT_RECEIVED`) — but the _gate_ is only as trustworthy as the host it was
|
||||
told to ask. Closing that means pinning the card-server host(s) the gate
|
||||
accepts; tracked in aiolabs/bitspire#91.
|
||||
Loading…
Add table
Add a link
Reference in a new issue