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"] +}