From b5a11a29a73b8c83d510102916eafbbf8e31af9e Mon Sep 17 00:00:00 2001
From: Patoo <262265744+patoo0x@users.noreply.github.com>
Date: Tue, 3 Mar 2026 05:43:46 -0500
Subject: [PATCH 1/3] fix: rename settings section labels (#566)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 'Experimental' → 'Chat' (section only contains Chat Settings)
- 'Key management' → 'Wallet backup' (section only contains Backup)
Reported by Lori (app audit). Copy fix, no structural changes.
---
app/i18n/en/index.ts | 2 +-
app/i18n/raw-i18n/source/en.json | 2 +-
app/screens/settings-screen/settings-screen.tsx | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/app/i18n/en/index.ts b/app/i18n/en/index.ts
index 38082a48e..1e92cfe30 100644
--- a/app/i18n/en/index.ts
+++ b/app/i18n/en/index.ts
@@ -835,7 +835,7 @@ const en: BaseTranslation = {
showNostrSecret: "Chat Settings",
beginnerMode: "Disable Bitcoin Account",
advanceMode: "Enable Bitcoin Account (Advanced Mode)",
- keysManagement: "Key management",
+ keysManagement: "Wallet backup",
showBtcAccount: "Show Bitcoin account",
hideBtcAccount: "Hide Bitcoin account"
},
diff --git a/app/i18n/raw-i18n/source/en.json b/app/i18n/raw-i18n/source/en.json
index e860a1e36..dd7e7e781 100644
--- a/app/i18n/raw-i18n/source/en.json
+++ b/app/i18n/raw-i18n/source/en.json
@@ -772,7 +772,7 @@
"showNostrSecret": "Chat Settings",
"beginnerMode": "Disable Bitcoin Account",
"advanceMode": "Enable Bitcoin Account (Advanced Mode)",
- "keysManagement": "Key management",
+ "keysManagement": "Wallet backup",
"showBtcAccount": "Show Bitcoin account",
"hideBtcAccount": "Hide Bitcoin account"
},
diff --git a/app/screens/settings-screen/settings-screen.tsx b/app/screens/settings-screen/settings-screen.tsx
index 00991bcbf..c6f20b3fa 100644
--- a/app/screens/settings-screen/settings-screen.tsx
+++ b/app/screens/settings-screen/settings-screen.tsx
@@ -107,7 +107,7 @@ export const SettingsScreen: React.FC = () => {
{(currentLevel === AccountLevel.Two || currentLevel === AccountLevel.Three) && (
)}
-
+
Date: Sat, 7 Mar 2026 16:34:13 -0500
Subject: [PATCH 2/3] feat: ENG-179 Cashu NFC card top-up from flash-mobile
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Users can now top up their Flash card with Cashu proofs directly
from the mobile app, enabling offline bearer payments.
New files:
app/nfc/cashu-apdu.ts APDU layer for CashuApplet (AID D2 76 00 00 85 01 02)
app/nfc/useCashuCard.ts IsoDep NFC session hook (NfcTech.IsoDep)
app/screens/card-screen/cashu-topup.tsx
6-step top-up screen:
amount → pin → tap card → mint → write → success
app/screens/card-screen/cashu-topup.helpers.ts
extractNonceFromSecret() + toCardWriteProof()
Flow:
1. User enters USD amount + card PIN
2. NFC: SELECT + GET_INFO + GET_PUBKEY (blank card detection)
3. GQL: cashuCardProvision (mints P2PK-locked proofs from user's USD wallet)
4. NFC: SET_PIN (blank) + VERIFY_PIN + LOAD_PROOF × N
5. Card is loaded, ready for offline tap-to-pay at Flash POS
Depends on: lnflash/flash#296 (cashuCardProvision mutation)
Modified:
app/screens/card-screen/card.tsx onCashuTopup() → cashuTopup route
app/components/card/EmptyCard.tsx optional 'Top Up Flash Card' button
app/components/card/Flashcard.tsx optional 'Flash Top-Up' icon button
app/navigation/stack-param-lists.ts cashuTopup: undefined route
app/navigation/root-navigator.tsx CashuTopup screen registered
app/screens/card-screen/index.ts exports CashuTopup
Closes ENG-179
---
app/components/card/EmptyCard.tsx | 13 +-
app/components/card/Flashcard.tsx | 11 +-
app/navigation/root-navigator.tsx | 9 +-
app/navigation/stack-param-lists.ts | 1 +
app/nfc/cashu-apdu.ts | 279 ++++++++++
app/nfc/useCashuCard.ts | 283 ++++++++++
app/screens/card-screen/card.tsx | 10 +-
.../card-screen/cashu-topup.helpers.ts | 42 ++
app/screens/card-screen/cashu-topup.tsx | 483 ++++++++++++++++++
app/screens/card-screen/index.ts | 1 +
10 files changed, 1128 insertions(+), 4 deletions(-)
create mode 100644 app/nfc/cashu-apdu.ts
create mode 100644 app/nfc/useCashuCard.ts
create mode 100644 app/screens/card-screen/cashu-topup.helpers.ts
create mode 100644 app/screens/card-screen/cashu-topup.tsx
diff --git a/app/components/card/EmptyCard.tsx b/app/components/card/EmptyCard.tsx
index 6e9fb6bbc..dc5dde74e 100644
--- a/app/components/card/EmptyCard.tsx
+++ b/app/components/card/EmptyCard.tsx
@@ -17,7 +17,11 @@ import EmptyFlashcard from "@app/assets/icons/empty-flashcard.svg"
const width = Dimensions.get("screen").width
-const EmptyCard = () => {
+type EmptyCardProps = {
+ onCashuTopup?: () => void
+}
+
+const EmptyCard = ({ onCashuTopup }: EmptyCardProps) => {
const navigation = useNavigation>()
const styles = useStyles()
const { LL } = useI18nContext()
@@ -39,6 +43,13 @@ const EmptyCard = () => {
onPress={() => readFlashcard(false)}
btnStyle={{ marginBottom: 10 }}
/>
+ {onCashuTopup && (
+
+ )}
)
diff --git a/app/components/card/Flashcard.tsx b/app/components/card/Flashcard.tsx
index 52cefef3c..61d910891 100644
--- a/app/components/card/Flashcard.tsx
+++ b/app/components/card/Flashcard.tsx
@@ -28,9 +28,10 @@ import { DisplayCurrency, toBtcMoneyAmount } from "@app/types/amounts"
type Props = {
onReload: () => void
onTopup: () => void
+ onCashuTopup?: () => void
}
-const Flashcard: React.FC = ({ onReload, onTopup }) => {
+const Flashcard: React.FC = ({ onReload, onTopup, onCashuTopup }) => {
const isAuthed = useIsAuthed()
const styles = useStyles()
const { colors } = useTheme().theme
@@ -72,6 +73,14 @@ const Flashcard: React.FC = ({ onReload, onTopup }) => {
+ {onCashuTopup && (
+
+ )}
{
title: LL.ReceiveScreen.topupFlashcard(),
}}
/>
+
>> 24) & 0xff;
+ payload[9] = (amount >>> 16) & 0xff;
+ payload[10] = (amount >>> 8) & 0xff;
+ payload[11] = amount & 0xff;
+ const nonceBytes = hexToBytes(nonceHex);
+ payload.set(nonceBytes.slice(0, 32), 12);
+ const cBytes = hexToBytes(cHex);
+ payload.set(cBytes.slice(0, 33), 44);
+
+ const payloadArr: number[] = [];
+ for (let i = 0; i < payload.length; i++) payloadArr.push(payload[i]);
+ return [CASHU_CLA, INS_LOAD_PROOF, 0x00, 0x00, 77].concat(payloadArr);
+}
+
+/**
+ * VERIFY_PIN — authenticate provisioning session.
+ * @param pinBytes 4–8 ASCII bytes of the PIN
+ */
+export function buildVerifyPin(pinBytes: number[]): number[] {
+ return [CASHU_CLA, INS_VERIFY_PIN, 0x00, 0x00, pinBytes.length, ...pinBytes];
+}
+
+/**
+ * SET_PIN — first-time PIN setup (one-time only, no auth required).
+ * @param pinBytes 4–8 ASCII bytes
+ */
+export function buildSetPin(pinBytes: number[]): number[] {
+ return [CASHU_CLA, INS_SET_PIN, 0x00, 0x00, pinBytes.length, ...pinBytes];
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Response parsers
+// ─────────────────────────────────────────────────────────────────────────────
+
+/** Parse GET_INFO 8-byte response */
+export function parseCardInfo(data: number[]): CardInfo {
+ return {
+ versionMajor: data[0],
+ versionMinor: data[1],
+ maxSlots: data[2],
+ unspentCount: data[3],
+ spentCount: data[4],
+ emptyCount: data[5],
+ capabilities: data[6],
+ pinState: data[7],
+ };
+}
+
+/** Parse GET_PUBKEY response (33 or 65 bytes) → hex string */
+export function parsePubkey(data: number[]): string {
+ // Normalise: if 65-byte uncompressed (0x04 prefix), compress it
+ if (data.length === 65 && data[0] === 0x04) {
+ return compressPoint(data);
+ }
+ return bytesToHex(data);
+}
+
+/** Parse GET_PROOF 78-byte response */
+export function parseProof(data: number[], slotIndex: number): CardProof {
+ return {
+ slotIndex,
+ status: data[0],
+ keysetId: bytesToHex(data.slice(1, 9)),
+ amount: (data[9] << 24) | (data[10] << 16) | (data[11] << 8) | data[12],
+ nonce: bytesToHex(data.slice(13, 45)),
+ C: bytesToHex(data.slice(45, 78)),
+ };
+}
+
+/** Parse GET_BALANCE 4-byte uint32 */
+export function parseBalance(data: number[]): number {
+ return (data[0] << 24) | (data[1] << 16) | (data[2] << 8) | data[3];
+}
+
+/** Extract SW (last 2 bytes of raw response) */
+export function getSW(response: number[]): number {
+ const n = response.length;
+ return ((response[n - 2] & 0xff) << 8) | (response[n - 1] & 0xff);
+}
+
+/** Data bytes (everything before last 2 SW bytes) */
+export function getResponseData(response: number[]): number[] {
+ return response.slice(0, response.length - 2);
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Helpers
+// ─────────────────────────────────────────────────────────────────────────────
+
+export function hexToBytes(hex: string): Uint8Array {
+ const len = hex.length;
+ const out = new Uint8Array(len / 2);
+ for (let i = 0; i < len; i += 2) {
+ out[i / 2] = parseInt(hex.slice(i, i + 2), 16);
+ }
+ return out;
+}
+
+export function bytesToHex(bytes: number[] | Uint8Array): string {
+ let hex = '';
+ for (let i = 0; i < bytes.length; i++) {
+ const h = (bytes[i] & 0xff).toString(16);
+ hex += h.length === 1 ? '0' + h : h;
+ }
+ return hex;
+}
+
+export function pinStringToBytes(pin: string): number[] {
+ return pin.split('').map(c => c.charCodeAt(0));
+}
+
+/** Compress an uncompressed EC point (0x04 prefix, 65 bytes) */
+function compressPoint(uncompressed: number[]): string {
+ const x = uncompressed.slice(1, 33);
+ const yLastByte = uncompressed[64];
+ const prefix = yLastByte % 2 === 0 ? 0x02 : 0x03;
+ return bytesToHex([prefix, ...x]);
+}
+
+/**
+ * Reconstruct the P2PK secret JSON string from on-card data.
+ * Used to compute the message hash for SPEND_PROOF verification.
+ *
+ * Format:
+ * ["P2PK",{"nonce":"","data":"","tags":[["sigflag","SIG_INPUTS"]]}]
+ */
+export function reconstructP2PKSecret(nonceHex: string, cardPubkeyHex: string): string {
+ return JSON.stringify([
+ 'P2PK',
+ {
+ nonce: nonceHex,
+ data: cardPubkeyHex,
+ tags: [['sigflag', 'SIG_INPUTS']],
+ },
+ ]);
+}
diff --git a/app/nfc/useCashuCard.ts b/app/nfc/useCashuCard.ts
new file mode 100644
index 000000000..685ca3bb2
--- /dev/null
+++ b/app/nfc/useCashuCard.ts
@@ -0,0 +1,283 @@
+/**
+ * useCashuCard.ts
+ *
+ * React hook for IsoDep (APDU) NFC sessions with the Cashu JavaCard applet.
+ * Manages NFC technology lifecycle — always call cleanup() when done.
+ *
+ * Usage:
+ * const card = useCashuCard();
+ * await card.startSession(); // request IsoDep technology
+ * const info = await card.getInfo();
+ * const pubkey = await card.getPubkey();
+ * await card.setPin(pin);
+ * await card.verifyPin(pin);
+ * await card.loadProof(keysetId, amount, nonce, C);
+ * await card.cleanup(); // always call on finish or error
+ */
+
+import {useCallback, useRef} from 'react';
+import NfcManager, {NfcTech} from 'react-native-nfc-manager';
+
+import {
+ buildSelectAid,
+ buildGetInfo,
+ buildGetPubkey,
+ buildGetBalance,
+ buildGetSlotStatus,
+ buildGetProof,
+ buildLoadProof,
+ buildVerifyPin,
+ buildSetPin,
+ parseCardInfo,
+ parsePubkey,
+ parseProof,
+ parseBalance,
+ getSW,
+ getResponseData,
+ pinStringToBytes,
+ CardInfo,
+ CardProof,
+ SW_OK,
+ CASHU_CLA,
+ INS_SPEND_PROOF,
+} from './cashu-apdu';
+
+export class CashuCardError extends Error {
+ constructor(
+ message: string,
+ public readonly sw?: number,
+ ) {
+ super(message);
+ this.name = 'CashuCardError';
+ }
+}
+
+const useCashuCard = () => {
+ const sessionActive = useRef(false);
+
+ // ─── Low-level transceive ───────────────────────────────────────────────
+
+ const send = useCallback(async (apdu: number[]): Promise => {
+ const response = await NfcManager.isoDepHandler.transceive(apdu);
+ const arr: number[] = [];
+ for (let i = 0; i < response.length; i++) arr.push(response[i]);
+ return arr;
+ }, []);
+
+ const sendAndCheck = useCallback(
+ async (apdu: number[], errorLabel: string): Promise => {
+ const raw = await send(apdu);
+ const sw = getSW(raw);
+ if (sw !== SW_OK) {
+ throw new CashuCardError(
+ `${errorLabel} failed (SW: 0x${sw.toString(16).toUpperCase()})`,
+ sw,
+ );
+ }
+ return getResponseData(raw);
+ },
+ [send],
+ );
+
+ // ─── Session lifecycle ──────────────────────────────────────────────────
+
+ const startSession = useCallback(async (): Promise => {
+ await NfcManager.requestTechnology(NfcTech.IsoDep);
+ sessionActive.current = true;
+
+ // SELECT AID
+ await sendAndCheck(buildSelectAid(), 'SELECT AID');
+ }, [sendAndCheck]);
+
+ const cleanup = useCallback(async (): Promise => {
+ if (sessionActive.current) {
+ try {
+ await NfcManager.cancelTechnologyRequest();
+ } catch {
+ // Ignore — card may have been removed
+ }
+ sessionActive.current = false;
+ }
+ }, []);
+
+ // ─── Read commands ──────────────────────────────────────────────────────
+
+ const getInfo = useCallback(async (): Promise => {
+ const data = await sendAndCheck(buildGetInfo(), 'GET_INFO');
+ return parseCardInfo(data);
+ }, [sendAndCheck]);
+
+ const getPubkey = useCallback(async (): Promise => {
+ const data = await sendAndCheck(buildGetPubkey(), 'GET_PUBKEY');
+ return parsePubkey(data);
+ }, [sendAndCheck]);
+
+ const getBalance = useCallback(async (): Promise => {
+ const data = await sendAndCheck(buildGetBalance(), 'GET_BALANCE');
+ return parseBalance(data);
+ }, [sendAndCheck]);
+
+ const getSlotStatuses = useCallback(async (): Promise => {
+ const data = await sendAndCheck(buildGetSlotStatus(), 'GET_SLOT_STATUS');
+ return data;
+ }, [sendAndCheck]);
+
+ const getProof = useCallback(
+ async (slotIndex: number): Promise => {
+ const data = await sendAndCheck(buildGetProof(slotIndex), 'GET_PROOF');
+ return parseProof(data, slotIndex);
+ },
+ [sendAndCheck],
+ );
+
+ // ─── Auth commands ──────────────────────────────────────────────────────
+
+ /** SET_PIN — first-time only, no prior auth required */
+ const setPin = useCallback(
+ async (pin: string): Promise => {
+ await sendAndCheck(buildSetPin(pinStringToBytes(pin)), 'SET_PIN');
+ },
+ [sendAndCheck],
+ );
+
+ /** VERIFY_PIN — establishes write session for LOAD_PROOF / CLEAR_SPENT */
+ const verifyPin = useCallback(
+ async (pin: string): Promise => {
+ await sendAndCheck(buildVerifyPin(pinStringToBytes(pin)), 'VERIFY_PIN');
+ },
+ [sendAndCheck],
+ );
+
+ // ─── Write commands ─────────────────────────────────────────────────────
+
+ /**
+ * LOAD_PROOF — write one proof to the card.
+ * Requires verifyPin() to have been called in this session.
+ *
+ * @param keysetIdHex 16-char hex (8 bytes)
+ * @param amount denomination in keyset's base unit
+ * @param nonceHex 64-char hex (32-byte nonce from P2PK secret)
+ * @param cHex 66-char hex (33-byte compressed C point)
+ * @returns slot index written
+ */
+ const loadProof = useCallback(
+ async (
+ keysetIdHex: string,
+ amount: number,
+ nonceHex: string,
+ cHex: string,
+ ): Promise => {
+ const data = await sendAndCheck(
+ buildLoadProof(keysetIdHex, amount, nonceHex, cHex),
+ 'LOAD_PROOF',
+ );
+ return data[0]; // slot index
+ },
+ [sendAndCheck],
+ );
+
+ // ─── Spend commands ─────────────────────────────────────────────────────
+
+ /**
+ * SPEND_PROOF — atomically marks the proof spent and returns a 64-byte
+ * BIP-340 Schnorr signature over the provided 32-byte message.
+ *
+ * msg = SHA256(reconstructP2PKSecret(proof.nonce, cardPubkey))
+ *
+ * @param slotIndex slot to spend (0–31)
+ * @param msg 32 bytes to sign (as number[])
+ * @returns 64-byte Schnorr signature as number[]
+ */
+ const spendProof = useCallback(
+ async (slotIndex: number, msg: number[]): Promise => {
+ const apdu = [CASHU_CLA, INS_SPEND_PROOF, slotIndex & 0xff, 0x00, 32].concat(msg);
+ return sendAndCheck(apdu, 'SPEND_PROOF');
+ },
+ [sendAndCheck],
+ );
+
+ // ─── Compound provisioning flow ─────────────────────────────────────────
+
+ /**
+ * Full provisioning flow for a blank card:
+ * 1. GET_INFO — verify blank (pinState=0, no existing proofs)
+ * 2. GET_PUBKEY — return for GQL call
+ * 3. (caller calls cashuCardProvision GQL, gets proofs back)
+ * 4. SET_PIN → VERIFY_PIN → LOAD_PROOF × N
+ *
+ * @returns card pubkey hex (33-byte compressed)
+ * @throws CashuCardError if card is not blank or PIN already set
+ */
+ const readBlankCardPubkey = useCallback(async (): Promise<{
+ pubkey: string;
+ info: CardInfo;
+ }> => {
+ const info = await getInfo();
+
+ if (info.pinState !== 0) {
+ throw new CashuCardError(
+ 'Card already has a PIN set — use top-up flow instead',
+ );
+ }
+ if (info.unspentCount > 0 || info.spentCount > 0) {
+ throw new CashuCardError(
+ 'Card already has proofs — use top-up flow instead',
+ );
+ }
+
+ const pubkey = await getPubkey();
+ return {pubkey, info};
+ }, [getInfo, getPubkey]);
+
+ /**
+ * Write proofs onto a card after cashuCardProvision GQL call.
+ * Handles SET_PIN (blank card) or VERIFY_PIN (top-up).
+ *
+ * @param proofs array of {keysetId, amount, nonce, C} proof objects
+ * @param pin PIN to set (blank) or verify (top-up)
+ * @param isBlank true = call SET_PIN first; false = just VERIFY_PIN
+ */
+ const writeProofs = useCallback(
+ async (
+ proofs: {keysetId: string; amount: number; nonce: string; C: string}[],
+ pin: string,
+ isBlank: boolean,
+ ): Promise => {
+ if (isBlank) {
+ await setPin(pin);
+ }
+ await verifyPin(pin);
+
+ const slots: number[] = [];
+ for (const proof of proofs) {
+ const slot = await loadProof(
+ proof.keysetId,
+ proof.amount,
+ proof.nonce,
+ proof.C,
+ );
+ slots.push(slot);
+ }
+ return slots;
+ },
+ [setPin, verifyPin, loadProof],
+ );
+
+ return {
+ startSession,
+ cleanup,
+ getInfo,
+ getPubkey,
+ getBalance,
+ getSlotStatuses,
+ getProof,
+ setPin,
+ verifyPin,
+ loadProof,
+ spendProof,
+ readBlankCardPubkey,
+ writeProofs,
+ };
+};
+
+export default useCashuCard;
diff --git a/app/screens/card-screen/card.tsx b/app/screens/card-screen/card.tsx
index 8030f73a4..9b1efc02f 100644
--- a/app/screens/card-screen/card.tsx
+++ b/app/screens/card-screen/card.tsx
@@ -97,13 +97,21 @@ export const CardScreen = () => {
})
}
+ const onCashuTopup = () => {
+ navigation.navigate("cashuTopup")
+ }
+
return (
- {lnurl ? : }
+ {lnurl ? (
+
+ ) : (
+
+ )}
)
}
diff --git a/app/screens/card-screen/cashu-topup.helpers.ts b/app/screens/card-screen/cashu-topup.helpers.ts
new file mode 100644
index 000000000..b03738d2f
--- /dev/null
+++ b/app/screens/card-screen/cashu-topup.helpers.ts
@@ -0,0 +1,42 @@
+/**
+ * cashu-topup.helpers.ts
+ * Shared helpers for the Cashu card top-up flow.
+ */
+
+interface CashuProofGql {
+ id: string
+ amount: number
+ secret: string
+ C: string
+}
+
+/**
+ * Extract the 32-byte nonce hex from a P2PK secret JSON string.
+ * The nonce is what gets stored on the card (32-byte field of 77-byte proof slot).
+ */
+export function extractNonceFromSecret(secret: string): string {
+ try {
+ const parsed = JSON.parse(secret) as [string, { nonce: string }]
+ if (parsed[0] !== "P2PK" || !parsed[1]?.nonce) throw new Error("Not P2PK")
+ return parsed[1].nonce
+ } catch {
+ throw new Error(`Invalid P2PK secret: ${secret.slice(0, 60)}`)
+ }
+}
+
+/**
+ * Convert a GQL proof into the flat format needed for useCashuCard.writeProofs().
+ */
+export function toCardWriteProof(proof: CashuProofGql): {
+ keysetId: string
+ amount: number
+ nonce: string
+ C: string
+} {
+ return {
+ keysetId: proof.id,
+ amount: proof.amount,
+ nonce: extractNonceFromSecret(proof.secret),
+ C: proof.C,
+ }
+}
diff --git a/app/screens/card-screen/cashu-topup.tsx b/app/screens/card-screen/cashu-topup.tsx
new file mode 100644
index 000000000..009b5b55c
--- /dev/null
+++ b/app/screens/card-screen/cashu-topup.tsx
@@ -0,0 +1,483 @@
+/**
+ * cashu-topup.tsx
+ *
+ * Cashu NFC card top-up screen (ENG-179).
+ *
+ * Flow:
+ * amount → User enters USD amount to load
+ * pin → User enters their card PIN (or sets one for blank cards)
+ * tapping → NFC: SELECT + GET_INFO + GET_PUBKEY
+ * minting → GQL: cashuCardProvision → proofs minted from user's USD wallet
+ * writing → NFC: (SET_PIN +) VERIFY_PIN + LOAD_PROOF × N
+ * success → Card is loaded, ready to spend
+ * error → Retry or cancel
+ */
+
+import React, { useCallback, useRef, useState } from "react"
+import {
+ ActivityIndicator,
+ Alert,
+ KeyboardAvoidingView,
+ Platform,
+ ScrollView,
+ StyleSheet,
+ TextInput,
+ TouchableOpacity,
+ View,
+} from "react-native"
+import { gql, useMutation } from "@apollo/client"
+import { StackNavigationProp } from "@react-navigation/stack"
+import { useNavigation } from "@react-navigation/native"
+import { makeStyles, Text, useTheme } from "@rneui/themed"
+import { Screen } from "@app/components/screen"
+import { useScanningQrCodeScreenQuery } from "@app/graphql/generated"
+import { useIsAuthed } from "@app/graphql/is-authed-context"
+import { WalletCurrency } from "@app/graphql/generated"
+
+import useCashuCard, { CashuCardError } from "../../nfc/useCashuCard"
+import { toCardWriteProof } from "./cashu-topup.helpers"
+import { RootStackParamList } from "@app/navigation/stack-param-lists"
+
+// ─── GQL ───────────────────────────────────────────────────────────────────
+// cashuCardProvision is defined in flash PR #296. Generated types pending
+// backend deployment — using manual typing until codegen is re-run.
+
+const CASHU_CARD_PROVISION = gql`
+ mutation cashuCardProvision($input: CashuCardProvisionInput!) {
+ cashuCardProvision(input: $input) {
+ errors {
+ __typename
+ message
+ }
+ proofs {
+ id
+ amount
+ secret
+ C
+ }
+ cardPubkey
+ totalAmountCents
+ }
+ }
+`
+
+interface CashuProofGql {
+ id: string
+ amount: number
+ secret: string
+ C: string
+}
+
+interface CashuCardProvisionPayload {
+ errors: { __typename: string; message: string }[]
+ proofs: CashuProofGql[]
+ cardPubkey: string
+ totalAmountCents: number
+}
+
+// ─── Types ─────────────────────────────────────────────────────────────────
+
+type Step = "amount" | "pin" | "tapping" | "minting" | "writing" | "success" | "error"
+
+type Props = {
+ navigation: StackNavigationProp
+}
+
+// ─── Component ─────────────────────────────────────────────────────────────
+
+export const CashuTopup: React.FC = () => {
+ const navigation = useNavigation()
+ const { colors } = useTheme().theme
+ const styles = useStyles()
+ const isAuthed = useIsAuthed()
+
+ const { data } = useScanningQrCodeScreenQuery({ skip: !isAuthed })
+ const wallets = data?.me?.defaultAccount?.wallets
+ const usdWallet = wallets?.find((w) => w.walletCurrency === WalletCurrency.Usd)
+
+ const [step, setStep] = useState("amount")
+ const [amountDisplay, setAmountDisplay] = useState("")
+ const [pin, setPin] = useState("")
+ const [progressMsg, setProgressMsg] = useState("")
+ const [errorMsg, setErrorMsg] = useState("")
+ const [successInfo, setSuccessInfo] = useState<{
+ proofCount: number
+ totalCents: number
+ } | null>(null)
+
+ const isTopUpRef = useRef(false)
+ const card = useCashuCard()
+
+ const [provisionMutation] = useMutation<{
+ cashuCardProvision: CashuCardProvisionPayload
+ }>(CASHU_CARD_PROVISION)
+
+ // ─── Helpers ──────────────────────────────────────────────────────────
+
+ const amountCents = (): number => {
+ const v = parseFloat(amountDisplay)
+ return isNaN(v) || v <= 0 ? 0 : Math.round(v * 100)
+ }
+
+ const formatCents = (cents: number) => `$${(cents / 100).toFixed(2)}`
+
+ // ─── Main top-up flow ─────────────────────────────────────────────────
+
+ const runTopup = useCallback(
+ async (pinValue: string) => {
+ if (!usdWallet?.id) {
+ Alert.alert("No USD wallet found")
+ return
+ }
+
+ try {
+ // Step: NFC — read card
+ setStep("tapping")
+ setProgressMsg("Hold card to phone…")
+ await card.startSession()
+
+ let cardPubkey: string
+ let isBlank: boolean
+ let availableSlots: number | undefined
+
+ try {
+ const { pubkey, info } = await card.readBlankCardPubkey()
+ cardPubkey = pubkey
+ isBlank = true
+ isTopUpRef.current = false
+ setProgressMsg(`Card ready · ${info.emptyCount} free slots`)
+ } catch (err) {
+ if (
+ err instanceof CashuCardError &&
+ err.message.includes("top-up flow")
+ ) {
+ isBlank = false
+ isTopUpRef.current = true
+ cardPubkey = await card.getPubkey()
+ const info = await card.getInfo()
+ if (info.emptyCount === 0) {
+ throw new CashuCardError("Card is full — no empty slots")
+ }
+ availableSlots = info.emptyCount
+ setProgressMsg(`Top-up · ${info.emptyCount} free slots`)
+ } else {
+ throw err
+ }
+ }
+
+ // Step: Mint proofs via GQL
+ setStep("minting")
+ setProgressMsg("Minting proofs…")
+
+ const result = await provisionMutation({
+ variables: {
+ input: {
+ walletId: usdWallet.id,
+ amountCents: amountCents(),
+ cardPubkey,
+ ...(availableSlots !== undefined && { availableSlots }),
+ },
+ },
+ })
+
+ const payload = result.data?.cashuCardProvision
+ if (!payload) throw new Error("No response from server")
+ if (payload.errors?.length > 0) throw new Error(payload.errors[0].message)
+ if (!payload.proofs?.length) throw new Error("No proofs returned from mint")
+
+ const { proofs, totalAmountCents } = payload
+
+ // Step: Write proofs to card
+ setStep("writing")
+ setProgressMsg(`Writing ${proofs.length} proofs…`)
+
+ const cardWriteProofs = proofs.map(toCardWriteProof)
+ await card.writeProofs(cardWriteProofs, pinValue, isBlank)
+ await card.cleanup()
+
+ // Success
+ setSuccessInfo({ proofCount: proofs.length, totalCents: totalAmountCents })
+ setStep("success")
+ } catch (err) {
+ await card.cleanup()
+
+ const msg = err instanceof Error ? err.message : "Unknown NFC error"
+ if (msg.toLowerCase().includes("cancel")) {
+ setStep("pin")
+ return
+ }
+ setErrorMsg(msg)
+ setStep("error")
+ }
+ },
+ [card, provisionMutation, usdWallet, amountDisplay],
+ )
+
+ // ─── Actions ──────────────────────────────────────────────────────────
+
+ const handleAmountNext = () => {
+ if (amountCents() < 100) {
+ Alert.alert("Minimum amount is $1.00")
+ return
+ }
+ setStep("pin")
+ }
+
+ const handlePinNext = () => {
+ if (pin.length < 4) {
+ Alert.alert("PIN must be at least 4 digits")
+ return
+ }
+ runTopup(pin)
+ }
+
+ const handleRetry = () => {
+ setStep("amount")
+ setAmountDisplay("")
+ setPin("")
+ setErrorMsg("")
+ }
+
+ // ─── Render ───────────────────────────────────────────────────────────
+
+ return (
+
+
+
+
+ {/* Amount */}
+ {step === "amount" && (
+
+
+ Top Up Flash Card
+
+
+ How much would you like to load (USD)?
+
+
+
+ $
+
+
+
+
+
+ Next →
+
+
+
+ )}
+
+ {/* PIN */}
+ {step === "pin" && (
+
+
+ Card PIN
+
+
+ Loading {formatCents(amountCents())} onto your card.{"\n"}
+ Enter your card PIN (4–8 digits). If this is a new card, you'll set the PIN now.
+
+
+
+
+ Tap Card →
+
+
+ setStep("amount")}>
+ ← Back
+
+
+ )}
+
+ {/* In-progress */}
+ {(step === "tapping" || step === "minting" || step === "writing") && (
+
+
+ {step === "tapping" ? "📡 Tap Card" : step === "minting" ? "⚙️ Minting" : "✍️ Writing"}
+
+
+ {progressMsg}
+ {step === "tapping" && (
+
+ Hold your Flash card near the top of the phone
+
+ )}
+
+ )}
+
+ {/* Success */}
+ {step === "success" && successInfo && (
+
+ ✅
+
+ Card Loaded!
+
+
+ {formatCents(successInfo.totalCents)} loaded across{" "}
+ {successInfo.proofCount} denomination{successInfo.proofCount !== 1 ? "s" : ""}.
+
+
+ Your card is ready for offline payments.
+
+ navigation.goBack()}
+ >
+
+ Done
+
+
+ {
+ setStep("amount")
+ setAmountDisplay("")
+ setPin("")
+ setSuccessInfo(null)
+ }}
+ >
+ Top Up Again
+
+
+ )}
+
+ {/* Error */}
+ {step === "error" && (
+
+ ❌
+
+ Top-Up Failed
+
+ {errorMsg}
+
+
+ Try Again
+
+
+ navigation.goBack()}>
+ Cancel
+
+
+ )}
+
+
+
+
+ )
+}
+
+// ─── Styles ─────────────────────────────────────────────────────────────────
+
+const useStyles = makeStyles(({ colors }) => ({
+ flex: {
+ flex: 1,
+ },
+ screen: {
+ flex: 1,
+ paddingHorizontal: 0,
+ },
+ content: {
+ flexGrow: 1,
+ justifyContent: "center",
+ padding: 24,
+ },
+ section: {
+ alignItems: "center",
+ },
+ title: {
+ marginBottom: 12,
+ textAlign: "center",
+ },
+ subtitle: {
+ color: colors.grey2,
+ textAlign: "center",
+ marginBottom: 24,
+ lineHeight: 22,
+ },
+ amountRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ marginBottom: 32,
+ },
+ currency: {
+ fontSize: 36,
+ marginRight: 4,
+ },
+ amountInput: {
+ fontSize: 48,
+ fontWeight: "700",
+ minWidth: 120,
+ textAlign: "center",
+ },
+ pinInput: {
+ fontSize: 32,
+ fontWeight: "700",
+ borderBottomWidth: 2,
+ width: 200,
+ textAlign: "center",
+ marginBottom: 32,
+ paddingBottom: 8,
+ letterSpacing: 12,
+ },
+ spinner: {
+ marginVertical: 32,
+ },
+ emoji: {
+ fontSize: 64,
+ marginBottom: 16,
+ },
+ primaryBtn: {
+ borderRadius: 12,
+ paddingVertical: 16,
+ paddingHorizontal: 48,
+ marginTop: 8,
+ width: "100%",
+ alignItems: "center",
+ },
+ primaryBtnText: {
+ color: colors.white,
+ fontSize: 17,
+ },
+ secondaryBtn: {
+ paddingVertical: 14,
+ marginTop: 4,
+ alignItems: "center",
+ },
+}))
diff --git a/app/screens/card-screen/index.ts b/app/screens/card-screen/index.ts
index 0b3ade402..b2cdd8c18 100644
--- a/app/screens/card-screen/index.ts
+++ b/app/screens/card-screen/index.ts
@@ -1,2 +1,3 @@
export * from "./card"
export * from "./flashcard-topup"
+export * from "./cashu-topup"
From 3930fbc1ff255ffe7c42a021b2b47f65ee3990c5 Mon Sep 17 00:00:00 2001
From: Patoo <262265744+patoo0x@users.noreply.github.com>
Date: Sat, 7 Mar 2026 17:01:52 -0500
Subject: [PATCH 3/3] test: unit tests for cashu-topup helpers (10/10 passing)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Pure-logic helper tests using babel-jest (avoids ts-jest/TS5.0 incompatibility).
10 tests covering extractNonceFromSecret and toCardWriteProof.
Also adds jest.helpers.config.js + tsconfig.helpers.json for running
helper-layer tests without ttypescript/ts-auto-mock transforms.
Note: the main jest.config.js has a pre-existing breakage — ttypescript is
incompatible with TypeScript 5 (read-only createProgram); ts-jest 29.4.6
requires TS >= 5.3 but flash-mobile has 5.0.4. These are pre-existing issues
unrelated to ENG-179.
Run: npx jest --config jest.helpers.config.js __tests__/card/
---
__tests__/card/cashu-topup-helpers.spec.ts | 116 +++++++++++++++++++++
jest.helpers.config.js | 24 +++++
tsconfig.helpers.json | 9 ++
3 files changed, 149 insertions(+)
create mode 100644 __tests__/card/cashu-topup-helpers.spec.ts
create mode 100644 jest.helpers.config.js
create mode 100644 tsconfig.helpers.json
diff --git a/__tests__/card/cashu-topup-helpers.spec.ts b/__tests__/card/cashu-topup-helpers.spec.ts
new file mode 100644
index 000000000..321179342
--- /dev/null
+++ b/__tests__/card/cashu-topup-helpers.spec.ts
@@ -0,0 +1,116 @@
+/**
+ * cashu-topup-helpers.spec.ts
+ *
+ * Unit tests for the Cashu card top-up helper functions.
+ * These are pure logic functions with no RN/NFC dependencies.
+ */
+
+import {
+ extractNonceFromSecret,
+ toCardWriteProof,
+} from "@app/screens/card-screen/cashu-topup.helpers"
+
+// ── Fixtures ──────────────────────────────────────────────────────────────
+
+// NUT-XX worked example from spec/NUT-XX.md
+const EXAMPLE_NONCE = "916c21b8c67da71e9d02f4e3adc6f30700c152e01a07ae30e3bcc6b55b0c9e5e"
+const EXAMPLE_PUBKEY = "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2"
+
+/** Build a P2PK secret JSON string from (nonce, pubkey) matching the on-card format */
+function makeP2PKSecret(nonce: string, pubkey: string): string {
+ return JSON.stringify([
+ "P2PK",
+ {
+ nonce,
+ data: pubkey,
+ tags: [["sigflag", "SIG_ALL"]],
+ },
+ ])
+}
+
+const EXAMPLE_SECRET = makeP2PKSecret(EXAMPLE_NONCE, EXAMPLE_PUBKEY)
+
+const EXAMPLE_PROOF = {
+ id: "0059534ce0bfa19a",
+ amount: 100,
+ secret: EXAMPLE_SECRET,
+ C: "024a43eddcf0e42dad32ca5c0e82e51d7a38e7a48b80e89d2e17cc94abb02c04c3",
+}
+
+// ── extractNonceFromSecret ────────────────────────────────────────────────
+
+describe("extractNonceFromSecret", () => {
+ it("extracts nonce from a valid P2PK secret", () => {
+ expect(extractNonceFromSecret(EXAMPLE_SECRET)).toBe(EXAMPLE_NONCE)
+ })
+
+ it("extracts nonce regardless of other fields", () => {
+ const secretWithExtras = JSON.stringify([
+ "P2PK",
+ {
+ nonce: "aabbcc",
+ data: EXAMPLE_PUBKEY,
+ tags: [["sigflag", "SIG_ALL"], ["locktime", "9999999999"]],
+ },
+ ])
+ expect(extractNonceFromSecret(secretWithExtras)).toBe("aabbcc")
+ })
+
+ it("throws for non-P2PK secrets", () => {
+ const htlcSecret = JSON.stringify([
+ "HTLC",
+ { nonce: "deadbeef", data: "something" },
+ ])
+ expect(() => extractNonceFromSecret(htlcSecret)).toThrow("Invalid P2PK secret")
+ })
+
+ it("throws for missing nonce field", () => {
+ const noNonce = JSON.stringify(["P2PK", { data: EXAMPLE_PUBKEY }])
+ expect(() => extractNonceFromSecret(noNonce)).toThrow("Invalid P2PK secret")
+ })
+
+ it("throws for invalid JSON", () => {
+ expect(() => extractNonceFromSecret("not-json")).toThrow("Invalid P2PK secret")
+ })
+
+ it("throws for plain string secrets (non-P2PK)", () => {
+ expect(() => extractNonceFromSecret("deadbeefdeadbeef")).toThrow(
+ "Invalid P2PK secret",
+ )
+ })
+})
+
+// ── toCardWriteProof ──────────────────────────────────────────────────────
+
+describe("toCardWriteProof", () => {
+ it("maps a GQL proof to the card write format", () => {
+ const result = toCardWriteProof(EXAMPLE_PROOF)
+ expect(result).toEqual({
+ keysetId: "0059534ce0bfa19a",
+ amount: 100,
+ nonce: EXAMPLE_NONCE,
+ C: "024a43eddcf0e42dad32ca5c0e82e51d7a38e7a48b80e89d2e17cc94abb02c04c3",
+ })
+ })
+
+ it("propagates nonce extraction errors", () => {
+ const badProof = { ...EXAMPLE_PROOF, secret: "not-a-p2pk-secret" }
+ expect(() => toCardWriteProof(badProof)).toThrow("Invalid P2PK secret")
+ })
+
+ it("preserves amount exactly (no rounding)", () => {
+ const proofs = [1, 2, 4, 8, 16, 32, 64].map((amount) => ({
+ ...EXAMPLE_PROOF,
+ amount,
+ }))
+ proofs.forEach((proof) => {
+ expect(toCardWriteProof(proof).amount).toBe(proof.amount)
+ })
+ })
+
+ it("uses proof.id as keysetId (not amount or C)", () => {
+ const result = toCardWriteProof(EXAMPLE_PROOF)
+ expect(result.keysetId).toBe(EXAMPLE_PROOF.id)
+ expect(result.keysetId).not.toBe(EXAMPLE_PROOF.C)
+ })
+})
diff --git a/jest.helpers.config.js b/jest.helpers.config.js
new file mode 100644
index 000000000..50e808e9b
--- /dev/null
+++ b/jest.helpers.config.js
@@ -0,0 +1,24 @@
+/**
+ * jest.helpers.config.js
+ *
+ * Minimal Jest config for pure-logic helper tests (no RN, no auto-mock transforms).
+ * Avoids the ttypescript/TypeScript-5 incompatibility in the main jest.config.js.
+ * Use: npx jest --config jest.helpers.config.js __tests__/card/
+ */
+module.exports = {
+ preset: "react-native",
+ transform: {
+ "\\.(ts|tsx)$": "babel-jest",
+ "^.+\\.svg$": "jest-transform-stub",
+ },
+ testRegex: "(/__tests__/.*\\.(test|spec))\\.(ts|tsx|js)$",
+ moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"],
+ rootDir: ".",
+ moduleNameMapper: {
+ "^@app/(.*)$": ["/app/$1"],
+ "^@mocks/(.*)$": ["/__mocks__/$1"],
+ },
+ transformIgnorePatterns: [
+ "node_modules/(?!(react-native|@react-native|@react-navigation|@rneui)/)",
+ ],
+}
diff --git a/tsconfig.helpers.json b/tsconfig.helpers.json
new file mode 100644
index 000000000..3f92f6a12
--- /dev/null
+++ b/tsconfig.helpers.json
@@ -0,0 +1,9 @@
+{
+ "extends": "./tsconfig",
+ "compilerOptions": {
+ "jsx": "react",
+ "types": ["@types/jest"]
+ },
+ "include": ["app", "__tests__/card"],
+ "exclude": ["node_modules"]
+}