From 07978a8de12fba10f9cce2ba96e85700b5c76c35 Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Wed, 5 Aug 2026 04:45:51 +0200 Subject: [PATCH] feat(machine): wire Bolt Card tap into cash-out displayingInvoice + UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renderer side of tap-to-pay. The store subscribes to the main-process reader (onNfcCardTapped/onNfcStatus); a tap during displayingInvoice pulls payment for the shown invoice via lnurlWithdraw (amount in msats), guarded against double-taps. Settlement still flows through the existing invoice watcher → PAYMENT_RECEIVED → dispensingCash, so the state machine is unchanged. Bolt Card state clears when leaving the invoice screen. CashOutView: "Tap Card or Scan to Pay" + live reader/processing/declined status on the invoice screen, plus a dev input to simulate a tap with a pasted lnurlw. Exposes nfcStatus / boltCardProcessing / simulateBoltCardTap. Co-Authored-By: Claude Opus 4.8 --- apps/machine/src/stores/atm.ts | 69 ++++++++++++++++++++++++++ apps/machine/src/views/CashOutView.vue | 52 +++++++++++++++++-- 2 files changed, 116 insertions(+), 5 deletions(-) diff --git a/apps/machine/src/stores/atm.ts b/apps/machine/src/stores/atm.ts index 7119500..06dea22 100644 --- a/apps/machine/src/stores/atm.ts +++ b/apps/machine/src/stores/atm.ts @@ -291,6 +291,11 @@ export const useAtmStore = defineStore('atm', () => { const debugMode = ref(true) const allowMockFallback = ref(true) // default true for browser dev const initError = ref(null) // fatal error → maintenance screen + // Bolt Card cash-out (NFC tap-to-pay). nfcStatus surfaces reader state on the + // invoice screen; boltCardProcessing gates against double-taps while a pull + // is in flight (settlement still arrives via the normal invoice watcher). + const nfcStatus = ref<{ state: string; message?: string } | null>(null) + const boltCardProcessing = ref(false) const fiatCode = ref('USD') // Defaults are 0 — the operator's fee config (received via Nostr // kind-30078 `bitspire-fees:` envelope from satmachineadmin) @@ -562,14 +567,73 @@ export const useAtmStore = defineStore('atm', () => { } } + // Clear Bolt Card state whenever we leave the invoice screen (dispensed, + // timed out, or cancelled) so a stale "processing"/error can't linger. + if (currentNested !== 'displayingInvoice' && prevNestedState === 'displayingInvoice') { + boltCardProcessing.value = false + nfcStatus.value = null + } + prevNestedState = currentNested }) // Start the machine actor.value.start() + setupNfcListener() console.log('[ATM] State machine initialized') } + // ── Bolt Card cash-out (NFC tap-to-pay) ─────────────────────────────────── + + /** + * A tapped Bolt Card during the cash-out invoice screen: pull payment for + * the shown invoice via LNURL-withdraw (main process). Settlement still + * arrives through the invoice watcher → PAYMENT_RECEIVED → dispensingCash; + * ok here only means the card accepted the pull. + */ + async function handleBoltCardTap(lnurlw: string) { + if (nestedState.value !== 'displayingInvoice') return + const invoice = context.value?.invoice + if (!invoice) return + if (boltCardProcessing.value) return // one pull at a time + boltCardProcessing.value = true + 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 }) + if (res.ok) { + nfcStatus.value = { state: 'accepted', message: 'Card accepted — confirming payment…' } + } else { + boltCardProcessing.value = false + nfcStatus.value = { state: 'declined', message: res.reason ?? 'Card declined' } + } + } catch (e) { + console.warn('[ATM] Bolt Card withdraw failed:', e) + boltCardProcessing.value = false + nfcStatus.value = { state: 'error', message: 'Card payment failed' } + } + } + + /** Wire the main-process reader once (idempotent via preload removeAllListeners). */ + function setupNfcListener() { + if (!isElectron || !window.electronAPI?.onNfcCardTapped) return + window.electronAPI.onNfcCardTapped((lnurlw) => { + void handleBoltCardTap(lnurlw) + }) + window.electronAPI.onNfcStatus?.((status) => { + // Only surface reader status on the invoice screen, and don't clobber an + // in-flight pull's message. + if (nestedState.value === 'displayingInvoice' && !boltCardProcessing.value) { + nfcStatus.value = status + } + }) + } + + /** Dev/mock: simulate a tap with a pasted lnurlw (test without a card). */ + function simulateBoltCardTap(lnurlw: string) { + void handleBoltCardTap(lnurlw) + } + /** * Group an array of inserted bill denominations into { denomination, count } pairs. */ @@ -1522,6 +1586,11 @@ export const useAtmStore = defineStore('atm', () => { isCashOut, nestedState, + // Bolt Card cash-out (NFC) + nfcStatus, + boltCardProcessing, + simulateBoltCardTap, + // Actions initialize, initializeWithLightning, diff --git a/apps/machine/src/views/CashOutView.vue b/apps/machine/src/views/CashOutView.vue index 4b4a4d2..6257882 100644 --- a/apps/machine/src/views/CashOutView.vue +++ b/apps/machine/src/views/CashOutView.vue @@ -15,6 +15,10 @@ const isElectron = typeof window !== 'undefined' && window.electronAPI !== undef const cashOutSteps = ['Select', 'Pay', 'Collect'] +// Dev-only: paste a real card's lnurlw to exercise the Bolt Card pull without +// the reader (single-use, so a live tap each time). +const mockLnurlw = ref('') + const currentStepIndex = computed(() => { switch (nestedState.value) { case 'fetchingRate': @@ -284,7 +288,9 @@ function formatFiat(cents: number): string {
-

Scan to Pay

+

+ {{ isElectron ? 'Tap Card or Scan to Pay' : 'Scan to Pay' }} +

{{ context ? formatSats(context.satsAmount) : 0 }} sats

@@ -295,10 +301,31 @@ function formatFiat(cents: number): string {

- -
- -

Waiting for payment...

+ +
+
+ +

+ {{ + atmStore.boltCardProcessing + ? 'Processing card…' + : isElectron + ? 'Tap your card or scan the QR' + : 'Waiting for payment...' + }} +

+
+

+ {{ atmStore.nfcStatus.message }} +

@@ -322,6 +349,21 @@ function formatFiat(cents: number): string { > Simulate Payment +
+ + +