fix(access): single-shot loaded card + ADR-003 amendment #92
3 changed files with 45 additions and 147 deletions
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>
commit
a652089441
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -294,6 +294,14 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
// is in flight (settlement still arrives via the normal invoice watcher).
|
||||
const nfcStatus = ref<{ state: string; message?: string } | null>(null)
|
||||
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
|
||||
// `locked` state bypasses straight to `idle` (behaviour identical to no gate).
|
||||
// Populated from RuntimeConfig.accessControl in initializeForProduction.
|
||||
|
|
@ -623,11 +631,11 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
* 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
|
||||
async function handleBoltCardTap(lnurlw: string): Promise<BoltCardOutcome> {
|
||||
if (nestedState.value !== 'displayingInvoice') return 'skipped'
|
||||
const invoice = context.value?.invoice
|
||||
if (!invoice) return
|
||||
if (boltCardProcessing.value) return // one pull at a time
|
||||
if (!invoice) return 'skipped'
|
||||
if (boltCardProcessing.value) return 'skipped' // one pull at a time
|
||||
boltCardProcessing.value = true
|
||||
nfcStatus.value = { state: 'processing', message: 'Reading card…' }
|
||||
try {
|
||||
|
|
@ -635,14 +643,16 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
const res = await window.electronAPI!.lnurlWithdraw({ lnurlw, bolt11: invoice, amountMsat })
|
||||
if (res.ok) {
|
||||
nfcStatus.value = { state: 'accepted', message: 'Card accepted — confirming payment…' }
|
||||
} else {
|
||||
return 'accepted'
|
||||
}
|
||||
boltCardProcessing.value = false
|
||||
nfcStatus.value = { state: 'declined', message: res.reason ?? 'Card declined' }
|
||||
}
|
||||
return 'declined'
|
||||
} catch (e) {
|
||||
console.warn('[ATM] Bolt Card withdraw failed:', e)
|
||||
boltCardProcessing.value = false
|
||||
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
|
||||
* path. Settlement + completion reuse the tested cash-in flow.
|
||||
*/
|
||||
async function handleBoltCardReceive(lnurlw: string) {
|
||||
if (!(isCashIn.value && nestedState.value === 'displayingQR')) return
|
||||
async function handleBoltCardReceive(lnurlw: string): Promise<BoltCardOutcome> {
|
||||
if (!(isCashIn.value && nestedState.value === 'displayingQR')) return 'skipped'
|
||||
const amountSats = context.value?.satsAmount ?? 0
|
||||
if (amountSats <= 0) return
|
||||
if (boltCardProcessing.value) return // one at a time
|
||||
if (amountSats <= 0) return 'skipped'
|
||||
if (boltCardProcessing.value) return 'skipped' // one at a time
|
||||
boltCardProcessing.value = true
|
||||
nfcStatus.value = { state: 'processing', message: 'Reading card…' }
|
||||
try {
|
||||
|
|
@ -666,20 +676,23 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
if (!res.ok || !res.bolt11) {
|
||||
boltCardProcessing.value = false
|
||||
nfcStatus.value = { state: 'declined', message: res.reason ?? 'Card could not receive' }
|
||||
return
|
||||
return 'declined'
|
||||
}
|
||||
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' }
|
||||
return 'declined'
|
||||
}
|
||||
// On success payInvoice fires PAYMENT_RECEIVED; state leaves displayingQR
|
||||
// and the subscribe-cleanup above resets nfcStatus/boltCardProcessing.
|
||||
return 'accepted'
|
||||
} catch (e) {
|
||||
console.warn('[ATM] Bolt Card receive failed:', e)
|
||||
boltCardProcessing.value = false
|
||||
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.
|
||||
* Cash-out pulls via the stored lnurlw; cash-in resolves it to the card
|
||||
* 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
|
||||
if (!card) return
|
||||
if (isCashOut.value) void handleBoltCardTap(card.lnurlw)
|
||||
else if (isCashIn.value) void handleBoltCardReceive(card.lnurlw)
|
||||
let outcome: BoltCardOutcome = 'skipped'
|
||||
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). */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue