feat(machine): Bolt Card (NFC) tap-to-receive on cash-in

Tap-to-receive for the buy flow, the receive counterpart to #83's
cash-out tap-to-pay. A Bolt Card only emits an lnurlw withdraw voucher
(wrong direction to deposit into it), so the tap is used as an
authenticated identity (external_id + SUN p/c) to resolve the card
wallet's lnurlp/Lightning Address; the ATM then pays an invoice for the
payout over its existing nostr transport.

- electron/lnurl-pay.ts: resolveCardInvoice() — resolve card -> pay
  target -> LUD-16/LUD-06 -> BOLT11. scanUrlToResolver() is the single
  HTTPS-today / nostr-tomorrow transport seam. 13 tests.
- IPC lnurl:pay-card (main-process HTTPS to dodge renderer CORS) +
  preload/electron.d.ts surface.
- stores/atm.ts: handleBoltCardReceive() settles via the existing
  payInvoice -> PAYMENT_RECEIVED path; the one NFC listener now routes
  the same tap by flow (cash-out pulls, cash-in receives).
- CashInView.vue: NFC status + dev tap input.
- docs/boltcard-receive-resolver.md: spec for the custom LNbits
  /boltcards/api/v1/pay/<id> resolver endpoint (omni-private side).

Card issuance is unchanged — same NDEF/keys/external_id; receive is a
server-side reading of the same tap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-08-06 00:30:13 +02:00
commit 64582e7fe6
8 changed files with 605 additions and 31 deletions

View file

