diff --git a/src/graphql/cashu.ts b/src/graphql/cashu.ts new file mode 100644 index 0000000..ac294db --- /dev/null +++ b/src/graphql/cashu.ts @@ -0,0 +1,89 @@ +import {gql} from '@apollo/client'; + +/** + * cashuCardProvision mutation + * + * Issues Cashu proofs onto a Flash card via the card's secp256k1 pubkey. + * Proofs are P2PK-locked to the card — only the card can authorise spending. + * + * The `availableSlots` param controls how many denominations to split into + * (omit for full 32-slot provisioning; pass free count for top-up). + * + * @see ENG-174 / ENG-175 — Flash backend implementation + */ +export const CASHU_CARD_PROVISION = gql` + mutation cashuCardProvision($input: CashuCardProvisionInput!) { + cashuCardProvision(input: $input) { + errors { + __typename + message + } + proofs { + id + amount + secret + C + } + cardPubkey + totalAmountCents + } + } +`; + +/** Shape of a single proof returned by the mutation */ +export interface CashuProofGql { + /** Keyset ID (hex string) */ + id: string; + /** Denomination in keyset's base unit (cents for USD keyset) */ + amount: number; + /** + * Full P2PK secret JSON string: + * ["P2PK", {"nonce": "", "data": "", "tags": [["sigflag","SIG_INPUTS"]]}] + */ + secret: string; + /** Unblinded mint signature — compressed secp256k1 point (hex) */ + C: string; +} + +export interface CashuCardProvisionPayload { + errors: {__typename: string; message: string}[]; + proofs: CashuProofGql[]; + cardPubkey: string; + totalAmountCents: number; +} + +/** + * Extract the 32-byte nonce (hex) from a P2PK secret JSON string. + * The nonce is what we store on-card (field 2 of the 77-byte proof payload). + * + * @throws if the secret is not valid P2PK format + */ +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 a P2PK secret'); + } + return parsed[1].nonce; + } catch { + throw new Error(`Invalid P2PK secret format: ${secret}`); + } +} + +/** + * Convert a CashuProofGql into the flat card-write format. + * Extracts nonce from the P2PK secret JSON. + */ +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/src/nfc/cashu-apdu.ts b/src/nfc/cashu-apdu.ts new file mode 100644 index 0000000..c1e5f0d --- /dev/null +++ b/src/nfc/cashu-apdu.ts @@ -0,0 +1,279 @@ +/** + * cashu-apdu.ts + * + * APDU builders and response parsers for the Cashu JavaCard applet + * (AID: D2 76 00 00 85 01 02, NUT-XX Profile B). + * + * Proof storage layout on-card (77-byte payload per slot): + * keyset_id [ 8] — raw bytes from keyset hex ID + * amount [ 4] — uint32 big-endian (denomination in cents/sats per keyset) + * nonce [32] — the nonce field from the P2PK secret JSON + * C [33] — compressed EC point (blind sig unblinded) + * + * The full P2PK secret JSON is NOT stored on-card. The POS reconstructs it + * from (nonce, card_pubkey) since cardPubKey is always readable via GET_PUBKEY: + * secret = JSON.stringify(["P2PK", {nonce: hex(nonce), data: card_pubkey, tags: [["sigflag","SIG_INPUTS"]]}]) + * + * For SPEND_PROOF, msg = SHA256(secret_json_string). + * + * @see https://github.com/lnflash/cashu-javacard/blob/main/spec/APDU.md + */ + +/** CLA byte for all Cashu applet commands */ +export const CASHU_CLA = 0xb0; + +/** AID: D2 76 00 00 85 01 02 */ +export const CASHU_AID = new Uint8Array([0xd2, 0x76, 0x00, 0x00, 0x85, 0x01, 0x02]); + +// Instruction bytes +export const INS_GET_INFO = 0x01; +export const INS_GET_PUBKEY = 0x10; +export const INS_GET_BALANCE = 0x11; +export const INS_GET_PROOF_COUNT = 0x12; +export const INS_GET_PROOF = 0x13; +export const INS_GET_SLOT_STATUS = 0x14; +export const INS_SPEND_PROOF = 0x20; +export const INS_SIGN_ARBITRARY = 0x21; +export const INS_LOAD_PROOF = 0x30; +export const INS_CLEAR_SPENT = 0x31; +export const INS_VERIFY_PIN = 0x40; +export const INS_SET_PIN = 0x41; +export const INS_CHANGE_PIN = 0x42; +export const INS_LOCK_CARD = 0x50; + +// Status words +export const SW_OK = 0x9000; +export const SW_WRONG_LENGTH = 0x6700; +export const SW_SECURITY_NOT_SATIS = 0x6982; +export const SW_PIN_BLOCKED = 0x6983; +export const SW_PIN_NOT_SET = 0x6984; +export const SW_CONDITIONS_NOT_SATIS = 0x6985; +export const SW_SLOT_OUT_OF_RANGE = 0x6a83; +export const SW_NO_SPACE = 0x6a84; +export const SW_SLOT_EMPTY = 0x6a88; + +// Slot status constants +export const SLOT_EMPTY = 0x00; +export const SLOT_UNSPENT = 0x01; +export const SLOT_SPENT = 0x02; + +// ───────────────────────────────────────────────────────────────────────────── +// Card info / proof types +// ───────────────────────────────────────────────────────────────────────────── + +export interface CardInfo { + versionMajor: number; + versionMinor: number; + maxSlots: number; + unspentCount: number; + spentCount: number; + emptyCount: number; + capabilities: number; // bitmask: bit0=secp256k1, bit1=Schnorr, bit2=PIN + pinState: number; // 0=unset, 1=set, 2=locked +} + +export interface CardProof { + /** Slot index on card */ + slotIndex: number; + /** Status: 0=empty, 1=unspent, 2=spent */ + status: number; + /** Keyset ID (8 bytes as hex string) */ + keysetId: string; + /** Denomination amount */ + amount: number; + /** 32-byte nonce (from P2PK secret) as hex */ + nonce: string; + /** 33-byte compressed C point as hex */ + C: string; +} + +// ───────────────────────────────────────────────────────────────────────────── +// APDU builders +// ───────────────────────────────────────────────────────────────────────────── + +/** SELECT AID command */ +export function buildSelectAid(): number[] { + const aid: number[] = []; + for (let i = 0; i < CASHU_AID.length; i++) aid.push(CASHU_AID[i]); + return [0x00, 0xa4, 0x04, 0x00, CASHU_AID.length].concat(aid); +} + +/** GET_INFO (B0 01 00 00 00) */ +export function buildGetInfo(): number[] { + return [CASHU_CLA, INS_GET_INFO, 0x00, 0x00, 0x00]; +} + +/** GET_PUBKEY (B0 10 00 00 00) */ +export function buildGetPubkey(): number[] { + return [CASHU_CLA, INS_GET_PUBKEY, 0x00, 0x00, 0x00]; +} + +/** GET_BALANCE (B0 11 00 00 00) */ +export function buildGetBalance(): number[] { + return [CASHU_CLA, INS_GET_BALANCE, 0x00, 0x00, 0x00]; +} + +/** GET_PROOF at slot index (B0 13 P1 00 00) */ +export function buildGetProof(slotIndex: number): number[] { + return [CASHU_CLA, INS_GET_PROOF, slotIndex & 0xff, 0x00, 0x00]; +} + +/** GET_SLOT_STATUS (B0 14 00 00 00) */ +export function buildGetSlotStatus(): number[] { + return [CASHU_CLA, INS_GET_SLOT_STATUS, 0x00, 0x00, 0x00]; +} + +/** + * LOAD_PROOF — write 77 bytes of proof data. + * Requires VERIFY_PIN session if PIN is set. + * + * @param keysetIdHex 16-character hex string (8 bytes) + * @param amount denomination uint32 + * @param nonceHex 64-character hex string (32 bytes, from P2PK secret nonce) + * @param cHex 66-character hex string (33 bytes compressed point) + */ +export function buildLoadProof( + keysetIdHex: string, + amount: number, + nonceHex: string, + cHex: string, +): number[] { + const payload = new Uint8Array(77); + const kidBytes = hexToBytes(keysetIdHex); + payload.set(kidBytes.slice(0, 8), 0); + payload[8] = (amount >>> 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/src/nfc/cashu-melt.ts b/src/nfc/cashu-melt.ts new file mode 100644 index 0000000..cd3f16c --- /dev/null +++ b/src/nfc/cashu-melt.ts @@ -0,0 +1,156 @@ +/** + * cashu-melt.ts + * + * Nutshell mint HTTP client for the melt (proof redemption) flow. + * Used by CashuPayment screen to redeem P2PK proofs via Lightning. + * + * Flow: + * 1. createMeltQuote(mintUrl, unit, bolt11Invoice) + * → POST /v1/melt/quote/bolt11 → {quote, amount, fee_reserve} + * 2. meltProofs(mintUrl, quote, proofs) + * → POST /v1/melt/bolt11 → {paid: true, payment_preimage} + * + * The `proof.witness` field must already be set to the Schnorr signature + * obtained from the card's SPEND_PROOF APDU before calling meltProofs(). + * + * @see https://github.com/cashubtc/nuts/blob/main/05.md NUT-05 melt + * @see https://github.com/cashubtc/nuts/blob/main/11.md NUT-11 P2PK + */ + +export interface MeltQuote { + quote: string; + amount: number; + fee_reserve: number; + paid: boolean; + expiry: number; +} + +export interface MeltProofInput { + id: string; // keyset ID hex + amount: number; + secret: string; // full P2PK JSON secret string + C: string; // compressed point hex (66 chars) + /** Stringified JSON: '{"signatures":["<64-byte-sig-hex>"]}' */ + witness: string; +} + +export interface MeltResult { + paid: boolean; + payment_preimage: string | null; +} + +export class MeltError extends Error { + constructor( + message: string, + public readonly status?: number, + public readonly detail?: string, + ) { + super(message); + this.name = 'MeltError'; + } +} + +/** + * Create a melt quote — locks in the fee and returns a quote ID. + * + * @param mintUrl e.g. "https://forge.flashapp.me" + * @param unit "usd" or "sat" + * @param bolt11 Lightning invoice to pay + */ +export async function createMeltQuote( + mintUrl: string, + unit: string, + bolt11: string, +): Promise { + const url = mintUrl.replace(/\/$/, '') + '/v1/melt/quote/bolt11'; + const resp = await fetch(url, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({unit, request: bolt11}), + }); + + if (!resp.ok) { + let detail = ''; + try { + const body = await resp.json(); + detail = body.detail || body.error || ''; + } catch {} + throw new MeltError( + `createMeltQuote failed (${resp.status}): ${detail}`, + resp.status, + detail, + ); + } + + return resp.json(); +} + +/** + * Melt proofs — redeems P2PK proofs to pay the Lightning invoice + * associated with the given quote. + * + * Each proof must have a `witness` containing the Schnorr signature + * from the card's SPEND_PROOF APDU. + * + * @param mintUrl e.g. "https://forge.flashapp.me" + * @param quoteId from createMeltQuote().quote + * @param proofs P2PK proofs with witness signatures + */ +export async function meltProofs( + mintUrl: string, + quoteId: string, + proofs: MeltProofInput[], +): Promise { + const url = mintUrl.replace(/\/$/, '') + '/v1/melt/bolt11'; + const resp = await fetch(url, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({quote: quoteId, inputs: proofs}), + }); + + if (!resp.ok) { + let detail = ''; + try { + const body = await resp.json(); + detail = body.detail || body.error || ''; + } catch {} + throw new MeltError( + `meltProofs failed (${resp.status}): ${detail}`, + resp.status, + detail, + ); + } + + return resp.json(); +} + +/** + * Select the minimum set of proofs covering amountCents. + * Greedy: sorts ascending, picks until sum >= target. + * + * @returns {selected, total, overpaymentCents} + */ +export function selectProofsForAmount( + proofs: {slotIndex: number; amount: number; keysetId: string; nonce: string; C: string}[], + amountCents: number, +): { + selected: typeof proofs; + total: number; + overpaymentCents: number; +} { + const sorted = proofs.slice().sort((a, b) => a.amount - b.amount); + const selected: typeof proofs = []; + let total = 0; + + for (const p of sorted) { + if (total >= amountCents) break; + selected.push(p); + total += p.amount; + } + + if (total < amountCents) { + return {selected: [], total: 0, overpaymentCents: 0}; + } + + return {selected, total, overpaymentCents: total - amountCents}; +} diff --git a/src/nfc/useCashuCard.ts b/src/nfc/useCashuCard.ts new file mode 100644 index 0000000..685ca3b --- /dev/null +++ b/src/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/src/routes/index.tsx b/src/routes/index.tsx index 392789c..07f8491 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -17,6 +17,8 @@ import { RegisteredRewardCards, EventSettings, } from '../screens'; +import CashuProvision from '../screens/CashuProvision'; +import CashuPayment from '../screens/CashuPayment'; // hooks import {useAppSelector} from '../store/hooks'; @@ -125,6 +127,27 @@ const Root = () => { animation: 'slide_from_right', }} /> + + ); }; diff --git a/src/screens/CashuPayment.tsx b/src/screens/CashuPayment.tsx new file mode 100644 index 0000000..cd66f65 --- /dev/null +++ b/src/screens/CashuPayment.tsx @@ -0,0 +1,419 @@ +/** + * CashuPayment.tsx + * + * Tap-to-pay settlement screen for Flash card payments (ENG-178). + * Customer taps their Flash card; merchant receives payment via Lightning. + * + * Flow: + * waiting → customer taps card + * reading → NFC: SELECT + GET_INFO + GET_PUBKEY + read unspent proofs + * spending → NFC: SPEND_PROOF × N (Schnorr sigs from card) + * melting → API: createMeltQuote + meltProofs at Cashu mint + * success → payment confirmed, navigate to Success + * error → retry or cancel + * + * Navigation params: + * paymentRequest bolt11 invoice for the merchant + * amountCents amount in USD cents (for proof selection + display) + */ + +import React, {useCallback, useEffect, useRef, useState} from 'react'; +import { + ActivityIndicator, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; +import {StackScreenProps} from '@react-navigation/stack'; + +import useCashuCard, {CashuCardError} from '../nfc/useCashuCard'; +import { + reconstructP2PKSecret, + bytesToHex, + hexToBytes, + SLOT_UNSPENT, +} from '../nfc/cashu-apdu'; +import { + createMeltQuote, + meltProofs, + selectProofsForAmount, + MeltProofInput, + MeltError, +} from '../nfc/cashu-melt'; +import {sha256Bytes} from '../utils/sha256'; +import {toastShow} from '../utils/toast'; +import {CASHU_MINT_URL} from '@env'; + +type Props = StackScreenProps; + +type Step = 'waiting' | 'reading' | 'spending' | 'melting' | 'success' | 'error'; + +const CashuPayment: React.FC = ({navigation, route}) => { + const {paymentRequest, amountCents} = route.params; + const mintUrl = CASHU_MINT_URL ?? 'https://forge.flashapp.me'; + + const [step, setStep] = useState('waiting'); + const [progressMsg, setProgressMsg] = useState(''); + const [errorMsg, setErrorMsg] = useState(''); + const [paidCents, setPaidCents] = useState(0); + + const card = useCashuCard(); + const runningRef = useRef(false); + + // ─── Main payment flow ────────────────────────────────────────────────── + + const runPayment = useCallback(async () => { + if (runningRef.current) return; + runningRef.current = true; + + try { + // ── 1. NFC: read card ────────────────────────────────────────────── + setStep('reading'); + setProgressMsg('Reading card…'); + await card.startSession(); + + // Get card pubkey (needed to reconstruct P2PK secret for each proof) + const cardPubkey = await card.getPubkey(); + const info = await card.getInfo(); + + if (info.unspentCount === 0) { + throw new CashuCardError('Card has no unspent proofs'); + } + + // Read all unspent proofs + const slotStatuses = await card.getSlotStatuses(); + const unspentProofs: { + slotIndex: number; + amount: number; + keysetId: string; + nonce: string; + C: string; + }[] = []; + + for (let i = 0; i < slotStatuses.length; i++) { + if (slotStatuses[i] === SLOT_UNSPENT) { + const proof = await card.getProof(i); + unspentProofs.push({ + slotIndex: proof.slotIndex, + amount: proof.amount, + keysetId: proof.keysetId, + nonce: proof.nonce, + C: proof.C, + }); + } + } + + // Select minimum proofs covering the payment amount + const {selected, total, overpaymentCents} = selectProofsForAmount( + unspentProofs, + amountCents, + ); + + if (selected.length === 0) { + throw new CashuCardError( + `Insufficient balance. Card has ${formatCents(unspentProofs.reduce((s, p) => s + p.amount, 0))}, ` + + `payment needs ${formatCents(amountCents)}`, + ); + } + + if (overpaymentCents > 0) { + // For v1: overpayment is accepted — card denominations may not match exactly + // Future: implement change via /v1/swap before melt + setProgressMsg( + `Overpayment: ${formatCents(overpaymentCents)} will be rounded up`, + ); + await sleep(800); + } + + // ── 2. NFC: SPEND_PROOF for each selected proof ──────────────────── + setStep('spending'); + setProgressMsg(`Authorising ${selected.length} proof${selected.length !== 1 ? 's' : ''}…`); + + const proofInputs: MeltProofInput[] = []; + + for (let i = 0; i < selected.length; i++) { + const p = selected[i]; + setProgressMsg(`Signing proof ${i + 1} of ${selected.length}…`); + + // Reconstruct the P2PK secret JSON + const secretJson = reconstructP2PKSecret(p.nonce, cardPubkey); + + // Hash it — this is the message the card signs + const msgBytes = sha256Bytes(utf8ToBytes(secretJson)); + const msgArr: number[] = []; + for (let j = 0; j < msgBytes.length; j++) msgArr.push(msgBytes[j]); + + // SPEND_PROOF → Schnorr signature (64 bytes) + const sigBytes = await card.spendProof(p.slotIndex, msgArr); + const sigHex = bytesToHex(sigBytes); + + proofInputs.push({ + id: p.keysetId, + amount: p.amount, + secret: secretJson, + C: p.C, + witness: JSON.stringify({signatures: [sigHex]}), + }); + } + + await card.cleanup(); + + // ── 3. API: melt proofs at mint ──────────────────────────────────── + setStep('melting'); + setProgressMsg('Creating payment quote…'); + + const quote = await createMeltQuote(mintUrl, 'usd', paymentRequest); + setProgressMsg('Processing payment…'); + + const result = await meltProofs(mintUrl, quote.quote, proofInputs); + + if (!result.paid) { + throw new MeltError('Mint did not confirm payment'); + } + + // ── 4. Success ───────────────────────────────────────────────────── + setPaidCents(total); + setStep('success'); + + // Short delay then navigate to Success screen + setTimeout(() => { + navigation.replace('Success', { + title: `Payment received: ${formatCents(total)}`, + }); + }, 1500); + + } catch (err) { + await card.cleanup(); + runningRef.current = false; + + // User cancelled NFC (tapped away) → allow retry + const msg = err instanceof Error ? err.message : 'Unknown error'; + if ( + msg.toLowerCase().includes('cancel') || + msg.toLowerCase().includes('user cancel') + ) { + setStep('waiting'); + return; + } + + setErrorMsg(msg); + setStep('error'); + toastShow({message: msg, type: 'error'}); + } + }, [card, paymentRequest, amountCents, mintUrl, navigation]); + + // Auto-start NFC on mount + useEffect(() => { + runPayment(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // ─── Helpers ─────────────────────────────────────────────────────────── + + const formatCents = (cents: number) => `$${(cents / 100).toFixed(2)}`; + + const handleRetry = () => { + runningRef.current = false; + setErrorMsg(''); + setStep('waiting'); + runPayment(); + }; + + // ─── Render ─────────────────────────────────────────────────────────── + + return ( + + + {/* Waiting — tap card */} + {step === 'waiting' && ( + + 📡 + Tap Flash Card + {formatCents(amountCents)} + + Ask the customer to hold their Flash card{'\n'}near the top of the phone + + navigation.goBack()}> + Cancel + + + )} + + {/* Reading / Spending / Melting — in-progress */} + {(step === 'reading' || step === 'spending' || step === 'melting') && ( + + + {step === 'reading' + ? '📖 Reading card' + : step === 'spending' + ? '✍️ Authorising' + : '⚡ Processing'} + + + {progressMsg} + {formatCents(amountCents)} + + )} + + {/* Success */} + {step === 'success' && ( + + + Payment Received + {formatCents(paidCents)} + + )} + + {/* Error */} + {step === 'error' && ( + + + Payment Failed + {errorMsg} + + Try Again + + navigation.goBack()}> + Cancel + + + )} + + + ); +}; + +// ─── Utility (avoid importing Node.js Buffer) ──────────────────────────────── + +function utf8ToBytes(str: string): Uint8Array { + const out: number[] = []; + for (let i = 0; i < str.length; i++) { + let cp = str.charCodeAt(i); + if (cp < 0x80) { + out.push(cp); + } else if (cp < 0x800) { + out.push((cp >> 6) | 0xc0, (cp & 0x3f) | 0x80); + } else if (cp >= 0xd800 && cp <= 0xdbff) { + const lo = str.charCodeAt(++i); + cp = 0x10000 + ((cp - 0xd800) << 10) + (lo - 0xdc00); + out.push( + (cp >> 18) | 0xf0, + ((cp >> 12) & 0x3f) | 0x80, + ((cp >> 6) & 0x3f) | 0x80, + (cp & 0x3f) | 0x80, + ); + } else { + out.push((cp >> 12) | 0xe0, ((cp >> 6) & 0x3f) | 0x80, (cp & 0x3f) | 0x80); + } + } + const arr = new Uint8Array(out.length); + for (let i = 0; i < out.length; i++) arr[i] = out[i]; + return arr; +} + +function sleep(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Styles +// ───────────────────────────────────────────────────────────────────────────── + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#0a0a0a', + justifyContent: 'center', + alignItems: 'center', + padding: 32, + }, + section: { + alignItems: 'center', + width: '100%', + }, + nfcIcon: { + fontSize: 80, + marginBottom: 24, + }, + successIcon: { + fontSize: 80, + marginBottom: 24, + }, + errorIcon: { + fontSize: 80, + marginBottom: 24, + }, + title: { + fontSize: 24, + fontFamily: 'Outfit-Bold', + color: '#FFFFFF', + marginBottom: 8, + textAlign: 'center', + }, + amount: { + fontSize: 48, + fontFamily: 'Outfit-Bold', + color: '#FF6600', + marginVertical: 16, + }, + subtitle: { + fontSize: 15, + fontFamily: 'Outfit-Regular', + color: '#AAAAAA', + textAlign: 'center', + lineHeight: 22, + marginBottom: 32, + }, + spinner: { + marginVertical: 32, + }, + progressText: { + fontSize: 15, + fontFamily: 'Outfit-Regular', + color: '#AAAAAA', + textAlign: 'center', + marginBottom: 8, + }, + errorText: { + fontSize: 14, + fontFamily: 'Outfit-Regular', + color: '#FF4444', + textAlign: 'center', + marginBottom: 32, + lineHeight: 20, + }, + primaryButton: { + backgroundColor: '#FF6600', + borderRadius: 12, + paddingVertical: 16, + paddingHorizontal: 48, + marginTop: 8, + width: '100%', + alignItems: 'center', + }, + primaryButtonText: { + fontSize: 17, + fontFamily: 'Outfit-Bold', + color: '#FFFFFF', + }, + cancelButton: { + paddingVertical: 14, + marginTop: 8, + }, + cancelText: { + fontSize: 16, + fontFamily: 'Outfit-Regular', + color: '#AAAAAA', + }, +}); + +export default CashuPayment; diff --git a/src/screens/CashuProvision.tsx b/src/screens/CashuProvision.tsx new file mode 100644 index 0000000..2873295 --- /dev/null +++ b/src/screens/CashuProvision.tsx @@ -0,0 +1,513 @@ +/** + * CashuProvision.tsx + * + * Merchant screen for issuing Flash cards (ENG-177). + * + * Flow: + * Step 1 — Enter USD amount to load + * Step 2 — Enter PIN (merchant sets a provisioning PIN for this card) + * Step 3 — Tap card → NFC session: GET_INFO (verify blank) + GET_PUBKEY + * Step 4 — Loading → calls cashuCardProvision GQL → receives proofs + * Step 5 — Writing → SET_PIN + VERIFY_PIN + LOAD_PROOF × N over NFC + * Step 6 — Success → card is ready to spend + * + * For top-up (card already has proofs / PIN set), the user enters the + * existing PIN and the flow skips SET_PIN → goes straight to VERIFY_PIN. + */ + +import React, {useCallback, useRef, useState} from 'react'; +import { + ActivityIndicator, + Alert, + KeyboardAvoidingView, + Platform, + ScrollView, + StyleSheet, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native'; +import {StackScreenProps} from '@react-navigation/stack'; +import {useMutation} from '@apollo/client'; + +import useCashuCard, {CashuCardError} from '../nfc/useCashuCard'; +import { + CASHU_CARD_PROVISION, + CashuCardProvisionPayload, + toCardWriteProof, +} from '../graphql/cashu'; +import {toastShow} from '../utils/toast'; +import {useAppSelector} from '../store/hooks'; + +type Props = StackScreenProps; + +// ───────────────────────────────────────────────────────────────────────────── +// Step definitions +// ───────────────────────────────────────────────────────────────────────────── + +type Step = + | 'amount' // Enter USD amount + | 'pin' // Enter provisioning PIN + | 'tapping' // NFC — reading card + | 'minting' // GQL — minting proofs on backend + | 'writing' // NFC — writing proofs to card + | 'success' // Done + | 'error'; // Unrecoverable error + +// ───────────────────────────────────────────────────────────────────────────── +// Component +// ───────────────────────────────────────────────────────────────────────────── + +const CashuProvision: React.FC = ({navigation}) => { + const walletId = useAppSelector(state => state.user.walletId); + + const [step, setStep] = useState('amount'); + const [amountDisplay, setAmountDisplay] = useState(''); // e.g. "10.00" + const [pin, setPin] = useState(''); + const [errorMessage, setErrorMessage] = useState(''); + const [progressMessage, setProgressMessage] = useState(''); + const [successInfo, setSuccessInfo] = useState<{ + proofCount: number; + totalCents: number; + } | null>(null); + + // Track whether this is a top-up (card has existing PIN) — determined on tap + const isTopUpRef = useRef(false); + + const card = useCashuCard(); + + const [provisionMutation] = useMutation<{ + cashuCardProvision: CashuCardProvisionPayload; + }>(CASHU_CARD_PROVISION); + + // ─── Amount helpers ─────────────────────────────────────────────────────── + + const amountCents = (): number => { + const val = parseFloat(amountDisplay); + if (isNaN(val) || val <= 0) return 0; + return Math.round(val * 100); + }; + + // ─── Main provisioning flow ─────────────────────────────────────────────── + + const runProvision = useCallback( + async (pinValue: string) => { + try { + // ── Step 3: NFC tap — read card ────────────────────────────────── + setStep('tapping'); + setProgressMessage('Hold card to phone…'); + await card.startSession(); + + let cardPubkey: string; + let isBlank: boolean; + + try { + const {pubkey, info} = await card.readBlankCardPubkey(); + cardPubkey = pubkey; + isBlank = true; + isTopUpRef.current = false; + setProgressMessage(`Card ready (${info.emptyCount} free slots)`); + } catch (err) { + if ( + err instanceof CashuCardError && + err.message.includes('top-up flow') + ) { + // Card has existing data — top-up flow + isTopUpRef.current = true; + isBlank = false; + cardPubkey = await card.getPubkey(); + const info = await card.getInfo(); + if (info.emptyCount === 0) { + throw new CashuCardError('Card is full — no empty slots available'); + } + setProgressMessage(`Top-up: ${info.emptyCount} free slots`); + } else { + throw err; + } + } + + // ── Step 4: Mint proofs via GQL ────────────────────────────────── + setStep('minting'); + setProgressMessage('Minting proofs…'); + + // Get available slot count for top-up denomination split + let availableSlots: number | undefined; + if (!isBlank) { + const info = await card.getInfo(); + availableSlots = info.emptyCount; + } + + const result = await provisionMutation({ + variables: { + input: { + walletId, + 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); + } + + const {proofs, totalAmountCents} = payload; + if (!proofs || proofs.length === 0) { + throw new Error('No proofs returned from mint'); + } + + setProgressMessage(`Writing ${proofs.length} proofs to card…`); + + // ── Step 5: Write proofs to card ───────────────────────────────── + setStep('writing'); + + const cardWriteProofs = proofs.map(toCardWriteProof); + await card.writeProofs(cardWriteProofs, pinValue, isBlank); + + await card.cleanup(); + + // ── Step 6: Success ────────────────────────────────────────────── + setSuccessInfo({ + proofCount: proofs.length, + totalCents: totalAmountCents, + }); + setStep('success'); + + } catch (err) { + await card.cleanup(); + + const message = + err instanceof Error ? err.message : 'Unknown NFC error'; + + // User cancelled NFC — go back to pin entry + if ( + message.toLowerCase().includes('cancel') || + message.toLowerCase().includes('user cancel') + ) { + setStep('pin'); + return; + } + + setErrorMessage(message); + setStep('error'); + toastShow({message, type: 'error'}); + } + }, + [card, provisionMutation, walletId, amountDisplay], + ); + + // ─── UI actions ─────────────────────────────────────────────────────────── + + const handleAmountNext = () => { + const cents = amountCents(); + if (cents < 100) { + toastShow({message: 'Minimum amount is $1.00', type: 'error'}); + return; + } + setStep('pin'); + }; + + const handlePinNext = () => { + if (pin.length < 4) { + toastShow({message: 'PIN must be at least 4 digits', type: 'error'}); + return; + } + runProvision(pin); + }; + + const handleRetry = () => { + setStep('amount'); + setErrorMessage(''); + setPin(''); + }; + + const handleDone = () => { + navigation.goBack(); + }; + + // ─── Render helpers ─────────────────────────────────────────────────────── + + const formatCents = (cents: number) => + `$${(cents / 100).toFixed(2)} USD`; + + // ───────────────────────────────────────────────────────────────────────── + // Render + // ───────────────────────────────────────────────────────────────────────── + + return ( + + + + {/* ── Step 1: Amount ──────────────────────────────────────────── */} + {step === 'amount' && ( + + Issue Flash Card + Enter the amount to load in USD + + $ + + + + Next → + + + )} + + {/* ── Step 2: PIN ──────────────────────────────────────────────── */} + {step === 'pin' && ( + + Set Card PIN + + Loading {formatCents(amountCents())} onto card.{'\n'} + Enter a PIN — the cardholder will need this to authorise future + top-ups. + + + + Tap Card → + + setStep('amount')}> + ← Back + + + )} + + {/* ── Steps 3–5: In-progress ───────────────────────────────────── */} + {(step === 'tapping' || + step === 'minting' || + step === 'writing') && ( + + + {step === 'tapping' + ? '📡 Tap Card' + : step === 'minting' + ? '⚙️ Minting' + : '✍️ Writing'} + + + {progressMessage} + {step === 'tapping' && ( + + Hold the Flash card near the top of your phone + + )} + + )} + + {/* ── Step 6: Success ──────────────────────────────────────────── */} + {step === 'success' && successInfo && ( + + + Card Ready! + + Loaded {formatCents(successInfo.totalCents)} across{' '} + {successInfo.proofCount} proof + {successInfo.proofCount !== 1 ? 's' : ''}. + + + The card is now ready for offline payments. + + + Done + + { + setStep('amount'); + setAmountDisplay(''); + setPin(''); + setSuccessInfo(null); + }}> + Issue Another + + + )} + + {/* ── Error ────────────────────────────────────────────────────── */} + {step === 'error' && ( + + + Something Went Wrong + {errorMessage} + + Try Again + + navigation.goBack()}> + Cancel + + + )} + + + + ); +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Styles +// ───────────────────────────────────────────────────────────────────────────── + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#0a0a0a', + }, + scroll: { + flexGrow: 1, + justifyContent: 'center', + padding: 24, + }, + section: { + alignItems: 'center', + }, + title: { + fontSize: 24, + fontFamily: 'Outfit-Bold', + color: '#FFFFFF', + marginBottom: 12, + textAlign: 'center', + }, + subtitle: { + fontSize: 15, + fontFamily: 'Outfit-Regular', + color: '#AAAAAA', + textAlign: 'center', + marginBottom: 32, + lineHeight: 22, + }, + hint: { + fontSize: 13, + fontFamily: 'Outfit-Regular', + color: '#666666', + textAlign: 'center', + marginTop: 12, + lineHeight: 19, + }, + inputRow: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 32, + }, + currencySymbol: { + fontSize: 36, + fontFamily: 'Outfit-Bold', + color: '#FF6600', + marginRight: 4, + }, + amountInput: { + fontSize: 48, + fontFamily: 'Outfit-Bold', + color: '#FFFFFF', + minWidth: 120, + textAlign: 'center', + }, + pinInput: { + fontSize: 32, + fontFamily: 'Outfit-Bold', + color: '#FFFFFF', + borderBottomWidth: 2, + borderBottomColor: '#FF6600', + width: 200, + textAlign: 'center', + marginBottom: 32, + paddingBottom: 8, + letterSpacing: 12, + }, + spinner: { + marginVertical: 32, + }, + progressText: { + fontSize: 16, + fontFamily: 'Outfit-Regular', + color: '#FFFFFF', + textAlign: 'center', + }, + successIcon: { + fontSize: 64, + marginBottom: 16, + }, + errorIcon: { + fontSize: 64, + marginBottom: 16, + }, + errorText: { + fontSize: 14, + fontFamily: 'Outfit-Regular', + color: '#FF4444', + textAlign: 'center', + marginBottom: 32, + lineHeight: 20, + }, + primaryButton: { + backgroundColor: '#FF6600', + borderRadius: 12, + paddingVertical: 16, + paddingHorizontal: 48, + marginTop: 8, + width: '100%', + alignItems: 'center', + }, + primaryButtonText: { + fontSize: 17, + fontFamily: 'Outfit-Bold', + color: '#FFFFFF', + }, + secondaryButton: { + paddingVertical: 14, + paddingHorizontal: 48, + marginTop: 8, + width: '100%', + alignItems: 'center', + }, + secondaryButtonText: { + fontSize: 16, + fontFamily: 'Outfit-Regular', + color: '#AAAAAA', + }, +}); + +export default CashuProvision; diff --git a/src/screens/Invoice.tsx b/src/screens/Invoice.tsx index efe726b..fe7bed1 100644 --- a/src/screens/Invoice.tsx +++ b/src/screens/Invoice.tsx @@ -45,7 +45,7 @@ type Props = StackScreenProps; const Invoice: React.FC = ({navigation}) => { const dispatch = useAppDispatch(); - const {paymentRequest, paymentHash, paymentSecret} = useAppSelector( + const {paymentRequest, paymentHash, paymentSecret, usdCents} = useAppSelector( state => state.invoice, ); const {satAmount, displayAmount, currency, isPrimaryAmountSats, memo} = @@ -319,6 +319,17 @@ const Invoice: React.FC = ({navigation}) => { )} + {paymentRequest && usdCents > 0 && ( + + navigation.navigate('CashuPayment', { + paymentRequest, + amountCents: usdCents, + }) + } + /> + )} navigation.goBack()} /> ); diff --git a/src/screens/Keypad.tsx b/src/screens/Keypad.tsx index 5971516..479684f 100644 --- a/src/screens/Keypad.tsx +++ b/src/screens/Keypad.tsx @@ -151,7 +151,10 @@ const Keypad = () => { if (result.data?.lnUsdInvoiceCreateOnBehalfOfRecipient?.invoice) { dispatch( - setInvoice(result.data.lnUsdInvoiceCreateOnBehalfOfRecipient.invoice), + setInvoice({ + ...result.data.lnUsdInvoiceCreateOnBehalfOfRecipient.invoice, + usdCents: Math.round(cents), + }), ); navigation.navigate('Invoice'); } else { diff --git a/src/screens/Profile.tsx b/src/screens/Profile.tsx index 1f781f0..63a1547 100644 --- a/src/screens/Profile.tsx +++ b/src/screens/Profile.tsx @@ -178,9 +178,14 @@ const Profile = () => { + navigation.navigate('CashuProvision')} + /> >> n) | (x << (32 - n))) >>> 0; +} + +/** + * Compute SHA-256 of a UTF-8 string. + * @returns hex digest string (64 chars) + */ +export function sha256Hex(message: string): string { + const bytes = utf8ToBytes(message); + const digest = sha256Bytes(bytes); + let hex = ''; + for (let i = 0; i < digest.length; i++) { + const h = digest[i].toString(16); + hex += h.length === 1 ? '0' + h : h; + } + return hex; +} + +/** + * Compute SHA-256 of raw bytes. + * @returns 32-byte array + */ +export function sha256Bytes(data: Uint8Array): Uint8Array { + // Initial hash values (first 32 bits of fractional parts of sqrt of first 8 primes) + let h0 = 0x6a09e667; + let h1 = 0xbb67ae85; + let h2 = 0x3c6ef372; + let h3 = 0xa54ff53a; + let h4 = 0x510e527f; + let h5 = 0x9b05688c; + let h6 = 0x1f83d9ab; + let h7 = 0x5be0cd19; + + // Pre-processing: add padding + const msgLen = data.length; + const bitLen = msgLen * 8; + // Padded length: multiple of 64, with room for the 1-bit, zeros, and 8-byte length + const padLen = msgLen % 64 < 56 ? 64 : 128; + const padded = new Uint8Array(msgLen + padLen - (msgLen % 64)); + padded.set(data); + padded[msgLen] = 0x80; + // Write 64-bit big-endian bit length at the end + // (only lower 32 bits needed for messages < 512MB) + const dv = new DataView(padded.buffer); + dv.setUint32(padded.length - 4, bitLen >>> 0, false); + dv.setUint32(padded.length - 8, Math.floor(bitLen / 0x100000000), false); + + // Process each 64-byte block + const w = new Array(64); + for (let offset = 0; offset < padded.length; offset += 64) { + for (let i = 0; i < 16; i++) { + w[i] = dv.getUint32(offset + i * 4, false); + } + for (let i = 16; i < 64; i++) { + const s0 = rotr32(w[i - 15], 7) ^ rotr32(w[i - 15], 18) ^ (w[i - 15] >>> 3); + const s1 = rotr32(w[i - 2], 17) ^ rotr32(w[i - 2], 19) ^ (w[i - 2] >>> 10); + w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0; + } + + let a = h0, b = h1, c = h2, d = h3; + let e = h4, f = h5, g = h6, h = h7; + + for (let i = 0; i < 64; i++) { + const S1 = rotr32(e, 6) ^ rotr32(e, 11) ^ rotr32(e, 25); + const ch = ((e & f) ^ (~e & g)) >>> 0; + const tmp1 = (h + S1 + ch + K[i] + w[i]) >>> 0; + const S0 = rotr32(a, 2) ^ rotr32(a, 13) ^ rotr32(a, 22); + const maj = ((a & b) ^ (a & c) ^ (b & c)) >>> 0; + const tmp2 = (S0 + maj) >>> 0; + + h = g; g = f; f = e; + e = (d + tmp1) >>> 0; + d = c; c = b; b = a; + a = (tmp1 + tmp2) >>> 0; + } + + h0 = (h0 + a) >>> 0; + h1 = (h1 + b) >>> 0; + h2 = (h2 + c) >>> 0; + h3 = (h3 + d) >>> 0; + h4 = (h4 + e) >>> 0; + h5 = (h5 + f) >>> 0; + h6 = (h6 + g) >>> 0; + h7 = (h7 + h) >>> 0; + } + + const out = new Uint8Array(32); + const outDv = new DataView(out.buffer); + outDv.setUint32(0, h0, false); + outDv.setUint32(4, h1, false); + outDv.setUint32(8, h2, false); + outDv.setUint32(12, h3, false); + outDv.setUint32(16, h4, false); + outDv.setUint32(20, h5, false); + outDv.setUint32(24, h6, false); + outDv.setUint32(28, h7, false); + return out; +} + +function utf8ToBytes(str: string): Uint8Array { + const out: number[] = []; + for (let i = 0; i < str.length; i++) { + let cp = str.charCodeAt(i); + if (cp < 0x80) { + out.push(cp); + } else if (cp < 0x800) { + out.push((cp >> 6) | 0xc0, (cp & 0x3f) | 0x80); + } else if (cp >= 0xd800 && cp <= 0xdbff) { + // Surrogate pair + const hi = cp; + const lo = str.charCodeAt(++i); + cp = 0x10000 + ((hi - 0xd800) << 10) + (lo - 0xdc00); + out.push( + (cp >> 18) | 0xf0, + ((cp >> 12) & 0x3f) | 0x80, + ((cp >> 6) & 0x3f) | 0x80, + (cp & 0x3f) | 0x80, + ); + } else { + out.push((cp >> 12) | 0xe0, ((cp >> 6) & 0x3f) | 0x80, (cp & 0x3f) | 0x80); + } + } + const arr = new Uint8Array(out.length); + for (let i = 0; i < out.length; i++) arr[i] = out[i]; + return arr; +}