fix(access): single-shot loaded card + ADR-003 amendment #92

Merged
padreug merged 2 commits from fix/access-gate-followups into dev 2026-09-20 14:41:45 +00:00
3 changed files with 45 additions and 147 deletions
Showing only changes of commit a652089441 - Show all commits

fix(access): drop the loaded Bolt Card after its first Complete attempt

The card loaded at tap-to-enter carries one SUN p/c pair, and the
boltcards server bumps the counter on the first GET. After a declined
Complete (limit below amount, callback failure, payment error) the
stored lnurlw can never succeed again, yet it stayed loaded with a
Complete button that would keep failing. The same held on the success
path when the payment never settled (cash-out invoice timeout back to
selectingAmount).

The tap handlers now report skipped / accepted / declined, and
completeWithCard clears the card after any real attempt, appending
'tap your card to try again' to a decline. A skipped outcome (guard
bounced it, no server call) keeps the card. A fresh tap on the cash
screen goes through the normal tap-to-pay/receive path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Padreug 2026-09-20 14:39:10 +02:00

View file

@ -1,53 +0,0 @@
/**
* Mock access reader (ADR-003) — SCAFFOLD, no hardware.
*
* A keyboard/console fallback for when no camera or NFC reader is present
* (headless dev, CI, a batm3 with a dead camera). Emits an npub scan on
* demand via two triggers:
* - `window.__bitspireMockCard(npub?)` — from the LockedView dev button or
* the devtools console.
* - the `F9` key — a quick tap on the physical machine.
*
* Registered only when it is the sole available reader (see `index.ts`), so it
* never shadows the real camera/NFC path.
*/
import { npubEncode } from 'nostr-tools/nip19'
import type { AccessReader, AccessReaderStartOptions, StopCapture } from './types'
/**
* Default npub the mock emits when none is supplied — derived from a fixed
* (all-ones) hex pubkey so it carries a valid bech32 checksum and survives
* `authorize()`'s nip19 decode. Not a real key; dev-only.
*/
export const MOCK_NPUB = npubEncode('11'.repeat(32))
interface MockCardGlobal {
__bitspireMockCard?: (npub?: string) => void
}
export class MockAccessReader implements AccessReader {
readonly kind = 'mock' as const
readonly label = 'Mock reader (dev)'
async isAvailable(): Promise<boolean> {
return true
}
async start(opts: AccessReaderStartOptions): Promise<StopCapture> {
const emit = (npub: string = MOCK_NPUB) => opts.onScan({ kind: 'npub', npub })
const g = globalThis as unknown as MockCardGlobal
g.__bitspireMockCard = emit
const onKey = (e: KeyboardEvent) => {
if (e.key === 'F9') emit()
}
window.addEventListener('keydown', onKey)
return () => {
window.removeEventListener('keydown', onKey)
if (g.__bitspireMockCard === emit) delete g.__bitspireMockCard
}
}
}

View file

@ -1,79 +0,0 @@
/**
* QR-npub access reader (ADR-003, PROTOTYPE).
*
* Until the NFC reader hardware exists, the batm3's camera — the same one the
* pairing wizard uses — reads a QR "badge" that encodes the user's npub. The
* decoded npub is handed to `authorize()`, which admits it (optionally behind
* a PIN). This is a thin adapter onto the same `qr/dom.js` decode loop as
* `pairing/qr-source.ts`; see that file for the capture-resolution rationale.
*
* It emits the raw decoded string as an `npub` scan and lets `authorize()`
* validate it — a stray, non-npub QR is rejected there, not here.
*/
import { QRCanvas, frontalCamera, frameLoop } from 'qr/dom.js'
import type { AccessReader, AccessReaderStartOptions, StopCapture } from './types'
export class QrNpubAccessReader implements AccessReader {
readonly kind = 'qr-npub' as const
readonly label = 'Camera (npub QR)'
async isAvailable(): Promise<boolean> {
return (
typeof navigator !== 'undefined' &&
!!navigator.mediaDevices &&
typeof navigator.mediaDevices.getUserMedia === 'function'
)
}
async start(opts: AccessReaderStartOptions): Promise<StopCapture> {
const { onScan, onError, video } = opts
if (!video) throw new Error('QrNpubAccessReader requires a <video> element')
const camera = await frontalCamera(video)
// Match the pairing source's deliberate capture resolution (see
// pairing/qr-source.ts) — 1280x960 balances px/module against decode speed
// on this fixed-focus panel. Soft `ideal` so a camera that can't honor it
// degrades instead of throwing.
try {
const stream = video.srcObject
if (stream instanceof MediaStream) {
await stream.getVideoTracks()[0]?.applyConstraints({
width: { ideal: 1280 },
height: { ideal: 960 },
})
}
} catch (e) {
onError?.(e)
}
const canvas = new QRCanvas() // decode-only
let stopped = false
let cancel: (() => void) | null = null
const stop: StopCapture = () => {
if (stopped) return
stopped = true
cancel?.()
camera.stop()
}
cancel = frameLoop(() => {
if (stopped) return
try {
const result = camera.readFrame(canvas, true)
if (result) {
// Stop on first decode so one badge isn't ingested repeatedly; the
// store restarts the reader if authorization fails.
stop()
onScan({ kind: 'npub', npub: result.trim() })
}
} catch (e) {
onError?.(e)
}
})
return stop
}
}

