Skip to content

feat: Cashu NFC card provisioning API (ENG-174)#296

Draft
forge0x wants to merge 6 commits intomainfrom
feat/cashu-card-provisioning
Draft

feat: Cashu NFC card provisioning API (ENG-174)#296
forge0x wants to merge 6 commits intomainfrom
feat/cashu-card-provisioning

Conversation

@forge0x
Copy link
Contributor

@forge0x forge0x commented Mar 7, 2026

What this does

Adds a cashuCardProvision GQL 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-client v0.1.0 — new standalone package (NUT-00/03/10 crypto + Nutshell HTTP client, zero Flash deps, 17 tests).

API

cashuCardProvision(input: {
  walletId: ID!
  amountCents: Int!
  cardPubkey: String!   # 33-byte compressed secp256k1, hex
}): CashuCardProvisionPayload

Returns proofs[] (id, amount, secret, C) ready to write to card.

Architecture

@lnflash/cashu-client   crypto + mint HTTP client (reusable, open source)
src/domain/cashu        re-exports package + Flash DomainError wrappers
src/services/cashu      thin wrappers injecting mintUrl from YAML config
src/app/cashu           provisionCashuCard use case (requires Flash wallet + payInvoice)

Payment confirmation

Calls POST /v1/mint/bolt11 directly 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

cashu:
  mintUrl: "https://forge.flashapp.me"

Related

patoo0x added 4 commits March 7, 2026 02:36
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
@linear
Copy link

linear bot commented Mar 7, 2026

patoo0x added 2 commits March 7, 2026 12:58
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants