feat(nfc): spike IsoDep APDU path for Cashu bearer cards - #67
Merged
Conversation
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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.tsxpath reads an NDEF text record off a BoltCard. This drives an ISO 7816 smartcard conversation over IsoDep. The distinction is the whole design:Structure
src/services/cashuCard.tssrc/services/cashuCardNfc.tsreact-native-nfc-managerIsoDep transport + session lifecycle.src/screens/CashuCardDebug.tsx__DEV__-gated.docs/13-cashu-card.mdThe 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.pyin 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.plistdeclarediso7816.select-identifierswithD2760000850100andD2760000850101(the NDEF AIDs) but not ours. iOS onlySELECTs identifiers on that list, so a Cashu card was unreachable on iPhone — and the failure surfaces atrequestTechnology, 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 inselectApplet().Reader mode vs HCE
Android cannot be an NFC reader and an HCE target simultaneously —
enableReaderModesuppresses 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 intry/finallyaroundcancelTechnologyRequest(), 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.LOAD_PROOFdoes.Verification
npx tsc --noEmit— 0 errorsnpx eslinton all new/changed files — cleanorigin/mainis 20 / 193, also all passing.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