View file

@ -294,6 +294,14 @@ export const useAtmStore = defineStore('atm', () => {
// is in flight (settlement still arrives via the normal invoice watcher). // is in flight (settlement still arrives via the normal invoice watcher).
const nfcStatus = ref<{ state: string; message?: string } | null>(null) const nfcStatus = ref<{ state: string; message?: string } | null>(null)
const boltCardProcessing = ref(false) const boltCardProcessing = ref(false)
// What a tap handler did with the voucher it was given:
// 'skipped' — a guard bounced it before any server call; the SUN p/c are
// untouched and the lnurlw can still be presented later.
// 'accepted' — the card's server took the voucher (payment in flight).
// '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'
// Access-control gate config (ADR-003). Defaults disabled → the machine's // Access-control gate config (ADR-003). Defaults disabled → the machine's
// `locked` state bypasses straight to `idle` (behaviour identical to no gate). // `locked` state bypasses straight to `idle` (behaviour identical to no gate).
// Populated from RuntimeConfig.accessControl in initializeForProduction. // Populated from RuntimeConfig.accessControl in initializeForProduction.
@ -623,11 +631,11 @@ export const useAtmStore = defineStore('atm', () => {
* arrives through the invoice watcher → PAYMENT_RECEIVED → dispensingCash; * arrives through the invoice watcher → PAYMENT_RECEIVED → dispensingCash;
* ok here only means the card accepted the pull. * ok here only means the card accepted the pull.
*/ */
async function handleBoltCardTap(lnurlw: string) { async function handleBoltCardTap(lnurlw: string): Promise<BoltCardOutcome> {
if (nestedState.value !== 'displayingInvoice') return if (nestedState.value !== 'displayingInvoice') return 'skipped'
const invoice = context.value?.invoice const invoice = context.value?.invoice
if (!invoice) return if (!invoice) return 'skipped'
if (boltCardProcessing.value) return // one pull at a time if (boltCardProcessing.value) return 'skipped' // one pull at a time
boltCardProcessing.value = true boltCardProcessing.value = true
nfcStatus.value = { state: 'processing', message: 'Reading card…' } nfcStatus.value = { state: 'processing', message: 'Reading card…' }
try { try {
@ -635,14 +643,16 @@ export const useAtmStore = defineStore('atm', () => {
const res = await window.electronAPI!.lnurlWithdraw({ lnurlw, bolt11: invoice, amountMsat }) const res = await window.electronAPI!.lnurlWithdraw({ lnurlw, bolt11: invoice, amountMsat })
if (res.ok) { if (res.ok) {
nfcStatus.value = { state: 'accepted', message: 'Card accepted — confirming payment…' } nfcStatus.value = { state: 'accepted', message: 'Card accepted — confirming payment…' }
} else { return 'accepted'
boltCardProcessing.value = false
nfcStatus.value = { state: 'declined', message: res.reason ?? 'Card declined' }
} }
boltCardProcessing.value = false
nfcStatus.value = { state: 'declined', message: res.reason ?? 'Card declined' }
return 'declined'
} catch (e) { } catch (e) {
console.warn('[ATM] Bolt Card withdraw failed:', e) console.warn('[ATM] Bolt Card withdraw failed:', e)
boltCardProcessing.value = false boltCardProcessing.value = false
nfcStatus.value = { state: 'error', message: 'Card payment failed' } nfcStatus.value = { state: 'error', message: 'Card payment failed' }
return 'declined'
} }
} }
@ -653,11 +663,11 @@ export const useAtmStore = defineStore('atm', () => {
* over the nostr transport via the normal `payInvoice` → PAYMENT_RECEIVED * over the nostr transport via the normal `payInvoice` → PAYMENT_RECEIVED
* path. Settlement + completion reuse the tested cash-in flow. * path. Settlement + completion reuse the tested cash-in flow.
*/ */
async function handleBoltCardReceive(lnurlw: string) { async function handleBoltCardReceive(lnurlw: string): Promise<BoltCardOutcome> {
if (!(isCashIn.value && nestedState.value === 'displayingQR')) return if (!(isCashIn.value && nestedState.value === 'displayingQR')) return 'skipped'
const amountSats = context.value?.satsAmount ?? 0 const amountSats = context.value?.satsAmount ?? 0
if (amountSats <= 0) return if (amountSats <= 0) return 'skipped'
if (boltCardProcessing.value) return // one at a time if (boltCardProcessing.value) return 'skipped' // one at a time
boltCardProcessing.value = true boltCardProcessing.value = true
nfcStatus.value = { state: 'processing', message: 'Reading card…' } nfcStatus.value = { state: 'processing', message: 'Reading card…' }
try { try {
@ -666,20 +676,23 @@ export const useAtmStore = defineStore('atm', () => {
if (!res.ok || !res.bolt11) { if (!res.ok || !res.bolt11) {
boltCardProcessing.value = false boltCardProcessing.value = false
nfcStatus.value = { state: 'declined', message: res.reason ?? 'Card could not receive' } nfcStatus.value = { state: 'declined', message: res.reason ?? 'Card could not receive' }
return return 'declined'
} }
nfcStatus.value = { state: 'accepted', message: 'Card found — sending sats…' } nfcStatus.value = { state: 'accepted', message: 'Card found — sending sats…' }
const paid = await payInvoice(res.bolt11) const paid = await payInvoice(res.bolt11)
if (!paid) { if (!paid) {
boltCardProcessing.value = false boltCardProcessing.value = false
nfcStatus.value = { state: 'error', message: paymentError.value ?? 'Payment failed' } nfcStatus.value = { state: 'error', message: paymentError.value ?? 'Payment failed' }
return 'declined'
} }
// On success payInvoice fires PAYMENT_RECEIVED; state leaves displayingQR // On success payInvoice fires PAYMENT_RECEIVED; state leaves displayingQR
// and the subscribe-cleanup above resets nfcStatus/boltCardProcessing. // and the subscribe-cleanup above resets nfcStatus/boltCardProcessing.
return 'accepted'
} catch (e) { } catch (e) {
console.warn('[ATM] Bolt Card receive failed:', e) console.warn('[ATM] Bolt Card receive failed:', e)
boltCardProcessing.value = false boltCardProcessing.value = false
nfcStatus.value = { state: 'error', message: 'Card payment failed' } nfcStatus.value = { state: 'error', message: 'Card payment failed' }
return 'declined'
} }
} }
@ -726,12 +739,29 @@ export const useAtmStore = defineStore('atm', () => {
* Complete a buy/sell using the Bolt Card loaded at entry — no second tap. * Complete a buy/sell using the Bolt Card loaded at entry — no second tap.
* Cash-out pulls via the stored lnurlw; cash-in resolves it to the card * Cash-out pulls via the stored lnurlw; cash-in resolves it to the card
* wallet's lnurlp and pays. Reuses the tap handlers verbatim. * wallet's lnurlp and pays. Reuses the tap handlers verbatim.
*
* The loaded card is SINGLE-SHOT: its lnurlw carries one SUN p/c pair and the
* boltcards server consumes it on the first GET, so once the voucher has been
* presented — accepted or declined — it can never succeed again. Drop it after
* the first real attempt and tell the customer to re-tap; a fresh tap on the
* cash screen goes straight through the normal tap-to-pay/receive path.
* A 'skipped' outcome (guard bounced it, no server call) keeps the card.
*/ */
function completeWithCard() { async function completeWithCard() {
const card = loadedBoltCard.value const card = loadedBoltCard.value
if (!card) return if (!card) return
if (isCashOut.value) void handleBoltCardTap(card.lnurlw) let outcome: BoltCardOutcome = 'skipped'
else if (isCashIn.value) void handleBoltCardReceive(card.lnurlw) if (isCashOut.value) outcome = await handleBoltCardTap(card.lnurlw)
else if (isCashIn.value) outcome = await handleBoltCardReceive(card.lnurlw)
if (outcome === 'skipped') return
loadedBoltCard.value = null
if (outcome === 'declined') {
const reason = nfcStatus.value?.message ?? 'Card declined'
nfcStatus.value = {
state: nfcStatus.value?.state ?? 'declined',
message: `${reason} — tap your card to try again`,
}
}
} }
/** Wire the main-process reader once (idempotent via preload removeAllListeners). */ /** Wire the main-process reader once (idempotent via preload removeAllListeners). */