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
Owner

What

Sell-bitcoin (cash-out) can now be paid by tapping a Bolt Card instead of scanning the invoice QR. The ATM already generates a BOLT11 for the amount; a tap makes the ATM the LNURL-withdrawing party and pulls payment for that same invoice — settlement flows through the existing invoice watcher, so the state machine is unchanged.

Validated end-to-end on the batm3: tap → NTAG424 NDEF read → LNURL-withdraw → invoice paid → dispensingCash (and it correctly declined a daily-limit card, surfacing the real LNURL error).

How it fits

  • Reader: Feitian KP382 (CCID). services.pcscd.enable + a polkit rule authorizing the sandboxed bitspire user. nfc-pcsc native addon (@pokusew/pcsclite) is rebuilt against Electron headers like better-sqlite3, with CPATH/LIBRARY_PATH pointing at nixpkgs pcsclite (its binding.gyp hardcodes Debian paths) and pcsclite.lib in buildInputs so autoPatchelf wires the RPATH.
  • Driver (main process): on tap, read the NTAG424 Type-4 NDEF over ISO7816 APDUs (select NDEF app → E104 → ReadBinary) and extract the lnurlw://…?p=…&c=… voucher (fresh SUN p/c per tap), forward on nfc:card-tapped. Lazy/guarded — a missing reader just reports unavailable; the QR path is never affected.
  • Executor (main process): executeLnurlWithdraw (LUD-03) — GET the voucher, POST our invoice to the callback. Node fetch to avoid renderer CORS.
  • Renderer: cash-out displayingInvoice subscribes to taps and calls the executor; "Tap Card or Scan" UI + live reader/processing/declined status.

Reliability notes (from on-hardware testing)

  • Read via the Capability Container / FileID E104 — a first attempt with 0004 gave "not a Bolt Card" (NTAG424 uses E104).
  • Single attempt + cooldown, not retries: hammering these cheap CCID readers wedges them into a present↔empty storm (only a USB replug clears it). After a failed read we cool down 1.5s; a steady hold reads cleanly.

Tests

  • executeLnurlWithdraw — 12 tests (scheme mapping, two-step happy path, ERROR/decline/over-limit, network failure).
  • NDEF read/parse — 7 tests (URI extraction, CC→E104 Type-4 sequence, empty/select-fail).
  • Renderer + electron typecheck clean.

Scope note

pcscd/reader config is in batm3.nix (the reader is batm3 hardware). The renderer/driver/executor are model-agnostic and no-op without a reader.

🤖 Generated with Claude Code

## What Sell-bitcoin (cash-out) can now be paid by **tapping a Bolt Card** instead of scanning the invoice QR. The ATM already generates a BOLT11 for the amount; a tap makes the ATM the LNURL-**withdrawing** party and pulls payment for that same invoice — settlement flows through the existing invoice watcher, so the state machine is unchanged. **Validated end-to-end on the batm3:** tap → NTAG424 NDEF read → LNURL-withdraw → invoice paid → `dispensingCash` (and it correctly *declined* a daily-limit card, surfacing the real LNURL error). ## How it fits - **Reader:** Feitian KP382 (CCID). `services.pcscd.enable` + a polkit rule authorizing the sandboxed `bitspire` user. `nfc-pcsc` native addon (`@pokusew/pcsclite`) is rebuilt against Electron headers like `better-sqlite3`, with `CPATH`/`LIBRARY_PATH` pointing at nixpkgs `pcsclite` (its `binding.gyp` hardcodes Debian paths) and `pcsclite.lib` in `buildInputs` so autoPatchelf wires the RPATH. - **Driver (main process):** on tap, read the NTAG424 Type-4 NDEF over ISO7816 APDUs (select NDEF app → `E104` → ReadBinary) and extract the `lnurlw://…?p=…&c=…` voucher (fresh SUN p/c per tap), forward on `nfc:card-tapped`. Lazy/guarded — a missing reader just reports `unavailable`; the QR path is never affected. - **Executor (main process):** `executeLnurlWithdraw` (LUD-03) — GET the voucher, POST our invoice to the callback. Node fetch to avoid renderer CORS. - **Renderer:** cash-out `displayingInvoice` subscribes to taps and calls the executor; "Tap Card or Scan" UI + live reader/processing/declined status. ## Reliability notes (from on-hardware testing) - Read via the **Capability Container / FileID `E104`** — a first attempt with `0004` gave "not a Bolt Card" (NTAG424 uses `E104`). - **Single attempt + cooldown**, not retries: hammering these cheap CCID readers wedges them into a present↔empty storm (only a USB replug clears it). After a failed read we cool down 1.5s; a steady hold reads cleanly. ## Tests - `executeLnurlWithdraw` — 12 tests (scheme mapping, two-step happy path, ERROR/decline/over-limit, network failure). - NDEF read/parse — 7 tests (URI extraction, CC→E104 Type-4 sequence, empty/select-fail). - Renderer + electron typecheck clean. ## Scope note pcscd/reader config is in `batm3.nix` (the reader is batm3 hardware). The renderer/driver/executor are model-agnostic and no-op without a reader. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
First, hardware-independent piece of Bolt Card tap-to-pay on the cash-out
flow. When a customer taps a Bolt Card, the ATM (which already has its
cash-out BOLT11) becomes the LNURL-*withdrawing* party: GET the card's
lnurlw voucher → GET callback?k1=…&pr=<invoice> so the card's wallet pays
the invoice. Settlement is still observed via the existing invoice watcher
(a returned ok=true means "card accepted the pull", not "cash dispensed").

