feat: Cashu NFC card provisioning API (ENG-174)#296
Draft
Conversation
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
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
…ing state
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
…newline - 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
…hu-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
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
patoo0x
added a commit
to lnflash/flash-mobile
that referenced
this pull request
Mar 7, 2026
Users can now top up their Flash card with Cashu proofs directly
from the mobile app, enabling offline bearer payments.
New files:
app/nfc/cashu-apdu.ts APDU layer for CashuApplet (AID D2 76 00 00 85 01 02)
app/nfc/useCashuCard.ts IsoDep NFC session hook (NfcTech.IsoDep)
app/screens/card-screen/cashu-topup.tsx
6-step top-up screen:
amount → pin → tap card → mint → write → success
app/screens/card-screen/cashu-topup.helpers.ts
extractNonceFromSecret() + toCardWriteProof()
Flow:
1. User enters USD amount + card PIN
2. NFC: SELECT + GET_INFO + GET_PUBKEY (blank card detection)
3. GQL: cashuCardProvision (mints P2PK-locked proofs from user's USD wallet)
4. NFC: SET_PIN (blank) + VERIFY_PIN + LOAD_PROOF × N
5. Card is loaded, ready for offline tap-to-pay at Flash POS
Depends on: lnflash/flash#296 (cashuCardProvision mutation)
Modified:
app/screens/card-screen/card.tsx onCashuTopup() → cashuTopup route
app/components/card/EmptyCard.tsx optional 'Top Up Flash Card' button
app/components/card/Flashcard.tsx optional 'Flash Top-Up' icon button
app/navigation/stack-param-lists.ts cashuTopup: undefined route
app/navigation/root-navigator.tsx CashuTopup screen registered
app/screens/card-screen/index.ts exports CashuTopup
Closes ENG-179
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this does
Adds a
cashuCardProvisionGQL mutation that mints P2PK-locked Cashu ecash proofs onto an NFC JavaCard (NUT-XX Profile B).POS taps card → reads pubkey → calls mutation → backend mints proofs locked to card → returns proof bundle → POS writes to card via LOAD_PROOF APDUs.
Depends on
@lnflash/cashu-clientv0.1.0 — new standalone package (NUT-00/03/10 crypto + Nutshell HTTP client, zero Flash deps, 17 tests).API
Returns
proofs[](id, amount, secret, C) ready to write to card.Architecture
Payment confirmation
Calls
POST /v1/mint/bolt11directly after payment — no quote state pre-check. Retries with backoff [500ms, 1s, 2s, 4s] on"quote not paid". Pattern from cashu-ts reference wallet.Config
Related