Skip to content

feat(nfc): spike IsoDep APDU path for Cashu bearer cards - #67

Merged
islandbitcoin merged 4 commits into
mainfrom
feat/cashu-card-isodep-spike
Aug 27, 2026
Merged

feat(nfc): spike IsoDep APDU path for Cashu bearer cards#67
islandbitcoin merged 4 commits into
mainfrom
feat/cashu-card-isodep-spike

Conversation

@islandbitcoin

@islandbitcoin islandbitcoin commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

What

Adds the terminal-side read path for the cashu-javacard applet (NUT-XX Profile B), so flash-pos can talk to a physical Cashu bearer card the day one arrives.

This is a spike. No card has run this yet. Nothing here has touched physical silicon.

Why this is a different NFC role from Flashcard

The existing src/contexts/Flashcard.tsx path reads an NDEF text record off a BoltCard. This drives an ISO 7816 smartcard conversation over IsoDep. The distinction is the whole design:

Flashcard (BoltCard) Cashu card
Chip NTAG 424 DNA JavaCard 3.0.5+ secure element
NFC layer NDEF text record ISO 7816 APDUs over IsoDep
What the card holds An LNURLW pointer The money itself
Internet at tap Required Not required

Structure

File Role
src/services/cashuCard.ts APDU protocol. Transport-agnostic — imports nothing NFC-related.
src/services/cashuCardNfc.ts react-native-nfc-manager IsoDep transport + session lifecycle.
src/screens/CashuCardDebug.tsx Bring-up harness, __DEV__-gated.
docs/13-cashu-card.md How to run it on hardware, platform requirements, known gaps.

The split is deliberate: all 50 tests exercise the protocol against a fake transceiver, so the byte-level logic is verified in CI and only the thin transport needs a card.

The protocol mirrors tools/cardctl/cardctl.py in cashu-javacard byte for byte rather than working from the spec alone, since that driver is what will be proven against real silicon first.

A real blocker this surfaced

ios/flash_pos/Info.plist declared iso7816.select-identifiers with D2760000850100 and D2760000850101 (the NDEF AIDs) but not ours. iOS only SELECTs identifiers on that list, so a Cashu card was unreachable on iPhone — and the failure surfaces at requestTechnology, which does not look like a card problem. Both our package AID (D2760000850102) and applet AID (D276000085010201) are now declared, matching the prefix-then-full fallback in selectApplet().

⚠️ Still unverified against a real device or App Store review.

Reader mode vs HCE

Android cannot be an NFC reader and an HCE target simultaneously — enableReaderMode suppresses HCE for the life of the session. Our card is a passive secure element, so the terminal must be the reader. Every session is wrapped in try/finally around cancelTechnologyRequest(), because a stranded reader session blocks HCE and every later tap until the app restarts.

Consequence worth recording: if flash-pos ever also wants to accept taps from Cashu phone wallets (HCE/NDEF, as Numo does), it must switch modes explicitly. It cannot serve both at once.

Safety

Read-only — SELECT → GET_INFO → GET_PUBKEY → GET_BALANCE. Nothing is written, no proof is spent, so it is safe to run against a loaded card.

Deliberately not implemented

  • SPEND_PROOF — burns a slot irreversibly before returning the signature. Wiring it needs the mint round-trip from cashu-client plus a decision about what happens when the card marks a proof spent and the network call then fails. That is a funds-loss design question, not a coding task.
  • PIN handling — spending needs no PIN by design (bearer semantics), but LOAD_PROOF does.
  • Any hardware validation.

Verification

  • npx tsc --noEmit — 0 errors
  • npx eslint on all new/changed files — clean
  • 50 new tests, all passing
  • Full suite: 25 suites / 294 tests, all passing. origin/main is 20 / 193, also all passing.

Correction. An earlier revision of this description claimed 7 pre-existing suite failures on main. That was wrong — main is fully green. The failures came from a stale local jest cache and were an artifact of my environment, not of this branch or of main. Re-measured on a clean detached origin/main twice to confirm.

Merge guidance

I would hold this until it has run against a physical card. The point of the spike is that none of it is hardware-proven.

🤖 Generated with Claude Code

https://claude.ai/code/session_016MsAGwtS4sNodzWu2VMTKR

Adds the terminal-side read path for the cashu-javacard applet (NUT-XX
Profile B), so flash-pos can talk to a physical Cashu card the day one
arrives.

This is a different NFC role from the existing Flashcard/BoltCard flow.
That path reads an NDEF text record off a tag; this one drives an ISO 7816
smartcard conversation over IsoDep. The card holds the money itself and
signs a BIP-340 witness on-chip rather than pointing at an LNURLW.

- src/services/cashuCard.ts — APDU protocol, deliberately transport-agnostic
  so the whole thing is unit-testable with no NFC and no hardware. Mirrors
  the reference driver in cashu-javacard tools/cardctl byte for byte.
- src/services/cashuCardNfc.ts — react-native-nfc-manager IsoDep transport.
  Every session is wrapped in try/finally around cancelTechnologyRequest;
  a stranded reader session blocks HCE and all later taps.
- src/screens/CashuCardDebug.tsx — bring-up harness, registered under
  __DEV__ only so it cannot reach a release build.
- ios: declare our AID in iso7816.select-identifiers. iOS only SELECTs
  listed identifiers, so without this the card was unreachable on iOS and
  the failure would not have looked like a card problem.

Read-only: SELECT -> GET_INFO -> GET_PUBKEY -> GET_BALANCE. No proof is
spent and nothing is written, so it is safe against a loaded card.

50 tests, all passing. Typecheck and lint clean. The 7 pre-existing suite
failures on main are unchanged and unrelated.

