feat(machine): NFC Bolt Card reader driver + IPC (main process)

Main-process driver over nfc-pcsc (PC/SC). On tap it reads the NTAG424
Type-4 NDEF file via ISO7816 APDUs (select NDEF app D2760000850101 →
select file → ReadBinary NLEN + message) and extracts the lnurlw voucher
(fresh SUN p/c per tap), forwarding it to the renderer on `nfc:card-tapped`
(+ `nfc:status`). Lazy, guarded import — a missing reader/pcscd just
reports 'unavailable', never breaking the cash-out QR path. Preload
removeAllListeners guards against a double payment-trigger on renderer reload.

- electron/nfc-service.ts: startNfcReader() + readNdefLnurlw()/extractLnurlw().
- electron/nfc-service.test.ts: 7 tests (NDEF URI extraction, Type-4 read
  sequence incl. AID select, empty-file + select-fail handling).
- main.ts start + IPC forward; preload + electron.d.ts listeners.
- add nfc-pcsc dep (native @pokusew/pcsclite; nix build handling next).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-08-05 04:42:07 +02:00
commit 84d746a0da
7 changed files with 281 additions and 1 deletions

View file

@ -0,0 +1,66 @@
import { describe, it, expect, vi } from 'vitest'
import { extractLnurlw, readNdefLnurlw } from './nfc-service'
const LNURLW =
'lnurlw://lnbits.l484.com/boltcards/api/v1/scan/abc123?p=DEADBEEFDEADBEEFDEADBEEFDEADBEEF&c=1122334455667788'
/** Build a Type-4 NDEF message with a single URI record carrying `uri`. */
function ndefUriMessage(uri: string): Buffer {
const uriBytes = Buffer.from(uri, 'ascii')
const payload = Buffer.concat([Buffer.from([0x00]), uriBytes]) // 0x00 = no prefix
// D1 = MB|ME|SR, TNF=well-known; type length 1; payload length; 'U'
return Buffer.concat([Buffer.from([0xd1, 0x01, payload.length, 0x55]), payload])
}
describe('extractLnurlw', () => {
it('pulls an lnurlw:// URI out of an NDEF record', () => {
expect(extractLnurlw(ndefUriMessage(LNURLW))).toBe(LNURLW)
})
it('pulls a boltcards https scan URL', () => {
const https = 'https://lnbits.l484.com/boltcards/api/v1/scan/x?p=aa&c=bb'
expect(extractLnurlw(ndefUriMessage(https))).toBe(https)
})
it('stops at the record boundary (no trailing binary)', () => {
const msg = Buffer.concat([ndefUriMessage(LNURLW), Buffer.from([0x00, 0xfe, 0x01])])
expect(extractLnurlw(msg)).toBe(LNURLW)
})
it('returns null when there is no lnurl', () => {
expect(extractLnurlw(Buffer.from('just some text', 'ascii'))).toBeNull()
})
})
describe('readNdefLnurlw', () => {
const SW_OK = Buffer.from([0x90, 0x00])
it('runs the Type-4 read sequence and returns the lnurlw', async () => {
const msg = ndefUriMessage(LNURLW)
const nlen = msg.length
const transmit = vi
.fn()
.mockResolvedValueOnce(SW_OK) // select NDEF app
.mockResolvedValueOnce(SW_OK) // select NDEF file
.mockResolvedValueOnce(Buffer.concat([Buffer.from([(nlen >> 8) & 0xff, nlen & 0xff]), SW_OK])) // NLEN
.mockResolvedValueOnce(Buffer.concat([msg, SW_OK])) // NDEF message
const out = await readNdefLnurlw(transmit)
expect(out).toBe(LNURLW)
// First APDU selects the NDEF application (AID D2760000850101).
expect(Buffer.from((transmit.mock.calls[0][0] as Buffer)).toString('hex')).toContain(
'd2760000850101'
)
})
it('returns null if selecting the NDEF app fails', async () => {
const transmit = vi.fn().mockResolvedValue(Buffer.from([0x6a, 0x82])) // file not found SW
expect(await readNdefLnurlw(transmit)).toBeNull()
})
it('returns null on an empty NDEF file', async () => {
const transmit = vi
.fn()
.mockResolvedValueOnce(SW_OK)
.mockResolvedValueOnce(SW_OK)
.mockResolvedValueOnce(Buffer.concat([Buffer.from([0x00, 0x00]), SW_OK])) // NLEN = 0
expect(await readNdefLnurlw(transmit)).toBeNull()
})
})