- electron/lnurl-withdraw.ts: executeLnurlWithdraw() + lnurlwToHttps().
  Runs in the main process (Node fetch) to avoid renderer CORS, since LNURL
  endpoints send no CORS headers. Fully injectable fetch for testing.
- electron/lnurl-withdraw.test.ts: 12 tests (scheme mapping, two-step happy
  path passing k1+pr, ERROR surfacing, non-withdraw tag, amount-over-limit
  short-circuit, callback decline, network failure).
- IPC `lnurl:withdraw` (main) + preload + electron.d.ts.

Next: pcscd + an nfc-pcsc reader driver (reads the NTAG424 NDEF lnurlw),
then wire the tap into the cashOut displayingInvoice state + "tap or scan" UI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Feitian KP382 (096e:0608) is a CCID contactless reader; PC/SC must be
running for the CCID driver to bind it. The app will talk to pcscd's socket
via nfc-pcsc. Idle/harmless when no reader is attached.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
Renderer side of tap-to-pay. The store subscribes to the main-process
reader (onNfcCardTapped/onNfcStatus); a tap during displayingInvoice pulls
payment for the shown invoice via lnurlWithdraw (amount in msats), guarded
against double-taps. Settlement still flows through the existing invoice
watcher → PAYMENT_RECEIVED → dispensingCash, so the state machine is
unchanged. Bolt Card state clears when leaving the invoice screen.

CashOutView: "Tap Card or Scan to Pay" + live reader/processing/declined
status on the invoice screen, plus a dev input to simulate a tap with a
pasted lnurlw. Exposes nfcStatus / boltCardProcessing / simulateBoltCardTap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rebuild @pokusew/pcsclite (V8 C++ addon) against Electron headers like
better-sqlite3, and package nfc-pcsc + @pokusew/pcsclite into the runtime
node_modules. Its binding.gyp hardcodes Debian /usr/include/PCSC + /usr/lib,
so point the compiler/linker at nixpkgs pcsclite via CPATH/LIBRARY_PATH
(winscard.h lives under include/PCSC); pcsclite.lib in buildInputs lets
autoPatchelf wire libpcsclite.so.1 into the .node RPATH. Bumps the pnpmDeps
hash for the added nfc-pcsc dependency.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
pcscd gates clients via polkit; the sandboxed bitspire user was "Rejected
unauthorized PC/SC client", so add a polkit rule granting it
access_pcsc/access_card. Also log NFC reader status + taps from the main
process to journald (value redacted — it carries the card's SUN p/c) so
reader detection and taps are observable during testing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
First real-card tap read the NDEF file with id 0004 and got "not a Bolt
Card" — NTAG424 (Bolt Cards) use FileID E104. Read the Capability Container
(EF E103) after selecting the NDEF app to learn the advertised NDEF FileID,
then read that file; fall back to E104/0004. Tolerates a transient transmit
error (surfaced as a retryable status; the next tap re-reads).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
Hammering a flaky CCID reader with rapid re-reads wedges it into a
present↔empty storm (only a USB replug clears it). After a failed read,
ignore card re-detections for 1.5s; successful reads don't cool down.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
padreug deleted branch feat/boltcard-nfc-cashout 2026-08-05 21:38:35 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
aiolabs/bitspire!83
No description provided.