Not implemented: SPEND_PROOF (burns a slot irreversibly before returning
the signature — needs the mint round-trip and a failure-mode decision),
PIN handling, and any hardware validation whatsoever.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MsAGwtS4sNodzWu2VMTKR
Dread and others added 3 commits August 27, 2026 09:35
An IsoDep session left pending is not a leak, it is a payment outage: while
requestTechnology is unresolved the native module claims every discovered tag
and never emits NfcManagerDiscoverTag, so FlashcardProvider's handleTag never
fires and the merchant's BoltCard tap is silently swallowed app-wide.

Session lifecycle:
- export cancelCardSession() and call it from a CashuCardDebug unmount cleanup,
  so navigating away mid-read cannot strand the session
- add a visible "Cancel read" control for ending a read without unmounting
- guard onRead against a second press (a second requestTechnology rejects with
  ERR_MULTI_REQ and its teardown then cancels the first session — both reads
  fail); add an optional `disabled` prop to TextButton so the affordance
  matches
- correct the reader-mode/HCE rationale in the header comment and in
  docs/13-cashu-card.md: FlashcardProvider already calls registerTagEvent(), so
  enableReaderMode is never invoked — the suppression comes from the pending
  techRequest

APDU protocol:
- append Le to the SELECT APDU (Case-4 GlobalPlatform). Without it iOS parses
  expectedResponseLength as -1, the card answers SW-only, and readCard silently
  falls back to the GET_INFO version. The fake card now models Le, so the
  regression cannot come back
- narrow selectApplet's catch: only 0x6A82 earns the fallback AID. A card that
  left the field mid-SELECT was being retried on a dead handle and reported as
  "applet not found"
- add CardProtocolError for length/framing failures. CardError(0, …) rendered
  as "… failed: unexpected status word (0x0000)" in front of a merchant and lied
  to anything branching on `sw`
- bound-check short-form Lc in buildApdu; a 300-byte payload was truncated to
  0x2C on the wire with no error, and the next commands to use it move money

Reachability:
- add a __DEV__-gated Profile → Settings row for the bring-up screen, and note
  on the RootStackType entry that the route only exists under __DEV__

Tests:
- new __tests__/screens/CashuCardDebug.test.tsx covering unmount cancellation,
  the in-flight guard, error surfacing and a successful read
- rename 'propagates a teardown failure never occurring on the happy path' to
  'swallows a teardown failure on the happy path' — it asserted the opposite of
  its name
- each new test verified to fail with its fix reverted

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MsAGwtS4sNodzWu2VMTKR
…er the __DEV__ gate

Review round 2.

Every react-native-nfc-manager error class is constructed with no arguments,
so `error.message` is the empty string for all of them. `describeCardFailure`
collapsed the entire transport error space to "Card read failed" — on the one
screen whose whole job is telling hardware failure modes apart. Map the classes
(UserCancel, RadioDisabled, TagConnectionLost/TagNotConnected, RetryExceeded,
TagResponseError, Timeout, SessionInvalidated, SystemBusy, UnsupportedFeature)
and fall back to the class name for the unmapped ones. `NfcErrorBase` is left
unmapped on purpose: it carries the raw native string.

CashuCardDebug now tracks a deliberate cancel, so pressing "Cancel read" no
longer paints a red error box for something the merchant just asked for. The
flag is cleared when the next read starts, so the failure after a cancel is
still reported.

The __DEV__ gate on the Cashu settings row was the only thing keeping a
dead-end NAVIGATE out of release builds and had no coverage at all — jest runs
with __DEV__ true and Profile.test.tsx stubbed Settings away. Add
__tests__/components/profile/Settings.test.tsx (both branches, flipping
__DEV__) and widen the Profile stub so the navigate wiring is exercised too.

jest.setup.js now hands tests the real NfcError classes (plus NfcTech), so the
instanceof-based mapping is asserted against the actual hierarchy rather than
look-alikes.

Docs: drop the two stale "reader mode" sentences that contradicted the "we are
not in reader mode" paragraph added last round, and document the error box.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MsAGwtS4sNodzWu2VMTKR
…or fallback

Two remaining review findings.

1. Cancel suppression was bound to a UI flag, so it never fired on iOS.

   `requestTechnology` on iOS presents a modal system scanning sheet over
   the app, which makes the in-app "Cancel read" button unreachable — the
   sheet's own Cancel is the only one available there. That path rejects
   with `UserCancel` without ever touching `cancelledRef`, so the screen
   painted a red "Read cancelled" failure box for an action the operator
   deliberately took. Exactly the false alarm the earlier fix was meant to
   remove, still live on the one platform with no alternative.

   Adds `isUserCancel()` next to `describeCardFailure` and suppresses on the
   error class as well as the flag. Both conditions are needed: the flag
   covers Android's in-app button, the predicate covers the iOS sheet.

   docs/13-cashu-card.md claimed cancel showed no error, unqualified, which
   was false on iOS. Corrected to state both mechanisms and why each is
   required.

2. The missing-NfcError fallback was documented but unverified.

   The module header states that a test mocking the package without
   `NfcError` should degrade to the generic branch rather than crash, but
   the existing test file mocks it with the real class hierarchy, so the
   guard, its memoization and the module-level cache could all be deleted
   with every test still green. Adds a second test file with its own module
   registry (the table memoizes on first use) that mocks the package with
   NfcError absent.

Verified the new iOS test actually fails without the fix: reverting the
`isUserCancel` condition fails that case alone and no other.

94 card tests, typecheck and lint clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MsAGwtS4sNodzWu2VMTKR
@islandbitcoin
islandbitcoin merged commit 8231d62 into main Aug 27, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant