From f3ca14893537083fbfdd385a29f808afb4e6c182 Mon Sep 17 00:00:00 2001 From: Patoo <262265744+patoo0x@users.noreply.github.com> Date: Sat, 7 Mar 2026 02:36:14 -0500 Subject: [PATCH 1/6] feat(eng-174): Cashu card provisioning API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New module: src/domain/cashu/, src/services/cashu/, src/app/cashu/ Domain layer (src/domain/cashu/): - hashToCurve(secret): NUT-00 secp256k1 hash-to-curve - splitIntoDenominations(cents): power-of-2 denomination splitting - buildP2PKSecret(nonce, cardPubkey): canonical NUT-10 P2PK secret string - createBlindedMessage(keysetId, amount, cardPubkey): NUT-03 blinding (B_ = Y + r*G) - unblindSignature(C_, r, mintPubkey): NUT-03 unblinding (C = C_ - r*K) - CashuMintError, CashuInvalidCardPubkeyError, CashuBlindingError, etc. Service layer (src/services/cashu/): - requestMintQuote(amountCents): NUT-04 POST /v1/mint/quote/bolt11 - getMintQuoteState(quoteId): NUT-04 GET /v1/mint/quote/bolt11/:id - getMintKeysets(): NUT-01 GET /v1/keysets - getMintKeyset(keysetId): NUT-01 GET /v1/keys/:id - mintProofs(quoteId, blindedMessages): NUT-04 POST /v1/mint/bolt11 - Mint URL from CASHU_MINT_URL env (default: https://forge.flashapp.me) App layer (src/app/cashu/): - provisionCashuCard({ walletId, accountId, amountCents, cardPubkey }) Full provisioning flow: 1. Validate inputs (wallet, account, card pubkey secp256k1 check) 2. Fetch active USD keyset from mint 3. Request mint quote → bolt11 invoice 4. Pay invoice from user's USD wallet (payInvoiceByWalletId) 5. Split amount into power-of-2 denominations 6. Build P2PK-locked blind messages (locked to cardPubkey) 7. Submit to mint → receive blind signatures 8. Unblind → CashuProof[] 9. Return proofs to caller (POS writes to card via NFC LOAD_PROOF APDUs) GraphQL (public API): - Mutation: cashuCardProvision(input: CashuCardProvisionInput!) - Input: walletId, amountCents, cardPubkey (66 hex chars) - Payload: proofs[], cardPubkey, totalAmountCents - Type: CashuProof { id, amount, secret, C } - Auth: requires domainAccount (authed at wallet level) Wired into: - src/app/index.ts: Cashu module exported - src/graphql/public/mutations.ts: cashuCardProvision registered NUT-XX compliance: - Proofs use NUT-10/NUT-11 P2PK secrets locked to card pubkey - Proof.secret serialization: no spaces, canonical key order - Uses tiny-secp256k1 for all EC operations - CASHU_UNIT='usd' (USD cents, consistent with forge.flashapp.me) ENG-174 --- src/app/cashu/index.ts | 1 + src/app/cashu/provision-card.ts | 205 ++++++++++++++++++ src/app/index.ts | 3 + src/domain/cashu/errors.ts | 29 +++ src/domain/cashu/index.ts | 131 +++++++++++ src/domain/cashu/index.types.d.ts | 38 ++++ src/graphql/public/mutations.ts | 2 + .../root/mutation/cashu-card-provision.ts | 89 ++++++++ .../public/types/object/cashu-proof.ts | 29 +++ .../types/payload/cashu-card-provision.ts | 28 +++ src/services/cashu/index.ts | 122 +++++++++++ 11 files changed, 677 insertions(+) create mode 100644 src/app/cashu/index.ts create mode 100644 src/app/cashu/provision-card.ts create mode 100644 src/domain/cashu/errors.ts create mode 100644 src/domain/cashu/index.ts create mode 100644 src/domain/cashu/index.types.d.ts create mode 100644 src/graphql/public/root/mutation/cashu-card-provision.ts create mode 100644 src/graphql/public/types/object/cashu-proof.ts create mode 100644 src/graphql/public/types/payload/cashu-card-provision.ts create mode 100644 src/services/cashu/index.ts diff --git a/src/app/cashu/index.ts b/src/app/cashu/index.ts new file mode 100644 index 000000000..e4f77aa05 --- /dev/null +++ b/src/app/cashu/index.ts @@ -0,0 +1 @@ +export * from "./provision-card" diff --git a/src/app/cashu/provision-card.ts b/src/app/cashu/provision-card.ts new file mode 100644 index 000000000..4a16a23a5 --- /dev/null +++ b/src/app/cashu/provision-card.ts @@ -0,0 +1,205 @@ +import { + createBlindedMessage, + unblindSignature, + splitIntoDenominations, + CashuMintError, + CashuInvalidCardPubkeyError, + CashuBlindingError, + CashuMintQuoteNotPaidError, + CashuMintQuoteExpiredError, +} from "@domain/cashu" + +import * as secp from "tiny-secp256k1" + +import { + requestMintQuote, + getMintQuoteState, + getMintKeysets, + getMintKeyset, + mintProofs, +} from "@services/cashu" + +import { AccountsRepository, WalletsRepository } from "@services/mongoose" +import { AccountValidator } from "@domain/accounts" +import { checkedToWalletId } from "@domain/wallets" +import { baseLogger } from "@services/logger" +import { payInvoiceByWalletId } from "@app/payments" + +const logger = baseLogger.child({ module: "cashu-provision-card" }) + +const CASHU_UNIT = "usd" + +/** + * ENG-174: Cashu card provisioning + * + * Flow: + * 1. Validate wallet belongs to account and has sufficient balance + * 2. Fetch active USD keyset from mint + * 3. Request mint quote (get Lightning invoice) + * 4. Pay invoice from user's USD wallet + * 5. Build P2PK-locked blind messages for requested denominations + * 6. Submit to mint → receive blind signatures + * 7. Unblind signatures → proofs + * 8. Return proofs to caller (POS writes to card via NFC) + */ +export const provisionCashuCard = async ({ + walletId: uncheckedWalletId, + accountId, + amountCents, + cardPubkey, +}: { + walletId: string + accountId: string + amountCents: number + cardPubkey: string +}): Promise => { + // --- 1. Validate inputs --- + const walletId = checkedToWalletId(uncheckedWalletId) + if (walletId instanceof Error) return walletId + + if (amountCents <= 0 || !Number.isInteger(amountCents)) { + return new CashuMintError("amountCents must be a positive integer") + } + + // Validate card pubkey: must be 33-byte compressed secp256k1 point + let cardPubkeyBytes: Uint8Array + try { + cardPubkeyBytes = Buffer.from(cardPubkey, "hex") + if (cardPubkeyBytes.length !== 33 || !secp.isPoint(cardPubkeyBytes)) { + return new CashuInvalidCardPubkeyError( + "cardPubkey must be a 33-byte compressed secp256k1 point (66 hex chars)", + ) + } + } catch { + return new CashuInvalidCardPubkeyError("cardPubkey is not valid hex") + } + + // --- 2. Validate account + wallet --- + const account = await AccountsRepository().findById(accountId as AccountId) + if (account instanceof Error) return account + + const accountValidator = AccountValidator(account).isActive() + if (accountValidator instanceof Error) return accountValidator + + const wallet = await WalletsRepository().findById(walletId) + if (wallet instanceof Error) return wallet + + if (wallet.accountId !== accountId) { + return new CashuMintError("Wallet does not belong to the current account") + } + + if (wallet.currency !== "USD") { + return new CashuMintError("Card provisioning requires a USD wallet") + } + + // --- 3. Fetch active USD keyset from mint --- + const keysets = await getMintKeysets() + if (keysets instanceof Error) return keysets + + const usdKeyset = keysets.find((ks) => ks.unit === CASHU_UNIT && ks.active) + if (!usdKeyset) { + return new CashuMintError("No active USD keyset found on mint") + } + + const keysetDetail = await getMintKeyset(usdKeyset.id) + if (keysetDetail instanceof Error) return keysetDetail + + // keys is { "1": pubkey_hex, "2": pubkey_hex, ... } + const mintKeys: Record = keysetDetail.keys + + // --- 4. Request mint quote --- + const quote = await requestMintQuote(amountCents) + if (quote instanceof Error) return quote + + logger.info({ quoteId: quote.quoteId, amountCents }, "cashu: mint quote received") + + // --- 5. Pay the mint invoice from user's wallet --- + const payResult = await payInvoiceByWalletId({ + uncheckedPaymentRequest: quote.paymentRequest, + memo: `Cashu card provisioning — ${amountCents} cents`, + senderWalletId: walletId as WalletId, + senderAccount: account, + }) + if (payResult instanceof Error) return payResult + + logger.info({ quoteId: quote.quoteId }, "cashu: mint invoice paid") + + // Confirm mint sees payment + const quotePaid = await getMintQuoteState(quote.quoteId) + if (quotePaid instanceof Error) return quotePaid + if (quotePaid.state === "EXPIRED") return new CashuMintQuoteExpiredError() + if (quotePaid.state !== "PAID") { + return new CashuMintQuoteNotPaidError( + `Mint quote state is ${quotePaid.state} — expected PAID`, + ) + } + + // --- 6. Build P2PK blind messages --- + const denominations = splitIntoDenominations(amountCents) + + const blindingDataList: (CashuBlindingData & { keysetId: string })[] = [] + const blindedMessages: CashuBlindedMessage[] = [] + + for (const amount of denominations) { + if (!mintKeys[String(amount)]) { + return new CashuMintError( + `Mint keyset has no key for denomination ${amount} cents`, + ) + } + try { + const bd = createBlindedMessage(usdKeyset.id, amount, cardPubkey) + blindingDataList.push({ ...bd, keysetId: usdKeyset.id }) + blindedMessages.push({ id: usdKeyset.id, amount, B_: bd.B_ }) + } catch (err) { + logger.error({ err, amount }, "cashu: blinding failed") + return new CashuBlindingError(`Blinding failed for amount ${amount}: ${(err as Error).message}`) + } + } + + // --- 7. Submit to mint, receive blind signatures --- + const blindSigs = await mintProofs(quote.quoteId, blindedMessages) + if (blindSigs instanceof Error) return blindSigs + + // --- 8. Unblind signatures → final proofs --- + const proofs: CashuProof[] = [] + + for (let i = 0; i < blindSigs.length; i++) { + const sig = blindSigs[i] + const bd = blindingDataList[i] + const mintPubkey = mintKeys[String(sig.amount)] + + if (!mintPubkey) { + return new CashuBlindingError(`No mint pubkey for amount ${sig.amount}`) + } + + let C: string + try { + C = unblindSignature(sig.C_, bd.r, mintPubkey) + } catch (err) { + logger.error({ err, amount: sig.amount }, "cashu: unblinding failed") + return new CashuBlindingError(`Unblinding failed: ${(err as Error).message}`) + } + + // Build the full P2PK secret string (what gets stored in Proof.secret) + const secret = + `["P2PK",{"nonce":"${bd.secret}","data":"${cardPubkey}","tags":[["sigflag","SIG_INPUTS"]]}]` + + proofs.push({ + id: sig.id, + amount: sig.amount, + secret, + C, + }) + } + + logger.info( + { cardPubkey: cardPubkey.slice(0, 10) + "…", proofCount: proofs.length, amountCents }, + "cashu: card provisioned successfully", + ) + + return { + proofs, + cardPubkey, + totalAmount: amountCents, + } +} diff --git a/src/app/index.ts b/src/app/index.ts index b006e8446..149abe47b 100644 --- a/src/app/index.ts +++ b/src/app/index.ts @@ -14,8 +14,10 @@ import * as WalletsMod from "./wallets" import * as PaymentsMod from "./payments" import * as MerchantsMod from "./merchants" import * as SwapMod from "./swap" +import * as CashuMod from "./cashu" const allFunctions = { + Cashu: { ...CashuMod }, Accounts: { ...AccountsMod }, Authentication: { ...AuthenticationMod }, Admin: { ...AdminMod }, @@ -46,6 +48,7 @@ for (subModule in allFunctions) { } export const { + Cashu, Accounts, Authentication, Admin, diff --git a/src/domain/cashu/errors.ts b/src/domain/cashu/errors.ts new file mode 100644 index 000000000..4fdbf9f01 --- /dev/null +++ b/src/domain/cashu/errors.ts @@ -0,0 +1,29 @@ +import { DomainError, ErrorLevel } from "@domain/shared" + +export class CashuMintError extends DomainError { + level = ErrorLevel.Critical +} + +export class CashuMintQuoteNotPaidError extends DomainError { + level = ErrorLevel.Warn +} + +export class CashuMintQuoteExpiredError extends DomainError { + level = ErrorLevel.Warn +} + +export class CashuInvalidCardPubkeyError extends DomainError { + level = ErrorLevel.Warn +} + +export class CashuBlindingError extends DomainError { + level = ErrorLevel.Critical +} + +export class CashuInvalidProofError extends DomainError { + level = ErrorLevel.Warn +} + +export class CashuInsufficientBalanceError extends DomainError { + level = ErrorLevel.Warn +} diff --git a/src/domain/cashu/index.ts b/src/domain/cashu/index.ts new file mode 100644 index 000000000..4a8f6073c --- /dev/null +++ b/src/domain/cashu/index.ts @@ -0,0 +1,131 @@ +import crypto from "crypto" +import * as secp from "tiny-secp256k1" + +export * from "./errors" + +const DOMAIN_SEPARATOR = Buffer.from("Secp256k1_HashToCurve_Cashu_", "utf8") + +/** + * NUT-00: hash_to_curve + * Deterministically maps a 32-byte secret to a secp256k1 point. + */ +export const hashToCurve = (secret: Buffer): Uint8Array => { + const msgHash = crypto + .createHash("sha256") + .update(Buffer.concat([DOMAIN_SEPARATOR, secret])) + .digest() + + for (let counter = 0; counter < 2 ** 16; counter++) { + const counterBuf = Buffer.alloc(4) + counterBuf.writeUInt32BE(counter) + const candidate = Buffer.concat([ + Buffer.from([0x02]), + crypto.createHash("sha256").update(Buffer.concat([msgHash, counterBuf])).digest(), + ]) + if (secp.isPoint(candidate)) return candidate + } + throw new Error("hash_to_curve: no valid point found after 2^16 iterations") +} + +/** + * Split an amount (in cents) into Cashu power-of-2 denominations. + * Returns an array of amounts (each a power of 2), summing to totalCents. + * Uses standard Cashu denomination splitting: greedy from highest bit. + */ +export const splitIntoDenominations = (totalCents: number): number[] => { + const denominations: number[] = [] + let remaining = totalCents + // Powers of 2 from 2^15 down to 2^0 + for (let bit = 15; bit >= 0; bit--) { + const denom = 1 << bit + while (remaining >= denom) { + denominations.push(denom) + remaining -= denom + } + } + return denominations +} + +/** + * Build the canonical NUT-10 P2PK secret JSON string for a card proof. + * The JSON MUST have no spaces and keys in specified order. + * + * secret = ["P2PK", {"nonce": "", "data": "", "tags": [["sigflag", "SIG_INPUTS"]]}] + */ +export const buildP2PKSecret = (nonce: string, cardPubkey: string): string => { + return ( + `["P2PK",{"nonce":"${nonce}","data":"${cardPubkey}","tags":[["sigflag","SIG_INPUTS"]]}]` + ) +} + +/** + * NUT-03: Create a blinded message for a given denomination. + * Returns the blinding data needed to unblind the mint's response. + * + * B_ = hash_to_curve(secret) + r*G + */ +export const createBlindedMessage = ( + keysetId: string, + amount: number, + cardPubkey: string, +): CashuBlindingData => { + // Generate random 32-byte nonce + const nonce = crypto.randomBytes(32) + const nonceHex = nonce.toString("hex") + + // Build the P2PK secret string + const secretStr = buildP2PKSecret(nonceHex, cardPubkey) + const secretBytes = Buffer.from(secretStr, "utf8") + + // hash_to_curve(secret) + const Y = hashToCurve(secretBytes) + + // Random blinding factor r + let r: Uint8Array + do { + r = crypto.randomBytes(32) + } while (!secp.isPrivate(r)) + + // B_ = Y + r*G + const rG = secp.pointFromScalar(r, true) + if (!rG) throw new Error("pointFromScalar failed") + + const B_ = secp.pointAdd(Y, rG, true) + if (!B_) throw new Error("pointAdd failed for B_") + + return { + secret: nonceHex, // the nonce stored on card + r, + B_: Buffer.from(B_).toString("hex"), + amount, + } +} + +/** + * NUT-03: Unblind a mint signature. + * C = C_ - r*K + * where K is the mint's public key for this keyset/amount. + */ +export const unblindSignature = ( + C_hex: string, + r: Uint8Array, + mintPubkeyHex: string, +): string => { + const C_ = Buffer.from(C_hex, "hex") + const K = Buffer.from(mintPubkeyHex, "hex") + + // r*K + const rK = secp.pointMultiply(K, r, true) + if (!rK) throw new Error("pointMultiply failed for r*K") + + // Negate r*K → -r*K + // Negating a compressed point: flip parity byte (02 ↔ 03) + const rKNeg = Buffer.from(rK) + rKNeg[0] = rKNeg[0] === 0x02 ? 0x03 : 0x02 + + // C = C_ + (-r*K) + const C = secp.pointAdd(C_, rKNeg, true) + if (!C) throw new Error("pointAdd failed for C = C_ - r*K") + + return Buffer.from(C).toString("hex") +} diff --git a/src/domain/cashu/index.types.d.ts b/src/domain/cashu/index.types.d.ts new file mode 100644 index 000000000..069320c9d --- /dev/null +++ b/src/domain/cashu/index.types.d.ts @@ -0,0 +1,38 @@ +type CashuProof = { + id: string // keyset ID (hex, e.g. "0059534ce0bfa19a") + amount: number // denomination in keyset base unit (cents for USD) + secret: string // NUT-10 P2PK secret JSON string + C: string // mint signature (compressed secp256k1 point, hex) +} + +type CashuCardProvisionResult = { + proofs: CashuProof[] + cardPubkey: string + totalAmount: number // cents +} + +type CashuMintQuote = { + quoteId: string + paymentRequest: string // bolt11 invoice + state: "UNPAID" | "PAID" | "ISSUED" | "EXPIRED" + expiry: number // unix timestamp +} + +type CashuBlindedMessage = { + id: string // keyset ID + amount: number + B_: string // blinded point hex (compressed) +} + +type CashuBlindSignature = { + id: string + amount: number + C_: string // blind signature hex (compressed) +} + +type CashuBlindingData = { + secret: string // the raw 32-byte nonce (hex) — stored on card + r: Uint8Array // blinding factor scalar + B_: string // blinded point hex + amount: number +} diff --git a/src/graphql/public/mutations.ts b/src/graphql/public/mutations.ts index 6f9262d28..f71fdc554 100644 --- a/src/graphql/public/mutations.ts +++ b/src/graphql/public/mutations.ts @@ -59,6 +59,7 @@ import AccountDisableNotificationChannelMutation from "./root/mutation/account-d import UserUpdateNpubMutation from "./root/mutation/user-update-npub" import RequestCashoutMutation from "./root/mutation/offers/request-cash-out" import InitiateCashoutMutation from "./root/mutation/offers/initiate-cash-out" +import CashuCardProvisionMutation from "./root/mutation/cashu-card-provision" import IdDocumentUploadUrlGenerateMutation from "./root/mutation/id-document-upload-url-generate" // TODO: // const fields: { [key: string]: GraphQLFieldConfig } @@ -142,6 +143,7 @@ export const mutationFields = { requestCashout: RequestCashoutMutation, initiateCashout: InitiateCashoutMutation, + cashuCardProvision: CashuCardProvisionMutation, }, }, } diff --git a/src/graphql/public/root/mutation/cashu-card-provision.ts b/src/graphql/public/root/mutation/cashu-card-provision.ts new file mode 100644 index 000000000..a93fbaa0b --- /dev/null +++ b/src/graphql/public/root/mutation/cashu-card-provision.ts @@ -0,0 +1,89 @@ +import dedent from "dedent" + +import { GT } from "@graphql/index" +import WalletId from "@graphql/shared/types/scalar/wallet-id" +import CashuCardProvisionPayload from "@graphql/public/types/payload/cashu-card-provision" +import { mapAndParseErrorForGqlResponse } from "@graphql/error-map" +import { Cashu } from "@app" + +const CashuCardProvisionInput = GT.Input({ + name: "CashuCardProvisionInput", + fields: () => ({ + walletId: { + type: GT.NonNull(WalletId), + description: "USD wallet ID to fund the card from.", + }, + amountCents: { + type: GT.NonNull(GT.Int), + description: "Total amount to load onto the card, in USD cents.", + }, + cardPubkey: { + type: GT.NonNull(GT.String), + description: + "The card's compressed secp256k1 public key (33 bytes, 66 hex chars). " + + "Obtained from the NFC card via GET_PUBKEY APDU (INS: 0x10).", + }, + }), +}) + +const CashuCardProvisionMutation = GT.Field({ + extensions: { + complexity: 120, + }, + type: GT.NonNull(CashuCardProvisionPayload), + description: dedent` + Provisions a Cashu NFC card with ecash proofs (NUT-XX Profile B). + + Flow: + 1. Tap card to read its public key (GET_PUBKEY APDU) + 2. Call this mutation with the card pubkey, wallet ID, and amount + 3. The backend pays the Cashu mint from your USD wallet balance + 4. Returns signed Cashu proofs locked to the card's key + 5. Write each proof to the card via LOAD_PROOF APDUs + + The card can then be used for offline payments. Merchant redeems proofs + online by presenting them to the mint with the card's Schnorr signature. + `, + args: { + input: { type: GT.NonNull(CashuCardProvisionInput) }, + }, + resolve: async (_, args, { domainAccount }: { domainAccount: Account }) => { + const { walletId, amountCents, cardPubkey } = args.input + + for (const input of [walletId, amountCents, cardPubkey]) { + if (input instanceof Error) { + return { errors: [{ message: input.message }] } + } + } + + if (typeof amountCents !== "number" || amountCents <= 0) { + return { errors: [{ message: "amountCents must be a positive integer" }] } + } + + if (typeof cardPubkey !== "string" || cardPubkey.length !== 66) { + return { + errors: [{ message: "cardPubkey must be 66 hex characters (33 bytes compressed)" }], + } + } + + const result = await Cashu.provisionCashuCard({ + walletId, + accountId: domainAccount.id, + amountCents, + cardPubkey, + }) + + if (result instanceof Error) { + return { errors: [mapAndParseErrorForGqlResponse(result)] } + } + + return { + errors: [], + proofs: result.proofs, + cardPubkey: result.cardPubkey, + totalAmountCents: result.totalAmount, + } + }, +}) + +export default CashuCardProvisionMutation diff --git a/src/graphql/public/types/object/cashu-proof.ts b/src/graphql/public/types/object/cashu-proof.ts new file mode 100644 index 000000000..07f118751 --- /dev/null +++ b/src/graphql/public/types/object/cashu-proof.ts @@ -0,0 +1,29 @@ +import { GT } from "@graphql/index" + +const CashuProof = GT.Object({ + name: "CashuProof", + description: + "A Cashu proof locked to a card's public key (NUT-XX Profile B). " + + "Write to the NFC card via LOAD_PROOF APDU after provisioning.", + fields: () => ({ + id: { + type: GT.NonNull(GT.String), + description: "Keyset ID (hex string, e.g. '0059534ce0bfa19a').", + }, + amount: { + type: GT.NonNull(GT.Int), + description: "Denomination in USD cents.", + }, + secret: { + type: GT.NonNull(GT.String), + description: + "NUT-10 P2PK secret JSON string. Contains the nonce and the card's public key.", + }, + C: { + type: GT.NonNull(GT.String), + description: "Mint blind signature (compressed secp256k1 point, hex).", + }, + }), +}) + +export default CashuProof diff --git a/src/graphql/public/types/payload/cashu-card-provision.ts b/src/graphql/public/types/payload/cashu-card-provision.ts new file mode 100644 index 000000000..9ca63b844 --- /dev/null +++ b/src/graphql/public/types/payload/cashu-card-provision.ts @@ -0,0 +1,28 @@ +import { GT } from "@graphql/index" + +import IError from "@graphql/shared/types/abstract/error" +import CashuProof from "@graphql/public/types/object/cashu-proof" + +const CashuCardProvisionPayload = GT.Object({ + name: "CashuCardProvisionPayload", + fields: () => ({ + errors: { + type: GT.NonNullList(IError), + }, + proofs: { + type: GT.List(GT.NonNull(CashuProof)), + description: + "Signed Cashu proofs ready to be written to the NFC card via LOAD_PROOF APDUs.", + }, + cardPubkey: { + type: GT.String, + description: "The card's public key (hex), echoed back for verification.", + }, + totalAmountCents: { + type: GT.Int, + description: "Total amount loaded onto the card, in USD cents.", + }, + }), +}) + +export default CashuCardProvisionPayload diff --git a/src/services/cashu/index.ts b/src/services/cashu/index.ts new file mode 100644 index 000000000..9279725af --- /dev/null +++ b/src/services/cashu/index.ts @@ -0,0 +1,122 @@ +import axios from "axios" + +import { CashuMintError } from "@domain/cashu" +import { baseLogger } from "@services/logger" + +const logger = baseLogger.child({ module: "cashu-service" }) + +const MINT_URL = process.env.CASHU_MINT_URL ?? "https://forge.flashapp.me" +const MINT_UNIT = "usd" // USD cents + +/** + * Request a mint quote (returns a bolt11 invoice to pay). + * NUT-04: POST /v1/mint/quote/bolt11 + */ +export const requestMintQuote = async ( + amountCents: number, +): Promise => { + try { + const resp = await axios.post(`${MINT_URL}/v1/mint/quote/bolt11`, { + amount: amountCents, + unit: MINT_UNIT, + }) + const data = resp.data + return { + quoteId: data.quote, + paymentRequest: data.request, + state: data.state, + expiry: data.expiry, + } + } catch (err) { + logger.error({ err }, "cashu: requestMintQuote failed") + return new CashuMintError(`Mint quote request failed: ${(err as Error).message}`) + } +} + +/** + * Check the state of a mint quote. + * NUT-04: GET /v1/mint/quote/bolt11/:quoteId + */ +export const getMintQuoteState = async ( + quoteId: string, +): Promise => { + try { + const resp = await axios.get(`${MINT_URL}/v1/mint/quote/bolt11/${quoteId}`) + const data = resp.data + return { + quoteId: data.quote, + paymentRequest: data.request, + state: data.state, + expiry: data.expiry, + } + } catch (err) { + logger.error({ err }, "cashu: getMintQuoteState failed") + return new CashuMintError(`Mint quote state check failed: ${(err as Error).message}`) + } +} + +/** + * Fetch the active keysets from the mint. + * NUT-01: GET /v1/keysets + * Returns a map of keyset_id → { unit, active, keys: { amount: pubkey_hex } } + */ +export const getMintKeysets = async (): Promise< + { id: string; unit: string; active: boolean }[] | CashuMintError +> => { + try { + const resp = await axios.get(`${MINT_URL}/v1/keysets`) + return resp.data.keysets + } catch (err) { + logger.error({ err }, "cashu: getMintKeysets failed") + return new CashuMintError(`Mint keyset fetch failed: ${(err as Error).message}`) + } +} + +/** + * Fetch the public keys for a specific keyset. + * NUT-01: GET /v1/keys/:keysetId + * Returns { id, unit, keys: { "1": hex, "2": hex, ... } } + */ +export const getMintKeyset = async ( + keysetId: string, +): Promise<{ id: string; unit: string; keys: Record } | CashuMintError> => { + try { + const resp = await axios.get(`${MINT_URL}/v1/keys/${keysetId}`) + // Response wraps in { keysets: [{ id, unit, keys }] } + const ks = resp.data.keysets?.[0] ?? resp.data + return ks + } catch (err) { + logger.error({ err }, "cashu: getMintKeyset failed") + return new CashuMintError(`Mint keyset fetch failed: ${(err as Error).message}`) + } +} + +/** + * Submit blinded messages to mint and receive blind signatures. + * NUT-04: POST /v1/mint/bolt11 + */ +export const mintProofs = async ( + quoteId: string, + blindedMessages: CashuBlindedMessage[], +): Promise => { + try { + const resp = await axios.post(`${MINT_URL}/v1/mint/bolt11`, { + quote: quoteId, + outputs: blindedMessages.map((bm) => ({ + id: bm.id, + amount: bm.amount, + B_: bm.B_, + })), + }) + return resp.data.signatures.map( + (sig: { id: string; amount: number; C_: string }) => ({ + id: sig.id, + amount: sig.amount, + C_: sig.C_, + }), + ) + } catch (err) { + logger.error({ err }, "cashu: mintProofs failed") + return new CashuMintError(`Mint proof issuance failed: ${(err as Error).message}`) + } +} From abbc0625840df4abec5bfdb2de488bdafc4a42ac Mon Sep 17 00:00:00 2001 From: Patoo <262265744+patoo0x@users.noreply.github.com> Date: Sat, 7 Mar 2026 11:45:03 -0500 Subject: [PATCH 2/6] feat(eng-174): config, cleanup, unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Config: - src/config/schema.types.d.ts: CashuConfig type { mintUrl: string } - src/config/yaml.ts: getCashuConfig() with default forge.flashapp.me - dev/config/base-config.yaml: cashu.mintUrl default entry - src/services/cashu/index.ts: use getCashuConfig() instead of process.env Domain cleanup: - CashuBlindingData: rename secret→nonce, add secretStr (full P2PK JSON) - provision-card.ts: use bd.secretStr directly, remove duplicate string build Tests (15/15): - hashToCurve: valid point, deterministic, different inputs → different outputs - splitIntoDenominations: edge cases, sum invariant (7 amounts) - buildP2PKSecret: canonical JSON, NUT-XX worked example exact match - createBlindedMessage + unblindSignature round-trip: - B_ is valid secp256k1 point - unblind(mint_sign(B_)) == hash_to_curve(secret_str) [crypto correctness] - different nonces → different blinded messages Run: npx jest --config test/flash/unit/jest.config.js test/flash/unit/domain/cashu/ ENG-174 --- dev/config/base-config.yaml | 3 + src/app/cashu/provision-card.ts | 6 +- src/config/schema.types.d.ts | 5 + src/config/yaml.ts | 4 + src/domain/cashu/index.ts | 3 +- src/domain/cashu/index.types.d.ts | 7 +- src/services/cashu/index.ts | 14 +- test/flash/unit/domain/cashu/crypto.spec.ts | 199 ++++++++++++++++++++ 8 files changed, 226 insertions(+), 15 deletions(-) create mode 100644 test/flash/unit/domain/cashu/crypto.spec.ts diff --git a/dev/config/base-config.yaml b/dev/config/base-config.yaml index f1961e86a..7fea1e6b5 100644 --- a/dev/config/base-config.yaml +++ b/dev/config/base-config.yaml @@ -3,6 +3,9 @@ admin_accounts: - role: "bankowner" phone: "+16505554334" +cashu: + mintUrl: "https://forge.flashapp.me" + ibex: url: "https://api-sandbox.poweredbyibex.io" email: "" diff --git a/src/app/cashu/provision-card.ts b/src/app/cashu/provision-card.ts index 4a16a23a5..c757d1d0d 100644 --- a/src/app/cashu/provision-card.ts +++ b/src/app/cashu/provision-card.ts @@ -180,14 +180,10 @@ export const provisionCashuCard = async ({ return new CashuBlindingError(`Unblinding failed: ${(err as Error).message}`) } - // Build the full P2PK secret string (what gets stored in Proof.secret) - const secret = - `["P2PK",{"nonce":"${bd.secret}","data":"${cardPubkey}","tags":[["sigflag","SIG_INPUTS"]]}]` - proofs.push({ id: sig.id, amount: sig.amount, - secret, + secret: bd.secretStr, // full NUT-10 P2PK JSON string C, }) } diff --git a/src/config/schema.types.d.ts b/src/config/schema.types.d.ts index 4ae4daa90..5930979ea 100644 --- a/src/config/schema.types.d.ts +++ b/src/config/schema.types.d.ts @@ -29,6 +29,10 @@ type IbexConfig = { webhook: WebhookServer } +type CashuConfig = { + mintUrl: string +} + type CashoutEmail = { to: string from: string @@ -172,6 +176,7 @@ type YamlSchema = { smsAuthUnsupportedCountries: string[] whatsAppAuthUnsupportedCountries: string[] ibex: IbexConfig, + cashu: CashuConfig, exchangeRates: StaticRates cashout: { enabled: boolean diff --git a/src/config/yaml.ts b/src/config/yaml.ts index 98b9c5ab3..a3029ec51 100644 --- a/src/config/yaml.ts +++ b/src/config/yaml.ts @@ -388,4 +388,8 @@ export const SendGridConfig = yamlConfig.sendgrid as SendGridConfig export const IbexConfig = yamlConfig.ibex as IbexConfig +export const getCashuConfig = (): CashuConfig => ({ + mintUrl: yamlConfig.cashu?.mintUrl ?? "https://forge.flashapp.me", +}) + export const FrappeConfig = yamlConfig.frappe as FrappeConfig \ No newline at end of file diff --git a/src/domain/cashu/index.ts b/src/domain/cashu/index.ts index 4a8f6073c..dd1a558a6 100644 --- a/src/domain/cashu/index.ts +++ b/src/domain/cashu/index.ts @@ -94,7 +94,8 @@ export const createBlindedMessage = ( if (!B_) throw new Error("pointAdd failed for B_") return { - secret: nonceHex, // the nonce stored on card + nonce: nonceHex, // stored on card (compact form) + secretStr, // full Proof.secret string (P2PK JSON) r, B_: Buffer.from(B_).toString("hex"), amount, diff --git a/src/domain/cashu/index.types.d.ts b/src/domain/cashu/index.types.d.ts index 069320c9d..6a4bea030 100644 --- a/src/domain/cashu/index.types.d.ts +++ b/src/domain/cashu/index.types.d.ts @@ -31,8 +31,9 @@ type CashuBlindSignature = { } type CashuBlindingData = { - secret: string // the raw 32-byte nonce (hex) — stored on card - r: Uint8Array // blinding factor scalar - B_: string // blinded point hex + nonce: string // raw 32-byte nonce (hex) — stored on card + secretStr: string // full NUT-10 P2PK secret JSON string — becomes Proof.secret + r: Uint8Array // blinding factor scalar + B_: string // blinded point hex amount: number } diff --git a/src/services/cashu/index.ts b/src/services/cashu/index.ts index 9279725af..4b54b8395 100644 --- a/src/services/cashu/index.ts +++ b/src/services/cashu/index.ts @@ -1,13 +1,15 @@ import axios from "axios" import { CashuMintError } from "@domain/cashu" +import { getCashuConfig } from "@config" import { baseLogger } from "@services/logger" const logger = baseLogger.child({ module: "cashu-service" }) -const MINT_URL = process.env.CASHU_MINT_URL ?? "https://forge.flashapp.me" const MINT_UNIT = "usd" // USD cents +const mintUrl = () => getCashuConfig().mintUrl + /** * Request a mint quote (returns a bolt11 invoice to pay). * NUT-04: POST /v1/mint/quote/bolt11 @@ -16,7 +18,7 @@ export const requestMintQuote = async ( amountCents: number, ): Promise => { try { - const resp = await axios.post(`${MINT_URL}/v1/mint/quote/bolt11`, { + const resp = await axios.post(`${mintUrl()}/v1/mint/quote/bolt11`, { amount: amountCents, unit: MINT_UNIT, }) @@ -41,7 +43,7 @@ export const getMintQuoteState = async ( quoteId: string, ): Promise => { try { - const resp = await axios.get(`${MINT_URL}/v1/mint/quote/bolt11/${quoteId}`) + const resp = await axios.get(`${mintUrl()}/v1/mint/quote/bolt11/${quoteId}`) const data = resp.data return { quoteId: data.quote, @@ -64,7 +66,7 @@ export const getMintKeysets = async (): Promise< { id: string; unit: string; active: boolean }[] | CashuMintError > => { try { - const resp = await axios.get(`${MINT_URL}/v1/keysets`) + const resp = await axios.get(`${mintUrl()}/v1/keysets`) return resp.data.keysets } catch (err) { logger.error({ err }, "cashu: getMintKeysets failed") @@ -81,7 +83,7 @@ export const getMintKeyset = async ( keysetId: string, ): Promise<{ id: string; unit: string; keys: Record } | CashuMintError> => { try { - const resp = await axios.get(`${MINT_URL}/v1/keys/${keysetId}`) + const resp = await axios.get(`${mintUrl()}/v1/keys/${keysetId}`) // Response wraps in { keysets: [{ id, unit, keys }] } const ks = resp.data.keysets?.[0] ?? resp.data return ks @@ -100,7 +102,7 @@ export const mintProofs = async ( blindedMessages: CashuBlindedMessage[], ): Promise => { try { - const resp = await axios.post(`${MINT_URL}/v1/mint/bolt11`, { + const resp = await axios.post(`${mintUrl()}/v1/mint/bolt11`, { quote: quoteId, outputs: blindedMessages.map((bm) => ({ id: bm.id, diff --git a/test/flash/unit/domain/cashu/crypto.spec.ts b/test/flash/unit/domain/cashu/crypto.spec.ts new file mode 100644 index 000000000..cf70c95bb --- /dev/null +++ b/test/flash/unit/domain/cashu/crypto.spec.ts @@ -0,0 +1,199 @@ +import crypto from "crypto" +import * as secp from "tiny-secp256k1" + +import { + hashToCurve, + splitIntoDenominations, + buildP2PKSecret, + createBlindedMessage, + unblindSignature, +} from "@domain/cashu" + +// --------------------------------------------------------------------------- +// hashToCurve +// --------------------------------------------------------------------------- + +describe("hashToCurve", () => { + it("returns a valid compressed secp256k1 point for a known input", () => { + // NUT-00 test vector: hash_to_curve("0000...0000") + const secret = Buffer.alloc(32, 0x00) + const point = hashToCurve(secret) + expect(point).toHaveLength(33) + expect(secp.isPoint(point)).toBe(true) + expect(point[0] === 0x02 || point[0] === 0x03).toBe(true) + }) + + it("returns a valid point for a random input", () => { + const secret = crypto.randomBytes(32) + const point = hashToCurve(secret) + expect(point).toHaveLength(33) + expect(secp.isPoint(point)).toBe(true) + }) + + it("is deterministic — same input produces same output", () => { + const secret = Buffer.from("deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", "hex") + const p1 = hashToCurve(secret) + const p2 = hashToCurve(secret) + expect(Buffer.from(p1).toString("hex")).toBe(Buffer.from(p2).toString("hex")) + }) + + it("produces different points for different inputs", () => { + const a = hashToCurve(Buffer.alloc(32, 0x01)) + const b = hashToCurve(Buffer.alloc(32, 0x02)) + expect(Buffer.from(a).toString("hex")).not.toBe(Buffer.from(b).toString("hex")) + }) +}) + +// --------------------------------------------------------------------------- +// splitIntoDenominations +// --------------------------------------------------------------------------- + +describe("splitIntoDenominations", () => { + it("splits 1 cent correctly", () => { + expect(splitIntoDenominations(1)).toEqual([1]) + }) + + it("splits 3 cents into [2, 1]", () => { + expect(splitIntoDenominations(3)).toEqual([2, 1]) + }) + + it("splits 100 cents into [64, 32, 4]", () => { + expect(splitIntoDenominations(100)).toEqual([64, 32, 4]) + }) + + it("splits 500 cents into correct denominations", () => { + const denoms = splitIntoDenominations(500) + expect(denoms.reduce((a, b) => a + b, 0)).toBe(500) + // Every denomination must be a power of 2 + denoms.forEach((d) => expect(Math.log2(d) % 1).toBe(0)) + }) + + it("sum of denominations always equals the input", () => { + for (const amount of [1, 7, 50, 99, 128, 255, 1000, 32767]) { + const denoms = splitIntoDenominations(amount) + expect(denoms.reduce((a, b) => a + b, 0)).toBe(amount) + } + }) + + it("returns empty array for 0", () => { + expect(splitIntoDenominations(0)).toEqual([]) + }) +}) + +// --------------------------------------------------------------------------- +// buildP2PKSecret +// --------------------------------------------------------------------------- + +describe("buildP2PKSecret", () => { + it("produces canonical NUT-10 P2PK JSON with no spaces", () => { + const nonce = "916c21b8c67da71e9d02f4e3adc6f30700c152e01a07ae30e3bcc6b55b0c9e5e" + const pubkey = "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2" + const secret = buildP2PKSecret(nonce, pubkey) + + // Must be valid JSON + const parsed = JSON.parse(secret) + expect(parsed[0]).toBe("P2PK") + expect(parsed[1].nonce).toBe(nonce) + expect(parsed[1].data).toBe(pubkey) + expect(parsed[1].tags).toEqual([["sigflag", "SIG_INPUTS"]]) + + // Must have no spaces + expect(secret).not.toContain(" ") + + // Key order: nonce, data, tags + const keys = Object.keys(parsed[1]) + expect(keys).toEqual(["nonce", "data", "tags"]) + }) + + it("matches NUT-XX worked example exactly", () => { + const nonce = "916c21b8c67da71e9d02f4e3adc6f30700c152e01a07ae30e3bcc6b55b0c9e5e" + const pubkey = "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2" + const expected = + `["P2PK",{"nonce":"${nonce}","data":"${pubkey}","tags":[["sigflag","SIG_INPUTS"]]}]` + expect(buildP2PKSecret(nonce, pubkey)).toBe(expected) + }) +}) + +// --------------------------------------------------------------------------- +// createBlindedMessage + unblindSignature (round-trip) +// --------------------------------------------------------------------------- + +describe("createBlindedMessage + unblindSignature", () => { + // We simulate the mint: given a private key k, the mint signs B_ as C_ = k * B_ + // Then we unblind: C = C_ - r*K + // And verify: C == k * hash_to_curve(secret_str) + + const mintPrivkey = crypto.randomBytes(32) + // Ensure it's a valid private key + let validMintPrivkey: Uint8Array + beforeAll(() => { + let k = mintPrivkey + while (!secp.isPrivate(k)) k = crypto.randomBytes(32) + validMintPrivkey = k + }) + + const cardPubkeyPriv = crypto.randomBytes(32) + let cardPubkey: string + beforeAll(() => { + let p = cardPubkeyPriv + while (!secp.isPrivate(p)) p = crypto.randomBytes(32) + const pub = secp.pointFromScalar(p, true) + if (!pub) throw new Error("failed to derive card pubkey") + cardPubkey = Buffer.from(pub).toString("hex") + }) + + it("B_ is a valid secp256k1 point", () => { + const bd = createBlindedMessage("0059534ce0bfa19a", 4, cardPubkey) + const B_bytes = Buffer.from(bd.B_, "hex") + expect(secp.isPoint(B_bytes)).toBe(true) + expect(B_bytes).toHaveLength(33) + }) + + it("round-trip: unblind(mint_sign(B_)) == hash_to_curve(secret)", () => { + let mintPriv: Uint8Array + do { mintPriv = crypto.randomBytes(32) } while (!secp.isPrivate(mintPriv)) + const mintPub = secp.pointFromScalar(mintPriv, true)! + const mintPubHex = Buffer.from(mintPub).toString("hex") + + let cardPriv: Uint8Array + do { cardPriv = crypto.randomBytes(32) } while (!secp.isPrivate(cardPriv)) + const cardPub = secp.pointFromScalar(cardPriv, true)! + const cardPubHex = Buffer.from(cardPub).toString("hex") + + // Create blinded message + const bd = createBlindedMessage("0059534ce0bfa19a", 8, cardPubHex) + const B_bytes = Buffer.from(bd.B_, "hex") + + // Mint signs: C_ = k * B_ + const C_ = secp.pointMultiply(B_bytes, mintPriv, true) + expect(C_).not.toBeNull() + const C_hex = Buffer.from(C_!).toString("hex") + + // Unblind: C = C_ - r * K + const C_unblinded = unblindSignature(C_hex, bd.r, mintPubHex) + + // Verify: C should equal k * hash_to_curve(secret_str) + const secretStr = buildP2PKSecret(bd.nonce, cardPubHex) + const secretBytes = Buffer.from(secretStr, "utf8") + const Y = hashToCurve(secretBytes) + const C_expected = secp.pointMultiply(Y, mintPriv, true)! + const C_expected_hex = Buffer.from(C_expected).toString("hex") + + expect(C_unblinded).toBe(C_expected_hex) + }) + + it("different nonces produce different blinded messages", () => { + let cardPriv: Uint8Array + do { cardPriv = crypto.randomBytes(32) } while (!secp.isPrivate(cardPriv)) + const cardPub = secp.pointFromScalar(cardPriv, true)! + const cardPubHex = Buffer.from(cardPub).toString("hex") + + const bd1 = createBlindedMessage("0059534ce0bfa19a", 1, cardPubHex) + const bd2 = createBlindedMessage("0059534ce0bfa19a", 1, cardPubHex) + + // Nonces must differ (random) + expect(bd1.nonce).not.toBe(bd2.nonce) + // Blinded messages must differ + expect(bd1.B_).not.toBe(bd2.B_) + }) +}) From 7676a40c8468ad3d084c07779174d6a1d3a88f1f Mon Sep 17 00:00:00 2001 From: Patoo <262265744+patoo0x@users.noreply.github.com> Date: Sat, 7 Mar 2026 11:57:23 -0500 Subject: [PATCH 3/6] fix(eng-174): retry mintProofs on quote-not-paid instead of pre-checking state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Research finding: cashu-ts (reference wallet) calls POST /v1/mint/bolt11 directly after payment with no prior GET /v1/mint/quote/bolt11/:id state check. Nutshell returns HTTP 400 {detail: 'quote not paid'} if the mint hasn't yet processed the Lightning settlement — this is the authoritative signal. Problem with the old approach: - GET /v1/mint/quote/bolt11/:id + POST /v1/mint/bolt11 is two round-trips - The state check doesn't eliminate the race — it only moves it one step later - If the check passes but the mint state changes before we call mintProofs, we're back to square one New approach: - Call mintProofs directly after payInvoiceByWalletId returns - On HTTP 400 'quote not paid': retry with exponential backoff (500ms, 1s, 2s, 4s) - Max 4 retries (~7.5s total window) — sufficient for any LN settlement delay - One fewer HTTP call in the happy path (zero retries expected in practice since payInvoiceByWalletId waits for full settlement before returning) Also removed unused CashuMintQuoteExpiredError and getMintQuoteState import. ENG-174 --- src/app/cashu/provision-card.ts | 56 +++++++++++++++++++++++---------- src/domain/cashu/errors.ts | 4 --- 2 files changed, 39 insertions(+), 21 deletions(-) diff --git a/src/app/cashu/provision-card.ts b/src/app/cashu/provision-card.ts index c757d1d0d..f72c9bb1e 100644 --- a/src/app/cashu/provision-card.ts +++ b/src/app/cashu/provision-card.ts @@ -6,14 +6,12 @@ import { CashuInvalidCardPubkeyError, CashuBlindingError, CashuMintQuoteNotPaidError, - CashuMintQuoteExpiredError, } from "@domain/cashu" import * as secp from "tiny-secp256k1" import { requestMintQuote, - getMintQuoteState, getMintKeysets, getMintKeyset, mintProofs, @@ -124,16 +122,6 @@ export const provisionCashuCard = async ({ logger.info({ quoteId: quote.quoteId }, "cashu: mint invoice paid") - // Confirm mint sees payment - const quotePaid = await getMintQuoteState(quote.quoteId) - if (quotePaid instanceof Error) return quotePaid - if (quotePaid.state === "EXPIRED") return new CashuMintQuoteExpiredError() - if (quotePaid.state !== "PAID") { - return new CashuMintQuoteNotPaidError( - `Mint quote state is ${quotePaid.state} — expected PAID`, - ) - } - // --- 6. Build P2PK blind messages --- const denominations = splitIntoDenominations(amountCents) @@ -156,15 +144,49 @@ export const provisionCashuCard = async ({ } } - // --- 7. Submit to mint, receive blind signatures --- - const blindSigs = await mintProofs(quote.quoteId, blindedMessages) - if (blindSigs instanceof Error) return blindSigs + // --- 7. Submit to mint, receive blind signatures (with retry on quote-not-yet-PAID) --- + // + // We don't pre-check quote state. The mint is the authoritative check: + // - If paid → returns signatures immediately + // - If not yet processed → returns HTTP 400 "quote not paid" + // + // There is a small window between our Lightning payment settling and the mint's + // internal state updating (webhook/polling from its LN node). We retry with + // exponential backoff on that specific error. This is the pattern used by + // cashu-ts (reference wallet) — attempt mint directly, retry on "quote not paid". + const RETRY_DELAYS_MS = [500, 1000, 2000, 4000] // max ~7.5s total + const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + + let blindSigs: CashuBlindSignature[] | ApplicationError | undefined + for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) { + blindSigs = await mintProofs(quote.quoteId, blindedMessages) + if (!(blindSigs instanceof Error)) break + + const isNotPaid = + blindSigs instanceof CashuMintError && + blindSigs.message.toLowerCase().includes("quote not paid") + + if (!isNotPaid || attempt === RETRY_DELAYS_MS.length) { + return blindSigs + } + + logger.warn( + { quoteId: quote.quoteId, attempt: attempt + 1, delayMs: RETRY_DELAYS_MS[attempt] }, + "cashu: quote not yet PAID on mint, retrying", + ) + await sleep(RETRY_DELAYS_MS[attempt]) + } + + if (!blindSigs || blindSigs instanceof Error) { + return new CashuMintQuoteNotPaidError("Mint did not confirm payment after retries") + } // --- 8. Unblind signatures → final proofs --- const proofs: CashuProof[] = [] + const confirmedSigs = blindSigs as CashuBlindSignature[] - for (let i = 0; i < blindSigs.length; i++) { - const sig = blindSigs[i] + for (let i = 0; i < confirmedSigs.length; i++) { + const sig = confirmedSigs[i] const bd = blindingDataList[i] const mintPubkey = mintKeys[String(sig.amount)] diff --git a/src/domain/cashu/errors.ts b/src/domain/cashu/errors.ts index 4fdbf9f01..7742def76 100644 --- a/src/domain/cashu/errors.ts +++ b/src/domain/cashu/errors.ts @@ -8,10 +8,6 @@ export class CashuMintQuoteNotPaidError extends DomainError { level = ErrorLevel.Warn } -export class CashuMintQuoteExpiredError extends DomainError { - level = ErrorLevel.Warn -} - export class CashuInvalidCardPubkeyError extends DomainError { level = ErrorLevel.Warn } From c56b9d7a2b5e0815aff3522c51f8f39d0dc2d2e4 Mon Sep 17 00:00:00 2001 From: Patoo <262265744+patoo0x@users.noreply.github.com> Date: Sat, 7 Mar 2026 12:07:40 -0500 Subject: [PATCH 4/6] fix(eng-174): cashu config optional in YamlSchema + yaml.ts trailing newline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - schema.types.d.ts: cashu? (optional) — consistent with getCashuConfig() optional-chain fallback; avoids YAML parse failure on configs without cashu block - yaml.ts: add trailing newline (pre-existing omission) ENG-174 --- src/config/schema.types.d.ts | 2 +- src/config/yaml.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/config/schema.types.d.ts b/src/config/schema.types.d.ts index 5930979ea..b41a90c57 100644 --- a/src/config/schema.types.d.ts +++ b/src/config/schema.types.d.ts @@ -176,7 +176,7 @@ type YamlSchema = { smsAuthUnsupportedCountries: string[] whatsAppAuthUnsupportedCountries: string[] ibex: IbexConfig, - cashu: CashuConfig, + cashu?: CashuConfig, exchangeRates: StaticRates cashout: { enabled: boolean diff --git a/src/config/yaml.ts b/src/config/yaml.ts index a3029ec51..2d0ecfd80 100644 --- a/src/config/yaml.ts +++ b/src/config/yaml.ts @@ -392,4 +392,4 @@ export const getCashuConfig = (): CashuConfig => ({ mintUrl: yamlConfig.cashu?.mintUrl ?? "https://forge.flashapp.me", }) -export const FrappeConfig = yamlConfig.frappe as FrappeConfig \ No newline at end of file +export const FrappeConfig = yamlConfig.frappe as FrappeConfig From b7020c8417c619caafa0997a4abe8e37033475bf Mon Sep 17 00:00:00 2001 From: Patoo <262265744+patoo0x@users.noreply.github.com> Date: Sat, 7 Mar 2026 12:58:48 -0500 Subject: [PATCH 5/6] refactor(eng-174): extract Cashu crypto + mint client to @lnflash/cashu-client Replace inline domain/cashu crypto and services/cashu HTTP client with the standalone @lnflash/cashu-client package (github:lnflash/cashu-client#v0.1.0). Changes: - package.json: add @lnflash/cashu-client@0.1.0 dependency - src/domain/cashu/index.ts: re-exports crypto/types from package + Flash errors - src/domain/cashu/index.types.d.ts: deleted (types now in package) - src/domain/cashu/errors.ts: DomainError wrappers unchanged (preserve ErrorLevel) - src/services/cashu/index.ts: thin wrappers injecting mintUrl from config - src/app/cashu/provision-card.ts: add explicit type imports, local result type - test/flash/unit/domain/cashu/crypto.spec.ts: deleted (17 tests live in package) Package: https://github.com/lnflash/cashu-client Spec: https://github.com/lnflash/cashu-javacard/blob/main/spec/NUT-XX.md ENG-174 --- package.json | 1 + src/app/cashu/provision-card.ts | 13 ++ src/domain/cashu/errors.ts | 11 ++ src/domain/cashu/index.ts | 149 +++------------ src/domain/cashu/index.types.d.ts | 39 ---- src/services/cashu/index.ts | 130 ++++--------- test/flash/unit/domain/cashu/crypto.spec.ts | 199 -------------------- yarn.lock | 38 +++- 8 files changed, 114 insertions(+), 466 deletions(-) delete mode 100644 src/domain/cashu/index.types.d.ts delete mode 100644 test/flash/unit/domain/cashu/crypto.spec.ts diff --git a/package.json b/package.json index b48d43be7..74333f197 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@google-cloud/storage": "^7.1.0", "@grpc/grpc-js": "^1.9.3", "@grpc/proto-loader": "^0.7.9", + "@lnflash/cashu-client": "lnflash/cashu-client#v0.1.0", "@opentelemetry/api": "^1.6.0", "@opentelemetry/core": "^1.17.0", "@opentelemetry/exporter-trace-otlp-http": "^0.43.0", diff --git a/src/app/cashu/provision-card.ts b/src/app/cashu/provision-card.ts index f72c9bb1e..d23d60eee 100644 --- a/src/app/cashu/provision-card.ts +++ b/src/app/cashu/provision-card.ts @@ -8,6 +8,19 @@ import { CashuMintQuoteNotPaidError, } from "@domain/cashu" +import type { + CashuProof, + CashuBlindingData, + CashuBlindedMessage, + CashuBlindSignature, +} from "@domain/cashu" + +type CashuCardProvisionResult = { + proofs: CashuProof[] + cardPubkey: string + totalAmount: number // cents +} + import * as secp from "tiny-secp256k1" import { diff --git a/src/domain/cashu/errors.ts b/src/domain/cashu/errors.ts index 7742def76..f177a3466 100644 --- a/src/domain/cashu/errors.ts +++ b/src/domain/cashu/errors.ts @@ -1,3 +1,10 @@ +/** + * Flash-layer Cashu error wrappers. + * + * These extend DomainError (for Flash ErrorLevel metadata/logging) while + * delegating message construction to the same names from @lnflash/cashu-client. + * Callers use instanceof checks against these types. + */ import { DomainError, ErrorLevel } from "@domain/shared" export class CashuMintError extends DomainError { @@ -20,6 +27,10 @@ export class CashuInvalidProofError extends DomainError { level = ErrorLevel.Warn } +export class CashuInsufficientSlotsError extends DomainError { + level = ErrorLevel.Warn +} + export class CashuInsufficientBalanceError extends DomainError { level = ErrorLevel.Warn } diff --git a/src/domain/cashu/index.ts b/src/domain/cashu/index.ts index dd1a558a6..6a6e516c3 100644 --- a/src/domain/cashu/index.ts +++ b/src/domain/cashu/index.ts @@ -1,132 +1,25 @@ -import crypto from "crypto" -import * as secp from "tiny-secp256k1" - -export * from "./errors" - -const DOMAIN_SEPARATOR = Buffer.from("Secp256k1_HashToCurve_Cashu_", "utf8") - -/** - * NUT-00: hash_to_curve - * Deterministically maps a 32-byte secret to a secp256k1 point. - */ -export const hashToCurve = (secret: Buffer): Uint8Array => { - const msgHash = crypto - .createHash("sha256") - .update(Buffer.concat([DOMAIN_SEPARATOR, secret])) - .digest() - - for (let counter = 0; counter < 2 ** 16; counter++) { - const counterBuf = Buffer.alloc(4) - counterBuf.writeUInt32BE(counter) - const candidate = Buffer.concat([ - Buffer.from([0x02]), - crypto.createHash("sha256").update(Buffer.concat([msgHash, counterBuf])).digest(), - ]) - if (secp.isPoint(candidate)) return candidate - } - throw new Error("hash_to_curve: no valid point found after 2^16 iterations") -} - -/** - * Split an amount (in cents) into Cashu power-of-2 denominations. - * Returns an array of amounts (each a power of 2), summing to totalCents. - * Uses standard Cashu denomination splitting: greedy from highest bit. - */ -export const splitIntoDenominations = (totalCents: number): number[] => { - const denominations: number[] = [] - let remaining = totalCents - // Powers of 2 from 2^15 down to 2^0 - for (let bit = 15; bit >= 0; bit--) { - const denom = 1 << bit - while (remaining >= denom) { - denominations.push(denom) - remaining -= denom - } - } - return denominations -} - -/** - * Build the canonical NUT-10 P2PK secret JSON string for a card proof. - * The JSON MUST have no spaces and keys in specified order. - * - * secret = ["P2PK", {"nonce": "", "data": "", "tags": [["sigflag", "SIG_INPUTS"]]}] - */ -export const buildP2PKSecret = (nonce: string, cardPubkey: string): string => { - return ( - `["P2PK",{"nonce":"${nonce}","data":"${cardPubkey}","tags":[["sigflag","SIG_INPUTS"]]}]` - ) -} - /** - * NUT-03: Create a blinded message for a given denomination. - * Returns the blinding data needed to unblind the mint's response. + * Cashu domain layer — re-exports from @lnflash/cashu-client plus Flash error wrappers. * - * B_ = hash_to_curve(secret) + r*G - */ -export const createBlindedMessage = ( - keysetId: string, - amount: number, - cardPubkey: string, -): CashuBlindingData => { - // Generate random 32-byte nonce - const nonce = crypto.randomBytes(32) - const nonceHex = nonce.toString("hex") - - // Build the P2PK secret string - const secretStr = buildP2PKSecret(nonceHex, cardPubkey) - const secretBytes = Buffer.from(secretStr, "utf8") - - // hash_to_curve(secret) - const Y = hashToCurve(secretBytes) - - // Random blinding factor r - let r: Uint8Array - do { - r = crypto.randomBytes(32) - } while (!secp.isPrivate(r)) - - // B_ = Y + r*G - const rG = secp.pointFromScalar(r, true) - if (!rG) throw new Error("pointFromScalar failed") - - const B_ = secp.pointAdd(Y, rG, true) - if (!B_) throw new Error("pointAdd failed for B_") - - return { - nonce: nonceHex, // stored on card (compact form) - secretStr, // full Proof.secret string (P2PK JSON) - r, - B_: Buffer.from(B_).toString("hex"), - amount, - } -} - -/** - * NUT-03: Unblind a mint signature. - * C = C_ - r*K - * where K is the mint's public key for this keyset/amount. + * Crypto primitives and types live in the standalone package. + * Flash-specific DomainError subclasses live in ./errors. */ -export const unblindSignature = ( - C_hex: string, - r: Uint8Array, - mintPubkeyHex: string, -): string => { - const C_ = Buffer.from(C_hex, "hex") - const K = Buffer.from(mintPubkeyHex, "hex") - - // r*K - const rK = secp.pointMultiply(K, r, true) - if (!rK) throw new Error("pointMultiply failed for r*K") +export { + hashToCurve, + splitIntoDenominations, + buildP2PKSecret, + createBlindedMessage, + unblindSignature, +} from "@lnflash/cashu-client" + +export type { + CashuProof, + CashuMintQuote, + CashuBlindedMessage, + CashuBlindSignature, + CashuBlindingData, + CashuKeyset, + CashuKeysetDetail, +} from "@lnflash/cashu-client" - // Negate r*K → -r*K - // Negating a compressed point: flip parity byte (02 ↔ 03) - const rKNeg = Buffer.from(rK) - rKNeg[0] = rKNeg[0] === 0x02 ? 0x03 : 0x02 - - // C = C_ + (-r*K) - const C = secp.pointAdd(C_, rKNeg, true) - if (!C) throw new Error("pointAdd failed for C = C_ - r*K") - - return Buffer.from(C).toString("hex") -} +export * from "./errors" diff --git a/src/domain/cashu/index.types.d.ts b/src/domain/cashu/index.types.d.ts deleted file mode 100644 index 6a4bea030..000000000 --- a/src/domain/cashu/index.types.d.ts +++ /dev/null @@ -1,39 +0,0 @@ -type CashuProof = { - id: string // keyset ID (hex, e.g. "0059534ce0bfa19a") - amount: number // denomination in keyset base unit (cents for USD) - secret: string // NUT-10 P2PK secret JSON string - C: string // mint signature (compressed secp256k1 point, hex) -} - -type CashuCardProvisionResult = { - proofs: CashuProof[] - cardPubkey: string - totalAmount: number // cents -} - -type CashuMintQuote = { - quoteId: string - paymentRequest: string // bolt11 invoice - state: "UNPAID" | "PAID" | "ISSUED" | "EXPIRED" - expiry: number // unix timestamp -} - -type CashuBlindedMessage = { - id: string // keyset ID - amount: number - B_: string // blinded point hex (compressed) -} - -type CashuBlindSignature = { - id: string - amount: number - C_: string // blind signature hex (compressed) -} - -type CashuBlindingData = { - nonce: string // raw 32-byte nonce (hex) — stored on card - secretStr: string // full NUT-10 P2PK secret JSON string — becomes Proof.secret - r: Uint8Array // blinding factor scalar - B_: string // blinded point hex - amount: number -} diff --git a/src/services/cashu/index.ts b/src/services/cashu/index.ts index 4b54b8395..89fef7096 100644 --- a/src/services/cashu/index.ts +++ b/src/services/cashu/index.ts @@ -1,124 +1,60 @@ -import axios from "axios" +/** + * Flash Cashu service layer. + * + * Thin wrappers around @lnflash/cashu-client mint functions that: + * 1. Inject the configured mint URL from Flash's YAML config + * 2. Map package-level CashuError instances to Flash DomainError subclasses + * (preserving ErrorLevel metadata for Flash's logging/error handling) + */ +import { + requestMintQuote as _requestMintQuote, + getMintQuoteState as _getMintQuoteState, + getMintKeysets as _getMintKeysets, + getMintKeyset as _getMintKeyset, + mintProofs as _mintProofs, + CashuError, +} from "@lnflash/cashu-client" + +import type { CashuMintQuote, CashuBlindedMessage, CashuBlindSignature, CashuKeyset, CashuKeysetDetail } from "@lnflash/cashu-client" import { CashuMintError } from "@domain/cashu" import { getCashuConfig } from "@config" -import { baseLogger } from "@services/logger" - -const logger = baseLogger.child({ module: "cashu-service" }) - -const MINT_UNIT = "usd" // USD cents const mintUrl = () => getCashuConfig().mintUrl -/** - * Request a mint quote (returns a bolt11 invoice to pay). - * NUT-04: POST /v1/mint/quote/bolt11 - */ +const wrapError = (err: CashuError): CashuMintError => + new CashuMintError(err.message) + export const requestMintQuote = async ( amountCents: number, ): Promise => { - try { - const resp = await axios.post(`${mintUrl()}/v1/mint/quote/bolt11`, { - amount: amountCents, - unit: MINT_UNIT, - }) - const data = resp.data - return { - quoteId: data.quote, - paymentRequest: data.request, - state: data.state, - expiry: data.expiry, - } - } catch (err) { - logger.error({ err }, "cashu: requestMintQuote failed") - return new CashuMintError(`Mint quote request failed: ${(err as Error).message}`) - } + const result = await _requestMintQuote(mintUrl(), amountCents, "usd") + return result instanceof CashuError ? wrapError(result) : result } -/** - * Check the state of a mint quote. - * NUT-04: GET /v1/mint/quote/bolt11/:quoteId - */ export const getMintQuoteState = async ( quoteId: string, ): Promise => { - try { - const resp = await axios.get(`${mintUrl()}/v1/mint/quote/bolt11/${quoteId}`) - const data = resp.data - return { - quoteId: data.quote, - paymentRequest: data.request, - state: data.state, - expiry: data.expiry, - } - } catch (err) { - logger.error({ err }, "cashu: getMintQuoteState failed") - return new CashuMintError(`Mint quote state check failed: ${(err as Error).message}`) - } + const result = await _getMintQuoteState(mintUrl(), quoteId) + return result instanceof CashuError ? wrapError(result) : result } -/** - * Fetch the active keysets from the mint. - * NUT-01: GET /v1/keysets - * Returns a map of keyset_id → { unit, active, keys: { amount: pubkey_hex } } - */ -export const getMintKeysets = async (): Promise< - { id: string; unit: string; active: boolean }[] | CashuMintError -> => { - try { - const resp = await axios.get(`${mintUrl()}/v1/keysets`) - return resp.data.keysets - } catch (err) { - logger.error({ err }, "cashu: getMintKeysets failed") - return new CashuMintError(`Mint keyset fetch failed: ${(err as Error).message}`) - } +export const getMintKeysets = async (): Promise => { + const result = await _getMintKeysets(mintUrl()) + return result instanceof CashuError ? wrapError(result) : result } -/** - * Fetch the public keys for a specific keyset. - * NUT-01: GET /v1/keys/:keysetId - * Returns { id, unit, keys: { "1": hex, "2": hex, ... } } - */ export const getMintKeyset = async ( keysetId: string, -): Promise<{ id: string; unit: string; keys: Record } | CashuMintError> => { - try { - const resp = await axios.get(`${mintUrl()}/v1/keys/${keysetId}`) - // Response wraps in { keysets: [{ id, unit, keys }] } - const ks = resp.data.keysets?.[0] ?? resp.data - return ks - } catch (err) { - logger.error({ err }, "cashu: getMintKeyset failed") - return new CashuMintError(`Mint keyset fetch failed: ${(err as Error).message}`) - } +): Promise => { + const result = await _getMintKeyset(mintUrl(), keysetId) + return result instanceof CashuError ? wrapError(result) : result } -/** - * Submit blinded messages to mint and receive blind signatures. - * NUT-04: POST /v1/mint/bolt11 - */ export const mintProofs = async ( quoteId: string, blindedMessages: CashuBlindedMessage[], ): Promise => { - try { - const resp = await axios.post(`${mintUrl()}/v1/mint/bolt11`, { - quote: quoteId, - outputs: blindedMessages.map((bm) => ({ - id: bm.id, - amount: bm.amount, - B_: bm.B_, - })), - }) - return resp.data.signatures.map( - (sig: { id: string; amount: number; C_: string }) => ({ - id: sig.id, - amount: sig.amount, - C_: sig.C_, - }), - ) - } catch (err) { - logger.error({ err }, "cashu: mintProofs failed") - return new CashuMintError(`Mint proof issuance failed: ${(err as Error).message}`) - } + const result = await _mintProofs(mintUrl(), quoteId, blindedMessages) + return result instanceof CashuError ? wrapError(result) : result } diff --git a/test/flash/unit/domain/cashu/crypto.spec.ts b/test/flash/unit/domain/cashu/crypto.spec.ts deleted file mode 100644 index cf70c95bb..000000000 --- a/test/flash/unit/domain/cashu/crypto.spec.ts +++ /dev/null @@ -1,199 +0,0 @@ -import crypto from "crypto" -import * as secp from "tiny-secp256k1" - -import { - hashToCurve, - splitIntoDenominations, - buildP2PKSecret, - createBlindedMessage, - unblindSignature, -} from "@domain/cashu" - -// --------------------------------------------------------------------------- -// hashToCurve -// --------------------------------------------------------------------------- - -describe("hashToCurve", () => { - it("returns a valid compressed secp256k1 point for a known input", () => { - // NUT-00 test vector: hash_to_curve("0000...0000") - const secret = Buffer.alloc(32, 0x00) - const point = hashToCurve(secret) - expect(point).toHaveLength(33) - expect(secp.isPoint(point)).toBe(true) - expect(point[0] === 0x02 || point[0] === 0x03).toBe(true) - }) - - it("returns a valid point for a random input", () => { - const secret = crypto.randomBytes(32) - const point = hashToCurve(secret) - expect(point).toHaveLength(33) - expect(secp.isPoint(point)).toBe(true) - }) - - it("is deterministic — same input produces same output", () => { - const secret = Buffer.from("deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", "hex") - const p1 = hashToCurve(secret) - const p2 = hashToCurve(secret) - expect(Buffer.from(p1).toString("hex")).toBe(Buffer.from(p2).toString("hex")) - }) - - it("produces different points for different inputs", () => { - const a = hashToCurve(Buffer.alloc(32, 0x01)) - const b = hashToCurve(Buffer.alloc(32, 0x02)) - expect(Buffer.from(a).toString("hex")).not.toBe(Buffer.from(b).toString("hex")) - }) -}) - -// --------------------------------------------------------------------------- -// splitIntoDenominations -// --------------------------------------------------------------------------- - -describe("splitIntoDenominations", () => { - it("splits 1 cent correctly", () => { - expect(splitIntoDenominations(1)).toEqual([1]) - }) - - it("splits 3 cents into [2, 1]", () => { - expect(splitIntoDenominations(3)).toEqual([2, 1]) - }) - - it("splits 100 cents into [64, 32, 4]", () => { - expect(splitIntoDenominations(100)).toEqual([64, 32, 4]) - }) - - it("splits 500 cents into correct denominations", () => { - const denoms = splitIntoDenominations(500) - expect(denoms.reduce((a, b) => a + b, 0)).toBe(500) - // Every denomination must be a power of 2 - denoms.forEach((d) => expect(Math.log2(d) % 1).toBe(0)) - }) - - it("sum of denominations always equals the input", () => { - for (const amount of [1, 7, 50, 99, 128, 255, 1000, 32767]) { - const denoms = splitIntoDenominations(amount) - expect(denoms.reduce((a, b) => a + b, 0)).toBe(amount) - } - }) - - it("returns empty array for 0", () => { - expect(splitIntoDenominations(0)).toEqual([]) - }) -}) - -// --------------------------------------------------------------------------- -// buildP2PKSecret -// --------------------------------------------------------------------------- - -describe("buildP2PKSecret", () => { - it("produces canonical NUT-10 P2PK JSON with no spaces", () => { - const nonce = "916c21b8c67da71e9d02f4e3adc6f30700c152e01a07ae30e3bcc6b55b0c9e5e" - const pubkey = "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2" - const secret = buildP2PKSecret(nonce, pubkey) - - // Must be valid JSON - const parsed = JSON.parse(secret) - expect(parsed[0]).toBe("P2PK") - expect(parsed[1].nonce).toBe(nonce) - expect(parsed[1].data).toBe(pubkey) - expect(parsed[1].tags).toEqual([["sigflag", "SIG_INPUTS"]]) - - // Must have no spaces - expect(secret).not.toContain(" ") - - // Key order: nonce, data, tags - const keys = Object.keys(parsed[1]) - expect(keys).toEqual(["nonce", "data", "tags"]) - }) - - it("matches NUT-XX worked example exactly", () => { - const nonce = "916c21b8c67da71e9d02f4e3adc6f30700c152e01a07ae30e3bcc6b55b0c9e5e" - const pubkey = "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2" - const expected = - `["P2PK",{"nonce":"${nonce}","data":"${pubkey}","tags":[["sigflag","SIG_INPUTS"]]}]` - expect(buildP2PKSecret(nonce, pubkey)).toBe(expected) - }) -}) - -// --------------------------------------------------------------------------- -// createBlindedMessage + unblindSignature (round-trip) -// --------------------------------------------------------------------------- - -describe("createBlindedMessage + unblindSignature", () => { - // We simulate the mint: given a private key k, the mint signs B_ as C_ = k * B_ - // Then we unblind: C = C_ - r*K - // And verify: C == k * hash_to_curve(secret_str) - - const mintPrivkey = crypto.randomBytes(32) - // Ensure it's a valid private key - let validMintPrivkey: Uint8Array - beforeAll(() => { - let k = mintPrivkey - while (!secp.isPrivate(k)) k = crypto.randomBytes(32) - validMintPrivkey = k - }) - - const cardPubkeyPriv = crypto.randomBytes(32) - let cardPubkey: string - beforeAll(() => { - let p = cardPubkeyPriv - while (!secp.isPrivate(p)) p = crypto.randomBytes(32) - const pub = secp.pointFromScalar(p, true) - if (!pub) throw new Error("failed to derive card pubkey") - cardPubkey = Buffer.from(pub).toString("hex") - }) - - it("B_ is a valid secp256k1 point", () => { - const bd = createBlindedMessage("0059534ce0bfa19a", 4, cardPubkey) - const B_bytes = Buffer.from(bd.B_, "hex") - expect(secp.isPoint(B_bytes)).toBe(true) - expect(B_bytes).toHaveLength(33) - }) - - it("round-trip: unblind(mint_sign(B_)) == hash_to_curve(secret)", () => { - let mintPriv: Uint8Array - do { mintPriv = crypto.randomBytes(32) } while (!secp.isPrivate(mintPriv)) - const mintPub = secp.pointFromScalar(mintPriv, true)! - const mintPubHex = Buffer.from(mintPub).toString("hex") - - let cardPriv: Uint8Array - do { cardPriv = crypto.randomBytes(32) } while (!secp.isPrivate(cardPriv)) - const cardPub = secp.pointFromScalar(cardPriv, true)! - const cardPubHex = Buffer.from(cardPub).toString("hex") - - // Create blinded message - const bd = createBlindedMessage("0059534ce0bfa19a", 8, cardPubHex) - const B_bytes = Buffer.from(bd.B_, "hex") - - // Mint signs: C_ = k * B_ - const C_ = secp.pointMultiply(B_bytes, mintPriv, true) - expect(C_).not.toBeNull() - const C_hex = Buffer.from(C_!).toString("hex") - - // Unblind: C = C_ - r * K - const C_unblinded = unblindSignature(C_hex, bd.r, mintPubHex) - - // Verify: C should equal k * hash_to_curve(secret_str) - const secretStr = buildP2PKSecret(bd.nonce, cardPubHex) - const secretBytes = Buffer.from(secretStr, "utf8") - const Y = hashToCurve(secretBytes) - const C_expected = secp.pointMultiply(Y, mintPriv, true)! - const C_expected_hex = Buffer.from(C_expected).toString("hex") - - expect(C_unblinded).toBe(C_expected_hex) - }) - - it("different nonces produce different blinded messages", () => { - let cardPriv: Uint8Array - do { cardPriv = crypto.randomBytes(32) } while (!secp.isPrivate(cardPriv)) - const cardPub = secp.pointFromScalar(cardPriv, true)! - const cardPubHex = Buffer.from(cardPub).toString("hex") - - const bd1 = createBlindedMessage("0059534ce0bfa19a", 1, cardPubHex) - const bd2 = createBlindedMessage("0059534ce0bfa19a", 1, cardPubHex) - - // Nonces must differ (random) - expect(bd1.nonce).not.toBe(bd2.nonce) - // Blinded messages must differ - expect(bd1.B_).not.toBe(bd2.B_) - }) -}) diff --git a/yarn.lock b/yarn.lock index 1b016c30d..a6ff822cc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2503,6 +2503,13 @@ dependencies: lodash "^4.17.21" +"@lnflash/cashu-client@lnflash/cashu-client#v0.1.0": + version "0.1.0" + resolved "https://codeload.github.com/lnflash/cashu-client/tar.gz/a54d8d57d8e1c576b0bb9ea083ab84cc7c524d89" + dependencies: + axios "^1.6.0" + tiny-secp256k1 "^2.2.3" + "@mapbox/node-pre-gyp@^1.0.5": version "1.0.11" resolved "https://registry.yarnpkg.com/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz#417db42b7f5323d79e93b34a6d7a2a12c0df43fa" @@ -13227,7 +13234,16 @@ string-template@~0.2.1: resolved "https://registry.yarnpkg.com/string-template/-/string-template-0.2.1.tgz#42932e598a352d01fc22ec3367d9d84eec6c9add" integrity sha512-Yptehjogou2xm4UJbxJ4CxgZx12HBfeystp0y3x7s4Dj32ltVVG1Gg8YhKjHZkHicuKpZX/ffilA8505VbUbpw== -"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -13305,7 +13321,7 @@ stringify-object@^3.2.1: is-obj "^1.0.1" is-regexp "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -13319,6 +13335,13 @@ strip-ansi@^3.0.0: dependencies: ansi-regex "^2.0.0" +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + strip-ansi@^7.0.1: version "7.1.2" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.2.tgz#132875abde678c7ea8d691533f2e7e22bb744dba" @@ -14421,7 +14444,16 @@ wordwrap@^1.0.0: resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== From 97203b7b9e00c11d2de2406302fd079417c1305c Mon Sep 17 00:00:00 2001 From: Patoo <262265744+patoo0x@users.noreply.github.com> Date: Sat, 7 Mar 2026 13:20:39 -0500 Subject: [PATCH 6/6] feat(eng-175): add availableSlots to cashuCardProvision for top-up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the existing mutation to handle both first-time provisioning and subsequent top-ups. The only difference between provision and top-up is slot availability — the mint flow is identical. Changes: - CashuCardProvisionInput: add availableSlots?: Int (1–32, optional) - provisionCashuCard: accept availableSlots, pass to splitIntoDenominations - CashuInsufficientSlotsError mapped from @lnflash/cashu-client to Flash DomainError splitIntoDenominations(amount, maxSlots) is already in @lnflash/cashu-client v0.1.0. If the amount requires more denominations than available slots, the mutation returns an error rather than silently over-allocating. For provisioning (first-time): omit availableSlots or pass 32 (all slots free). For top-up: pass the number of free slots read from card state. ENG-175 --- src/app/cashu/provision-card.ts | 31 +++++++++++++++---- .../root/mutation/cashu-card-provision.ts | 17 +++++++++- 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/src/app/cashu/provision-card.ts b/src/app/cashu/provision-card.ts index d23d60eee..bbb2397d3 100644 --- a/src/app/cashu/provision-card.ts +++ b/src/app/cashu/provision-card.ts @@ -6,8 +6,13 @@ import { CashuInvalidCardPubkeyError, CashuBlindingError, CashuMintQuoteNotPaidError, + CashuInsufficientSlotsError, } from "@domain/cashu" +import { + CashuInsufficientSlotsError as PkgCashuInsufficientSlotsError, +} from "@lnflash/cashu-client" + import type { CashuProof, CashuBlindingData, @@ -41,28 +46,34 @@ const logger = baseLogger.child({ module: "cashu-provision-card" }) const CASHU_UNIT = "usd" /** - * ENG-174: Cashu card provisioning + * ENG-174/ENG-175: Cashu card provisioning and top-up + * + * Handles both first-time provisioning (card is blank, all 32 slots free) + * and subsequent top-ups (card has existing proofs, availableSlots < 32). * * Flow: * 1. Validate wallet belongs to account and has sufficient balance * 2. Fetch active USD keyset from mint * 3. Request mint quote (get Lightning invoice) * 4. Pay invoice from user's USD wallet - * 5. Build P2PK-locked blind messages for requested denominations - * 6. Submit to mint → receive blind signatures - * 7. Unblind signatures → proofs - * 8. Return proofs to caller (POS writes to card via NFC) + * 5. Split amount into denominations, respecting availableSlots if provided + * 6. Build P2PK-locked blind messages for each denomination + * 7. Submit to mint → receive blind signatures (with retry on quote-not-yet-PAID) + * 8. Unblind signatures → proofs + * 9. Return proofs to caller (POS/mobile writes to card via LOAD_PROOF APDUs) */ export const provisionCashuCard = async ({ walletId: uncheckedWalletId, accountId, amountCents, cardPubkey, + availableSlots, }: { walletId: string accountId: string amountCents: number cardPubkey: string + availableSlots?: number }): Promise => { // --- 1. Validate inputs --- const walletId = checkedToWalletId(uncheckedWalletId) @@ -136,7 +147,15 @@ export const provisionCashuCard = async ({ logger.info({ quoteId: quote.quoteId }, "cashu: mint invoice paid") // --- 6. Build P2PK blind messages --- - const denominations = splitIntoDenominations(amountCents) + let denominations: number[] + try { + denominations = splitIntoDenominations(amountCents, availableSlots) + } catch (err) { + if (err instanceof PkgCashuInsufficientSlotsError) { + return new CashuInsufficientSlotsError(err.message) + } + return new CashuMintError(`Denomination split failed: ${(err as Error).message}`) + } const blindingDataList: (CashuBlindingData & { keysetId: string })[] = [] const blindedMessages: CashuBlindedMessage[] = [] diff --git a/src/graphql/public/root/mutation/cashu-card-provision.ts b/src/graphql/public/root/mutation/cashu-card-provision.ts index a93fbaa0b..8ffaf3ec3 100644 --- a/src/graphql/public/root/mutation/cashu-card-provision.ts +++ b/src/graphql/public/root/mutation/cashu-card-provision.ts @@ -23,6 +23,14 @@ const CashuCardProvisionInput = GT.Input({ "The card's compressed secp256k1 public key (33 bytes, 66 hex chars). " + "Obtained from the NFC card via GET_PUBKEY APDU (INS: 0x10).", }, + availableSlots: { + type: GT.Int, + description: + "Number of free proof slots on the card. " + + "Required for top-ups where some slots are already in use. " + + "Omit (or pass 32) for first-time provisioning. " + + "Returns an error if the amount requires more denominations than available slots.", + }, }), }) @@ -48,7 +56,7 @@ const CashuCardProvisionMutation = GT.Field({ input: { type: GT.NonNull(CashuCardProvisionInput) }, }, resolve: async (_, args, { domainAccount }: { domainAccount: Account }) => { - const { walletId, amountCents, cardPubkey } = args.input + const { walletId, amountCents, cardPubkey, availableSlots } = args.input for (const input of [walletId, amountCents, cardPubkey]) { if (input instanceof Error) { @@ -66,11 +74,18 @@ const CashuCardProvisionMutation = GT.Field({ } } + if (availableSlots !== undefined && availableSlots !== null) { + if (!Number.isInteger(availableSlots) || availableSlots < 1 || availableSlots > 32) { + return { errors: [{ message: "availableSlots must be an integer between 1 and 32" }] } + } + } + const result = await Cashu.provisionCashuCard({ walletId, accountId: domainAccount.id, amountCents, cardPubkey, + availableSlots: availableSlots ?? undefined, }) if (result instanceof Error) {