feat(machine): Bolt Card (NFC) tap-to-pay on cash-out #83

Merged
padreug merged 9 commits from feat/boltcard-nfc-cashout into dev 2026-08-05 21:38:34 +00:00
2 changed files with 116 additions and 5 deletions
Showing only changes of commit 07978a8de1 - Show all commits

feat(machine): wire Bolt Card tap into cash-out displayingInvoice + UI

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 <noreply@anthropic.com>
Patrick Mulligan 2026-08-05 04:45:51 +02:00

View file

@ -291,6 +291,11 @@ export const useAtmStore = defineStore('atm', () => {
const debugMode = ref(true) const debugMode = ref(true)
const allowMockFallback = ref(true) // default true for browser dev const allowMockFallback = ref(true) // default true for browser dev
const initError = ref<string | null>(null) // fatal error → maintenance screen const initError = ref<string | null>(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') const fiatCode = ref('USD')
// Defaults are 0 — the operator's fee config (received via Nostr // Defaults are 0 — the operator's fee config (received via Nostr
// kind-30078 `bitspire-fees:<atm_pubkey>` envelope from satmachineadmin) // kind-30078 `bitspire-fees:<atm_pubkey>` 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 prevNestedState = currentNested
}) })
// Start the machine // Start the machine
actor.value.start() actor.value.start()
setupNfcListener()
console.log('[ATM] State machine initialized') 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. * Group an array of inserted bill denominations into { denomination, count } pairs.
*/ */
@ -1522,6 +1586,11 @@ export const useAtmStore = defineStore('atm', () => {
isCashOut, isCashOut,
nestedState, nestedState,
// Bolt Card cash-out (NFC)
nfcStatus,
boltCardProcessing,
simulateBoltCardTap,
// Actions // Actions
initialize, initialize,
initializeWithLightning, initializeWithLightning,

View file

@ -15,6 +15,10 @@ const isElectron = typeof window !== 'undefined' && window.electronAPI !== undef
const cashOutSteps = ['Select', 'Pay', 'Collect'] 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(() => { const currentStepIndex = computed(() => {
switch (nestedState.value) { switch (nestedState.value) {
case 'fetchingRate': case 'fetchingRate':
@ -284,7 +288,9 @@ function formatFiat(cents: number): string {
<div <div
class="flex w-full lg:w-[52%] flex-col items-center justify-center gap-3 lg:gap-5 px-4 lg:px-[4vw] py-4 lg:py-0" class="flex w-full lg:w-[52%] flex-col items-center justify-center gap-3 lg:gap-5 px-4 lg:px-[4vw] py-4 lg:py-0"
> >
<p class="text-lg lg:text-[2rem] font-semibold text-warning">Scan to Pay</p> <p class="text-lg lg:text-[2rem] font-semibold text-warning">
{{ isElectron ? 'Tap Card or Scan to Pay' : 'Scan to Pay' }}
</p>
<p class="text-3xl lg:text-[7vh] font-bold text-bitcoin leading-tight"> <p class="text-3xl lg:text-[7vh] font-bold text-bitcoin leading-tight">
{{ context ? formatSats(context.satsAmount) : 0 }} sats {{ context ? formatSats(context.satsAmount) : 0 }} sats
</p> </p>
@ -295,10 +301,31 @@ function formatFiat(cents: number): string {
</Badge> </Badge>
</p> </p>
<!-- Waiting indicator --> <!-- Waiting indicator + Bolt Card status -->
<div class="flex items-center gap-3 pt-2 lg:pt-4"> <div class="flex flex-col items-center gap-2 pt-2 lg:pt-4">
<div class="flex items-center gap-3">
<PickaxeIcon :size="32" /> <PickaxeIcon :size="32" />
<p class="text-sm lg:text-xl text-muted-foreground">Waiting for payment...</p> <p class="text-sm lg:text-xl text-muted-foreground">
{{
atmStore.boltCardProcessing
? 'Processing card…'
: isElectron
? 'Tap your card or scan the QR'
: 'Waiting for payment...'
}}
</p>
</div>
<p
v-if="atmStore.nfcStatus?.message"
class="text-sm lg:text-lg"
:class="
atmStore.nfcStatus.state === 'declined' || atmStore.nfcStatus.state === 'error'
? 'text-destructive'
: 'text-muted-foreground'
"
>
{{ atmStore.nfcStatus.message }}
</p>
</div> </div>
<!-- Invoice info with copy button (web-ui only) --> <!-- Invoice info with copy button (web-ui only) -->
@ -322,6 +349,21 @@ function formatFiat(cents: number): string {
> >
Simulate Payment Simulate Payment
</Button> </Button>
<div class="mt-2 flex items-center gap-2">
<input
v-model="mockLnurlw"
placeholder="lnurlw://… (paste to simulate a card tap)"
class="w-56 rounded border border-input bg-background px-2 py-1 text-xs"
/>
<Button
variant="outline"
size="sm"
:disabled="!mockLnurlw"
@click="atmStore.simulateBoltCardTap(mockLnurlw)"
>
Tap
</Button>
</div>
</AlertDescription> </AlertDescription>
</Alert> </Alert>
</div> </div>