@ -11,14 +11,8 @@ import {
} from '@bitSpire/state-machine'
import { initializeLightningServices, fetchBtcPrice } from '@/services/lightning'
import { classifyInitError } from '@/services/init-error'
import {
startOperatorConfigService,
type OperatorConfigService,
} from '@/services/operator-config'
import {
startOperatorFeesService,
type OperatorFeesService,
} from '@/services/operator-fees'
import { startOperatorConfigService, type OperatorConfigService } from '@/services/operator-config'
import { startOperatorFeesService, type OperatorFeesService } from '@/services/operator-fees'
import type { HalConfig, HalServices } from '@/services/hal'
import type { MachineModel } from '@/config'
import type { LightningBackend } from '@/services/lightning'
@ -52,7 +46,8 @@ function computeFeeSats(ctx: ATMContext, isCashIn: boolean): number {
`Unit fraction expected (0.05 = 5%), not a percentage.`
)
}
const principalSats = ctx.exchangeRate > 0 ? Math.floor((ctx.fiatCents / 100) * ctx.exchangeRate) : 0
const principalSats =
ctx.exchangeRate > 0 ? Math.floor((ctx.fiatCents / 100) * ctx.exchangeRate) : 0
const feeSats = isCashIn
? principalSats - ctx.satsAmount // cash-in: customer receives less than principal
: ctx.satsAmount - principalSats // cash-out: customer pays more than principal
@ -567,9 +562,13 @@ 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') {
// Clear Bolt Card state whenever we leave a tap screen — the cash-out
// invoice ('displayingInvoice') or the cash-in QR ('displayingQR') — so a
// stale "processing"/error can't linger into the next flow.
const leftBoltCardScreen =
(prevNestedState === 'displayingInvoice' && currentNested !== 'displayingInvoice') ||
(prevNestedState === 'displayingQR' && currentNested !== 'displayingQR')
if (leftBoltCardScreen) {
boltCardProcessing.value = false
nfcStatus.value = null
}
@ -614,26 +613,76 @@ export const useAtmStore = defineStore('atm', () => {
}
}
/**
* A tapped Bolt Card during the cash-in QR screen: RECEIVE sats to the card.
* The card's lnurlw is only a spend voucher, so we resolve it to the card
* wallet's lnurlp (main process), fetch an invoice for the payout, and pay it
* over the nostr transport via the normal `payInvoice` → PAYMENT_RECEIVED
* path. Settlement + completion reuse the tested cash-in flow.
*/
async function handleBoltCardReceive(lnurlw: string) {
if (!(isCashIn.value && nestedState.value === 'displayingQR')) return
const amountSats = context.value?.satsAmount ?? 0
if (amountSats <= 0) return
if (boltCardProcessing.value) return // one at a time
boltCardProcessing.value = true
nfcStatus.value = { state: 'processing', message: 'Reading card…' }
try {
const amountMsat = amountSats * 1000
const res = await window.electronAPI!.resolveCardInvoice({ lnurlw, amountMsat })
if (!res.ok || !res.bolt11) {
boltCardProcessing.value = false
nfcStatus.value = { state: 'declined', message: res.reason ?? 'Card could not receive' }
return
}
nfcStatus.value = { state: 'accepted', message: 'Card found — sending sats…' }
const paid = await payInvoice(res.bolt11)
if (!paid) {
boltCardProcessing.value = false
nfcStatus.value = { state: 'error', message: paymentError.value ?? 'Payment failed' }
}
// On success payInvoice fires PAYMENT_RECEIVED; state leaves displayingQR
// and the subscribe-cleanup above resets nfcStatus/boltCardProcessing.
} catch (e) {
console.warn('[ATM] Bolt Card receive 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)
// Route the same physical tap by flow: cash-out pulls, cash-in receives.
if (isCashOut.value && nestedState.value === 'displayingInvoice') {
void handleBoltCardTap(lnurlw)
} else if (isCashIn.value && nestedState.value === 'displayingQR') {
void handleBoltCardReceive(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) {
// Only surface reader status on a tap screen, and don't clobber an
// in-flight tap's message.
const onTapScreen =
(isCashOut.value && nestedState.value === 'displayingInvoice') ||
(isCashIn.value && nestedState.value === 'displayingQR')
if (onTapScreen && !boltCardProcessing.value) {
nfcStatus.value = status
}
})
}
/** Dev/mock: simulate a tap with a pasted lnurlw (test without a card). */
/** Dev/mock: simulate a cash-out tap with a pasted lnurlw (test without a card). */
function simulateBoltCardTap(lnurlw: string) {
void handleBoltCardTap(lnurlw)
}
/** Dev/mock: simulate a cash-in (receive) tap with a pasted lnurlw. */
function simulateBoltCardReceive(lnurlw: string) {
void handleBoltCardReceive(lnurlw)
}
/**
* Group an array of inserted bill denominations into { denomination, count } pairs.
*/
@ -1586,10 +1635,11 @@ export const useAtmStore = defineStore('atm', () => {
isCashOut,
nestedState,
// Bolt Card cash-out (NFC)
// Bolt Card (NFC): cash-out pulls, cash-in receives
nfcStatus,
boltCardProcessing,
simulateBoltCardTap,
simulateBoltCardReceive,
// Actions
initialize,

View file

@ -109,6 +109,11 @@ declare global {
bolt11: string
amountMsat?: number
}) => Promise<{ ok: boolean; reason?: string }>
/** Bolt Card cash-in: resolve a tapped card + amount to a BOLT11 to pay. */
resolveCardInvoice: (args: {
lnurlw: string
amountMsat: number
}) => Promise<{ ok: boolean; bolt11?: string; reason?: string }>
applyOperatorCassettesConfig: (
payload: { positions: Record<string, { denomination: number; count: number }> },
eventCreatedAt: number

View file

@ -48,6 +48,9 @@ const showCancelButton = computed(() => {
// Invoice input for manual payment
const invoiceInput = ref('')
// Dev: paste an lnurlw to simulate a Bolt Card tap-to-receive
const mockLnurlw = ref('')
// Copy state for ndebit URI
const copied = ref(false)
@ -279,7 +282,9 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)
<Button
class="w-full bg-gradient-to-r from-orange-500 to-yellow-400 text-black hover:from-orange-600 hover:to-yellow-500"
size="kiosk-lg"
:disabled="!context || context.billsInserted.length === 0 || context.billPending !== null"
:disabled="
!context || context.billsInserted.length === 0 || context.billPending !== null
"
@click="finishInserting"
>
Done Inserting
@ -331,10 +336,31 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)
{{ atmStore.fiatSymbol }}{{ ((context?.fiatCents || 0) / 100).toFixed(2) }}
</p>
<!-- Waiting indicator -->
<div class="flex items-center gap-3 pt-2 lg:pt-4">
<PickaxeIcon :size="32" />
<p class="text-sm lg:text-xl text-muted-foreground">Waiting for wallet scan...</p>
<!-- Waiting indicator + Bolt Card tap-to-receive status -->
<div class="flex flex-col items-center gap-2 pt-2 lg:pt-4">
<div class="flex items-center gap-3">
<PickaxeIcon :size="32" />
<p class="text-sm lg:text-xl text-muted-foreground">
{{
atmStore.boltCardProcessing
? 'Processing card…'
: isElectron
? 'Tap your card or scan to receive'
: 'Waiting for wallet scan...'
}}
</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>
<!-- LNURL URI (web-ui only) -->
@ -353,8 +379,8 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)
</div>
</div>
<!-- Debug: Simulate payment button -->
<div v-if="atmStore.debugMode" class="pt-2">
<!-- Debug: simulate payment / Bolt Card tap-to-receive -->
<div v-if="atmStore.debugMode" class="pt-2 flex flex-col items-center gap-2">
<Button
variant="ghost"
size="sm"
@ -363,6 +389,21 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)
>
Dev: Skip to Success
</Button>
<div class="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.simulateBoltCardReceive(mockLnurlw)"
>
Tap
</Button>
</div>
</div>
</div>