From 735132b0321d1e58174fc02bbafa1b298980897e Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 20 Sep 2026 17:07:16 +0200 Subject: [PATCH 1/3] feat(machine): open a verified Bolt Card session at tap-to-enter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../machine/electron/boltcard-session.test.ts | 125 +++++++++++++ apps/machine/electron/boltcard-session.ts | 167 ++++++++++++++++++ apps/machine/electron/lnurl-pay.test.ts | 47 ++++- apps/machine/electron/lnurl-pay.ts | 32 +++- apps/machine/electron/lnurl-withdraw.test.ts | 43 ++++- apps/machine/electron/lnurl-withdraw.ts | 52 +++++- apps/machine/electron/main.ts | 40 ++++- apps/machine/electron/preload.ts | 17 ++ .../access/__tests__/authorize.test.ts | 20 +-- apps/machine/src/services/access/types.ts | 13 +- apps/machine/src/stores/atm.ts | 102 +++++++---- apps/machine/src/types/electron.d.ts | 44 +++++ 12 files changed, 639 insertions(+), 63 deletions(-) create mode 100644 apps/machine/electron/boltcard-session.test.ts create mode 100644 apps/machine/electron/boltcard-session.ts diff --git a/apps/machine/electron/boltcard-session.test.ts b/apps/machine/electron/boltcard-session.test.ts new file mode 100644 index 0000000..58e30f4 --- /dev/null +++ b/apps/machine/electron/boltcard-session.test.ts @@ -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' }) + }) +}) diff --git a/apps/machine/electron/boltcard-session.ts b/apps/machine/electron/boltcard-session.ts new file mode 100644 index 0000000..a799178 --- /dev/null +++ b/apps/machine/electron/boltcard-session.ts @@ -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/?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/?p=&c=`; the session endpoint is its sibling + * `…/boltcards/api/v1/session/?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 => 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 { + 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, + }, + } +} diff --git a/apps/machine/electron/lnurl-pay.test.ts b/apps/machine/electron/lnurl-pay.test.ts index 165688f..4dbe150 100644 --- a/apps/machine/electron/lnurl-pay.test.ts +++ b/apps/machine/electron/lnurl-pay.test.ts @@ -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.' }) + }) +}) diff --git a/apps/machine/electron/lnurl-pay.ts b/apps/machine/electron/lnurl-pay.ts index 91da90c..cb408e6 100644 --- a/apps/machine/electron/lnurl-pay.ts +++ b/apps/machine/electron/lnurl-pay.ts @@ -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 { + 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 { + const ctx: Ctx = { doFetch: opts.fetchImpl ?? fetch, timeoutMs: opts.timeoutMs ?? 15_000 } + return requestInvoice(step, amountMsat, ctx) } diff --git a/apps/machine/electron/lnurl-withdraw.test.ts b/apps/machine/electron/lnurl-withdraw.test.ts index 4d14611..cceda64 100644 --- a/apps/machine/electron/lnurl-withdraw.test.ts +++ b/apps/machine/electron/lnurl-withdraw.test.ts @@ -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', + }) + }) +}) diff --git a/apps/machine/electron/lnurl-withdraw.ts b/apps/machine/electron/lnurl-withdraw.ts index eb93c08..35429ce 100644 --- a/apps/machine/electron/lnurl-withdraw.ts +++ b/apps/machine/electron/lnurl-withdraw.ts @@ -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 { } 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 { + 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) }) diff --git a/apps/machine/electron/main.ts b/apps/machine/electron/main.ts index 018cef6..0331392 100644 --- a/apps/machine/electron/main.ts +++ b/apps/machine/electron/main.ts @@ -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 => { + 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)) diff --git a/apps/machine/electron/preload.ts b/apps/machine/electron/preload.ts index 8ced2d5..df4ba0b 100644 --- a/apps/machine/electron/preload.ts +++ b/apps/machine/electron/preload.ts @@ -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 => + 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 diff --git a/apps/machine/src/services/access/__tests__/authorize.test.ts b/apps/machine/src/services/access/__tests__/authorize.test.ts index 521cfcb..9fc0c22 100644 --- a/apps/machine/src/services/access/__tests__/authorize.test.ts +++ b/apps/machine/src/services/access/__tests__/authorize.test.ts @@ -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' }) }) }) diff --git a/apps/machine/src/services/access/types.ts b/apps/machine/src/services/access/types.ts index 26ae8d5..4c7a4e3 100644 --- a/apps/machine/src/services/access/types.ts +++ b/apps/machine/src/services/access/types.ts @@ -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 } diff --git a/apps/machine/src/stores/atm.ts b/apps/machine/src/stores/atm.ts index 07eee0f..34acf7e 100644 --- a/apps/machine/src/stores/atm.ts +++ b/apps/machine/src/stores/atm.ts @@ -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(null) const fiatCode = ref('USD') // Defaults are 0 — the operator's fee config (received via Nostr // kind-30078 `bitspire-fees:` 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 { + async function handleBoltCardTap(source: BoltCardSource): Promise { 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 { + async function handleBoltCardReceive(source: BoltCardSource): Promise { 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, diff --git a/apps/machine/src/types/electron.d.ts b/apps/machine/src/types/electron.d.ts index 95425f6..1ab609c 100644 --- a/apps/machine/src/types/electron.d.ts +++ b/apps/machine/src/types/electron.d.ts @@ -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 + /** Cash-out via a session's withdraw step (no tap). */ + withdrawWithSession: (args: { + withdraw: NonNullable + 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 }, eventCreatedAt: number From 6779d8ef557f01fd8bac38f2f3cbcabff05ad597 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 20 Sep 2026 17:07:17 +0200 Subject: [PATCH 2/3] feat(machine): show the card holder's balance, hidden by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tap-to-enter session is effectively the holder logging into their card wallet, so show its balance — but a kiosk in a public place must not display a stranger's balance unasked. CardChip renders the card label with the balance masked (••••••) and an eye toggle; revealed, it mirrors the LNbits wallet page: sats, then the fiat equivalent formatted with Intl currency style. Fiat comes from the card server (the wallet's own currency, else the instance default, at its rate) and falls back to the ATM's fiat at its display rate when the server priced nothing. Shown on the idle menu and both cash screens; reveal state resets on re-lock. Co-Authored-By: Claude Fable 5.1 --- apps/machine/src/components/CardChip.vue | 78 ++++++++++++++++++++++++ apps/machine/src/stores/atm.ts | 27 ++++++++ apps/machine/src/views/CashInView.vue | 5 +- apps/machine/src/views/CashOutView.vue | 5 +- apps/machine/src/views/IdleView.vue | 3 + 5 files changed, 112 insertions(+), 6 deletions(-) create mode 100644 apps/machine/src/components/CardChip.vue diff --git a/apps/machine/src/components/CardChip.vue b/apps/machine/src/components/CardChip.vue new file mode 100644 index 0000000..c59060c --- /dev/null +++ b/apps/machine/src/components/CardChip.vue @@ -0,0 +1,78 @@ + + + diff --git a/apps/machine/src/stores/atm.ts b/apps/machine/src/stores/atm.ts index 34acf7e..fd00df2 100644 --- a/apps/machine/src/stores/atm.ts +++ b/apps/machine/src/stores/atm.ts @@ -324,6 +324,9 @@ export const useAtmStore = defineStore('atm', () => { // hit-keyed withdraw/pay steps plus balance + fiat for display. Cleared when // the machine re-locks. Never logged. const loadedBoltCard = ref(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:` envelope from satmachineadmin) @@ -459,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 @@ -517,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 @@ -753,6 +776,7 @@ export const useAtmStore = defineStore('atm', () => { }) if (outcome.status === 'granted') { loadedBoltCard.value = opened.session + cardBalanceRevealed.value = false nfcStatus.value = { state: 'accepted', message: 'Card accepted' } grantAccess(outcome.role, outcome.credentialIdHash) } else { @@ -1869,6 +1893,9 @@ export const useAtmStore = defineStore('atm', () => { simulateBoltCardReceive, // Tap-to-enter: card session opened at the locked screen, reused at Complete loadedBoltCard, + cardBalanceRevealed, + loadedCardFiat, + toggleCardBalance, completeWithCard, simulateBoltCardEntry, diff --git a/apps/machine/src/views/CashInView.vue b/apps/machine/src/views/CashInView.vue index b75a2a4..a86acd0 100644 --- a/apps/machine/src/views/CashInView.vue +++ b/apps/machine/src/views/CashInView.vue @@ -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" > -

- Card ••{{ atmStore.loadedBoltCard.externalId.slice(-4) }} -

+