From 46e52f6598eb9e69aa637af069c62f247aaaafe4 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 19 Sep 2026 09:58:42 +0200 Subject: [PATCH 01/49] chore: scrub "Lamassu" from shipped labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The kiosk's still read "Lamassu ATM" — visible as the browser tab on the public demo, and inherited by the Electron window. The product has been bitSpire since the rename; Lamassu belongs in the provenance credits (README, the c0b69d1 boundary note), not on the artifact. Rename the user-facing labels that ship: the page title, the flake description (surfaces in `nix flake metadata`), the ISO build banner, the header comments on the live-USB config / udev rules / app derivation that land on the machine image, and the workspace packages' descriptions. Deliberately NOT touched, because they are identifiers rather than labels and renaming them has deployed-machine consequences: - VITE_LAMASSU_MACHINE_MODEL / VITE_LAMASSU_FIAT_CODE (provisioned .env) - LamassuEventKind (exported enum) - localStorage keys lamassu-theme / lamassu-color-mode (would reset every machine's stored theme) - docker container names + devenv scripts (dev-only) - the packages/hal Cargo crate name Hardware names in HAL driver comments ("Lamassu Sintra", "Douro", "Tejo") stay: those are the physical machines' real names — that IS the credit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013A6683cCHnQxFUosx1krY4 --- apps/machine/index.html | 2 +- deploy/nixos/build-iso.sh | 2 +- deploy/nixos/live.nix | 2 +- deploy/nixos/udev/99-bitspire-hardware.rules | 2 +- flake.nix | 2 +- nix/mkAtmApp.nix | 2 +- packages/hal/package.json | 4 ++-- packages/hal/src/index.ts | 2 +- packages/nostr-client/package.json | 2 +- packages/nostr-client/src/client.ts | 2 +- packages/nostr-client/src/events.ts | 2 +- packages/nostr-client/src/index.ts | 2 +- packages/nostr-client/src/types.ts | 2 +- packages/ui-shared/package.json | 2 +- packages/ui-shared/src/index.ts | 2 +- 15 files changed, 16 insertions(+), 16 deletions(-) diff --git a/apps/machine/index.html b/apps/machine/index.html index cc57859..e461ca2 100644 --- a/apps/machine/index.html +++ b/apps/machine/index.html @@ -18,7 +18,7 @@ http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws: wss: http: https:; img-src 'self' data: blob:; font-src 'self'; frame-src 'none'; object-src 'none'" /> - <title>Lamassu ATM + bitSpire ATM From ebb07ce22d2227f9b4c3e58da4ae256ce88fe6cf Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 22 Sep 2026 18:16:06 +0200 Subject: [PATCH 23/49] fix(lightning): arm the cash-out settlement watch before the invoice is shown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A one-tap Bolt Card Complete settles in about a second. Subscribing took two sequential nostr round trips first — decode_payment to recover the hash, then subscribe_payments — roughly eight seconds against a remote relay, because the watch was armed when the invoice was DISPLAYED. The settlement push is an ephemeral event with no replay, so it fired before anything was listening: the machine sat on a paid invoice until it timed out and the customer's sats were taken with no cash dispensed. On sintra 2026-09-22 this hit both one-tap sells (26,660 and 26,500 sats). The old two-tap flow only ever worked because fumbling with the card covered the window; at 07:18 the push landed two seconds after the watch went live. Three layered defences, one mechanism: - Arm at creation. generateInvoice does not resolve until the watch is live, so the invoice cannot reach the screen unwatched. - Take the payment hash from the create_invoice response instead of decoding it back off the bolt11 — the value was already in hand and the round trip was half the window (repo guidance says as much). - Latch and poll. A settlement that still beats the consumer is replayed on attach, and get_payment runs alongside the subscription so a push that is lost or never sent cannot strand a payment either. Co-Authored-By: Claude Fable 5.1 --- .../__tests__/settlement-watch.test.ts | 148 ++++++++++++ apps/machine/src/services/lightning.ts | 226 ++++++++++++++---- 2 files changed, 331 insertions(+), 43 deletions(-) create mode 100644 apps/machine/src/services/__tests__/settlement-watch.test.ts diff --git a/apps/machine/src/services/__tests__/settlement-watch.test.ts b/apps/machine/src/services/__tests__/settlement-watch.test.ts new file mode 100644 index 0000000..87a136b --- /dev/null +++ b/apps/machine/src/services/__tests__/settlement-watch.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { initialContext, type ATMContext } from '@bitSpire/state-machine' +import type { LnbitsClient, LnbitsPayment } from '@bitSpire/lnbits' +import { createATMServices } from '../lightning' + +/** + * The cash-out settlement watch (2026-09-22 regression). + * + * A one-tap Bolt Card Complete settles in about a second; subscribing over + * nostr takes several. When the watch was armed at display time the push — + * an ephemeral event with no replay — fired before anything listened, and the + * machine sat on a paid invoice until it timed out, taking the sats without + * dispensing. These pin the three defences: arm before the invoice is handed + * out, latch a settlement that still beats the consumer, and poll so a push + * that never arrives cannot strand a payment. + */ + +const BOLT11 = 'lnbc265u1p4t9gthpp5td44vd9a0s5er' +const HASH = 'aa'.repeat(32) + +const paid = (preimage = 'PREIMAGE'): LnbitsPayment => + ({ payment_hash: HASH, status: 'success', preimage }) as LnbitsPayment + +function makeLnbits(over: Partial> = {}) { + let pushTo: ((p: LnbitsPayment) => void) | null = null + const api = { + createInvoice: vi.fn(async () => ({ payment_request: BOLT11, payment_hash: HASH })), + subscribePayments: vi.fn( + async (_w: unknown, _f: unknown, onPush: (p: LnbitsPayment) => void) => { + pushTo = onPush + return 'sub-1' + } + ), + getPayment: vi.fn(async (): Promise => null), + unsubscribe: vi.fn(async () => true), + decodePayment: vi.fn(async () => ({ payment_hash: HASH })), + ...over, + } + return { api, push: (p: LnbitsPayment) => pushTo?.(p) } +} + +const ctx = (): ATMContext => ({ ...initialContext, satsAmount: 26_500, exchangeRate: 1325 }) + +const services = (l: { api: Record }) => + createATMServices(vi.fn(), l.api as unknown as LnbitsClient, 'wallet-1') + +beforeEach(() => vi.useFakeTimers()) +afterEach(() => vi.useRealTimers()) + +describe('cash-out settlement watch', () => { + it('is armed before the invoice is handed out, without decoding it back', async () => { + const l = makeLnbits() + const invoice = await services(l).generateInvoice(ctx()) + + expect(invoice).toBe(BOLT11) + // Armed during generateInvoice, not later at display time. + expect(l.api.subscribePayments).toHaveBeenCalledTimes(1) + expect(l.api.subscribePayments.mock.calls[0]![1]).toMatchObject({ payment_hash: HASH }) + // The hash came from the creation response, so no round trip to recover it. + expect(l.api.decodePayment).not.toHaveBeenCalled() + }) + + it('replays a settlement that beat the consumer (the race that lost payments)', async () => { + const l = makeLnbits() + const svc = services(l) + const invoice = await svc.generateInvoice(ctx()) + + // Card pays before the machine reaches displayingInvoice. + l.push(paid()) + + const onPaid = vi.fn() + svc.watchInvoice(invoice, onPaid) + await vi.advanceTimersByTimeAsync(0) + + expect(onPaid).toHaveBeenCalledWith('PREIMAGE') + }) + + it('delivers a push that arrives while the consumer is attached', async () => { + const l = makeLnbits() + const svc = services(l) + const invoice = await svc.generateInvoice(ctx()) + + const onPaid = vi.fn() + svc.watchInvoice(invoice, onPaid) + l.push(paid('LATER')) + + expect(onPaid).toHaveBeenCalledWith('LATER') + }) + + it('settles from the poll when no push ever arrives', async () => { + const l = makeLnbits() + l.api.getPayment = vi.fn(async () => paid('VIA-POLL')) + const svc = services(l) + const invoice = await svc.generateInvoice(ctx()) + + const onPaid = vi.fn() + svc.watchInvoice(invoice, onPaid) + expect(onPaid).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(7_000) + expect(onPaid).toHaveBeenCalledWith('VIA-POLL') + }) + + it('polls even when arming the subscription fails', async () => { + const l = makeLnbits() + l.api.subscribePayments = vi.fn(async () => { + throw new Error('relay down') + }) + l.api.getPayment = vi.fn(async () => paid('VIA-POLL')) + const svc = services(l) + const invoice = await svc.generateInvoice(ctx()) + + const onPaid = vi.fn() + svc.watchInvoice(invoice, onPaid) + await vi.advanceTimersByTimeAsync(7_000) + + expect(onPaid).toHaveBeenCalledWith('VIA-POLL') + }) + + it('reports each settlement once, whichever path saw it first', async () => { + const l = makeLnbits() + l.api.getPayment = vi.fn(async () => paid('VIA-POLL')) + const svc = services(l) + const invoice = await svc.generateInvoice(ctx()) + + const onPaid = vi.fn() + svc.watchInvoice(invoice, onPaid) + l.push(paid('VIA-PUSH')) + await vi.advanceTimersByTimeAsync(20_000) + + expect(onPaid).toHaveBeenCalledTimes(1) + expect(onPaid).toHaveBeenCalledWith('VIA-PUSH') + }) + + it('stops polling and unsubscribes when the transaction ends', async () => { + const l = makeLnbits() + const svc = services(l) + const invoice = await svc.generateInvoice(ctx()) + const stop = svc.watchInvoice(invoice, vi.fn()) + + stop() + expect(l.api.unsubscribe).toHaveBeenCalledWith(undefined, 'sub-1') + + const pollsAfterStop = (l.api.getPayment as ReturnType).mock.calls.length + await vi.advanceTimersByTimeAsync(30_000) + expect((l.api.getPayment as ReturnType).mock.calls.length).toBe(pollsAfterStop) + }) +}) diff --git a/apps/machine/src/services/lightning.ts b/apps/machine/src/services/lightning.ts index aa6b52c..ee9a441 100644 --- a/apps/machine/src/services/lightning.ts +++ b/apps/machine/src/services/lightning.ts @@ -217,7 +217,7 @@ export interface LightningBackend { }): Promise<{ paymentRequest: string; paymentHash?: string }> payInvoice( bolt11: string, - amountSats: number, + amountSats: number ): Promise<{ success: boolean; preimage?: string; error?: string }> } @@ -434,12 +434,12 @@ export async function initializeLightningServices(options?: { console.log( '[Lightning] Relay(s):', relays.join(', '), - envRelay ? '(env)' : transport?.relays.length ? '(pairing)' : '(default)', + envRelay ? '(env)' : transport?.relays.length ? '(pairing)' : '(default)' ) console.log( '[Lightning] LNbits server pubkey:', CONFIG.lnbitsServerPubkey || '(not configured)', - envPubkey ? '(env)' : transport?.lnbitsServerPubkey ? '(pairing)' : '', + envPubkey ? '(env)' : transport?.lnbitsServerPubkey ? '(pairing)' : '' ) // Operator pubkey provenance. Today the ONLY source is VITE_OPERATOR_PUBKEYS // (env). An empty set disables the fees/operator-config services → the machine @@ -449,7 +449,7 @@ export async function initializeLightningServices(options?: { '[Lightning] Operator pubkey(s):', CONFIG.operatorPubkeys.length ? CONFIG.operatorPubkeys.join(', ') + ' (env)' - : '(none — fee/operator config gated until a server-delivered operator pubkey; #70 P1)', + : '(none — fee/operator config gated until a server-delivered operator pubkey; #70 P1)' ) // Strict mode: validate the RESOLVED config is production-ready (no @@ -473,7 +473,7 @@ export async function initializeLightningServices(options?: { if (!CONFIG.lnbitsServerPubkey) { throw new Error( '[Lightning] LNbits server pubkey is required — set VITE_LNBITS_SERVER_PUBKEY ' + - 'or pair with a seed that carries lnbits_npub (aiolabs/bitspire#70).', + 'or pair with a seed that carries lnbits_npub (aiolabs/bitspire#70).' ) } @@ -542,7 +542,11 @@ export async function initializeLightningServices(options?: { const mc = await lnbits.getMachineConfig() if (mc.operator_pubkey) { CONFIG.operatorPubkeys = [mc.operator_pubkey] - console.log('[Lightning] Operator pubkey(s):', mc.operator_pubkey, '(server-delivered, #70 P1)') + console.log( + '[Lightning] Operator pubkey(s):', + mc.operator_pubkey, + '(server-delivered, #70 P1)' + ) } if (mc.fee_config && isElectron && window.electronAPI) { // Persist the server-delivered fee config so atm.ts's awaiting-fees gate @@ -555,17 +559,17 @@ export async function initializeLightningServices(options?: { cashOutFeeFraction: mc.fee_config.cash_out_fee_fraction, schemaVersion: mc.fee_config.schema_version, }, - mc.created_at, + mc.created_at ) console.log( '[Lightning] Server-delivered fee config:', - applied.applied ? 'applied' : `skipped (${applied.reason})`, + applied.applied ? 'applied' : `skipped (${applied.reason})` ) } } catch (e) { console.warn( '[Lightning] get_machine_config unavailable; falling back to env/kind-30078 for operator config:', - (e as Error).message, + (e as Error).message ) } } @@ -671,7 +675,7 @@ export async function initializeLightningServices(options?: { } }, lnbits, - lnbitsWalletId, + lnbitsWalletId ) return { @@ -706,13 +710,142 @@ export async function initializeLightningServices(options?: { /** * Create ATMServices implementation using the LNbits nostr-transport. */ -function createATMServices( +export function createATMServices( onPaymentSuccess: (preimage: string) => void, lnbits: LnbitsClient, - lnbitsWalletId: string, + lnbitsWalletId: string ): ATMServices { const onPaymentCallback = onPaymentSuccess + // ── Cash-out settlement watch ─────────────────────────────────────────── + /** + * A watch on one cash-out invoice, armed the moment the invoice exists and + * consumed later by the state machine's `displayingInvoice` actor. + * + * Arming at creation rather than at display closes a race that swallowed + * real payments on 2026-09-22 (sintra). Subscribing costs a nostr round + * trip — about four seconds against a remote relay — while a one-tap Bolt + * Card Complete settles in roughly one. The settlement push is an ephemeral + * event with no replay, so it fired before anything was listening and the + * machine sat on a paid invoice until it timed out, taking the sats without + * dispensing. The old two-tap flow only worked because fumbling with the + * card covered the gap. + * + * Three defences, in order: the invoice is not returned until its watch is + * armed; a settlement that still beats the UI is latched and replayed when + * the consumer attaches; and a poll runs alongside the subscription so a + * lost or unsent push cannot strand a payment either way. + */ + interface InvoiceWatch { + paymentHash: string + subId: string | null + /** Preimage seen before a consumer attached; replayed on attach. */ + settled: string | null + consumer: ((preimage: string) => void) | null + poll: ReturnType | null + released: boolean + } + const invoiceWatches = new Map() + + // Backstop cadence. One cheap RPC; on the money path a little extra relay + // traffic is worth far more than a payment that lands with no cash. + const SETTLEMENT_POLL_MS = 6_000 + + function stopInvoiceWatchPoll(watch: InvoiceWatch): void { + if (watch.poll) { + clearInterval(watch.poll) + watch.poll = null + } + } + + /** Deliver a settlement exactly once, to the consumer or into the latch. */ + function settleInvoiceWatch(watch: InvoiceWatch, preimage: string, via: string): void { + if (watch.released || watch.settled) return + watch.settled = preimage + stopInvoiceWatchPoll(watch) + console.log(`[ATM Service] Invoice paid (${via})!`) + watch.consumer?.(preimage) + } + + function startInvoiceWatchPoll(watch: InvoiceWatch): void { + let inFlight = false + watch.poll = setInterval(() => { + if (inFlight || watch.settled || watch.released) return + inFlight = true + void lnbits + .getPayment(watch.paymentHash) + .then((payment) => { + if (payment?.status === 'success') { + settleInvoiceWatch(watch, payment.preimage ?? 'payment-confirmed', 'poll') + } + }) + .catch(() => { + /* transport blip — the next tick retries */ + }) + .finally(() => { + inFlight = false + }) + }, SETTLEMENT_POLL_MS) + } + + /** Arm the watch for a freshly created invoice. Resolves once it is live. */ + async function armInvoiceWatch(bolt11: string, paymentHash: string): Promise { + const startedAt = Date.now() + const watch: InvoiceWatch = { + paymentHash, + subId: null, + settled: null, + consumer: null, + poll: null, + released: false, + } + invoiceWatches.set(bolt11, watch) + try { + // walletId omitted: payment_hash is the natural primary key for "wait + // for THIS invoice to settle." Under path B + // (NOSTR_TRANSPORT_ROSTER_REQUIRED=true) lnbits routes the payment to + // the operator's wallet, so a subscription scoped to the ATM's + // pre-override wallet_id would AND-filter the settlement out and never + // fire. With wallet_id omitted, lnbits resolves the wallet from + // get_standalone_payment(payment_hash) and ownership-checks against the + // auth'd account — works on both pre/post-override wallets. + // Coordination log 2026-05-31T18:50Z (lnbits) for the confirmation, + // §18:35Z for the joint smoke that surfaced the bug. + watch.subId = await lnbits.subscribePayments( + undefined, + { payment_hash: paymentHash, max_seconds: 600 }, + (push) => { + if (push.payment_hash !== paymentHash || push.status !== 'success') return + settleInvoiceWatch(watch, push.preimage ?? 'payment-confirmed', 'LNbits push') + }, + (reason) => console.log(`[ATM Service] Settlement subscription closed (${reason})`) + ) + console.log( + `[ATM Service] Settlement watch armed in ${Date.now() - startedAt} ms ` + + `(hash ${paymentHash.slice(0, 12)}…)` + ) + } catch (e) { + // The poll below then carries settlement on its own, which is exactly + // why it runs whether or not the subscription came up. + console.error('[ATM Service] Settlement subscribe failed — polling only:', e) + } + startInvoiceWatchPoll(watch) + } + + /** Tear a watch down: the transaction ended, one way or another. */ + function releaseInvoiceWatch(bolt11: string): void { + const watch = invoiceWatches.get(bolt11) + if (!watch) return + watch.released = true + watch.consumer = null + stopInvoiceWatchPoll(watch) + invoiceWatches.delete(bolt11) + if (watch.subId) { + // wallet_id omitted to match the subscribePayments call above. + void lnbits.unsubscribe(undefined, watch.subId).catch(() => {}) + } + } + return { /** * 3b.4: ndebit cash-in path removed. CashInView.vue ignores this @@ -806,7 +939,7 @@ function createATMServices( if (onPaymentCallback) { onPaymentCallback(push.preimage ?? `lnurl-withdraw-${link.link_id}`) } - }, + } ) // Wire per-session cleanup so abort/expiry tears it down cleanly. const session = lnurlSessions.get(link.link_id) @@ -849,15 +982,14 @@ function createATMServices( * matches machine fiat_code * - `type: "cash_out"` / `source: "bitspire"` — discriminators */ + // (see armInvoiceWatch below — the watch is armed before this resolves) generateInvoice: async (context: ATMContext): Promise => { const amountSats = context.satsAmount // Cash-out: satsAmount = principal + commission. principal is // derived from the raw market rate (no commission baked in) so a // consumer can independently audit the split. const principalSats = - context.exchangeRate > 0 - ? Math.floor((context.fiatCents / 100) * context.exchangeRate) - : 0 + context.exchangeRate > 0 ? Math.floor((context.fiatCents / 100) * context.exchangeRate) : 0 const feeSats = Math.max(0, amountSats - principalSats) console.log( '[ATM Service] Generating invoice — gross', @@ -897,6 +1029,17 @@ function createATMServices( if (!payment.payment_request) { throw new Error('LNbits createInvoice returned empty payment_request') } + // Arm the settlement watch BEFORE the invoice reaches the screen, and + // take the payment hash from the response we already have rather than + // spending a round trip decoding it back off the bolt11. See + // armInvoiceWatch for why the timing matters. + if (payment.payment_hash) { + await armInvoiceWatch(payment.payment_request, payment.payment_hash) + } else { + console.error( + '[ATM Service] createInvoice returned no payment_hash — settlement watch will arm late' + ) + } return payment.payment_request }, @@ -1063,15 +1206,30 @@ function createATMServices( * push, filtered by payment_hash. Returns a cleanup function. */ watchInvoice: (invoice: string, callback: (preimage: string) => void): (() => void) => { - console.log('[ATM Service] Watching invoice for payment:', invoice.slice(0, 32) + '...') - if (!invoice.toLowerCase().startsWith('ln')) { console.error('[ATM Service] Invalid invoice format - expected BOLT11') return () => {} } + console.log('[ATM Service] Watching invoice for payment:', invoice.slice(0, 32) + '...') + const armed = invoiceWatches.get(invoice) + if (armed) { + armed.consumer = callback + // Settled between arming and display (a one-tap card pull can beat the + // state transition): replay the latched settlement instead of waiting + // on a push that has already come and gone. + if (armed.settled) { + const preimage = armed.settled + queueMicrotask(() => callback(preimage)) + } + return () => releaseInvoiceWatch(invoice) + } + + // No armed watch: an invoice this service didn't create. Recover the + // hash over the wire and arm now. This is the pre-2026-09-22 behaviour + // and carries the race that arming-at-creation fixes, so say so. + console.warn('[ATM Service] No armed settlement watch for this invoice — arming late') let cancelled = false - let subId: string | null = null ;(async () => { try { const decoded = await lnbits.decodePayment(invoice) @@ -1081,36 +1239,18 @@ function createATMServices( return } if (cancelled) return - // walletId omitted: payment_hash is the natural primary key for - // "wait for THIS invoice to settle." Under path B - // (NOSTR_TRANSPORT_ROSTER_REQUIRED=true) lnbits routes the payment - // to the operator's wallet, so a subscription scoped to the ATM's - // pre-override wallet_id would AND-filter the settlement out and - // never fire. With wallet_id omitted, lnbits resolves the wallet - // from get_standalone_payment(payment_hash) and ownership-checks - // against the auth'd account — works on both pre/post-override - // wallets. Coordination log 2026-05-31T18:50Z (lnbits) for the - // confirmation, §18:35Z for the joint smoke that surfaced the bug. - subId = await lnbits.subscribePayments( - undefined, - { payment_hash: paymentHash, max_seconds: 600 }, - (push) => { - if (push.payment_hash !== paymentHash) return - if (push.status !== 'success') return - console.log('[ATM Service] Invoice paid (LNbits push)!') - callback(push.preimage ?? 'payment-confirmed') - }, - ) + await armInvoiceWatch(invoice, paymentHash) + const late = invoiceWatches.get(invoice) + if (!late || cancelled) return + late.consumer = callback + if (late.settled) callback(late.settled) } catch (e) { console.error('[ATM Service] LNbits watchInvoice failed:', e) } })() return () => { cancelled = true - if (subId) { - // wallet_id omitted to match the subscribePayments call above. - void lnbits.unsubscribe(undefined, subId).catch(() => {}) - } + releaseInvoiceWatch(invoice) } }, From 67573008ee81b29104a3b1efbec00284c497e06e Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 22 Sep 2026 18:16:06 +0200 Subject: [PATCH 24/49] fix(machine): surface a payment taken with no cash dispensed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a card accepted a cash-out pull and settlement never confirmed, the machine returned to the amount screen as though nothing had happened — the customer's wallet had paid and there was nothing on screen or in the journal to say so. Latch that transition, log it with the txid, and show a red notice naming the reference an operator can reconcile against. Co-Authored-By: Claude Fable 5.1 --- apps/machine/src/stores/atm.ts | 29 ++++++++++++++++++++++++++ apps/machine/src/views/CashOutView.vue | 18 ++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/apps/machine/src/stores/atm.ts b/apps/machine/src/stores/atm.ts index c868322..f9af8cb 100644 --- a/apps/machine/src/stores/atm.ts +++ b/apps/machine/src/stores/atm.ts @@ -343,6 +343,12 @@ export const useAtmStore = defineStore('atm', () => { // 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) + // Set when a card accepted a cash-out pull and the machine then left the + // invoice screen without ever seeing the payment settle. The customer's + // wallet paid and no cash came out, so this must be visible and carry the + // reference an operator can reconcile against — never a silent return to + // the amount screen. Cleared when the next transaction starts. + const settlementError = ref<{ txid: string | null; message: string } | null>(null) // When the card server names a currency but didn't price the balance (its // rate cache was cold — it never blocks the unlock on a rate lookup), price // it here from the ATM's own rate source, in that currency. @@ -663,6 +669,26 @@ export const useAtmStore = defineStore('atm', () => { (prevNestedState === 'displayingInvoice' && currentNested !== 'displayingInvoice') || (prevNestedState === 'displayingQR' && currentNested !== 'displayingQR') if (leftBoltCardScreen) { + // The card accepted the pull (that's what leaves processing latched + // with an 'accepted' status) but we left the invoice screen for + // somewhere other than the dispenser: the payment was taken and no + // cash followed. Surface it with the txid instead of dropping the + // customer back on the amount screen as if nothing had happened. + if ( + prevNestedState === 'displayingInvoice' && + currentNested !== 'dispensingCash' && + boltCardProcessing.value && + nfcStatus.value?.state === 'accepted' + ) { + const txid = context.value?.txid ?? null + console.error( + `[ATM] Settlement never confirmed after the card accepted the pull — txid=${txid}` + ) + settlementError.value = { + txid, + message: 'Your payment was accepted but the cash was not dispensed.', + } + } boltCardProcessing.value = false nfcStatus.value = null } @@ -1786,10 +1812,12 @@ export const useAtmStore = defineStore('atm', () => { // Convenience methods for common events function selectCashIn() { + settlementError.value = null send({ type: 'SELECT_CASH_IN' }) } function selectCashOut() { + settlementError.value = null send({ type: 'SELECT_CASH_OUT' }) } @@ -1942,6 +1970,7 @@ export const useAtmStore = defineStore('atm', () => { simulateBoltCardEntry, // Access control (ADR-003) + settlementError, accessControl, isLocked, grantAccess, diff --git a/apps/machine/src/views/CashOutView.vue b/apps/machine/src/views/CashOutView.vue index 50980ec..50a43a2 100644 --- a/apps/machine/src/views/CashOutView.vue +++ b/apps/machine/src/views/CashOutView.vue @@ -178,6 +178,24 @@ function formatFiat(cents: number): string { key="selectingAmount" class="flex flex-1 flex-col justify-center px-4 lg:px-[8vw] py-4 lg:py-6 gap-4 lg:gap-6" > + +
+

+ {{ atmStore.settlementError.message }} +

+

+ Please contact the operator. +

+
+
Date: Tue, 22 Sep 2026 17:50:49 +0200 Subject: [PATCH 25/49] fix(access): instrument Complete, and say when a card can't sell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pressing Complete on a session that fails leaves nothing in the journal: the decline path has no logging, so a failed sell is indistinguishable from a button that never fired. Add the telemetry that was missing — completeWithCard logs outcome, duration and reason, and the entry line now says whether selling is available at all. Two real defects alongside it. A session whose withdraw step the card server withheld (daily limit spent, card disabled) still rendered a Complete Sale button that could only ever fail; it now shows the server's reason instead. And the decline path advised 'tap your card to try again' even for refusals a re-tap cannot lift, so 'blocked' is now its own outcome: nothing was consumed, the session stays loaded, and no re-tap is suggested. Co-Authored-By: Claude Fable 5.1 --- apps/machine/src/stores/atm.ts | 42 ++++++++++++++++++++------ apps/machine/src/views/CashOutView.vue | 13 ++++++++ 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/apps/machine/src/stores/atm.ts b/apps/machine/src/stores/atm.ts index f9af8cb..0ed4719 100644 --- a/apps/machine/src/stores/atm.ts +++ b/apps/machine/src/stores/atm.ts @@ -301,7 +301,11 @@ export const useAtmStore = defineStore('atm', () => { // 'declined' — presented and refused, or failed after presentation. The // 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' + // 'blocked' — refused BEFORE anything was presented, because the card + // server withheld that step when the session opened (e.g. the + // card's daily limit is already spent). Nothing was consumed + // and no re-tap can lift it, so the session stays loaded. + type BoltCardOutcome = 'skipped' | 'accepted' | 'declined' | 'blocked' // 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). @@ -715,6 +719,15 @@ export const useAtmStore = defineStore('atm', () => { const invoice = context.value?.invoice if (!invoice) return 'skipped' if (boltCardProcessing.value) return 'skipped' // one pull at a time + // The card server withheld the withdraw step when this session opened, so + // there is nothing to present. Say why instead of attempting a payment. + if ('session' in source && !source.session.withdraw) { + nfcStatus.value = { + state: 'declined', + message: source.session.withdrawBlockedReason ?? 'This card cannot sell right now', + } + return 'blocked' + } boltCardProcessing.value = true nfcStatus.value = { state: 'processing', message: 'Reading card…' } try { @@ -722,13 +735,12 @@ export const useAtmStore = defineStore('atm', () => { const api = window.electronAPI! const res = 'session' in source - ? source.session.withdraw - ? await api.withdrawWithSession({ - withdraw: plainWithdrawStep(source.session.withdraw), - bolt11: invoice, - amountMsat, - }) - : { ok: false, reason: source.session.withdrawBlockedReason ?? 'card cannot pay now' } + ? await api.withdrawWithSession({ + // Non-null: a withheld step is pre-flighted above. + withdraw: plainWithdrawStep(source.session.withdraw!), + bolt11: invoice, + amountMsat, + }) : await api.lnurlWithdraw({ lnurlw: source.lnurlw, bolt11: invoice, amountMsat }) if (res.ok) { nfcStatus.value = { state: 'accepted', message: 'Card accepted — confirming payment…' } @@ -819,7 +831,10 @@ export const useAtmStore = defineStore('atm', () => { console.info( `[ATM] Card session ${opened.ok ? 'opened' : 'refused'} in ${Date.now() - t0} ms` + (opened.ok - ? ` (fiat ${opened.session.fiat === null ? 'not ' : ''}priced by card server)` + ? ` (fiat ${opened.session.fiat === null ? 'not ' : ''}priced by card server; sell ` + + (opened.session.withdraw + ? 'available)' + : `BLOCKED: ${opened.session.withdrawBlockedReason ?? 'withdraw step withheld'})`) : '') ) if (!opened.ok) { @@ -878,10 +893,19 @@ export const useAtmStore = defineStore('atm', () => { async function completeWithCard() { const card = loadedBoltCard.value if (!card) return + const t0 = Date.now() let outcome: BoltCardOutcome = 'skipped' if (isCashOut.value) outcome = await handleBoltCardTap({ session: card }) else if (isCashIn.value) outcome = await handleBoltCardReceive({ session: card }) + console.info( + `[ATM] Complete with card: ${outcome} in ${Date.now() - t0} ms` + + (outcome === 'accepted' ? '' : ` — ${nfcStatus.value?.message ?? 'no reason given'}`) + ) if (outcome === 'skipped') return + // Blocked is the card server's standing answer for this visit: nothing was + // consumed, and re-tapping cannot lift it. Keep the session loaded so the + // chip still shows whose card it is, and skip the re-tap advice below. + if (outcome === 'blocked') return loadedBoltCard.value = null if (outcome === 'declined') { const reason = nfcStatus.value?.message ?? 'Card declined' diff --git a/apps/machine/src/views/CashOutView.vue b/apps/machine/src/views/CashOutView.vue index 50a43a2..896f165 100644 --- a/apps/machine/src/views/CashOutView.vue +++ b/apps/machine/src/views/CashOutView.vue @@ -353,7 +353,20 @@ function formatFiat(cents: number): string { class="flex w-full max-w-md flex-col items-center gap-2 pt-2" > + +

+ {{ + atmStore.loadedBoltCard.withdrawBlockedReason ?? 'This card cannot sell right now' + }} +