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) } }, 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. +

+
+