Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions dev/config/base-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ admin_accounts:
- role: "bankowner"
phone: "+16505554334"

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

ibex:
url: "https://api-sandbox.poweredbyibex.io"
email: ""
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/app/cashu/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./provision-card"
255 changes: 255 additions & 0 deletions src/app/cashu/provision-card.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
import {
createBlindedMessage,
unblindSignature,
splitIntoDenominations,
CashuMintError,
CashuInvalidCardPubkeyError,
CashuBlindingError,
CashuMintQuoteNotPaidError,
CashuInsufficientSlotsError,
} from "@domain/cashu"

import {
CashuInsufficientSlotsError as PkgCashuInsufficientSlotsError,
} from "@lnflash/cashu-client"

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 {
requestMintQuote,
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/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. 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<CashuCardProvisionResult | ApplicationError> => {
// --- 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<string, string> = 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")

// --- 6. Build P2PK blind messages ---
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[] = []

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 (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 < confirmedSigs.length; i++) {
const sig = confirmedSigs[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}`)
}

proofs.push({
id: sig.id,
amount: sig.amount,
secret: bd.secretStr, // full NUT-10 P2PK JSON string
C,
})
}

logger.info(
{ cardPubkey: cardPubkey.slice(0, 10) + "…", proofCount: proofs.length, amountCents },
"cashu: card provisioned successfully",
)

return {
proofs,
cardPubkey,
totalAmount: amountCents,
}
}
3 changes: 3 additions & 0 deletions src/app/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down Expand Up @@ -46,6 +48,7 @@ for (subModule in allFunctions) {
}

export const {
Cashu,
Accounts,
Authentication,
Admin,
Expand Down
5 changes: 5 additions & 0 deletions src/config/schema.types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ type IbexConfig = {
webhook: WebhookServer
}

type CashuConfig = {
mintUrl: string
}

type CashoutEmail = {
to: string
from: string
Expand Down Expand Up @@ -172,6 +176,7 @@ type YamlSchema = {
smsAuthUnsupportedCountries: string[]
whatsAppAuthUnsupportedCountries: string[]
ibex: IbexConfig,
cashu?: CashuConfig,
exchangeRates: StaticRates
cashout: {
enabled: boolean
Expand Down
6 changes: 5 additions & 1 deletion src/config/yaml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -388,4 +388,8 @@ export const SendGridConfig = yamlConfig.sendgrid as SendGridConfig

export const IbexConfig = yamlConfig.ibex as IbexConfig

export const FrappeConfig = yamlConfig.frappe as FrappeConfig
export const getCashuConfig = (): CashuConfig => ({
mintUrl: yamlConfig.cashu?.mintUrl ?? "https://forge.flashapp.me",
})

export const FrappeConfig = yamlConfig.frappe as FrappeConfig
36 changes: 36 additions & 0 deletions src/domain/cashu/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* 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 {
level = ErrorLevel.Critical
}

export class CashuMintQuoteNotPaidError 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 CashuInsufficientSlotsError extends DomainError {
level = ErrorLevel.Warn
}

export class CashuInsufficientBalanceError extends DomainError {
level = ErrorLevel.Warn
}
25 changes: 25 additions & 0 deletions src/domain/cashu/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* Cashu domain layer — re-exports from @lnflash/cashu-client plus Flash error wrappers.
*
* Crypto primitives and types live in the standalone package.
* Flash-specific DomainError subclasses live in ./errors.
*/
export {
hashToCurve,
splitIntoDenominations,
buildP2PKSecret,
createBlindedMessage,
unblindSignature,
} from "@lnflash/cashu-client"

export type {
CashuProof,
CashuMintQuote,
CashuBlindedMessage,
CashuBlindSignature,
CashuBlindingData,
CashuKeyset,
CashuKeysetDetail,
} from "@lnflash/cashu-client"

export * from "./errors"
Loading