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
Showing only changes of commit ed04c63b2f - Show all commits

perf(machine): fewer NFC APDUs (E104-first) + single-attempt read

Retrying a read hammered the cheap CCID reader into a stuck present↔empty
loop (only cleared by a reboot), so drop the retry: a read is a single
attempt and the user re-taps if the RF link drops mid-read. Also skip the
Capability-Container round-trip in the common case — NTAG424 Bolt Cards use
NDEF FileID E104, so try E104/0004 directly and only read the CC to discover
the id if both fail. Fewer APDUs → a read completes inside a shorter stable
window.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Patrick Mulligan 2026-08-05 20:16:50 +02:00

View file

@ -87,20 +87,20 @@ export async function readNdefLnurlw(
return null return null
} }
// Prefer the FileID advertised by the Capability Container (EF E103). // NTAG424 Bolt Cards use NDEF FileID E104. Try it (and 0004) directly to
const candidates: Array<[number, number]> = [] // minimise APDU round-trips over a flaky RF link; only fall back to reading
// the Capability Container to discover the id if both direct reads fail.
for (const fid of [[0xe1, 0x04] as [number, number], [0x00, 0x04] as [number, number]]) {
const found = await readNdefFile(send, fid)
if (found) return found
}
if (swOk(await send([0x00, 0xa4, 0x00, 0x0c, 0x02, 0xe1, 0x03]))) { if (swOk(await send([0x00, 0xa4, 0x00, 0x0c, 0x02, 0xe1, 0x03]))) {
const cc = await send([0x00, 0xb0, 0x00, 0x00, 0x0f]) const cc = await send([0x00, 0xb0, 0x00, 0x00, 0x0f])
// CC layout: …[07]=TLV tag 0x04, [08]=len, [09..10]=NDEF FileID. // CC layout: …[07]=TLV tag 0x04, [08]=len, [09..10]=NDEF FileID.
if (swOk(cc) && cc.length >= 13 && cc[7] === 0x04) candidates.push([cc[9], cc[10]]) if (swOk(cc) && cc.length >= 13 && cc[7] === 0x04) {
} const found = await readNdefFile(send, [cc[9], cc[10]])
for (const fid of [[0xe1, 0x04] as [number, number], [0x00, 0x04] as [number, number]]) { if (found) return found
if (!candidates.some((c) => c[0] === fid[0] && c[1] === fid[1])) candidates.push(fid) }
}
for (const fid of candidates) {
const found = await readNdefFile(send, fid)
if (found) return found
} }
return null return null
} }
@ -157,12 +157,19 @@ export async function startNfcReader(
r.on('card', async () => { r.on('card', async () => {
onStatus({ state: 'reading', reader: name }) onStatus({ state: 'reading', reader: name })
// Single attempt: hammering a flaky RF link with retries wedges these
// cheap CCID readers. A read is a few APDU round-trips; if the card
// shifts mid-read the transmit fails and the user simply re-taps.
try { try {
const lnurlw = await readNdefLnurlw((apdu, maxLen) => r.transmit(apdu, maxLen)) const lnurlw = await readNdefLnurlw((apdu, maxLen) => r.transmit(apdu, maxLen))
if (lnurlw) onCard(lnurlw) if (lnurlw) {
else onStatus({ state: 'error', reader: name, message: 'not a Bolt Card' }) onCard(lnurlw)
} else {
onStatus({ state: 'error', reader: name, message: 'not a Bolt Card' })
}
} catch (e) { } catch (e) {
onStatus({ state: 'error', reader: name, message: errMsg(e) }) onStatus({ state: 'error', reader: name, message: 'card read failed — hold steady & retap' })
void e
} }
}) })
r.on('card.off', () => onStatus({ state: 'card-removed', reader: name })) r.on('card.off', () => onStatus({ state: 'card-removed', reader: name }))