From e0723158ebcb277b9e4899f2002d5fc15020a777 Mon Sep 17 00:00:00 2001 From: IgboPharaoh Date: Wed, 29 Jul 2026 20:28:11 +0100 Subject: [PATCH 1/5] feat(core): make mint quote accounting authoritative --- packages/adapter-tests/src/index.ts | 78 ++++++++++- packages/core/adapter.ts | 5 + packages/core/models/MintQuote.ts | 93 +++++++++++-- packages/core/models/MintQuoteState.ts | 1 + packages/core/quotes/MintQuoteObservation.ts | 65 +++++---- packages/core/quotes/QuoteLifecycle.ts | 31 ++--- packages/core/repositories/index.ts | 4 +- .../memory/MemoryMintQuoteRepository.ts | 25 ++-- .../watchers/MintOperationProcessor.ts | 11 +- .../watchers/MintOperationWatcherService.ts | 29 ++-- .../test/unit/MemoryQuoteRepository.test.ts | 19 +++ .../test/unit/MintOperationService.test.ts | 19 ++- .../unit/MintOperationWatcherService.test.ts | 36 +++-- packages/core/test/unit/MintQuote.test.ts | 129 ++++++++++++++++++ .../test/unit/MintQuoteObservation.test.ts | 98 ++++++++++++- .../core/test/unit/MintQuoteProcessor.test.ts | 67 ++++----- .../expo-sqlite/src/test/contract.test.ts | 3 + .../src/repositories/MintQuoteRepository.ts | 19 +-- packages/indexeddb/src/test/contract.test.ts | 3 + .../src/repositories/MintQuoteRepository.ts | 23 +--- packages/sqlite-bun/src/test/contract.test.ts | 3 + packages/sqlite3/src/test/contract.test.ts | 3 + 22 files changed, 593 insertions(+), 171 deletions(-) diff --git a/packages/adapter-tests/src/index.ts b/packages/adapter-tests/src/index.ts index b896ee87..4dc0440b 100644 --- a/packages/adapter-tests/src/index.ts +++ b/packages/adapter-tests/src/index.ts @@ -445,6 +445,80 @@ export function createDummyAuthSession(overrides?: Partial): AuthSe }; } +export async function runMintQuoteRepositoryContract( + options: ContractOptions, + runner: ContractRunner, +): Promise { + const { describe, it, expect } = runner; + + describe('MintQuoteRepository contract', () => { + it('keeps canonical BOLT11 accounting authoritative over legacy state updates', async () => { + const { repositories, dispose } = await options.createRepositories(); + try { + const quote = createDummyMintQuote({ + state: 'UNPAID', + amountPaid: Amount.from(3), + amountIssued: Amount.zero(), + remoteUpdatedAt: 10, + }); + await repositories.mintQuoteRepository.upsertMintQuote(quote); + await repositories.mintQuoteRepository.setMintQuoteState( + quote.mintUrl, + quote.method, + quote.quoteId, + 'UNPAID', + 20, + ); + + const stored = await repositories.mintQuoteRepository.getMintQuote( + quote.mintUrl, + quote.method, + quote.quoteId, + ); + + expect(stored?.method).toBe('bolt11'); + if (stored?.method !== 'bolt11') throw new Error('Expected BOLT11 quote'); + expect(stored.state).toBe('PAID'); + expect(stored.amountPaid.equals(Amount.from(3))).toBe(true); + expect(stored.amountIssued.isZero()).toBe(true); + expect(stored.remoteUpdatedAt).toBe(10); + } finally { + await dispose(); + } + }); + + it('selects pending BOLT11 quotes from accounting instead of stored state', async () => { + const { repositories, dispose } = await options.createRepositories(); + try { + const pending = createDummyMintQuote({ + quoteId: 'accounting-pending', + quote: 'accounting-pending', + state: 'ISSUED', + amountPaid: Amount.from(3), + amountIssued: Amount.zero(), + }); + const issued = createDummyMintQuote({ + quoteId: 'accounting-issued', + quote: 'accounting-issued', + state: 'UNPAID', + amountPaid: Amount.from(3), + amountIssued: Amount.from(3), + }); + await repositories.mintQuoteRepository.upsertMintQuote(pending); + await repositories.mintQuoteRepository.upsertMintQuote(issued); + + const storedPending = await repositories.mintQuoteRepository.getPendingMintQuotes('bolt11'); + const pendingIds = storedPending.map((quote) => quote.quoteId); + + expect(pendingIds.includes('accounting-pending')).toBe(true); + expect(pendingIds.includes('accounting-issued')).toBe(false); + } finally { + await dispose(); + } + }); + }); +} + export async function runMintOperationRepositoryContract( options: ContractOptions, runner: ContractRunner, @@ -583,6 +657,8 @@ export async function runMintOperationRepositoryContract( mintUrl: 'https://mint.test/', quoteId: 'canonical-quote', quote: 'canonical-quote', + state: 'UNPAID', + amountPaid: Amount.from(3), remoteUpdatedAt: 10, }); await repositories.mintQuoteRepository.upsertMintQuote(quote); @@ -590,7 +666,7 @@ export async function runMintOperationRepositoryContract( 'https://mint.test', 'bolt11', 'canonical-quote', - 'PAID', + 'UNPAID', 20, ); diff --git a/packages/core/adapter.ts b/packages/core/adapter.ts index f9678cb2..1bdd08f9 100644 --- a/packages/core/adapter.ts +++ b/packages/core/adapter.ts @@ -38,9 +38,14 @@ export type { QuoteIdentity, } from './models/index.ts'; export { + applyBolt11MintQuoteStateFallback, compareHistoryEntries, + deriveBolt11MintQuoteState, getMintQuoteAmount, getMintQuoteRemoteState, + isBolt11MintQuoteIssued, + isBolt11MintQuotePaid, + isBolt11MintQuoteUnpaid, isMintQuotePending, isStatefulMintQuote, operationHistoryId, diff --git a/packages/core/models/MintQuote.ts b/packages/core/models/MintQuote.ts index 4172648f..d7a167e5 100644 --- a/packages/core/models/MintQuote.ts +++ b/packages/core/models/MintQuote.ts @@ -80,10 +80,85 @@ export function isStatefulMintQuote(quote: MintQuote): quote is MintQuote<'bolt1 return quote.method === 'bolt11'; } +/** Derives the deprecated BOLT11 state projection from canonical quote accounting. */ +export function deriveBolt11MintQuoteState( + amountPaid: Amount, + amountIssued: Amount, +): MintMethodRemoteState<'bolt11'> { + return amountPaid.isZero() && amountIssued.isZero() + ? 'UNPAID' + : amountPaid.greaterThan(amountIssued) + ? 'PAID' + : 'ISSUED'; +} + +/** Returns whether canonical BOLT11 accounting represents an unpaid quote. */ +export function isBolt11MintQuoteUnpaid(quote: MintQuote<'bolt11'>): boolean { + return quote.amountPaid.isZero() && quote.amountIssued.isZero(); +} + +/** Returns whether canonical BOLT11 accounting can fund the quote's exact mint operation. */ +export function isBolt11MintQuotePaid(quote: MintQuote<'bolt11'>): boolean { + return ( + quote.amountIssued.isZero() && + quote.amountPaid.greaterThanOrEqual(quote.amount) && + getMintQuoteAvailableAmount(quote).greaterThanOrEqual(quote.amount) + ); +} + +/** Returns whether canonical BOLT11 accounting has issued the quote's full fixed amount. */ +export function isBolt11MintQuoteIssued(quote: MintQuote<'bolt11'>): boolean { + return quote.amountIssued.greaterThanOrEqual(quote.amount); +} + +/** + * Applies a legacy BOLT11 state observation without allowing it to reduce canonical accounting. + * + * @deprecated Legacy state is a fallback for snapshots that do not carry Mint Quote Accounting. + */ +export function applyBolt11MintQuoteStateFallback( + quote: MintQuote<'bolt11'>, + state: MintMethodRemoteState<'bolt11'>, + observedAt = Date.now(), +): MintQuote<'bolt11'> { + const hasLegacyProjectionShape = + (quote.amountPaid.isZero() && quote.amountIssued.isZero()) || + (quote.amountPaid.equals(quote.amount) && quote.amountIssued.isZero()) || + (quote.amountPaid.equals(quote.amount) && quote.amountIssued.equals(quote.amount)); + if (quote.remoteUpdatedAt !== null || !hasLegacyProjectionShape) { + return { + ...quote, + state: deriveBolt11MintQuoteState(quote.amountPaid, quote.amountIssued), + }; + } + + const paidFallback = state === 'UNPAID' ? Amount.zero() : quote.amount; + const issuedFallback = state === 'ISSUED' ? quote.amount : Amount.zero(); + const amountPaid = quote.amountPaid.greaterThan(paidFallback) ? quote.amountPaid : paidFallback; + const amountIssued = quote.amountIssued.greaterThan(issuedFallback) + ? quote.amountIssued + : issuedFallback; + + return { + ...quote, + state: deriveBolt11MintQuoteState(amountPaid, amountIssued), + amountPaid, + amountIssued, + updatedAt: observedAt, + }; +} + +/** + * Returns the deprecated BOLT11 state projection for compatibility consumers. + * + * @deprecated Use `amountPaid` and `amountIssued`, or the canonical accounting predicates. + */ export function getMintQuoteRemoteState( quote: MintQuote, ): MintMethodRemoteState<'bolt11'> | undefined { - return isStatefulMintQuote(quote) ? quote.state : undefined; + return isStatefulMintQuote(quote) + ? deriveBolt11MintQuoteState(quote.amountPaid, quote.amountIssued) + : undefined; } /** @@ -101,16 +176,12 @@ export function getMintQuoteAmount(quote: MintQuote): Amount | undefined { } export function getMintQuoteAvailableAmount(quote: MintQuote): Amount { - if (quote.reusable) { - return quote.amountPaid.subtract(quote.amountIssued); - } - - return quote.state === 'PAID' ? quote.amount : Amount.zero(); + return quote.amountPaid.subtract(quote.amountIssued); } export function isMintQuotePending(quote: MintQuote): boolean { if (isStatefulMintQuote(quote)) { - return quote.state !== 'ISSUED'; + return !isBolt11MintQuoteIssued(quote); } return true; @@ -133,7 +204,11 @@ export function mintQuoteFromBolt11Response( quote: MintQuoteBolt11Response, options?: { now?: number }, ): MintQuote<'bolt11'> { - const canonicalQuote = mintQuoteObservationFromBolt11Response(mintUrl, quote, options); + const observation = mintQuoteObservationFromBolt11Response(mintUrl, quote, options); + const canonicalQuote: MintQuote<'bolt11'> = { + ...observation, + state: deriveBolt11MintQuoteState(observation.amountPaid, observation.amountIssued), + }; assertValidMintQuoteAccounting( canonicalQuote.quoteId, canonicalQuote.amountPaid, @@ -182,7 +257,7 @@ export function mintQuoteToMethodSnapshot( unit: quote.unit, expiry: quote.expiry, pubkey: quote.pubkey, - state: quote.state, + state: deriveBolt11MintQuoteState(quote.amountPaid, quote.amountIssued), amount_paid: quote.amountPaid, amount_issued: quote.amountIssued, updated_at: quote.remoteUpdatedAt, diff --git a/packages/core/models/MintQuoteState.ts b/packages/core/models/MintQuoteState.ts index 437d4b21..b28de3eb 100644 --- a/packages/core/models/MintQuoteState.ts +++ b/packages/core/models/MintQuoteState.ts @@ -1 +1,2 @@ +/** @deprecated Use canonical `amountPaid` and `amountIssued` Mint Quote Accounting. */ export type MintQuoteState = 'UNPAID' | 'PAID' | 'ISSUED'; diff --git a/packages/core/quotes/MintQuoteObservation.ts b/packages/core/quotes/MintQuoteObservation.ts index cd442c48..bb91c82f 100644 --- a/packages/core/quotes/MintQuoteObservation.ts +++ b/packages/core/quotes/MintQuoteObservation.ts @@ -1,10 +1,8 @@ -import { isStatefulMintQuote, type MintQuote } from '../models/MintQuote'; - -const MINT_QUOTE_STATE_RANK = { - UNPAID: 0, - PAID: 1, - ISSUED: 2, -} as const; +import { + deriveBolt11MintQuoteState, + isStatefulMintQuote, + type MintQuote, +} from '../models/MintQuote'; export type MintQuoteObservationDisposition = | 'accepted-meaningful-change' @@ -19,12 +17,12 @@ export interface MintQuoteObservationResolution { disposition: MintQuoteObservationDisposition; } -function isMintQuoteStateDowngrade(existing: MintQuote, incoming: MintQuote): boolean { - return ( - isStatefulMintQuote(existing) && - isStatefulMintQuote(incoming) && - MINT_QUOTE_STATE_RANK[incoming.state] < MINT_QUOTE_STATE_RANK[existing.state] - ); +function withCanonicalCompatibilityProjection(quote: MintQuote): MintQuote { + if (!isStatefulMintQuote(quote)) return quote; + return { + ...quote, + state: deriveBolt11MintQuoteState(quote.amountPaid, quote.amountIssued), + }; } function hasAccountingComponentDecrease(existing: MintQuote, incoming: MintQuote): boolean { @@ -51,8 +49,7 @@ function hasMeaningfulChange(existing: MintQuote | null, incoming: MintQuote): b } if (isStatefulMintQuote(existing) && isStatefulMintQuote(incoming)) { - // Until #387, non-terminal BOLT11 state changes still alter canonical claimability. - return !existing.amount.equals(incoming.amount) || existing.state !== incoming.state; + return !existing.amount.equals(incoming.amount); } if (existing.method === 'bolt12' && incoming.method === 'bolt12') { @@ -72,13 +69,19 @@ export function resolveMintQuoteObservation( ): MintQuoteObservationResolution { if (incoming.amountIssued.greaterThan(incoming.amountPaid)) { return { - resolvedQuote: existing ?? incoming, + resolvedQuote: existing ?? withCanonicalCompatibilityProjection(incoming), disposition: 'ignored-invalid-background', }; } + if (!existing || existing.method !== incoming.method || existing.quoteId !== incoming.quoteId) { + return { + resolvedQuote: withCanonicalCompatibilityProjection(incoming), + disposition: 'accepted-meaningful-change', + }; + } + if ( - existing && existing.remoteUpdatedAt !== null && incoming.remoteUpdatedAt !== null && incoming.remoteUpdatedAt < existing.remoteUpdatedAt @@ -90,7 +93,6 @@ export function resolveMintQuoteObservation( } if ( - existing && existing.remoteUpdatedAt !== null && incoming.remoteUpdatedAt !== null && incoming.remoteUpdatedAt === existing.remoteUpdatedAt && @@ -103,10 +105,13 @@ export function resolveMintQuoteObservation( }; } + // Once BOLT11 accounting has remote ordering, an unversioned compatibility observation + // must not replace it. Legacy `state` remains a projection rather than an authority. if ( - existing && - (hasAccountingComponentDecrease(existing, incoming) || - isMintQuoteStateDowngrade(existing, incoming)) + isStatefulMintQuote(existing) && + isStatefulMintQuote(incoming) && + existing.remoteUpdatedAt !== null && + incoming.remoteUpdatedAt === null ) { return { resolvedQuote: existing, @@ -114,7 +119,14 @@ export function resolveMintQuoteObservation( }; } - if (existing && (existing.remoteUpdatedAt === null || incoming.remoteUpdatedAt === null)) { + if (hasAccountingComponentDecrease(existing, incoming)) { + return { + resolvedQuote: existing, + disposition: 'ignored-stale', + }; + } + + if (existing.remoteUpdatedAt === null || incoming.remoteUpdatedAt === null) { const hasNewerAccounting = incoming.amountPaid .add(incoming.amountIssued) .greaterThan(existing.amountPaid.add(existing.amountIssued)); @@ -126,14 +138,15 @@ export function resolveMintQuoteObservation( } } - const resolvedQuote = - existing && existing.remoteUpdatedAt !== null && incoming.remoteUpdatedAt === null + const resolvedQuote = withCanonicalCompatibilityProjection( + existing.remoteUpdatedAt !== null && incoming.remoteUpdatedAt === null ? { ...incoming, remoteUpdatedAt: existing.remoteUpdatedAt } - : incoming; + : incoming, + ); if (hasMeaningfulChange(existing, resolvedQuote)) { return { resolvedQuote, disposition: 'accepted-meaningful-change' }; } - if (existing?.remoteUpdatedAt === resolvedQuote.remoteUpdatedAt) { + if (existing.remoteUpdatedAt === resolvedQuote.remoteUpdatedAt) { return { resolvedQuote: existing, disposition: 'ignored-unchanged' }; } return { resolvedQuote, disposition: 'accepted-freshness-only' }; diff --git a/packages/core/quotes/QuoteLifecycle.ts b/packages/core/quotes/QuoteLifecycle.ts index 6ebb4d64..3975f623 100644 --- a/packages/core/quotes/QuoteLifecycle.ts +++ b/packages/core/quotes/QuoteLifecycle.ts @@ -8,7 +8,10 @@ import type { MeltHandlerProvider } from '../infra/handlers/melt'; import type { MintHandlerProvider } from '../infra/handlers/mint'; import type { Logger } from '../logging/Logger'; import { + applyBolt11MintQuoteStateFallback, + deriveBolt11MintQuoteState, getMintQuoteAmount, + isBolt11MintQuoteIssued, mintQuoteFromBolt11Response, mintQuoteFromBolt12Response, mintQuoteFromOnchainResponse, @@ -519,7 +522,6 @@ export class QuoteLifecycle { (await this.mintQuoteRepository.getMintQuote(mintUrl, method, quote.quoteId)) ?? quote; this.logger?.info('Mint quote created', { mintUrl: persistedQuote.mintUrl, - quoteId: persistedQuote.quoteId, method, amount: getMintQuoteAmount(persistedQuote)?.toString(), unit: persistedQuote.unit, @@ -1077,13 +1079,7 @@ export class QuoteLifecycle { ); } - const state = - bolt11Quote.state ?? - (amountPaid.isZero() && amountIssued.isZero() - ? 'UNPAID' - : amountPaid.greaterThan(amountIssued) - ? 'PAID' - : 'ISSUED'); + const state = deriveBolt11MintQuoteState(amountPaid, amountIssued); return { ...bolt11Quote, @@ -1245,7 +1241,6 @@ export class QuoteLifecycle { this.logger?.warn(message, { mintUrl: incoming.mintUrl, method: incoming.method, - quoteId: incoming.quoteId, existingRemoteUpdatedAt: existing?.remoteUpdatedAt ?? null, incomingRemoteUpdatedAt: incoming.remoteUpdatedAt, existingAmountPaid: existing?.amountPaid.toString(), @@ -1283,16 +1278,12 @@ export class QuoteLifecycle { `Cannot record quote observation: mint quote ${operation.quoteId} for ${operation.method} at ${operation.mintUrl} was not found`, ); } - if (!isStatefulMintQuote(existing)) return existing; - - return { - ...existing, - state, - amountPaid: state === 'UNPAID' ? Amount.zero() : existing.amount, - amountIssued: state === 'ISSUED' ? existing.amount : Amount.zero(), - remoteUpdatedAt: null, - updatedAt: observedAt, - }; + if (!isStatefulMintQuote(existing)) { + throw new Error( + `Cannot record legacy quote state for ${operation.method} mint quote ${operation.quoteId}`, + ); + } + return applyBolt11MintQuoteStateFallback(existing, state, observedAt); }, ); await this.emitMintQuoteUpdatedIfNeeded(quote, remoteStateChanged); @@ -1550,7 +1541,7 @@ export class QuoteLifecycle { } private assertMintQuoteCanPrepare(quote: MintQuote, context: string): void { - if (isStatefulMintQuote(quote) && quote.state === 'ISSUED') { + if (isStatefulMintQuote(quote) && isBolt11MintQuoteIssued(quote)) { throw new Error(`Cannot prepare ${context}: quote is terminal`); } } diff --git a/packages/core/repositories/index.ts b/packages/core/repositories/index.ts index c15a7ff9..74f0276f 100644 --- a/packages/core/repositories/index.ts +++ b/packages/core/repositories/index.ts @@ -147,7 +147,9 @@ export interface MintQuoteRepository { upsertMintQuote(quote: MintQuote): Promise; /** - * Update state for the exact method-scoped mint quote row. + * Update canonical BOLT11 accounting from a legacy state observation. + * + * @deprecated Persist a canonical quote observation with `amountPaid` and `amountIssued`. */ setMintQuoteState( mintUrl: string, diff --git a/packages/core/repositories/memory/MemoryMintQuoteRepository.ts b/packages/core/repositories/memory/MemoryMintQuoteRepository.ts index 69574c70..4e5768cc 100644 --- a/packages/core/repositories/memory/MemoryMintQuoteRepository.ts +++ b/packages/core/repositories/memory/MemoryMintQuoteRepository.ts @@ -1,6 +1,11 @@ -import { Amount } from '@cashu/cashu-ts'; import { QuoteIdentityConflictError } from '@core/models/Error'; -import { isMintQuotePending, isStatefulMintQuote, type MintQuote } from '@core/models/MintQuote'; +import { + applyBolt11MintQuoteStateFallback, + deriveBolt11MintQuoteState, + isMintQuotePending, + isStatefulMintQuote, + type MintQuote, +} from '@core/models/MintQuote'; import type { QuoteIdentity } from '@core/models/QuoteIdentity'; import type { MintMethodRemoteState } from '@core/operations/mint/MintMethodHandler'; import type { MintQuoteRepository } from '..'; @@ -53,8 +58,14 @@ export class MemoryMintQuoteRepository implements MintQuoteRepository { } const existing = await this.getMintQuote(normalizedMintUrl, quote.method, quote.quoteId); const key = this.makeKey(normalizedMintUrl, quote.method, quote.quoteId); + const canonicalQuote = isStatefulMintQuote(quote) + ? { + ...quote, + state: deriveBolt11MintQuoteState(quote.amountPaid, quote.amountIssued), + } + : quote; this.quotes.set(key, { - ...quote, + ...canonicalQuote, mintUrl: normalizedMintUrl, quote: quote.quoteId, createdAt: existing?.createdAt ?? quote.createdAt, @@ -73,13 +84,7 @@ export class MemoryMintQuoteRepository implements MintQuoteRepository { const existing = this.quotes.get(key); if (!existing) return; if (!isStatefulMintQuote(existing)) return; - this.quotes.set(key, { - ...existing, - state, - amountPaid: state === 'UNPAID' ? Amount.zero() : existing.amount, - amountIssued: state === 'ISSUED' ? existing.amount : Amount.zero(), - updatedAt: observedAt, - }); + this.quotes.set(key, applyBolt11MintQuoteStateFallback(existing, state, observedAt)); } async getPendingMintQuotes(method?: string): Promise { diff --git a/packages/core/services/watchers/MintOperationProcessor.ts b/packages/core/services/watchers/MintOperationProcessor.ts index c2225310..b2adb984 100644 --- a/packages/core/services/watchers/MintOperationProcessor.ts +++ b/packages/core/services/watchers/MintOperationProcessor.ts @@ -2,7 +2,7 @@ import type { EventBus, CoreEvents } from '@core/events'; import type { Logger } from '../../logging/Logger.ts'; import type { MintMethod, MintOperationService } from '@core/operations/mint'; import { MintOperationError, NetworkError } from '../../models/Error'; -import { getMintQuoteRemoteState } from '../../models/MintQuote.ts'; +import { isBolt11MintQuotePaid, isStatefulMintQuote } from '../../models/MintQuote.ts'; import type { QuoteLifecycle } from '../../quotes/QuoteLifecycle.ts'; interface QueueItem { @@ -106,7 +106,7 @@ export class MintOperationProcessor { return; } - if (getMintQuoteRemoteState(quote) !== 'PAID') { + if (!isStatefulMintQuote(quote) || !isBolt11MintQuotePaid(quote)) { return; } @@ -139,7 +139,7 @@ export class MintOperationProcessor { return; } - if (quote && getMintQuoteRemoteState(quote) === 'PAID') { + if (quote && isStatefulMintQuote(quote) && isBolt11MintQuotePaid(quote)) { this.enqueue(mintUrl, operation.id, operation.method); } }); @@ -307,7 +307,6 @@ export class MintOperationProcessor { this.logger?.debug('Reusable mint quote claim already in progress', { mintUrl, method, - quoteId, }); return; } @@ -324,7 +323,6 @@ export class MintOperationProcessor { this.logger?.debug('Reusable mint quote has no locally claimable balance', { mintUrl, method, - quoteId, }); return; } @@ -336,8 +334,7 @@ export class MintOperationProcessor { this.logger?.warn('Failed to check or claim reusable mint quote', { mintUrl, method, - quoteId, - error: error instanceof Error ? error.message : String(error), + errorName: error instanceof Error ? error.name : typeof error, }); } finally { this.claimingQuotes.delete(key); diff --git a/packages/core/services/watchers/MintOperationWatcherService.ts b/packages/core/services/watchers/MintOperationWatcherService.ts index 8eca69c0..54af1cc3 100644 --- a/packages/core/services/watchers/MintOperationWatcherService.ts +++ b/packages/core/services/watchers/MintOperationWatcherService.ts @@ -1,3 +1,4 @@ +import { Amount } from '@cashu/cashu-ts'; import type { EventBus, CoreEvents } from '@core/events'; import type { Logger } from '../../logging/Logger.ts'; import type { SubscriptionManager, UnsubscribeHandler } from '@core/infra/SubscriptionManager.ts'; @@ -26,14 +27,22 @@ interface MintQuoteWatchPolicy { keepWatchingWithoutOperationInterest?: boolean; } +function hasBolt11CompletedIssuance(payload: MintMethodQuoteSnapshot<'bolt11'>): boolean { + if (payload.amount_paid === undefined || payload.amount_issued === undefined) return false; + const amount = Amount.from(payload.amount); + const amountIssued = Amount.from(payload.amount_issued); + return amountIssued.greaterThanOrEqual(amount); +} + const mintQuoteWatchPolicies: { [M in MintMethod]?: MintQuoteWatchPolicy; } = { bolt11: { subscriptionKind: 'bolt11_mint_quote', getPayloadQuoteId: (payload) => payload.quote, - shouldRecordPayload: (payload) => payload.state === 'PAID' || payload.state === 'ISSUED', - shouldStopWatching: (payload) => payload.state === 'ISSUED', + shouldRecordPayload: (payload) => + payload.amount_paid !== undefined && payload.amount_issued !== undefined, + shouldStopWatching: hasBolt11CompletedIssuance, }, onchain: { subscriptionKind: 'onchain_mint_quote', @@ -141,8 +150,7 @@ export class MintOperationWatcherService { this.logger?.error('Failed to start watching pending mint operation', { operationId: operation.id, mintUrl: operation.mintUrl, - quoteId: operation.quoteId, - err, + errorName: err instanceof Error ? err.name : typeof err, }); } }); @@ -163,8 +171,8 @@ export class MintOperationWatcherService { } catch (err) { this.logger?.error('Failed to start watching canonical mint quote', { mintUrl: quote.mintUrl, - quoteId: quote.quoteId, - err, + method: quote.method, + errorName: err instanceof Error ? err.name : typeof err, }); } }); @@ -537,9 +545,8 @@ export class MintOperationWatcherService { } catch (err) { this.logger?.error('Failed to persist mint quote update from remote update', { mintUrl, - quoteId, method: record.method, - err, + errorName: err instanceof Error ? err.name : typeof err, }); } } @@ -577,7 +584,11 @@ export class MintOperationWatcherService { try { await record.stop?.(); } catch (err) { - this.logger?.warn('Unsubscribe watcher failed', { key, err }); + this.logger?.warn('Unsubscribe watcher failed', { + mintUrl: record.mintUrl, + method: record.method, + errorName: err instanceof Error ? err.name : typeof err, + }); } finally { this.removeWatchRecord(key); } diff --git a/packages/core/test/unit/MemoryQuoteRepository.test.ts b/packages/core/test/unit/MemoryQuoteRepository.test.ts index 1c851d3c..75a0d0ee 100644 --- a/packages/core/test/unit/MemoryQuoteRepository.test.ts +++ b/packages/core/test/unit/MemoryQuoteRepository.test.ts @@ -102,6 +102,25 @@ describe('memory quote repositories', () => { ); }); + it('does not let a legacy BOLT11 state fallback regress canonical accounting', async () => { + const repository = new MemoryMintQuoteRepository(); + const quote = makeMintQuote({ + state: 'PAID', + amountPaid: Amount.from(1), + amountIssued: Amount.zero(), + }); + await repository.upsertMintQuote(quote); + + await repository.setMintQuoteState(quote.mintUrl, quote.method, quote.quoteId, 'UNPAID', 10); + const stored = await repository.getMintQuote(quote.mintUrl, quote.method, quote.quoteId); + + expect(stored?.method).toBe('bolt11'); + if (stored?.method !== 'bolt11') throw new Error('Expected BOLT11 quote'); + expect(stored.amountPaid.equals(Amount.from(1))).toBe(true); + expect(stored.amountIssued.isZero()).toBe(true); + expect(stored.state).toBe('PAID'); + }); + it('looks up melt quotes by canonical identity and rejects method collisions', async () => { const repository = new MemoryMeltQuoteRepository(); await repository.upsertMeltQuote(makeMeltQuote({ quoteId: 'identity-melt' })); diff --git a/packages/core/test/unit/MintOperationService.test.ts b/packages/core/test/unit/MintOperationService.test.ts index 76632879..25b3b5a8 100644 --- a/packages/core/test/unit/MintOperationService.test.ts +++ b/packages/core/test/unit/MintOperationService.test.ts @@ -933,7 +933,10 @@ describe('MintOperationService', () => { expect(quoteUpdatedEvents).toHaveLength(0); expect(logger.warn).toHaveBeenCalledWith( 'Ignoring Mint Quote Observation with invalid accounting', - expect.objectContaining({ quoteId: onchainQuoteId }), + expect.objectContaining({ mintUrl, method: 'onchain' }), + ); + expect(JSON.stringify((logger.warn as Mock).mock.calls[0]?.[1])).not.toContain( + onchainQuoteId, ); }); @@ -1135,11 +1138,13 @@ describe('MintOperationService', () => { expect.objectContaining({ mintUrl, method: 'onchain', - quoteId: onchainQuoteId, existingAmountPaid: '10', incomingAmountPaid: '11', }), ); + expect(JSON.stringify((logger.warn as Mock).mock.calls[0]?.[1])).not.toContain( + onchainQuoteId, + ); }); it('recordMintQuoteSnapshot accepts a monotonic accounting increase without losing freshness', async () => { @@ -1249,11 +1254,13 @@ describe('MintOperationService', () => { expect.objectContaining({ mintUrl, method: 'onchain', - quoteId: onchainQuoteId, incomingAmountPaid: '9', incomingAmountIssued: '11', }), ); + expect(JSON.stringify((logger.warn as Mock).mock.calls[0]?.[1])).not.toContain( + onchainQuoteId, + ); }); it('recordMintQuoteSnapshot persists timestamp-only freshness without emitting', async () => { @@ -2625,7 +2632,7 @@ describe('MintOperationService', () => { expect(persistedDuringEvent).toEqual(['PAID']); }); - it('recordQuoteObservation applies compatibility state to the latest canonical quote', async () => { + it('recordQuoteObservation cannot override newer ordered accounting', async () => { const initialExpiry = Math.floor(Date.now() / 1000) + 3600; const newerExpiry = initialExpiry + 3600; await quoteRepo.upsertMintQuote( @@ -2679,8 +2686,8 @@ describe('MintOperationService', () => { await Promise.all([newerSnapshot, compatibilityObservation]); const stored = await quoteRepo.getMintQuote(mintUrl, 'bolt11', quoteId); - expect(stored?.state).toBe('ISSUED'); - expect(stored?.amountIssued.toString()).toBe('10'); + expect(stored?.state).toBe('PAID'); + expect(stored?.amountIssued.toString()).toBe('0'); expect(stored?.request).toBe('lnbc1newer'); expect(stored?.expiry).toBe(newerExpiry); expect(stored?.remoteUpdatedAt).toBe(21); diff --git a/packages/core/test/unit/MintOperationWatcherService.test.ts b/packages/core/test/unit/MintOperationWatcherService.test.ts index 32e92fbe..f16da17e 100644 --- a/packages/core/test/unit/MintOperationWatcherService.test.ts +++ b/packages/core/test/unit/MintOperationWatcherService.test.ts @@ -364,7 +364,7 @@ describe('MintOperationWatcherService', () => { await watcher.stop(); }); - it('records PAID subscription updates without re-checking the quote remotely', async () => { + it('records paid accounting even when the compatibility state contradicts it', async () => { const operation = makePendingOperation(); const observePendingOperation = mock(async () => { throw new Error('should not re-check'); @@ -381,8 +381,8 @@ describe('MintOperationWatcherService', () => { expiry: quote.expiry, state: quote.state, reusable: false as const, - amountPaid: quote.state === 'UNPAID' ? Amount.zero() : quote.amount, - amountIssued: quote.state === 'ISSUED' ? quote.amount : Amount.zero(), + amountPaid: Amount.from(quote.amount_paid), + amountIssued: Amount.from(quote.amount_issued), remoteUpdatedAt: quote.updated_at, quoteData: { amount: quote.amount, @@ -424,7 +424,10 @@ describe('MintOperationWatcherService', () => { amount: operation.amount, unit: operation.unit, expiry: operation.expiry, - state: 'PAID', + state: 'UNPAID', + amount_paid: operation.amount, + amount_issued: Amount.zero(), + updated_at: 20, }); expect(getOperation).not.toHaveBeenCalled(); @@ -432,7 +435,12 @@ describe('MintOperationWatcherService', () => { expect(recordMintQuoteSnapshot).toHaveBeenCalledWith( mintUrl, 'bolt11', - expect.objectContaining({ quote: quoteId, state: 'PAID' }), + expect.objectContaining({ + quote: quoteId, + state: 'UNPAID', + amount_paid: operation.amount, + amount_issued: Amount.zero(), + }), ); expect(unsubscribe).not.toHaveBeenCalled(); @@ -476,7 +484,7 @@ describe('MintOperationWatcherService', () => { await watcher.stop(); }); - it('records ISSUED subscription updates and stops watching the operation', async () => { + it('stops on issued accounting even when the compatibility state contradicts it', async () => { const operation = makePendingOperation(); const recordMintQuoteSnapshot = mock( async (_mintUrl: string, _method: string, quote: MintQuoteBolt11Response) => ({ @@ -490,8 +498,8 @@ describe('MintOperationWatcherService', () => { expiry: quote.expiry, state: quote.state, reusable: false as const, - amountPaid: quote.state === 'UNPAID' ? Amount.zero() : quote.amount, - amountIssued: quote.state === 'ISSUED' ? quote.amount : Amount.zero(), + amountPaid: Amount.from(quote.amount_paid), + amountIssued: Amount.from(quote.amount_issued), remoteUpdatedAt: quote.updated_at, quoteData: { amount: quote.amount, @@ -528,13 +536,21 @@ describe('MintOperationWatcherService', () => { amount: operation.amount, unit: operation.unit, expiry: operation.expiry, - state: 'ISSUED', + state: 'PAID', + amount_paid: operation.amount, + amount_issued: operation.amount, + updated_at: 21, }); expect(recordMintQuoteSnapshot).toHaveBeenCalledWith( mintUrl, 'bolt11', - expect.objectContaining({ quote: quoteId, state: 'ISSUED' }), + expect.objectContaining({ + quote: quoteId, + state: 'PAID', + amount_paid: operation.amount, + amount_issued: operation.amount, + }), ); expect(unsubscribe).toHaveBeenCalledTimes(1); diff --git a/packages/core/test/unit/MintQuote.test.ts b/packages/core/test/unit/MintQuote.test.ts index 1e9d7d92..165fe372 100644 --- a/packages/core/test/unit/MintQuote.test.ts +++ b/packages/core/test/unit/MintQuote.test.ts @@ -6,9 +6,18 @@ import { import { describe, expect, it } from 'bun:test'; import { + applyBolt11MintQuoteStateFallback, + deriveBolt11MintQuoteState, + getMintQuoteAvailableAmount, getMintQuoteAmount, + getMintQuoteRemoteState, + isBolt11MintQuoteIssued, + isBolt11MintQuotePaid, + isBolt11MintQuoteUnpaid, + isMintQuotePending, mintQuoteFromBolt11Response, mintQuoteFromBolt12Response, + mintQuoteToMethodSnapshot, } from '../../models/MintQuote'; describe('MintQuote model', () => { @@ -33,6 +42,126 @@ describe('MintQuote model', () => { expect(quote.remoteUpdatedAt).toBe(null); }); + it('uses BOLT11 accounting instead of the deprecated state projection', () => { + const quote = mintQuoteFromBolt11Response('https://mint.test', { + quote: 'quote-accounting', + request: 'lnbc...', + method: 'bolt11', + amount: Amount.from(100), + unit: 'sat', + expiry: 123, + state: 'UNPAID', + amount_paid: Amount.from(100), + amount_issued: Amount.from(40), + updated_at: 55, + } satisfies MintQuoteBolt11Response); + + expect(quote.state).toBe('PAID'); + expect(quote.amountPaid.equals(Amount.from(100))).toBe(true); + expect(quote.amountIssued.equals(Amount.from(40))).toBe(true); + expect(quote.remoteUpdatedAt).toBe(55); + expect(getMintQuoteAvailableAmount(quote).equals(Amount.from(60))).toBe(true); + expect(isMintQuotePending(quote)).toBe(true); + }); + + it('rejects contradictory BOLT11 accounting', () => { + expect(() => + mintQuoteFromBolt11Response('https://mint.test', { + quote: 'quote-invalid-accounting', + request: 'lnbc...', + method: 'bolt11', + amount: Amount.from(100), + unit: 'sat', + expiry: 123, + state: 'PAID', + amount_paid: Amount.from(100), + amount_issued: Amount.from(101), + updated_at: 55, + } satisfies MintQuoteBolt11Response), + ).toThrow('amount_issued greater than amount_paid'); + }); + + it('uses canonical predicates for ready and terminal BOLT11 quotes', () => { + const paid = mintQuoteFromBolt11Response('https://mint.test', { + quote: 'quote-paid', + request: 'lnbc...', + method: 'bolt11', + amount: Amount.from(100), + unit: 'sat', + expiry: 123, + amount_paid: Amount.from(100), + amount_issued: Amount.zero(), + updated_at: 42, + state: 'ISSUED', + } satisfies MintQuoteBolt11Response); + const issued = { + ...paid, + amountIssued: Amount.from(100), + state: 'PAID' as const, + }; + + expect(deriveBolt11MintQuoteState(paid.amountPaid, paid.amountIssued)).toBe('PAID'); + expect(getMintQuoteRemoteState({ ...paid, state: 'ISSUED' })).toBe('PAID'); + expect(isBolt11MintQuoteUnpaid(paid)).toBe(false); + expect(isBolt11MintQuotePaid(paid)).toBe(true); + expect(isBolt11MintQuoteIssued(paid)).toBe(false); + expect(mintQuoteToMethodSnapshot<'bolt11'>(paid).state).toBe('PAID'); + + expect(getMintQuoteRemoteState(issued)).toBe('ISSUED'); + expect(isBolt11MintQuotePaid(issued)).toBe(false); + expect(isBolt11MintQuoteIssued(issued)).toBe(true); + expect(isMintQuotePending(issued)).toBe(false); + }); + + it('does not treat partial accounting as ready or terminal', () => { + const partiallyPaid = mintQuoteFromBolt11Response('https://mint.test', { + quote: 'quote-partially-paid', + request: 'lnbc...', + method: 'bolt11', + amount: Amount.from(100), + unit: 'sat', + expiry: 123, + amount_paid: Amount.from(50), + amount_issued: Amount.zero(), + updated_at: 44, + state: 'PAID', + } satisfies MintQuoteBolt11Response); + const partiallyIssued = { + ...partiallyPaid, + amountIssued: Amount.from(50), + state: 'ISSUED' as const, + }; + + expect(isBolt11MintQuotePaid(partiallyPaid)).toBe(false); + expect(isMintQuotePending(partiallyPaid)).toBe(true); + expect(isBolt11MintQuoteIssued(partiallyIssued)).toBe(false); + expect(isMintQuotePending(partiallyIssued)).toBe(true); + }); + + it('does not let a legacy state replace ordered or partial accounting', () => { + const ordered = mintQuoteFromBolt11Response('https://mint.test', { + quote: 'quote-ordered', + request: 'lnbc...', + method: 'bolt11', + amount: Amount.from(100), + unit: 'sat', + expiry: 123, + amount_paid: Amount.from(50), + amount_issued: Amount.zero(), + updated_at: 44, + state: 'PAID', + } satisfies MintQuoteBolt11Response); + const unordered = { ...ordered, remoteUpdatedAt: null }; + + const orderedResult = applyBolt11MintQuoteStateFallback(ordered, 'ISSUED', 45); + const unorderedResult = applyBolt11MintQuoteStateFallback(unordered, 'ISSUED', 45); + + expect(orderedResult.amountIssued.isZero()).toBe(true); + expect(orderedResult.remoteUpdatedAt).toBe(44); + expect(unorderedResult.amountPaid.equals(Amount.from(50))).toBe(true); + expect(unorderedResult.amountIssued.isZero()).toBe(true); + }); + it('keeps BOLT12 offer amounts separate from mint operation amounts', () => { const quote = mintQuoteFromBolt12Response('https://mint.test', { quote: 'quote-1', diff --git a/packages/core/test/unit/MintQuoteObservation.test.ts b/packages/core/test/unit/MintQuoteObservation.test.ts index 8d849105..57ae5915 100644 --- a/packages/core/test/unit/MintQuoteObservation.test.ts +++ b/packages/core/test/unit/MintQuoteObservation.test.ts @@ -11,7 +11,7 @@ describe('resolveMintQuoteObservation', () => { const mintUrl = 'https://mint.test'; const expiry = Math.floor(Date.now() / 1000) + 3600; - it('classifies a forward BOLT11 state transition as meaningful', () => { + it('treats a compatibility-only BOLT11 state transition as freshness-only', () => { const existing = mintQuoteFromBolt11Fixture(mintUrl, { quote: 'bolt11-state-change', request: 'lnbc1test', @@ -29,8 +29,102 @@ describe('resolveMintQuoteObservation', () => { const resolution = resolveMintQuoteObservation(existing, incoming); + expect(resolution.disposition).toBe('accepted-freshness-only'); + expect(resolution.resolvedQuote.state).toBe('PAID'); + expect(resolution.resolvedQuote.remoteUpdatedAt).toBe(21); + }); + + it('ignores BOLT11 observations older than the stored remote update', () => { + const existing = mintQuoteFromBolt11Fixture(mintUrl, { + quote: 'bolt11-stale', + request: 'lnbc1test', + amount: Amount.from(10), + unit: 'sat', + expiry, + amount_paid: Amount.from(10), + amount_issued: Amount.zero(), + updated_at: 20, + }); + const incoming = { + ...existing, + amountIssued: Amount.from(10), + remoteUpdatedAt: 19, + }; + + const resolution = resolveMintQuoteObservation(existing, incoming); + + expect(resolution.disposition).toBe('ignored-stale'); + expect(resolution.resolvedQuote).toBe(existing); + }); + + it('ignores conflicting accounting at the same remote update', () => { + const existing = mintQuoteFromBolt11Fixture(mintUrl, { + quote: 'bolt11-conflict', + request: 'lnbc1test', + amount: Amount.from(10), + unit: 'sat', + expiry, + amount_paid: Amount.from(10), + amount_issued: Amount.zero(), + updated_at: 20, + }); + const incoming = { + ...existing, + amountIssued: Amount.from(5), + }; + + const resolution = resolveMintQuoteObservation(existing, incoming); + + expect(resolution.disposition).toBe('ignored-conflicting-accounting'); + expect(resolution.resolvedQuote).toBe(existing); + }); + + it('ignores accounting component regressions even with a newer update', () => { + const existing = mintQuoteFromBolt11Fixture(mintUrl, { + quote: 'bolt11-regression', + request: 'lnbc1test', + amount: Amount.from(10), + unit: 'sat', + expiry, + amount_paid: Amount.from(10), + amount_issued: Amount.from(5), + updated_at: 20, + }); + const incoming = { + ...existing, + amountIssued: Amount.zero(), + remoteUpdatedAt: 21, + }; + + const resolution = resolveMintQuoteObservation(existing, incoming); + + expect(resolution.disposition).toBe('ignored-stale'); + expect(resolution.resolvedQuote).toBe(existing); + }); + + it('accepts monotonic accounting at a newer remote update', () => { + const existing = mintQuoteFromBolt11Fixture(mintUrl, { + quote: 'bolt11-forward', + request: 'lnbc1test', + amount: Amount.from(10), + unit: 'sat', + expiry, + amount_paid: Amount.from(10), + amount_issued: Amount.zero(), + updated_at: 20, + }); + const incoming = { + ...existing, + amountIssued: Amount.from(10), + remoteUpdatedAt: 21, + state: 'UNPAID' as const, + }; + + const resolution = resolveMintQuoteObservation(existing, incoming); + expect(resolution.disposition).toBe('accepted-meaningful-change'); - expect(resolution.resolvedQuote).toBe(incoming); + expect(resolution.resolvedQuote.state).toBe('ISSUED'); + expect(resolution.resolvedQuote.amountIssued.equals(Amount.from(10))).toBe(true); }); it('classifies freshness-only changes for amountless BOLT12 quotes', () => { diff --git a/packages/core/test/unit/MintQuoteProcessor.test.ts b/packages/core/test/unit/MintQuoteProcessor.test.ts index 219db46a..6311d37c 100644 --- a/packages/core/test/unit/MintQuoteProcessor.test.ts +++ b/packages/core/test/unit/MintQuoteProcessor.test.ts @@ -1,3 +1,4 @@ +import { Amount } from '@cashu/cashu-ts'; import { describe, it, beforeEach, afterEach, expect } from 'bun:test'; import { MintOperationProcessor } from '../../services/watchers/MintOperationProcessor'; import { EventBus } from '../../events/EventBus'; @@ -21,6 +22,26 @@ describe('MintOperationProcessor', () => { const TEST_RETRY_DELAY = 100; const TEST_INITIAL_DELAY = 10; + const makeBolt11Quote = (quoteId: string, paid: boolean) => ({ + mintUrl: 'https://mint.test', + method: 'bolt11' as const, + quoteId, + quote: quoteId, + request: 'lnbc1test', + amount: Amount.from(10), + unit: 'sat', + expiry: null, + // Deliberately contradictory: processor decisions must use canonical accounting. + state: paid ? ('UNPAID' as const) : ('PAID' as const), + reusable: false as const, + amountPaid: paid ? Amount.from(10) : Amount.zero(), + amountIssued: Amount.zero(), + remoteUpdatedAt: null, + quoteData: { amount: Amount.from(10) }, + createdAt: 0, + updatedAt: 0, + }); + beforeEach(() => { bus = new EventBus(); finalizeCalls = []; @@ -56,19 +77,7 @@ describe('MintOperationProcessor', () => { mockQuoteLifecycle = { async getMintQuote() { - return { - mintUrl: 'https://mint.test', - method: 'bolt11', - quoteId: 'quote-2', - quote: 'quote-2', - request: 'lnbc1test', - amount: 10, - unit: 'sat', - expiry: null, - state: 'PAID', - reusable: false, - quoteData: { amount: 10 }, - } as any; + return makeBolt11Quote('quote-2', true); }, } as unknown as QuoteLifecycle; @@ -109,13 +118,7 @@ describe('MintOperationProcessor', () => { mintUrl: 'https://mint.test', method: 'bolt11', quoteId: 'quote-1', - quote: { - mintUrl: 'https://mint.test', - method: 'bolt11', - quoteId: 'quote-1', - quote: 'quote-1', - state: 'PAID', - } as any, + quote: makeBolt11Quote('quote-1', true), }); await sleep(TEST_PROCESS_INTERVAL * 2 + 50); @@ -165,13 +168,7 @@ describe('MintOperationProcessor', () => { mintUrl: 'https://mint.test', method: 'bolt11', quoteId: 'shared-quote', - quote: { - mintUrl: 'https://mint.test', - method: 'bolt11', - quoteId: 'shared-quote', - quote: 'shared-quote', - state: 'PAID', - } as any, + quote: makeBolt11Quote('shared-quote', true), }); await sleep(TEST_PROCESS_INTERVAL * 2 + 50); @@ -369,13 +366,7 @@ describe('MintOperationProcessor', () => { mintUrl: 'https://mint.test', method: 'bolt11', quoteId: 'quote-4', - quote: { - mintUrl: 'https://mint.test', - method: 'bolt11', - quoteId: 'quote-4', - quote: 'quote-4', - state: 'UNPAID', - } as any, + quote: makeBolt11Quote('quote-4', false), }); await sleep(TEST_PROCESS_INTERVAL + 20); @@ -391,13 +382,7 @@ describe('MintOperationProcessor', () => { mintUrl: 'https://mint.test', method: 'bolt11', quoteId: 'quote-5', - quote: { - mintUrl: 'https://mint.test', - method: 'bolt11', - quoteId: 'quote-5', - quote: 'quote-5', - state: 'PAID', - } as any, + quote: makeBolt11Quote('quote-5', true), }); } diff --git a/packages/expo-sqlite/src/test/contract.test.ts b/packages/expo-sqlite/src/test/contract.test.ts index c3be2f04..2a427f81 100644 --- a/packages/expo-sqlite/src/test/contract.test.ts +++ b/packages/expo-sqlite/src/test/contract.test.ts @@ -5,6 +5,7 @@ import { runAuthSessionRepositoryContract, runProofRepositoryContract, runMintOperationRepositoryContract, + runMintQuoteRepositoryContract, runPaymentRequestReceiveRepositoryContract, runReceiveOperationRepositoryContract, runSendOperationRepositoryContract, @@ -164,6 +165,8 @@ runProofRepositoryContract({ createRepositories }, { describe, it, expect }); runMintOperationRepositoryContract({ createRepositories }, { describe, it, expect }); +runMintQuoteRepositoryContract({ createRepositories }, { describe, it, expect }); + runReceiveOperationRepositoryContract({ createRepositories }, { describe, it, expect }); runSendOperationRepositoryContract({ createRepositories }, { describe, it, expect }); diff --git a/packages/indexeddb/src/repositories/MintQuoteRepository.ts b/packages/indexeddb/src/repositories/MintQuoteRepository.ts index 2773a7f8..bbe17d69 100644 --- a/packages/indexeddb/src/repositories/MintQuoteRepository.ts +++ b/packages/indexeddb/src/repositories/MintQuoteRepository.ts @@ -1,6 +1,8 @@ import type { MintQuoteRepository } from '@cashu/coco-core/adapter'; import { + applyBolt11MintQuoteStateFallback, deserializeAmount, + deriveBolt11MintQuoteState, getMintQuoteAmount, getMintQuoteRemoteState, isMintQuotePending, @@ -61,12 +63,7 @@ function rowToMintQuote(row: MintQuoteRow): MintQuote { const amount = deserializeAmount(quoteData.amount ?? row.amount ?? 0); const amountPaid = deserializeAmount(row.amountPaid); const amountIssued = deserializeAmount(row.amountIssued); - const state = (row.state ?? - (amountPaid.isZero() && amountIssued.isZero() - ? 'UNPAID' - : amountPaid.greaterThan(amountIssued) - ? 'PAID' - : 'ISSUED')) as MintMethodRemoteState; + const state = deriveBolt11MintQuoteState(amountPaid, amountIssued); return { mintUrl: row.mintUrl, method: 'bolt11', @@ -188,13 +185,9 @@ export class IdbMintQuoteRepository implements MintQuoteRepository { .table('coco_cashu_canonical_mint_quotes') .get([normalizeMintUrl(mintUrl), method, quoteId])) as MintQuoteRow | undefined; if (!existing) return; - await (this.db as any).table('coco_cashu_canonical_mint_quotes').put({ - ...existing, - state, - amountPaid: state === 'UNPAID' ? '0' : existing.amount, - amountIssued: state === 'ISSUED' ? existing.amount : '0', - updatedAt: observedAt, - } as MintQuoteRow); + const quote = rowToMintQuote(existing); + if (!isStatefulMintQuote(quote)) return; + await this.upsertMintQuote(applyBolt11MintQuoteStateFallback(quote, state, observedAt)); } async getPendingMintQuotes(method?: string): Promise { diff --git a/packages/indexeddb/src/test/contract.test.ts b/packages/indexeddb/src/test/contract.test.ts index b08b9ac7..73c5ad97 100644 --- a/packages/indexeddb/src/test/contract.test.ts +++ b/packages/indexeddb/src/test/contract.test.ts @@ -5,6 +5,7 @@ import { runAuthSessionRepositoryContract, runProofRepositoryContract, runMintOperationRepositoryContract, + runMintQuoteRepositoryContract, runPaymentRequestReceiveRepositoryContract, runReceiveOperationRepositoryContract, runSendOperationRepositoryContract, @@ -48,6 +49,8 @@ runProofRepositoryContract({ createRepositories }, { describe, it, expect }); runMintOperationRepositoryContract({ createRepositories }, { describe, it, expect }); +runMintQuoteRepositoryContract({ createRepositories }, { describe, it, expect }); + runReceiveOperationRepositoryContract({ createRepositories }, { describe, it, expect }); runSendOperationRepositoryContract({ createRepositories }, { describe, it, expect }); diff --git a/packages/sql-storage/src/repositories/MintQuoteRepository.ts b/packages/sql-storage/src/repositories/MintQuoteRepository.ts index adbc75eb..9a15b41a 100644 --- a/packages/sql-storage/src/repositories/MintQuoteRepository.ts +++ b/packages/sql-storage/src/repositories/MintQuoteRepository.ts @@ -1,5 +1,7 @@ import { + applyBolt11MintQuoteStateFallback, deserializeAmount, + deriveBolt11MintQuoteState, getMintQuoteAmount, getMintQuoteRemoteState, isMintQuotePending, @@ -80,12 +82,7 @@ function rowToMintQuote(row: MintQuoteRow): MintQuote { const amount = deserializeAmount(quoteData.amount ?? row.amount ?? 0); const amountPaid = deserializeAmount(row.amountPaid); const amountIssued = deserializeAmount(row.amountIssued); - const state = (row.state ?? - (amountPaid.isZero() && amountIssued.isZero() - ? 'UNPAID' - : amountPaid.greaterThan(amountIssued) - ? 'PAID' - : 'ISSUED')) as MintMethodRemoteState; + const state = deriveBolt11MintQuoteState(amountPaid, amountIssued); return { mintUrl: row.mintUrl, method: 'bolt11', @@ -229,15 +226,9 @@ export class SqliteMintQuoteRepository implements MintQuoteRepository { state: MintMethodRemoteState, observedAt = Date.now(), ): Promise { - await this.db.run( - `UPDATE coco_cashu_canonical_mint_quotes - SET state = ?, - amountPaid = CASE WHEN ? IN ('PAID', 'ISSUED') THEN amount ELSE '0' END, - amountIssued = CASE WHEN ? = 'ISSUED' THEN amount ELSE '0' END, - updatedAt = ? - WHERE mintUrl = ? AND method = ? AND quoteId = ?`, - [state, state, state, observedAt, normalizeMintUrl(mintUrl), method, quoteId], - ); + const quote = await this.getMintQuote(mintUrl, method, quoteId); + if (!quote || !isStatefulMintQuote(quote)) return; + await this.upsertMintQuote(applyBolt11MintQuoteStateFallback(quote, state, observedAt)); } async getPendingMintQuotes(method?: string): Promise { @@ -246,7 +237,7 @@ export class SqliteMintQuoteRepository implements MintQuoteRepository { quoteDataJson, amountPaid, amountIssued, remoteUpdatedAt, reusable, createdAt, updatedAt FROM coco_cashu_canonical_mint_quotes - WHERE (state IS NULL OR state != 'ISSUED') ${method ? 'AND method = ?' : ''}`, + ${method ? 'WHERE method = ?' : ''}`, method ? [method] : [], ); return rows.map(rowToMintQuote).filter(isMintQuotePending); diff --git a/packages/sqlite-bun/src/test/contract.test.ts b/packages/sqlite-bun/src/test/contract.test.ts index 3462dee2..bfa168e4 100644 --- a/packages/sqlite-bun/src/test/contract.test.ts +++ b/packages/sqlite-bun/src/test/contract.test.ts @@ -5,6 +5,7 @@ import { runAuthSessionRepositoryContract, runProofRepositoryContract, runMintOperationRepositoryContract, + runMintQuoteRepositoryContract, runPaymentRequestReceiveRepositoryContract, runReceiveOperationRepositoryContract, runSendOperationRepositoryContract, @@ -56,6 +57,8 @@ runProofRepositoryContract({ createRepositories }, { describe, it, expect }); runMintOperationRepositoryContract({ createRepositories }, { describe, it, expect }); +runMintQuoteRepositoryContract({ createRepositories }, { describe, it, expect }); + runReceiveOperationRepositoryContract({ createRepositories }, { describe, it, expect }); runSendOperationRepositoryContract({ createRepositories }, { describe, it, expect }); diff --git a/packages/sqlite3/src/test/contract.test.ts b/packages/sqlite3/src/test/contract.test.ts index 92579dc3..8d5f69a6 100644 --- a/packages/sqlite3/src/test/contract.test.ts +++ b/packages/sqlite3/src/test/contract.test.ts @@ -5,6 +5,7 @@ import { runAuthSessionRepositoryContract, runProofRepositoryContract, runMintOperationRepositoryContract, + runMintQuoteRepositoryContract, runPaymentRequestReceiveRepositoryContract, runReceiveOperationRepositoryContract, runSendOperationRepositoryContract, @@ -56,6 +57,8 @@ runProofRepositoryContract({ createRepositories }, { describe, it, expect }); runMintOperationRepositoryContract({ createRepositories }, { describe, it, expect }); +runMintQuoteRepositoryContract({ createRepositories }, { describe, it, expect }); + runReceiveOperationRepositoryContract({ createRepositories }, { describe, it, expect }); runSendOperationRepositoryContract({ createRepositories }, { describe, it, expect }); From 5002d30c8698ec3c7071964ffa7e084ec90f364b Mon Sep 17 00:00:00 2001 From: IgboPharaoh Date: Wed, 29 Jul 2026 20:28:33 +0100 Subject: [PATCH 2/5] feat(core): add locked BOLT11 mint quote support --- packages/core/Manager.ts | 6 +- packages/core/api/QuoteApi.ts | 3 + .../infra/handlers/mint/MintBolt11Handler.ts | 183 +++++++++++++----- .../core/operations/mint/MintMethodHandler.ts | 4 +- packages/core/services/MintService.ts | 29 ++- .../core/test/unit/MintBolt11Handler.test.ts | 181 ++++++++++++++++- packages/core/test/unit/MintService.test.ts | 12 ++ packages/core/test/unit/QuoteApi.test.ts | 16 ++ 8 files changed, 367 insertions(+), 67 deletions(-) diff --git a/packages/core/Manager.ts b/packages/core/Manager.ts index 99d987f8..60b485ea 100644 --- a/packages/core/Manager.ts +++ b/packages/core/Manager.ts @@ -638,7 +638,6 @@ export class Manager { if (!trusted) { this.logger.debug('Skipping legacy mint quote reconciliation for untrusted mint', { mintUrl: quote.mintUrl, - quoteId: quote.quote, }); skipped.push(quote.quote); continue; @@ -670,8 +669,7 @@ export class Manager { } catch (err) { this.logger.warn('Failed to reconcile legacy mint quote', { mintUrl: quote.mintUrl, - quoteId: quote.quote, - err, + errorName: err instanceof Error ? err.name : typeof err, }); skipped.push(quote.quote); } @@ -969,7 +967,7 @@ export class Manager { onchain: new MeltOnchainHandler(), }); const mintHandlerProvider = new MintHandlerProvider({ - bolt11: new MintBolt11Handler(), + bolt11: new MintBolt11Handler(keyRingService), onchain: new MintOnchainHandler(keyRingService), bolt12: new MintBolt12Handler(keyRingService), }); diff --git a/packages/core/api/QuoteApi.ts b/packages/core/api/QuoteApi.ts index 5aaff2a4..1a077273 100644 --- a/packages/core/api/QuoteApi.ts +++ b/packages/core/api/QuoteApi.ts @@ -15,6 +15,8 @@ export type CreateMintQuoteInput = method: 'bolt11'; amount: UnitAmountLike; unit?: string; + /** Create a NUT-20 quote locked to a fresh, persisted wallet key. */ + locked?: boolean; } | { mintUrl: string; @@ -65,6 +67,7 @@ export class MintQuoteApi { const parsed = parseUnitAmount(input.amount, { explicitUnit: input.unit }); return this.quoteLifecycle.createMintQuote(input.mintUrl, input.method, { amount: parsed, + ...(input.locked === true ? { locked: true } : {}), }); } diff --git a/packages/core/infra/handlers/mint/MintBolt11Handler.ts b/packages/core/infra/handlers/mint/MintBolt11Handler.ts index 66522aeb..e759ceef 100644 --- a/packages/core/infra/handlers/mint/MintBolt11Handler.ts +++ b/packages/core/infra/handlers/mint/MintBolt11Handler.ts @@ -1,3 +1,6 @@ +import { Amount, type MintQuoteBolt11Response } from '@cashu/cashu-ts'; +import { bytesToHex } from '@noble/curves/utils.js'; +import { assertSameUnit } from '@core/amounts'; import type { CreateMintQuoteContext, ExecuteContext, @@ -12,16 +15,41 @@ import type { PendingMintCheckResult, FetchRemoteMintQuoteContext, } from '@core/operations/mint'; -import { MintOperationError } from '../../../models/Error'; -import { assertSameUnit } from '@core/amounts'; import { deserializeOutputData, mapProofToCoreProof, serializeOutputData } from '@core/utils'; -import { Amount, type MintQuoteBolt11Response } from '@cashu/cashu-ts'; -import { mintQuoteFromBolt11Response, type MintQuote } from '../../../models/MintQuote'; +import { MintOperationError } from '../../../models/Error'; +import type { KeyRingService } from '../../../services/KeyRingService'; +import { + deriveBolt11MintQuoteState, + isBolt11MintQuoteIssued, + isBolt11MintQuotePaid, + isBolt11MintQuoteUnpaid, + mintQuoteFromBolt11Response, + type MintQuote, +} from '../../../models/MintQuote'; import { mintQuoteObservationFromBolt11Response } from '../../../models/MintQuoteObservationFactory'; export class MintBolt11Handler implements MintMethodHandler<'bolt11'> { + constructor(private readonly keyRingService: KeyRingService) {} + async createQuote(ctx: CreateMintQuoteContext<'bolt11'>): Promise> { - const remoteQuote = await ctx.wallet.createMintQuoteBolt11(ctx.createQuoteData.amount.amount); + const { amount, locked, pubkey } = ctx.createQuoteData; + const shouldLock = locked === true || pubkey !== undefined; + if (shouldLock) { + await ctx.mintService.assertNutSupported(ctx.mintUrl, 20, 'locked BOLT11 mint quote'); + } + const lockPubkey = + shouldLock && !pubkey + ? (await this.keyRingService.generateMintQuoteKeyPair()).publicKeyHex + : pubkey; + if (lockPubkey && pubkey) { + await this.requireQuoteKey(lockPubkey); + } + const remoteQuote = lockPubkey + ? await ctx.wallet.createLockedMintQuote(amount.amount, lockPubkey) + : await ctx.wallet.createMintQuoteBolt11(amount.amount); + if (lockPubkey && remoteQuote.pubkey !== lockPubkey) { + throw new Error('Mint returned a BOLT11 quote with an unexpected NUT-20 public key'); + } return mintQuoteFromBolt11Response(ctx.mintUrl, remoteQuote); } @@ -39,7 +67,7 @@ export class MintBolt11Handler implements MintMethodHandler<'bolt11'> { ): Promise & MintMethodMeta<'bolt11'>> { const quote = ctx.importedQuote; if (!quote) { - throw new Error(`Mint quote ${ctx.operation.quoteId ?? '(missing)'} was not provided`); + throw new Error('BOLT11 mint quote was not provided'); } if (!quote.amount || quote.amount.isZero()) { @@ -47,9 +75,7 @@ export class MintBolt11Handler implements MintMethodHandler<'bolt11'> { } if (ctx.operation.quoteId !== quote.quote) { - throw new Error( - `Mint quote ${quote.quote} does not match operation quote ${ctx.operation.quoteId}`, - ); + throw new Error(`Mint quote ${quote.quote} does not match the operation quote`); } if (!quote.amount.equals(ctx.operation.amount)) { @@ -59,6 +85,7 @@ export class MintBolt11Handler implements MintMethodHandler<'bolt11'> { } assertSameUnit(quote.unit, ctx.operation.unit, `Mint quote ${quote.quote}`); + await this.requireQuoteKey(quote.pubkey); const outputData = await ctx.proofService.createOutputsAndIncrementCounters( ctx.operation.mintUrl, @@ -88,12 +115,13 @@ export class MintBolt11Handler implements MintMethodHandler<'bolt11'> { async execute(ctx: ExecuteContext<'bolt11'>): Promise { const outputData = deserializeOutputData(ctx.operation.outputData); + const mintConfig = await this.getMintConfig(ctx.operation.pubkey); try { const proofs = await ctx.wallet.mintProofsBolt11( ctx.operation.amount, ctx.operation.quoteId, - undefined, + mintConfig, { type: 'custom', data: outputData.keep, @@ -105,6 +133,14 @@ export class MintBolt11Handler implements MintMethodHandler<'bolt11'> { if (err instanceof MintOperationError && err.code === 20002) { return { status: 'ALREADY_ISSUED' }; } + if (ctx.operation.pubkey) { + const errorMessage = err instanceof Error ? err.message : String(err); + const message = `Locked BOLT11 mint failed: ${errorMessage}`; + if (err instanceof MintOperationError) { + throw new MintOperationError(err.code, message); + } + throw new Error(message); + } throw err; } } @@ -117,22 +153,35 @@ export class MintBolt11Handler implements MintMethodHandler<'bolt11'> { } catch (error) { ctx.logger?.warn('Failed to check mint quote state during recovery', { mintUrl, - quoteId, - error: error instanceof Error ? error.message : String(error), + errorName: error instanceof Error ? error.name : typeof error, + ...(error instanceof MintOperationError ? { errorCode: error.code } : {}), }); + const errorMessage = error instanceof Error ? error.message : String(error); return { status: 'PENDING', - error: error instanceof Error ? error.message : String(error), + error: `Failed to check mint quote during recovery: ${errorMessage}`, }; } - if (remoteQuote.state === 'PAID') { + if ( + (ctx.operation.pubkey !== undefined || remoteQuote.pubkey !== undefined) && + remoteQuote.pubkey !== ctx.operation.pubkey + ) { + return { + status: 'TERMINAL', + error: 'Recovered BOLT11 mint operation has mismatched NUT-20 quote ownership', + }; + } + + const canonicalRemoteQuote = mintQuoteFromBolt11Response(mintUrl, remoteQuote); + if (isBolt11MintQuotePaid(canonicalRemoteQuote)) { const outputData = deserializeOutputData(ctx.operation.outputData); try { + const mintConfig = await this.getMintConfig(ctx.operation.pubkey); const proofs = await ctx.wallet.mintProofsBolt11( ctx.operation.amount, ctx.operation.quoteId, - undefined, + mintConfig, { type: 'custom', data: outputData.keep, @@ -160,25 +209,26 @@ export class MintBolt11Handler implements MintMethodHandler<'bolt11'> { } else { return { status: 'PENDING', - error: err.message, + error: `Mint issuance retry failed: ${err.message}`, }; } } else { + const errorMessage = err instanceof Error ? err.message : String(err); return { status: 'PENDING', - error: err instanceof Error ? err.message : String(err), + error: `Mint issuance retry failed: ${errorMessage}`, }; } } - } else if (remoteQuote.state === 'UNPAID') { + } else if (isBolt11MintQuoteUnpaid(canonicalRemoteQuote)) { return { status: 'PENDING', error: `Recovered: quote ${quoteId} is still UNPAID`, }; - } else if (remoteQuote.state !== 'ISSUED') { + } else if (!isBolt11MintQuoteIssued(canonicalRemoteQuote)) { return { status: 'PENDING', - error: `Recovered: quote ${quoteId} remains in remote state ${remoteQuote.state}`, + error: `Recovered: quote ${quoteId} has unresolved Mint Quote Accounting`, }; } @@ -199,44 +249,85 @@ export class MintBolt11Handler implements MintMethodHandler<'bolt11'> { } return { status: 'FINALIZED' }; } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); return { status: 'PENDING', - error: error instanceof Error ? error.message : String(error), + error: `Mint proof recovery failed: ${errorMessage}`, }; } } async checkPending(ctx: PendingContext<'bolt11'>): Promise> { const { mintUrl, quoteId } = ctx.operation; - ctx.logger?.info('Checking pending mint operation', { mintUrl, quoteId }); + ctx.logger?.info('Checking pending mint operation', { mintUrl }); const quote = await ctx.mintAdapter.checkMintQuote(mintUrl, 'bolt11', quoteId); - ctx.logger?.info('Pending mint quote state', { mintUrl, quoteId, state: quote.state }); + const canonicalQuote = mintQuoteFromBolt11Response(mintUrl, quote); + const remoteState = deriveBolt11MintQuoteState( + canonicalQuote.amountPaid, + canonicalQuote.amountIssued, + ); + ctx.logger?.info('Pending mint quote accounting', { + mintUrl, + amountPaid: canonicalQuote.amountPaid.toString(), + amountIssued: canonicalQuote.amountIssued.toString(), + compatibilityState: remoteState, + }); const observedRemoteStateAt = Date.now(); - switch (quote.state) { - case 'UNPAID': - return { - observedRemoteState: quote.state, - observedRemoteStateAt, - category: 'waiting', - }; - case 'PAID': - return { - observedRemoteState: quote.state, - observedRemoteStateAt, - category: 'ready', - }; - case 'ISSUED': - return { - observedRemoteState: quote.state, - observedRemoteStateAt, - category: 'completed', - }; - default: - throw new Error( - `Unexpected mint quote state: ${quote.state} for quote ${quoteId} at mint ${mintUrl}`, - ); + if (isBolt11MintQuoteUnpaid(canonicalQuote)) { + return { + observedRemoteState: remoteState, + observedRemoteStateAt, + quoteSnapshot: quote, + category: 'waiting', + }; + } + if (isBolt11MintQuotePaid(canonicalQuote)) { + return { + observedRemoteState: remoteState, + observedRemoteStateAt, + quoteSnapshot: quote, + category: 'ready', + }; + } + if (isBolt11MintQuoteIssued(canonicalQuote)) { + return { + observedRemoteState: remoteState, + observedRemoteStateAt, + quoteSnapshot: quote, + category: 'completed', + }; + } + + return { + observedRemoteState: remoteState, + observedRemoteStateAt, + quoteSnapshot: quote, + category: 'waiting', + }; + } + + async validateQuoteForPrepare(quote: MintQuote<'bolt11'>): Promise { + await this.requireQuoteKey(quote.pubkey); + } + + private async requireQuoteKey(pubkey: string | undefined): Promise { + if (!pubkey) return; + const key = await this.keyRingService.getMintQuoteKeyPair(pubkey); + if (!key) { + throw new Error('Missing NUT-20 mint quote key for locked BOLT11 quote'); + } + } + + private async getMintConfig( + pubkey: string | undefined, + ): Promise<{ privkey: string } | undefined> { + if (!pubkey) return undefined; + const key = await this.keyRingService.getMintQuoteKeyPair(pubkey); + if (!key) { + throw new Error('Missing NUT-20 mint quote key for locked BOLT11 quote'); } + return { privkey: bytesToHex(key.secretKey) }; } } diff --git a/packages/core/operations/mint/MintMethodHandler.ts b/packages/core/operations/mint/MintMethodHandler.ts index b26c507b..ea10cea0 100644 --- a/packages/core/operations/mint/MintMethodHandler.ts +++ b/packages/core/operations/mint/MintMethodHandler.ts @@ -60,10 +60,11 @@ export type CompatibleMintQuoteBolt12Response = Omit< export interface MintMethodDefinitions { bolt11: { methodData: Record; - createQuoteData: { amount: UnitAmount }; + createQuoteData: { amount: UnitAmount; locked?: boolean; pubkey?: string }; quoteData: { amount: Amount; }; + /** @deprecated Compatibility projection of canonical Mint Quote Accounting. */ remoteState: 'UNPAID' | 'PAID' | 'ISSUED'; quote: MintQuoteBolt11Response; }; @@ -184,6 +185,7 @@ export type RecoverExecutingResult = export type PendingMintCheckCategory = 'waiting' | 'ready' | 'completed' | 'terminal'; export interface PendingMintCheckResult { + /** @deprecated Return `quoteSnapshot` with canonical accounting whenever available. */ observedRemoteState?: MintMethodRemoteState; observedRemoteStateAt: number; quoteSnapshot?: MintMethodQuoteSnapshot; diff --git a/packages/core/services/MintService.ts b/packages/core/services/MintService.ts index 8a04d866..dde4f33a 100644 --- a/packages/core/services/MintService.ts +++ b/packages/core/services/MintService.ts @@ -111,9 +111,11 @@ type NutMethodSettings = { }; type NutSupportSettings = { - supported?: boolean; + supported?: unknown; }; +export type TopLevelNutCapability = 11 | 20; + export class MintService { private readonly mintRepo: MintRepository; private readonly keysetRepo: KeysetRepository; @@ -258,12 +260,13 @@ export class MintService { /** * Returns whether a mint advertises support for a top-level NUT capability. * - * This currently supports NUT-11 checks only. Mint information is resolved via + * Supports boolean top-level capability metadata used by recovery and security + * preflight. Mint information is resolved via * `getMintInfo()`, so stale local records may be refreshed and fetch failures * propagate to the caller. Missing, malformed, or disabled settings return * `false` rather than throwing. */ - async supportsNut(mintUrl: string, nut: 11): Promise { + async supportsNut(mintUrl: string, nut: TopLevelNutCapability): Promise { this.assertSupportCapabilityNut(nut); const normalizedMintUrl = normalizeMintUrl(mintUrl); const mintInfo = await this.getMintInfo(normalizedMintUrl); @@ -303,11 +306,14 @@ export class MintService { /** * Requires a mint to advertise a top-level NUT capability. * - * This currently supports NUT-11 checks only. Returns when support is - * advertised, throws `ProofValidationError` when support is absent, and lets - * mint-info refresh/fetch failures propagate unchanged. + * Returns when support is advertised, throws `ProofValidationError` when + * support is absent, and lets mint-info refresh/fetch failures propagate. */ - async assertNutSupported(mintUrl: string, nut: 11, scope?: string): Promise { + async assertNutSupported( + mintUrl: string, + nut: TopLevelNutCapability, + scope?: string, + ): Promise { if (await this.supportsNut(mintUrl, nut)) { return; } @@ -513,7 +519,10 @@ export class MintService { return nuts?.[String(nut)] as NutMethodSettings | undefined; } - private getNutSupportSettings(mintInfo: MintInfo, nut: 11): NutSupportSettings | undefined { + private getNutSupportSettings( + mintInfo: MintInfo, + nut: TopLevelNutCapability, + ): NutSupportSettings | undefined { const nuts = mintInfo.nuts as Record | undefined; const settings = nuts?.[String(nut)]; if (!settings || typeof settings !== 'object') { @@ -530,8 +539,8 @@ export class MintService { } } - private assertSupportCapabilityNut(nut: number): asserts nut is 11 { - if (nut !== 11) { + private assertSupportCapabilityNut(nut: number): asserts nut is TopLevelNutCapability { + if (nut !== 11 && nut !== 20) { throw new ProofValidationError(`NUT-${nut} support capability checks are not implemented`); } } diff --git a/packages/core/test/unit/MintBolt11Handler.test.ts b/packages/core/test/unit/MintBolt11Handler.test.ts index ff003026..a5a94635 100644 --- a/packages/core/test/unit/MintBolt11Handler.test.ts +++ b/packages/core/test/unit/MintBolt11Handler.test.ts @@ -1,4 +1,5 @@ import { Amount } from '@cashu/cashu-ts'; +import { bytesToHex } from '@noble/curves/utils.js'; import { describe, it, beforeEach, expect, mock, type Mock } from 'bun:test'; import { OutputData, type MintQuoteBolt11Response, type Wallet } from '@cashu/cashu-ts'; import { MintBolt11Handler } from '../../infra/handlers/mint/MintBolt11Handler'; @@ -7,6 +8,7 @@ import { EventBus } from '../../events/EventBus'; import type { CoreEvents } from '../../events/types'; import type { CreateMintQuoteContext, + ExecuteContext, PendingContext, PrepareContext, RecoverExecutingContext, @@ -19,11 +21,14 @@ import type { MintService } from '../../services/MintService'; import type { MintAdapter } from '../../infra'; import type { ProofRepository } from '../../repositories'; import type { Logger } from '../../logging/Logger'; +import type { KeyRingService } from '../../services/KeyRingService'; describe('MintBolt11Handler', () => { const mintUrl = 'https://mint.test'; const quoteId = 'quote-1'; const keysetId = 'keyset-1'; + const quotePubkey = `02${'11'.repeat(32)}`; + const quoteSecretKey = new Uint8Array(32).fill(7); let handler: MintBolt11Handler; let wallet: Wallet; @@ -34,18 +39,28 @@ describe('MintBolt11Handler', () => { let mintService: MintService; let eventBus: EventBus; let logger: Logger; + let keyRingService: KeyRingService; const outputData = serializeOutputData({ keep: [ new OutputData( { - amount: Amount.from(10), + amount: Amount.from(6), id: keysetId, B_: 'B_out_1', }, BigInt(1), new TextEncoder().encode('out-1'), ), + new OutputData( + { + amount: Amount.from(4), + id: keysetId, + B_: 'B_out_2', + }, + BigInt(2), + new TextEncoder().encode('out-2'), + ), ], send: [], }); @@ -152,6 +167,20 @@ describe('MintBolt11Handler', () => { logger, }); + const buildExecuteContext = ( + operationOverride: ExecuteContext<'bolt11'>['operation'] = executingOperation, + ): ExecuteContext<'bolt11'> => ({ + operation: operationOverride, + wallet, + mintAdapter, + proofService, + proofRepository, + walletService, + mintService, + eventBus, + logger, + }); + const buildPendingContext = (): PendingContext<'bolt11'> => ({ operation: { ...executingOperation, @@ -168,10 +197,23 @@ describe('MintBolt11Handler', () => { }); beforeEach(() => { - handler = new MintBolt11Handler(); + keyRingService = { + generateMintQuoteKeyPair: mock(async () => ({ + publicKeyHex: quotePubkey, + secretKey: quoteSecretKey, + purpose: 'nut20_mint_quote' as const, + })), + getMintQuoteKeyPair: mock(async () => ({ + publicKeyHex: quotePubkey, + secretKey: quoteSecretKey, + purpose: 'nut20_mint_quote' as const, + })), + } as unknown as KeyRingService; + handler = new MintBolt11Handler(keyRingService); wallet = { createMintQuoteBolt11: mock(async () => quote), + createLockedMintQuote: mock(async () => ({ ...quote, pubkey: quotePubkey })), mintProofsBolt11: mock(async () => { throw new MintOperationError(20007, 'Quote expired'); }), @@ -189,9 +231,14 @@ describe('MintBolt11Handler', () => { proofRepository = {} as ProofRepository; walletService = {} as WalletService; - mintService = {} as MintService; + mintService = { + assertNutSupported: mock(async () => {}), + } as unknown as MintService; eventBus = new EventBus(); - logger = { info: mock(() => {}) } as unknown as Logger; + logger = { + info: mock(() => {}), + warn: mock(() => {}), + } as unknown as Logger; }); describe('quotes', () => { @@ -210,6 +257,83 @@ describe('MintBolt11Handler', () => { expect(result.quoteId).toBe(quoteId); expect(result.method).toBe('bolt11'); }); + + it('creates an opt-in locked BOLT11 quote with a fresh persisted key', async () => { + const result = await handler.createQuote({ + ...buildCreateQuoteContext(), + createQuoteData: { + amount: { amount: Amount.from(10), unit: 'sat' }, + locked: true, + }, + }); + + expect(mintService.assertNutSupported).toHaveBeenCalledWith( + mintUrl, + 20, + 'locked BOLT11 mint quote', + ); + expect(keyRingService.generateMintQuoteKeyPair).toHaveBeenCalledTimes(1); + expect(wallet.createLockedMintQuote).toHaveBeenCalledWith(Amount.from(10), quotePubkey); + expect(result.pubkey).toBe(quotePubkey); + expect((wallet.createMintQuoteBolt11 as Mock).mock.calls).toHaveLength(0); + }); + + it('rejects a locked quote before remote creation when its key is not persisted', async () => { + (keyRingService.getMintQuoteKeyPair as Mock).mockResolvedValueOnce(null); + + await expect( + handler.createQuote({ + ...buildCreateQuoteContext(), + createQuoteData: { + amount: { amount: Amount.from(10), unit: 'sat' }, + pubkey: quotePubkey, + }, + }), + ).rejects.toThrow('Missing NUT-20 mint quote key'); + + expect(mintService.assertNutSupported).toHaveBeenCalledWith( + mintUrl, + 20, + 'locked BOLT11 mint quote', + ); + expect(wallet.createLockedMintQuote).not.toHaveBeenCalled(); + }); + }); + + describe('execute', () => { + it('signs locked BOLT11 issuance with the persisted NUT-20 key', async () => { + (wallet.mintProofsBolt11 as Mock).mockImplementation(async () => []); + + await handler.execute(buildExecuteContext({ ...executingOperation, pubkey: quotePubkey })); + + expect(keyRingService.getMintQuoteKeyPair).toHaveBeenCalledWith(quotePubkey); + const call = (wallet.mintProofsBolt11 as Mock).mock.calls[0]; + expect(call?.[0]).toEqual(Amount.from(10)); + expect(call?.[1]).toBe(quoteId); + expect(call?.[2]).toEqual({ privkey: bytesToHex(quoteSecretKey) }); + const customOutputs = call?.[3] as + | { type: string; data: Array<{ blindedMessage: { B_: string } }> } + | undefined; + expect(customOutputs?.type).toBe('custom'); + expect(customOutputs?.data.map(({ blindedMessage }) => blindedMessage.B_)).toEqual([ + 'B_out_1', + 'B_out_2', + ]); + }); + + it('preserves mint error codes and diagnostic details', async () => { + (wallet.mintProofsBolt11 as Mock).mockImplementation(async () => { + throw new MintOperationError(20007, `Quote ${quoteId} expired`); + }); + + const error = await handler + .execute(buildExecuteContext({ ...executingOperation, pubkey: quotePubkey })) + .catch((caught) => caught); + + expect(error).toBeInstanceOf(MintOperationError); + expect(error.code).toBe(20007); + expect(error.message).toContain(`Quote ${quoteId} expired`); + }); }); describe('recoverExecuting', () => { @@ -223,12 +347,49 @@ describe('MintBolt11Handler', () => { expect((wallet.mintProofsBolt11 as Mock).mock.calls.length).toBe(1); expect((proofService.saveProofs as Mock).mock.calls.length).toBe(0); }); + + it('recovers locked issuance using accounting authority and the persisted key', async () => { + (mintAdapter.checkMintQuote as Mock).mockImplementation(async () => ({ + ...quote, + state: 'UNPAID', + pubkey: quotePubkey, + amount_paid: Amount.from(10), + amount_issued: Amount.zero(), + updated_at: 10, + })); + (wallet.mintProofsBolt11 as Mock).mockImplementation(async () => []); + + const result = await handler.recoverExecuting({ + ...buildRecoverContext(), + operation: { ...executingOperation, pubkey: quotePubkey }, + }); + + expect(result).toEqual({ status: 'FINALIZED' }); + const call = (wallet.mintProofsBolt11 as Mock).mock.calls[0]; + expect(call?.[2]).toEqual({ privkey: bytesToHex(quoteSecretKey) }); + expect(proofService.saveProofs).toHaveBeenCalledTimes(1); + }); + + it('returns diagnostic errors without copying quote-bearing errors into logs', async () => { + (mintAdapter.checkMintQuote as Mock).mockRejectedValueOnce( + new Error(`Quote ${quoteId} is temporarily unavailable`), + ); + + const result = await handler.recoverExecuting(buildRecoverContext()); + + expect(result).toEqual({ + status: 'PENDING', + error: `Failed to check mint quote during recovery: Quote ${quoteId} is temporarily unavailable`, + }); + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(JSON.stringify((logger.warn as Mock).mock.calls[0]?.[1])).not.toContain(quoteId); + }); }); describe('prepare', () => { it('requires the service to provide an existing quote snapshot', async () => { await expect(handler.prepare(buildPrepareContext())).rejects.toThrow( - 'Mint quote quote-1 was not provided', + 'BOLT11 mint quote was not provided', ); expect((wallet.createMintQuoteBolt11 as Mock).mock.calls).toHaveLength(0); }); @@ -267,12 +428,20 @@ describe('MintBolt11Handler', () => { }); describe('checkPending', () => { - it('returns the observed remote state with a normalized ready category', async () => { + it('uses canonical accounting when the compatibility state is contradictory', async () => { + (mintAdapter.checkMintQuote as Mock).mockResolvedValueOnce({ + ...quote, + state: 'UNPAID', + }); + const result = await handler.checkPending(buildPendingContext()); expect(result.observedRemoteState).toBe('PAID'); expect(result.category).toBe('ready'); expect(result.observedRemoteStateAt).toEqual(expect.any(Number)); + for (const [, meta] of (logger.info as Mock).mock.calls) { + expect(JSON.stringify(meta)).not.toContain(quoteId); + } }); }); }); diff --git a/packages/core/test/unit/MintService.test.ts b/packages/core/test/unit/MintService.test.ts index 7905dd8f..106d6973 100644 --- a/packages/core/test/unit/MintService.test.ts +++ b/packages/core/test/unit/MintService.test.ts @@ -334,6 +334,18 @@ describe('MintService', () => { ).resolves.toBeUndefined(); }); + it('checks NUT-20 support through the typed API', async () => { + useMintInfo({ + ...mockMintInfo, + nuts: { + ...mockMintInfo.nuts, + '20': { supported: true }, + }, + } as MintInfo); + + await expect(service.assertNutSupported(testMintUrl, 20)).resolves.toBeUndefined(); + }); + it('returns false and rejects when NUT-11 metadata is missing', async () => { useMintInfo({ ...mockMintInfo, diff --git a/packages/core/test/unit/QuoteApi.test.ts b/packages/core/test/unit/QuoteApi.test.ts index 5dbc79b8..661adfef 100644 --- a/packages/core/test/unit/QuoteApi.test.ts +++ b/packages/core/test/unit/QuoteApi.test.ts @@ -149,6 +149,22 @@ describe('QuoteApi', () => { }); }); + it('delegates opt-in locked BOLT11 quote creation', async () => { + await expect( + api.mint.create({ + mintUrl, + method: 'bolt11', + amount: Amount.from(10), + locked: true, + }), + ).resolves.toBe(mintQuote); + + expect(quoteLifecycle.createMintQuote).toHaveBeenCalledWith(mintUrl, 'bolt11', { + amount: { amount: Amount.from(10), unit: 'sat' }, + locked: true, + }); + }); + it('delegates BOLT12 mint quote creation with optional amount data', async () => { await expect( api.mint.create({ From f15b83cfb8ba68d46c9331ec9d62904d440a4913 Mon Sep 17 00:00:00 2001 From: IgboPharaoh Date: Wed, 29 Jul 2026 20:29:13 +0100 Subject: [PATCH 3/5] chore: add secure BOLT11 foundation changeset --- .changeset/secure-bolt11-foundations.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .changeset/secure-bolt11-foundations.md diff --git a/.changeset/secure-bolt11-foundations.md b/.changeset/secure-bolt11-foundations.md new file mode 100644 index 00000000..a6296450 --- /dev/null +++ b/.changeset/secure-bolt11-foundations.md @@ -0,0 +1,13 @@ +--- +'@cashu/coco-core': minor +'@cashu/coco-sql-storage': patch +'@cashu/coco-sqlite': patch +'@cashu/coco-sqlite-bun': patch +'@cashu/coco-expo-sqlite': patch +'@cashu/coco-indexeddb': patch +'@cashu/coco-adapter-tests': patch +--- + +Add canonical BOLT11 accounting predicates, opt-in NUT-20 locked quote handling, readable +diagnostics, and quote-free structured logging. Keep SQL and IndexedDB quote state projections +aligned with the canonical accounting fields. From 3671483e6f670b0c1112a6ce3ddb33f3b1fc6b80 Mon Sep 17 00:00:00 2001 From: IgboPharaoh Date: Thu, 30 Jul 2026 12:58:11 +0100 Subject: [PATCH 4/5] feat(core): secure mint quote accounting and BOLT11 signing --- .changeset/secure-bolt11-foundations.md | 6 +- packages/adapter-tests/src/index.ts | 115 ++++++++++++++++++ packages/core/Manager.ts | 4 +- .../infra/handlers/mint/MintBolt11Handler.ts | 75 ++++++++---- .../core/operations/mint/MintMethodHandler.ts | 7 +- packages/core/quotes/QuoteLifecycle.ts | 2 + .../watchers/MintOperationProcessor.ts | 5 +- .../watchers/MintOperationWatcherService.ts | 19 ++- .../core/test/unit/MintBolt11Handler.test.ts | 49 ++++++-- .../test/unit/MintOperationService.test.ts | 18 +-- .../unit/MintOperationWatcherService.test.ts | 41 +++++++ .../src/repositories/MintQuoteRepository.ts | 16 +-- .../src/repositories/MintQuoteRepository.ts | 27 +++- 13 files changed, 317 insertions(+), 67 deletions(-) diff --git a/.changeset/secure-bolt11-foundations.md b/.changeset/secure-bolt11-foundations.md index a6296450..1b2357f4 100644 --- a/.changeset/secure-bolt11-foundations.md +++ b/.changeset/secure-bolt11-foundations.md @@ -8,6 +8,6 @@ '@cashu/coco-adapter-tests': patch --- -Add canonical BOLT11 accounting predicates, opt-in NUT-20 locked quote handling, readable -diagnostics, and quote-free structured logging. Keep SQL and IndexedDB quote state projections -aligned with the canonical accounting fields. +Add canonical BOLT11 accounting predicates and opt-in NUT-20 locked quote handling with readable +diagnostics. Keep SQL and IndexedDB quote state projections aligned with the canonical accounting +fields. diff --git a/packages/adapter-tests/src/index.ts b/packages/adapter-tests/src/index.ts index 4dc0440b..5e0fbfd6 100644 --- a/packages/adapter-tests/src/index.ts +++ b/packages/adapter-tests/src/index.ts @@ -452,6 +452,70 @@ export async function runMintQuoteRepositoryContract( const { describe, it, expect } = runner; describe('MintQuoteRepository contract', () => { + it('applies legacy BOLT11 state updates monotonically', async () => { + const { repositories, dispose } = await options.createRepositories(); + try { + const quote = createDummyMintQuote({ + state: 'UNPAID', + amountPaid: Amount.zero(), + amountIssued: Amount.zero(), + remoteUpdatedAt: null, + }); + await repositories.mintQuoteRepository.upsertMintQuote(quote); + + await repositories.mintQuoteRepository.setMintQuoteState( + quote.mintUrl, + quote.method, + quote.quoteId, + 'PAID', + 20, + ); + await repositories.mintQuoteRepository.setMintQuoteState( + quote.mintUrl, + quote.method, + quote.quoteId, + 'UNPAID', + 30, + ); + + const paid = await repositories.mintQuoteRepository.getMintQuote( + quote.mintUrl, + quote.method, + quote.quoteId, + ); + expect(paid?.method).toBe('bolt11'); + if (paid?.method !== 'bolt11') throw new Error('Expected BOLT11 quote'); + expect(paid.state).toBe('PAID'); + expect(paid.amountPaid.equals(quote.amount)).toBe(true); + expect(paid.amountIssued.isZero()).toBe(true); + expect(paid.remoteUpdatedAt).toBe(null); + expect(paid.updatedAt).toBe(30); + + await repositories.mintQuoteRepository.setMintQuoteState( + quote.mintUrl, + quote.method, + quote.quoteId, + 'ISSUED', + 40, + ); + + const issued = await repositories.mintQuoteRepository.getMintQuote( + quote.mintUrl, + quote.method, + quote.quoteId, + ); + expect(issued?.method).toBe('bolt11'); + if (issued?.method !== 'bolt11') throw new Error('Expected BOLT11 quote'); + expect(issued.state).toBe('ISSUED'); + expect(issued.amountPaid.equals(quote.amount)).toBe(true); + expect(issued.amountIssued.equals(quote.amount)).toBe(true); + expect(issued.remoteUpdatedAt).toBe(null); + expect(issued.updatedAt).toBe(40); + } finally { + await dispose(); + } + }); + it('keeps canonical BOLT11 accounting authoritative over legacy state updates', async () => { const { repositories, dispose } = await options.createRepositories(); try { @@ -487,6 +551,57 @@ export async function runMintQuoteRepositoryContract( } }); + it('keeps concurrent legacy state updates from overwriting canonical accounting', async () => { + const { repositories, dispose } = await options.createRepositories(); + try { + for (let i = 0; i < 10; i++) { + const quote = createDummyMintQuote({ + quoteId: `concurrent-accounting-${i}`, + quote: `concurrent-accounting-${i}`, + state: 'UNPAID', + amountPaid: Amount.zero(), + amountIssued: Amount.zero(), + remoteUpdatedAt: null, + }); + const canonical = { + ...quote, + state: 'PAID' as const, + amountPaid: Amount.from(3), + amountIssued: Amount.zero(), + remoteUpdatedAt: 10, + updatedAt: 10, + }; + await repositories.mintQuoteRepository.upsertMintQuote(quote); + + await Promise.all([ + repositories.mintQuoteRepository.setMintQuoteState( + quote.mintUrl, + quote.method, + quote.quoteId, + 'ISSUED', + 20, + ), + repositories.mintQuoteRepository.upsertMintQuote(canonical), + ]); + + const stored = await repositories.mintQuoteRepository.getMintQuote( + quote.mintUrl, + quote.method, + quote.quoteId, + ); + + expect(stored?.method).toBe('bolt11'); + if (stored?.method !== 'bolt11') throw new Error('Expected BOLT11 quote'); + expect(stored.state).toBe('PAID'); + expect(stored.amountPaid.equals(Amount.from(3))).toBe(true); + expect(stored.amountIssued.isZero()).toBe(true); + expect(stored.remoteUpdatedAt).toBe(10); + } + } finally { + await dispose(); + } + }); + it('selects pending BOLT11 quotes from accounting instead of stored state', async () => { const { repositories, dispose } = await options.createRepositories(); try { diff --git a/packages/core/Manager.ts b/packages/core/Manager.ts index 60b485ea..2d1e6670 100644 --- a/packages/core/Manager.ts +++ b/packages/core/Manager.ts @@ -638,6 +638,7 @@ export class Manager { if (!trusted) { this.logger.debug('Skipping legacy mint quote reconciliation for untrusted mint', { mintUrl: quote.mintUrl, + quoteId: quote.quote, }); skipped.push(quote.quote); continue; @@ -669,7 +670,8 @@ export class Manager { } catch (err) { this.logger.warn('Failed to reconcile legacy mint quote', { mintUrl: quote.mintUrl, - errorName: err instanceof Error ? err.name : typeof err, + quoteId: quote.quote, + err, }); skipped.push(quote.quote); } diff --git a/packages/core/infra/handlers/mint/MintBolt11Handler.ts b/packages/core/infra/handlers/mint/MintBolt11Handler.ts index e759ceef..f43ba889 100644 --- a/packages/core/infra/handlers/mint/MintBolt11Handler.ts +++ b/packages/core/infra/handlers/mint/MintBolt11Handler.ts @@ -16,7 +16,7 @@ import type { FetchRemoteMintQuoteContext, } from '@core/operations/mint'; import { deserializeOutputData, mapProofToCoreProof, serializeOutputData } from '@core/utils'; -import { MintOperationError } from '../../../models/Error'; +import { MintOperationError, ProofValidationError } from '../../../models/Error'; import type { KeyRingService } from '../../../services/KeyRingService'; import { deriveBolt11MintQuoteState, @@ -32,16 +32,20 @@ export class MintBolt11Handler implements MintMethodHandler<'bolt11'> { constructor(private readonly keyRingService: KeyRingService) {} async createQuote(ctx: CreateMintQuoteContext<'bolt11'>): Promise> { - const { amount, locked, pubkey } = ctx.createQuoteData; - const shouldLock = locked === true || pubkey !== undefined; + const { amount, locked, ownedPubkey } = ctx.createQuoteData; + const shouldLock = locked === true || ownedPubkey !== undefined; if (shouldLock) { await ctx.mintService.assertNutSupported(ctx.mintUrl, 20, 'locked BOLT11 mint quote'); } + // TODO: Reserve mint-quote derivation indexes atomically in the upstream key service; + // concurrent quote creation can otherwise reuse the same derived key. const lockPubkey = - shouldLock && !pubkey + shouldLock && !ownedPubkey ? (await this.keyRingService.generateMintQuoteKeyPair()).publicKeyHex - : pubkey; - if (lockPubkey && pubkey) { + : ownedPubkey; + if (lockPubkey && ownedPubkey) { + // TODO: Support third-party quote locks as a distinct flow. Coco currently expects to + // redeem every created quote, so an explicitly supplied key must be locally owned. await this.requireQuoteKey(lockPubkey); } const remoteQuote = lockPubkey @@ -67,7 +71,7 @@ export class MintBolt11Handler implements MintMethodHandler<'bolt11'> { ): Promise & MintMethodMeta<'bolt11'>> { const quote = ctx.importedQuote; if (!quote) { - throw new Error('BOLT11 mint quote was not provided'); + throw new Error(`Mint quote ${ctx.operation.quoteId ?? '(missing)'} was not provided`); } if (!quote.amount || quote.amount.isZero()) { @@ -75,7 +79,9 @@ export class MintBolt11Handler implements MintMethodHandler<'bolt11'> { } if (ctx.operation.quoteId !== quote.quote) { - throw new Error(`Mint quote ${quote.quote} does not match the operation quote`); + throw new Error( + `Mint quote ${quote.quote} does not match operation quote ${ctx.operation.quoteId}`, + ); } if (!quote.amount.equals(ctx.operation.amount)) { @@ -115,13 +121,13 @@ export class MintBolt11Handler implements MintMethodHandler<'bolt11'> { async execute(ctx: ExecuteContext<'bolt11'>): Promise { const outputData = deserializeOutputData(ctx.operation.outputData); - const mintConfig = await this.getMintConfig(ctx.operation.pubkey); + const signingOptions = await this.getMintQuoteSigningOptions(ctx.operation.pubkey); try { const proofs = await ctx.wallet.mintProofsBolt11( ctx.operation.amount, ctx.operation.quoteId, - mintConfig, + signingOptions, { type: 'custom', data: outputData.keep, @@ -139,7 +145,7 @@ export class MintBolt11Handler implements MintMethodHandler<'bolt11'> { if (err instanceof MintOperationError) { throw new MintOperationError(err.code, message); } - throw new Error(message); + throw new Error(message, { cause: err }); } throw err; } @@ -153,13 +159,12 @@ export class MintBolt11Handler implements MintMethodHandler<'bolt11'> { } catch (error) { ctx.logger?.warn('Failed to check mint quote state during recovery', { mintUrl, - errorName: error instanceof Error ? error.name : typeof error, - ...(error instanceof MintOperationError ? { errorCode: error.code } : {}), + quoteId, + error: error instanceof Error ? error.message : String(error), }); - const errorMessage = error instanceof Error ? error.message : String(error); return { status: 'PENDING', - error: `Failed to check mint quote during recovery: ${errorMessage}`, + error: error instanceof Error ? error.message : String(error), }; } @@ -177,11 +182,11 @@ export class MintBolt11Handler implements MintMethodHandler<'bolt11'> { if (isBolt11MintQuotePaid(canonicalRemoteQuote)) { const outputData = deserializeOutputData(ctx.operation.outputData); try { - const mintConfig = await this.getMintConfig(ctx.operation.pubkey); + const signingOptions = await this.getMintQuoteSigningOptions(ctx.operation.pubkey); const proofs = await ctx.wallet.mintProofsBolt11( ctx.operation.amount, ctx.operation.quoteId, - mintConfig, + signingOptions, { type: 'custom', data: outputData.keep, @@ -209,14 +214,13 @@ export class MintBolt11Handler implements MintMethodHandler<'bolt11'> { } else { return { status: 'PENDING', - error: `Mint issuance retry failed: ${err.message}`, + error: err.message, }; } } else { - const errorMessage = err instanceof Error ? err.message : String(err); return { status: 'PENDING', - error: `Mint issuance retry failed: ${errorMessage}`, + error: err instanceof Error ? err.message : String(err), }; } } @@ -249,19 +253,19 @@ export class MintBolt11Handler implements MintMethodHandler<'bolt11'> { } return { status: 'FINALIZED' }; } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); return { status: 'PENDING', - error: `Mint proof recovery failed: ${errorMessage}`, + error: error instanceof Error ? error.message : String(error), }; } } async checkPending(ctx: PendingContext<'bolt11'>): Promise> { const { mintUrl, quoteId } = ctx.operation; - ctx.logger?.info('Checking pending mint operation', { mintUrl }); + ctx.logger?.info('Checking pending mint operation', { mintUrl, quoteId }); const quote = await ctx.mintAdapter.checkMintQuote(mintUrl, 'bolt11', quoteId); + this.assertPendingQuoteMatchesOperation(quote, ctx.operation); const canonicalQuote = mintQuoteFromBolt11Response(mintUrl, quote); const remoteState = deriveBolt11MintQuoteState( canonicalQuote.amountPaid, @@ -269,6 +273,7 @@ export class MintBolt11Handler implements MintMethodHandler<'bolt11'> { ); ctx.logger?.info('Pending mint quote accounting', { mintUrl, + quoteId, amountPaid: canonicalQuote.amountPaid.toString(), amountIssued: canonicalQuote.amountIssued.toString(), compatibilityState: remoteState, @@ -312,6 +317,28 @@ export class MintBolt11Handler implements MintMethodHandler<'bolt11'> { await this.requireQuoteKey(quote.pubkey); } + private assertPendingQuoteMatchesOperation( + quote: MintQuoteBolt11Response, + operation: PendingMintOperation<'bolt11'>, + ): void { + if (quote.quote !== operation.quoteId || quote.request !== operation.request) { + throw new ProofValidationError( + `Polled BOLT11 mint quote ${quote.quote} conflicts with pending operation identity`, + ); + } + assertSameUnit(quote.unit, operation.unit, `Polled BOLT11 mint quote ${quote.quote}`); + if (!Amount.from(quote.amount).equals(operation.amount)) { + throw new ProofValidationError( + `Polled BOLT11 mint quote ${quote.quote} conflicts with pending operation amount`, + ); + } + if ((quote.pubkey ?? undefined) !== (operation.pubkey ?? undefined)) { + throw new ProofValidationError( + `Polled BOLT11 mint quote ${quote.quote} conflicts with pending operation ownership`, + ); + } + } + private async requireQuoteKey(pubkey: string | undefined): Promise { if (!pubkey) return; const key = await this.keyRingService.getMintQuoteKeyPair(pubkey); @@ -320,7 +347,7 @@ export class MintBolt11Handler implements MintMethodHandler<'bolt11'> { } } - private async getMintConfig( + private async getMintQuoteSigningOptions( pubkey: string | undefined, ): Promise<{ privkey: string } | undefined> { if (!pubkey) return undefined; diff --git a/packages/core/operations/mint/MintMethodHandler.ts b/packages/core/operations/mint/MintMethodHandler.ts index ea10cea0..1567638a 100644 --- a/packages/core/operations/mint/MintMethodHandler.ts +++ b/packages/core/operations/mint/MintMethodHandler.ts @@ -60,7 +60,12 @@ export type CompatibleMintQuoteBolt12Response = Omit< export interface MintMethodDefinitions { bolt11: { methodData: Record; - createQuoteData: { amount: UnitAmount; locked?: boolean; pubkey?: string }; + createQuoteData: { + amount: UnitAmount; + locked?: boolean; + /** Existing Coco-owned NUT-20 key to use instead of generating a fresh key. */ + ownedPubkey?: string; + }; quoteData: { amount: Amount; }; diff --git a/packages/core/quotes/QuoteLifecycle.ts b/packages/core/quotes/QuoteLifecycle.ts index 3975f623..86b944cc 100644 --- a/packages/core/quotes/QuoteLifecycle.ts +++ b/packages/core/quotes/QuoteLifecycle.ts @@ -522,6 +522,7 @@ export class QuoteLifecycle { (await this.mintQuoteRepository.getMintQuote(mintUrl, method, quote.quoteId)) ?? quote; this.logger?.info('Mint quote created', { mintUrl: persistedQuote.mintUrl, + quoteId: persistedQuote.quoteId, method, amount: getMintQuoteAmount(persistedQuote)?.toString(), unit: persistedQuote.unit, @@ -1241,6 +1242,7 @@ export class QuoteLifecycle { this.logger?.warn(message, { mintUrl: incoming.mintUrl, method: incoming.method, + quoteId: incoming.quoteId, existingRemoteUpdatedAt: existing?.remoteUpdatedAt ?? null, incomingRemoteUpdatedAt: incoming.remoteUpdatedAt, existingAmountPaid: existing?.amountPaid.toString(), diff --git a/packages/core/services/watchers/MintOperationProcessor.ts b/packages/core/services/watchers/MintOperationProcessor.ts index b2adb984..4b3b7720 100644 --- a/packages/core/services/watchers/MintOperationProcessor.ts +++ b/packages/core/services/watchers/MintOperationProcessor.ts @@ -307,6 +307,7 @@ export class MintOperationProcessor { this.logger?.debug('Reusable mint quote claim already in progress', { mintUrl, method, + quoteId, }); return; } @@ -323,6 +324,7 @@ export class MintOperationProcessor { this.logger?.debug('Reusable mint quote has no locally claimable balance', { mintUrl, method, + quoteId, }); return; } @@ -334,7 +336,8 @@ export class MintOperationProcessor { this.logger?.warn('Failed to check or claim reusable mint quote', { mintUrl, method, - errorName: error instanceof Error ? error.name : typeof error, + quoteId, + error: error instanceof Error ? error.message : String(error), }); } finally { this.claimingQuotes.delete(key); diff --git a/packages/core/services/watchers/MintOperationWatcherService.ts b/packages/core/services/watchers/MintOperationWatcherService.ts index 54af1cc3..bd97b767 100644 --- a/packages/core/services/watchers/MintOperationWatcherService.ts +++ b/packages/core/services/watchers/MintOperationWatcherService.ts @@ -30,8 +30,9 @@ interface MintQuoteWatchPolicy { function hasBolt11CompletedIssuance(payload: MintMethodQuoteSnapshot<'bolt11'>): boolean { if (payload.amount_paid === undefined || payload.amount_issued === undefined) return false; const amount = Amount.from(payload.amount); + const amountPaid = Amount.from(payload.amount_paid); const amountIssued = Amount.from(payload.amount_issued); - return amountIssued.greaterThanOrEqual(amount); + return !amountIssued.greaterThan(amountPaid) && amountIssued.greaterThanOrEqual(amount); } const mintQuoteWatchPolicies: { @@ -150,7 +151,8 @@ export class MintOperationWatcherService { this.logger?.error('Failed to start watching pending mint operation', { operationId: operation.id, mintUrl: operation.mintUrl, - errorName: err instanceof Error ? err.name : typeof err, + quoteId: operation.quoteId, + err, }); } }); @@ -171,8 +173,8 @@ export class MintOperationWatcherService { } catch (err) { this.logger?.error('Failed to start watching canonical mint quote', { mintUrl: quote.mintUrl, - method: quote.method, - errorName: err instanceof Error ? err.name : typeof err, + quoteId: quote.quoteId, + err, }); } }); @@ -545,8 +547,9 @@ export class MintOperationWatcherService { } catch (err) { this.logger?.error('Failed to persist mint quote update from remote update', { mintUrl, + quoteId, method: record.method, - errorName: err instanceof Error ? err.name : typeof err, + err, }); } } @@ -584,11 +587,7 @@ export class MintOperationWatcherService { try { await record.stop?.(); } catch (err) { - this.logger?.warn('Unsubscribe watcher failed', { - mintUrl: record.mintUrl, - method: record.method, - errorName: err instanceof Error ? err.name : typeof err, - }); + this.logger?.warn('Unsubscribe watcher failed', { key, err }); } finally { this.removeWatchRecord(key); } diff --git a/packages/core/test/unit/MintBolt11Handler.test.ts b/packages/core/test/unit/MintBolt11Handler.test.ts index a5a94635..661e0a83 100644 --- a/packages/core/test/unit/MintBolt11Handler.test.ts +++ b/packages/core/test/unit/MintBolt11Handler.test.ts @@ -278,7 +278,7 @@ describe('MintBolt11Handler', () => { expect((wallet.createMintQuoteBolt11 as Mock).mock.calls).toHaveLength(0); }); - it('rejects a locked quote before remote creation when its key is not persisted', async () => { + it('rejects an owned locking key before remote creation when it is not persisted', async () => { (keyRingService.getMintQuoteKeyPair as Mock).mockResolvedValueOnce(null); await expect( @@ -286,7 +286,7 @@ describe('MintBolt11Handler', () => { ...buildCreateQuoteContext(), createQuoteData: { amount: { amount: Amount.from(10), unit: 'sat' }, - pubkey: quotePubkey, + ownedPubkey: quotePubkey, }, }), ).rejects.toThrow('Missing NUT-20 mint quote key'); @@ -334,6 +334,19 @@ describe('MintBolt11Handler', () => { expect(error.code).toBe(20007); expect(error.message).toContain(`Quote ${quoteId} expired`); }); + + it('preserves non-protocol locked issuance errors as the cause', async () => { + const originalError = new Error('Transport disconnected'); + (wallet.mintProofsBolt11 as Mock).mockRejectedValueOnce(originalError); + + const error = await handler + .execute(buildExecuteContext({ ...executingOperation, pubkey: quotePubkey })) + .catch((caught) => caught); + + expect(error).toBeInstanceOf(Error); + expect(error.message).toBe('Locked BOLT11 mint failed: Transport disconnected'); + expect(error.cause).toBe(originalError); + }); }); describe('recoverExecuting', () => { @@ -370,7 +383,7 @@ describe('MintBolt11Handler', () => { expect(proofService.saveProofs).toHaveBeenCalledTimes(1); }); - it('returns diagnostic errors without copying quote-bearing errors into logs', async () => { + it('preserves quote context in recovery errors and logs', async () => { (mintAdapter.checkMintQuote as Mock).mockRejectedValueOnce( new Error(`Quote ${quoteId} is temporarily unavailable`), ); @@ -379,17 +392,21 @@ describe('MintBolt11Handler', () => { expect(result).toEqual({ status: 'PENDING', - error: `Failed to check mint quote during recovery: Quote ${quoteId} is temporarily unavailable`, + error: `Quote ${quoteId} is temporarily unavailable`, }); expect(logger.warn).toHaveBeenCalledTimes(1); - expect(JSON.stringify((logger.warn as Mock).mock.calls[0]?.[1])).not.toContain(quoteId); + expect((logger.warn as Mock).mock.calls[0]?.[1]).toEqual({ + mintUrl, + quoteId, + error: `Quote ${quoteId} is temporarily unavailable`, + }); }); }); describe('prepare', () => { it('requires the service to provide an existing quote snapshot', async () => { await expect(handler.prepare(buildPrepareContext())).rejects.toThrow( - 'BOLT11 mint quote was not provided', + `Mint quote ${quoteId} was not provided`, ); expect((wallet.createMintQuoteBolt11 as Mock).mock.calls).toHaveLength(0); }); @@ -428,6 +445,24 @@ describe('MintBolt11Handler', () => { }); describe('checkPending', () => { + it.each([ + ['quote ID', { quote: 'other-quote' }], + ['request', { request: 'lnbc1other' }], + ['unit', { unit: 'usd' }], + ['amount', { amount: Amount.from(11) }], + ['NUT-20 pubkey', { pubkey: quotePubkey }], + ] as Array<[string, Partial]>)( + 'rejects a polled snapshot with a mismatched %s', + async (_field, override) => { + (mintAdapter.checkMintQuote as Mock).mockResolvedValueOnce({ + ...quote, + ...override, + }); + + await expect(handler.checkPending(buildPendingContext())).rejects.toThrow(); + }, + ); + it('uses canonical accounting when the compatibility state is contradictory', async () => { (mintAdapter.checkMintQuote as Mock).mockResolvedValueOnce({ ...quote, @@ -440,7 +475,7 @@ describe('MintBolt11Handler', () => { expect(result.category).toBe('ready'); expect(result.observedRemoteStateAt).toEqual(expect.any(Number)); for (const [, meta] of (logger.info as Mock).mock.calls) { - expect(JSON.stringify(meta)).not.toContain(quoteId); + expect(meta).toMatchObject({ mintUrl, quoteId }); } }); }); diff --git a/packages/core/test/unit/MintOperationService.test.ts b/packages/core/test/unit/MintOperationService.test.ts index 25b3b5a8..be8952d1 100644 --- a/packages/core/test/unit/MintOperationService.test.ts +++ b/packages/core/test/unit/MintOperationService.test.ts @@ -935,9 +935,9 @@ describe('MintOperationService', () => { 'Ignoring Mint Quote Observation with invalid accounting', expect.objectContaining({ mintUrl, method: 'onchain' }), ); - expect(JSON.stringify((logger.warn as Mock).mock.calls[0]?.[1])).not.toContain( - onchainQuoteId, - ); + expect((logger.warn as Mock).mock.calls[0]?.[1]).toMatchObject({ + quoteId: onchainQuoteId, + }); }); it('recordMintQuoteSnapshot preserves BOLT11 downgrade protection at a newer update time', async () => { @@ -1142,9 +1142,9 @@ describe('MintOperationService', () => { incomingAmountPaid: '11', }), ); - expect(JSON.stringify((logger.warn as Mock).mock.calls[0]?.[1])).not.toContain( - onchainQuoteId, - ); + expect((logger.warn as Mock).mock.calls[0]?.[1]).toMatchObject({ + quoteId: onchainQuoteId, + }); }); it('recordMintQuoteSnapshot accepts a monotonic accounting increase without losing freshness', async () => { @@ -1258,9 +1258,9 @@ describe('MintOperationService', () => { incomingAmountIssued: '11', }), ); - expect(JSON.stringify((logger.warn as Mock).mock.calls[0]?.[1])).not.toContain( - onchainQuoteId, - ); + expect((logger.warn as Mock).mock.calls[0]?.[1]).toMatchObject({ + quoteId: onchainQuoteId, + }); }); it('recordMintQuoteSnapshot persists timestamp-only freshness without emitting', async () => { diff --git a/packages/core/test/unit/MintOperationWatcherService.test.ts b/packages/core/test/unit/MintOperationWatcherService.test.ts index f16da17e..f4a6e39b 100644 --- a/packages/core/test/unit/MintOperationWatcherService.test.ts +++ b/packages/core/test/unit/MintOperationWatcherService.test.ts @@ -557,6 +557,47 @@ describe('MintOperationWatcherService', () => { await watcher.stop(); }); + it('keeps watching after invalid issuance accounting', async () => { + const operation = makePendingOperation(); + const recordMintQuoteSnapshot = mock(async () => { + throw new Error('Invalid Mint Quote Accounting'); + }); + const watcher = makeWatcher({ + quoteLifecycle: makeQuoteLifecycle({ + recordMintQuoteSnapshot, + }), + options: { watchExistingPendingOnStart: false, watchExistingPendingQuotesOnStart: false }, + }); + + await watcher.start(); + await bus.emit('mint-op:pending', { + mintUrl, + operationId: operation.id, + operation, + }); + + if (!callback) { + throw new Error('Expected watcher subscription callback'); + } + + await callback({ + quote: quoteId, + request: operation.request, + amount: operation.amount, + unit: operation.unit, + expiry: operation.expiry, + state: 'ISSUED', + amount_paid: Amount.from(9), + amount_issued: operation.amount, + updated_at: 21, + }); + + expect(recordMintQuoteSnapshot).toHaveBeenCalledTimes(1); + expect(unsubscribe).not.toHaveBeenCalled(); + + await watcher.stop(); + }); + it('creates one subscription for canonical and operation interest in the same quote', async () => { const quote = makeBolt11Quote(); const operation = makePendingOperation(); diff --git a/packages/indexeddb/src/repositories/MintQuoteRepository.ts b/packages/indexeddb/src/repositories/MintQuoteRepository.ts index bbe17d69..4ab571af 100644 --- a/packages/indexeddb/src/repositories/MintQuoteRepository.ts +++ b/packages/indexeddb/src/repositories/MintQuoteRepository.ts @@ -181,13 +181,15 @@ export class IdbMintQuoteRepository implements MintQuoteRepository { state: MintMethodRemoteState, observedAt = Date.now(), ): Promise { - const existing = (await (this.db as any) - .table('coco_cashu_canonical_mint_quotes') - .get([normalizeMintUrl(mintUrl), method, quoteId])) as MintQuoteRow | undefined; - if (!existing) return; - const quote = rowToMintQuote(existing); - if (!isStatefulMintQuote(quote)) return; - await this.upsertMintQuote(applyBolt11MintQuoteStateFallback(quote, state, observedAt)); + await this.db.runTransaction('rw', ['coco_cashu_canonical_mint_quotes'], async () => { + const existing = (await (this.db as any) + .table('coco_cashu_canonical_mint_quotes') + .get([normalizeMintUrl(mintUrl), method, quoteId])) as MintQuoteRow | undefined; + if (!existing) return; + const quote = rowToMintQuote(existing); + if (!isStatefulMintQuote(quote)) return; + await this.upsertMintQuote(applyBolt11MintQuoteStateFallback(quote, state, observedAt)); + }); } async getPendingMintQuotes(method?: string): Promise { diff --git a/packages/sql-storage/src/repositories/MintQuoteRepository.ts b/packages/sql-storage/src/repositories/MintQuoteRepository.ts index 9a15b41a..0d2467b9 100644 --- a/packages/sql-storage/src/repositories/MintQuoteRepository.ts +++ b/packages/sql-storage/src/repositories/MintQuoteRepository.ts @@ -1,5 +1,4 @@ import { - applyBolt11MintQuoteStateFallback, deserializeAmount, deriveBolt11MintQuoteState, getMintQuoteAmount, @@ -226,9 +225,29 @@ export class SqliteMintQuoteRepository implements MintQuoteRepository { state: MintMethodRemoteState, observedAt = Date.now(), ): Promise { - const quote = await this.getMintQuote(mintUrl, method, quoteId); - if (!quote || !isStatefulMintQuote(quote)) return; - await this.upsertMintQuote(applyBolt11MintQuoteStateFallback(quote, state, observedAt)); + // Keep this legacy fallback in one conditional statement. Canonical snapshots carry + // remoteUpdatedAt, so a concurrent fallback becomes a no-op instead of overwriting accounting. + await this.db.run( + `UPDATE coco_cashu_canonical_mint_quotes + SET state = CASE + WHEN amountIssued = amount OR ? = 'ISSUED' THEN 'ISSUED' + WHEN amountPaid = amount OR ? = 'PAID' THEN 'PAID' + ELSE 'UNPAID' + END, + amountPaid = CASE WHEN ? IN ('PAID', 'ISSUED') THEN amount ELSE amountPaid END, + amountIssued = CASE WHEN ? = 'ISSUED' THEN amount ELSE amountIssued END, + updatedAt = ? + WHERE mintUrl = ? AND method = ? AND quoteId = ? + AND method = 'bolt11' + AND amount IS NOT NULL + AND remoteUpdatedAt IS NULL + AND ( + (amountPaid = '0' AND amountIssued = '0') + OR (amountPaid = amount AND amountIssued = '0') + OR (amountPaid = amount AND amountIssued = amount) + )`, + [state, state, state, state, observedAt, normalizeMintUrl(mintUrl), method, quoteId], + ); } async getPendingMintQuotes(method?: string): Promise { From 034e4efa13e4c982ce7c2921a2d2ace147c7cf26 Mon Sep 17 00:00:00 2001 From: Egge Date: Fri, 31 Jul 2026 12:15:02 +0000 Subject: [PATCH 5/5] fix(core): classify mint quote failures --- .../infra/handlers/mint/MintBolt11Handler.ts | 32 +++++--- .../infra/handlers/mint/MintBolt12Handler.ts | 18 +++-- .../infra/handlers/mint/MintOnchainHandler.ts | 16 ++-- packages/core/models/Error.ts | 16 ++++ packages/core/models/MintQuote.ts | 4 +- packages/core/quotes/QuoteLifecycle.ts | 80 +++++++++++-------- .../core/test/unit/MintBolt11Handler.test.ts | 62 +++++++++++--- .../core/test/unit/MintBolt12Handler.test.ts | 34 +++++--- .../core/test/unit/MintOnchainHandler.test.ts | 22 +++-- .../test/unit/MintOperationService.test.ts | 21 ++++- packages/core/test/unit/MintQuote.test.ts | 9 ++- .../test/unit/QuoteLifecyclePolling.test.ts | 11 ++- 12 files changed, 229 insertions(+), 96 deletions(-) diff --git a/packages/core/infra/handlers/mint/MintBolt11Handler.ts b/packages/core/infra/handlers/mint/MintBolt11Handler.ts index f43ba889..f2bd475f 100644 --- a/packages/core/infra/handlers/mint/MintBolt11Handler.ts +++ b/packages/core/infra/handlers/mint/MintBolt11Handler.ts @@ -16,7 +16,11 @@ import type { FetchRemoteMintQuoteContext, } from '@core/operations/mint'; import { deserializeOutputData, mapProofToCoreProof, serializeOutputData } from '@core/utils'; -import { MintOperationError, ProofValidationError } from '../../../models/Error'; +import { + MintOperationError, + MintQuoteKeyError, + MintQuoteValidationError, +} from '../../../models/Error'; import type { KeyRingService } from '../../../services/KeyRingService'; import { deriveBolt11MintQuoteState, @@ -52,7 +56,9 @@ export class MintBolt11Handler implements MintMethodHandler<'bolt11'> { ? await ctx.wallet.createLockedMintQuote(amount.amount, lockPubkey) : await ctx.wallet.createMintQuoteBolt11(amount.amount); if (lockPubkey && remoteQuote.pubkey !== lockPubkey) { - throw new Error('Mint returned a BOLT11 quote with an unexpected NUT-20 public key'); + throw new MintQuoteValidationError( + 'Mint returned a BOLT11 quote with an unexpected NUT-20 public key', + ); } return mintQuoteFromBolt11Response(ctx.mintUrl, remoteQuote); } @@ -75,17 +81,17 @@ export class MintBolt11Handler implements MintMethodHandler<'bolt11'> { } if (!quote.amount || quote.amount.isZero()) { - throw new Error(`Mint quote ${quote.quote} has invalid amount`); + throw new MintQuoteValidationError(`Mint quote ${quote.quote} has invalid amount`); } if (ctx.operation.quoteId !== quote.quote) { - throw new Error( + throw new MintQuoteValidationError( `Mint quote ${quote.quote} does not match operation quote ${ctx.operation.quoteId}`, ); } if (!quote.amount.equals(ctx.operation.amount)) { - throw new Error( + throw new MintQuoteValidationError( `Mint quote ${quote.quote} amount ${quote.amount} does not match requested amount ${ctx.operation.amount}`, ); } @@ -140,11 +146,11 @@ export class MintBolt11Handler implements MintMethodHandler<'bolt11'> { return { status: 'ALREADY_ISSUED' }; } if (ctx.operation.pubkey) { - const errorMessage = err instanceof Error ? err.message : String(err); - const message = `Locked BOLT11 mint failed: ${errorMessage}`; if (err instanceof MintOperationError) { - throw new MintOperationError(err.code, message); + throw err; } + const errorMessage = err instanceof Error ? err.message : String(err); + const message = `Locked BOLT11 mint failed: ${errorMessage}`; throw new Error(message, { cause: err }); } throw err; @@ -322,18 +328,18 @@ export class MintBolt11Handler implements MintMethodHandler<'bolt11'> { operation: PendingMintOperation<'bolt11'>, ): void { if (quote.quote !== operation.quoteId || quote.request !== operation.request) { - throw new ProofValidationError( + throw new MintQuoteValidationError( `Polled BOLT11 mint quote ${quote.quote} conflicts with pending operation identity`, ); } assertSameUnit(quote.unit, operation.unit, `Polled BOLT11 mint quote ${quote.quote}`); if (!Amount.from(quote.amount).equals(operation.amount)) { - throw new ProofValidationError( + throw new MintQuoteValidationError( `Polled BOLT11 mint quote ${quote.quote} conflicts with pending operation amount`, ); } if ((quote.pubkey ?? undefined) !== (operation.pubkey ?? undefined)) { - throw new ProofValidationError( + throw new MintQuoteValidationError( `Polled BOLT11 mint quote ${quote.quote} conflicts with pending operation ownership`, ); } @@ -343,7 +349,7 @@ export class MintBolt11Handler implements MintMethodHandler<'bolt11'> { if (!pubkey) return; const key = await this.keyRingService.getMintQuoteKeyPair(pubkey); if (!key) { - throw new Error('Missing NUT-20 mint quote key for locked BOLT11 quote'); + throw new MintQuoteKeyError('Missing NUT-20 mint quote key for locked BOLT11 quote'); } } @@ -353,7 +359,7 @@ export class MintBolt11Handler implements MintMethodHandler<'bolt11'> { if (!pubkey) return undefined; const key = await this.keyRingService.getMintQuoteKeyPair(pubkey); if (!key) { - throw new Error('Missing NUT-20 mint quote key for locked BOLT11 quote'); + throw new MintQuoteKeyError('Missing NUT-20 mint quote key for locked BOLT11 quote'); } return { privkey: bytesToHex(key.secretKey) }; } diff --git a/packages/core/infra/handlers/mint/MintBolt12Handler.ts b/packages/core/infra/handlers/mint/MintBolt12Handler.ts index 4f7d95cc..6bdfb203 100644 --- a/packages/core/infra/handlers/mint/MintBolt12Handler.ts +++ b/packages/core/infra/handlers/mint/MintBolt12Handler.ts @@ -3,7 +3,11 @@ import { assertSameUnit, normalizeUnitAmount } from '@core/amounts'; import type { KeyRingService } from '@core/services'; import { deserializeOutputData, mapProofToCoreProof, serializeOutputData } from '@core/utils'; import { bytesToHex } from '@noble/curves/utils.js'; -import { MintOperationError } from '../../../models/Error'; +import { + MintOperationError, + MintQuoteKeyError, + MintQuoteValidationError, +} from '../../../models/Error'; import { mintQuoteFromBolt12Response, type MintQuote } from '../../../models/MintQuote'; import { mintQuoteObservationFromBolt12Response } from '../../../models/MintQuoteObservationFactory'; import type { @@ -73,7 +77,7 @@ export class MintBolt12Handler implements MintMethodHandler<'bolt12'> { } if (ctx.operation.quoteId !== quote.quote) { - throw new Error( + throw new MintQuoteValidationError( `Mint quote ${quote.quote} does not match operation quote ${ctx.operation.quoteId}`, ); } @@ -108,7 +112,7 @@ export class MintBolt12Handler implements MintMethodHandler<'bolt12'> { async execute(ctx: ExecuteContext<'bolt12'>): Promise { const quoteKey = await this.keyRingService.getMintQuoteKeyPair(ctx.operation.pubkey ?? ''); if (!quoteKey) { - throw new Error( + throw new MintQuoteKeyError( `Missing NUT-20 mint quote key for pubkey ${ctx.operation.pubkey ?? '(missing)'}`, ); } @@ -316,7 +320,7 @@ export class MintBolt12Handler implements MintMethodHandler<'bolt12'> { private async requireQuoteKey(pubkey: string): Promise { const quoteKey = await this.keyRingService.getMintQuoteKeyPair(pubkey); if (!quoteKey) { - throw new Error(`Missing NUT-20 mint quote key for pubkey ${pubkey}`); + throw new MintQuoteKeyError(`Missing NUT-20 mint quote key for pubkey ${pubkey}`); } } @@ -327,7 +331,7 @@ export class MintBolt12Handler implements MintMethodHandler<'bolt12'> { expectedAmount?: Amount, ): void { if (quote.pubkey !== expectedPubkey) { - throw new Error( + throw new MintQuoteValidationError( `BOLT12 mint quote ${quote.quote} returned pubkey ${quote.pubkey} instead of requested pubkey ${expectedPubkey}`, ); } @@ -336,7 +340,7 @@ export class MintBolt12Handler implements MintMethodHandler<'bolt12'> { this.assertQuoteAmount(quote, expectedAmount); if (Amount.from(quote.amount_paid).lessThan(Amount.from(quote.amount_issued))) { - throw new Error( + throw new MintQuoteValidationError( `BOLT12 mint quote ${quote.quote} has amount_issued greater than amount_paid`, ); } @@ -349,7 +353,7 @@ export class MintBolt12Handler implements MintMethodHandler<'bolt12'> { if (!quote.amount || !quote.amount.equals(expectedAmount)) { const observedAmount = quote.amount ?? '(missing)'; - throw new Error( + throw new MintQuoteValidationError( `Mint quote ${quote.quote} amount ${observedAmount} ` + `does not match requested amount ${expectedAmount}`, ); diff --git a/packages/core/infra/handlers/mint/MintOnchainHandler.ts b/packages/core/infra/handlers/mint/MintOnchainHandler.ts index ff5b313c..1dc9531a 100644 --- a/packages/core/infra/handlers/mint/MintOnchainHandler.ts +++ b/packages/core/infra/handlers/mint/MintOnchainHandler.ts @@ -7,7 +7,11 @@ import { assertSameUnit } from '@core/amounts'; import type { KeyRingService } from '@core/services'; import { deserializeOutputData, mapProofToCoreProof, serializeOutputData } from '@core/utils'; import { bytesToHex } from '@noble/curves/utils.js'; -import { MintOperationError } from '../../../models/Error'; +import { + MintOperationError, + MintQuoteKeyError, + MintQuoteValidationError, +} from '../../../models/Error'; import { mintQuoteFromOnchainResponse, type MintQuote, @@ -68,7 +72,7 @@ export class MintOnchainHandler implements MintMethodHandler<'onchain'> { } if (ctx.operation.quoteId !== quote.quote) { - throw new Error( + throw new MintQuoteValidationError( `Mint quote ${quote.quote} does not match operation quote ${ctx.operation.quoteId}`, ); } @@ -103,7 +107,7 @@ export class MintOnchainHandler implements MintMethodHandler<'onchain'> { async execute(ctx: ExecuteContext<'onchain'>): Promise { const quoteKey = await this.keyRingService.getMintQuoteKeyPair(ctx.operation.pubkey ?? ''); if (!quoteKey) { - throw new Error( + throw new MintQuoteKeyError( `Missing NUT-20 mint quote key for pubkey ${ctx.operation.pubkey ?? '(missing)'}`, ); } @@ -296,7 +300,7 @@ export class MintOnchainHandler implements MintMethodHandler<'onchain'> { private async requireQuoteKey(pubkey: string): Promise { const quoteKey = await this.keyRingService.getMintQuoteKeyPair(pubkey); if (!quoteKey) { - throw new Error(`Missing NUT-20 mint quote key for pubkey ${pubkey}`); + throw new MintQuoteKeyError(`Missing NUT-20 mint quote key for pubkey ${pubkey}`); } } @@ -306,7 +310,7 @@ export class MintOnchainHandler implements MintMethodHandler<'onchain'> { expectedUnit: string, ): void { if (quote.pubkey !== expectedPubkey) { - throw new Error( + throw new MintQuoteValidationError( `Onchain mint quote ${quote.quote} returned pubkey ${quote.pubkey} instead of requested pubkey ${expectedPubkey}`, ); } @@ -318,7 +322,7 @@ export class MintOnchainHandler implements MintMethodHandler<'onchain'> { Amount.from(quote.amount_issued ?? Amount.zero()), ) ) { - throw new Error( + throw new MintQuoteValidationError( `Onchain mint quote ${quote.quote} has amount_issued greater than amount_paid`, ); } diff --git a/packages/core/models/Error.ts b/packages/core/models/Error.ts index d333ac9c..a1cea0da 100644 --- a/packages/core/models/Error.ts +++ b/packages/core/models/Error.ts @@ -37,6 +37,22 @@ export class ProofValidationError extends Error { } } +export class MintQuoteValidationError extends Error { + constructor(message: string, cause?: unknown) { + super(message); + this.name = 'MintQuoteValidationError'; + (this as unknown as { cause?: unknown }).cause = cause; + } +} + +export class MintQuoteKeyError extends Error { + constructor(message: string, cause?: unknown) { + super(message); + this.name = 'MintQuoteKeyError'; + (this as unknown as { cause?: unknown }).cause = cause; + } +} + export class UnitValidationError extends Error { constructor(message: string) { super(message); diff --git a/packages/core/models/MintQuote.ts b/packages/core/models/MintQuote.ts index d7a167e5..a7454bb6 100644 --- a/packages/core/models/MintQuote.ts +++ b/packages/core/models/MintQuote.ts @@ -4,7 +4,7 @@ import { type MintQuoteBolt12Response, type MintQuoteOnchainResponse as CashuMintQuoteOnchainResponse, } from '@cashu/cashu-ts'; -import { ProofValidationError } from './Error'; +import { MintQuoteValidationError } from './Error'; import { mintQuoteObservationFromBolt11Response, mintQuoteObservationFromBolt12Response, @@ -193,7 +193,7 @@ function assertValidMintQuoteAccounting( amountIssued: Amount, ): void { if (amountIssued.greaterThan(amountPaid)) { - throw new ProofValidationError( + throw new MintQuoteValidationError( `Mint quote ${quoteId} has amount_issued greater than amount_paid`, ); } diff --git a/packages/core/quotes/QuoteLifecycle.ts b/packages/core/quotes/QuoteLifecycle.ts index 86b944cc..00ef8c91 100644 --- a/packages/core/quotes/QuoteLifecycle.ts +++ b/packages/core/quotes/QuoteLifecycle.ts @@ -28,6 +28,7 @@ import { meltQuoteToMethodSnapshot, type MeltQuote } from '../models/MeltQuote'; import { HttpResponseError, MintOperationError, + MintQuoteValidationError, NetworkError, ProofValidationError, QuoteIdentityConflictError, @@ -92,7 +93,7 @@ function assertMintQuotePollingSnapshotStructureUnchecked( !Number.isSafeInteger(snapshot.expiry)) || (snapshot.pubkey !== undefined && typeof snapshot.pubkey !== 'string') ) { - throw new ProofValidationError('Mint quote batch observation has invalid base fields'); + throw new MintQuoteValidationError('Mint quote batch observation has invalid base fields'); } if (method === 'bolt11') { @@ -103,7 +104,7 @@ function assertMintQuotePollingSnapshotStructureUnchecked( const hasAmountIssued = bolt11.amount_issued !== undefined; const hasCompleteAccounting = hasAmountPaid && hasAmountIssued; if (amount.isZero() || !hasCompleteAccounting) { - throw new ProofValidationError('BOLT11 mint quote batch observation is invalid'); + throw new MintQuoteValidationError('BOLT11 mint quote batch observation is invalid'); } if ( hasState && @@ -111,10 +112,10 @@ function assertMintQuotePollingSnapshotStructureUnchecked( bolt11.state !== 'PAID' && bolt11.state !== 'ISSUED' ) { - throw new ProofValidationError('BOLT11 mint quote batch observation is invalid'); + throw new MintQuoteValidationError('BOLT11 mint quote batch observation is invalid'); } if (!hasState) { - throw new ProofValidationError('BOLT11 mint quote batch observation is invalid'); + throw new MintQuoteValidationError('BOLT11 mint quote batch observation is invalid'); } if (hasCompleteAccounting) { Amount.from(bolt11.amount_paid!); @@ -127,7 +128,9 @@ function assertMintQuotePollingSnapshotStructureUnchecked( | MintMethodQuoteSnapshot<'bolt12'> | MintMethodQuoteSnapshot<'onchain'>; if (!hasReusableSettlementAmounts(reusable)) { - throw new ProofValidationError(`${method} mint quote batch observation lacks settlement data`); + throw new MintQuoteValidationError( + `${method} mint quote batch observation lacks settlement data`, + ); } Amount.from(reusable.amount_paid ?? Amount.zero()); Amount.from(reusable.amount_issued ?? Amount.zero()); @@ -145,17 +148,16 @@ function assertMintQuotePollingSnapshotStructure( try { return assertMintQuotePollingSnapshotStructureUnchecked(method, snapshot); } catch (error) { - if (error instanceof ProofValidationError) throw error; - const validationError = new ProofValidationError( + if (error instanceof MintQuoteValidationError) throw error; + throw new MintQuoteValidationError( `${method} mint quote batch observation has invalid amount fields`, + error, ); - (validationError as Error & { cause?: unknown }).cause = error; - throw validationError; } } function isDefinitiveMintQuotePollingValidation(error: Error): boolean { - return error instanceof ProofValidationError || error instanceof QuoteIdentityConflictError; + return error instanceof MintQuoteValidationError || error instanceof QuoteIdentityConflictError; } function equalOptionalAmount( @@ -587,7 +589,7 @@ export class QuoteLifecycle { return failedMintQuotePollingResult( normalizedIdentities, 'validation', - new ProofValidationError( + new MintQuoteValidationError( 'Mint quote polling selections require one mint and unique non-empty quote identities', ), ); @@ -596,7 +598,7 @@ export class QuoteLifecycle { return failedMintQuotePollingResult( normalizedIdentities, 'validation', - new ProofValidationError(`Unsupported built-in mint quote polling method ${method}`), + new MintQuoteValidationError(`Unsupported built-in mint quote polling method ${method}`), ); } if (!(await this.mintService.isTrustedMint(mintUrl))) { @@ -619,7 +621,7 @@ export class QuoteLifecycle { return failedMintQuotePollingResult( normalizedIdentities, 'incompatibility', - new ProofValidationError( + new MintQuoteValidationError( `Mint ${mintUrl} does not advertise NUT-29 quote checks for ${method}`, ), ); @@ -652,7 +654,7 @@ export class QuoteLifecycle { return failedMintQuotePollingResult( normalizedIdentities, 'malformed-response', - new ProofValidationError('Mint quote batch check returned a non-array response'), + new MintQuoteValidationError('Mint quote batch check returned a non-array response'), ); } const snapshots = response; @@ -666,7 +668,7 @@ export class QuoteLifecycle { if (!snapshot || typeof snapshot !== 'object') { responseFailures.push({ category: 'malformed-response', - error: new ProofValidationError( + error: new MintQuoteValidationError( `Mint quote batch response element ${responseIndex} has no identity`, ), responseIndex, @@ -677,7 +679,7 @@ export class QuoteLifecycle { if (typeof responseQuoteId !== 'string' || responseQuoteId.length === 0) { responseFailures.push({ category: 'malformed-response', - error: new ProofValidationError( + error: new MintQuoteValidationError( `Mint quote batch response element ${responseIndex} has no identity`, ), responseIndex, @@ -687,7 +689,7 @@ export class QuoteLifecycle { if (!selectedQuoteIds.has(responseQuoteId)) { responseFailures.push({ category: 'malformed-response', - error: new ProofValidationError( + error: new MintQuoteValidationError( `Mint quote batch response contains unselected quote ${responseQuoteId}`, ), responseIndex, @@ -719,7 +721,7 @@ export class QuoteLifecycle { hasDefinitiveBatchFailure = true; failedByQuoteId.set(identity.quoteId, { category: 'malformed-response', - error: new ProofValidationError( + error: new MintQuoteValidationError( `Mint quote batch response is missing quote ${identity.quoteId}`, ), responseQuoteId: identity.quoteId, @@ -783,7 +785,7 @@ export class QuoteLifecycle { hasDefinitiveBatchFailure = true; failedByQuoteId.set(identity.quoteId, { category: 'malformed-response', - error: new ProofValidationError( + error: new MintQuoteValidationError( `Mint quote batch response contains conflicting duplicates for quote ${identity.quoteId}`, ), responseQuoteId: identity.quoteId, @@ -797,7 +799,7 @@ export class QuoteLifecycle { responseFailures.push( ...validCandidates.slice(1).map(({ responseIndex }) => ({ category: 'malformed-response' as const, - error: new ProofValidationError( + error: new MintQuoteValidationError( `Mint quote batch response contains duplicate quote ${identity.quoteId}`, ), responseIndex, @@ -853,7 +855,7 @@ export class QuoteLifecycle { snapshot = assertMintQuotePollingSnapshotStructure(method, snapshot); const existing = await this.mintQuoteRepository.getMintQuoteById({ mintUrl, quoteId }); if (!existing) { - throw new ProofValidationError( + throw new MintQuoteValidationError( `Mint quote ${quoteId} batch observation has no canonical quote`, ); } @@ -866,7 +868,7 @@ export class QuoteLifecycle { normalizeUnit(snapshot.unit) !== existing.unit || (snapshot.pubkey ?? undefined) !== (existing.pubkey ?? undefined) ) { - throw new ProofValidationError( + throw new MintQuoteValidationError( `Mint quote ${quoteId} batch observation conflicts with canonical identity fields`, ); } @@ -876,7 +878,7 @@ export class QuoteLifecycle { (snapshot as MintMethodQuoteSnapshot<'bolt11'>).amount as AmountLike, ); if (!amount.equals(existing.amount)) { - throw new ProofValidationError( + throw new MintQuoteValidationError( `Mint quote ${quoteId} batch observation conflicts with canonical amount`, ); } @@ -892,7 +894,7 @@ export class QuoteLifecycle { existingAmount !== undefined && !Amount.from(incomingAmount as AmountLike).equals(existingAmount)) ) { - throw new ProofValidationError( + throw new MintQuoteValidationError( `Mint quote ${quoteId} batch observation conflicts with canonical amount`, ); } @@ -1022,7 +1024,7 @@ export class QuoteLifecycle { ): MintMethodQuoteSnapshot { const reportedMethod = (quote as { method?: unknown }).method; if (reportedMethod !== undefined && reportedMethod !== method) { - throw new ProofValidationError( + throw new MintQuoteValidationError( `Mint quote ${quote.quote} reports method ${String(reportedMethod)} instead of ${method}`, ); } @@ -1033,25 +1035,31 @@ export class QuoteLifecycle { updatedAt !== null && (typeof updatedAt !== 'number' || !Number.isSafeInteger(updatedAt)) ) { - throw new ProofValidationError(`Mint quote ${quote.quote} has invalid updated_at`); + throw new MintQuoteValidationError(`Mint quote ${quote.quote} has invalid updated_at`); } if (method === 'bolt11') { const bolt11Quote = quote as MintMethodQuoteImportSnapshot<'bolt11'>; const rawAmount = (bolt11Quote as { amount?: unknown }).amount; if (rawAmount === undefined || rawAmount === null) { - throw new ProofValidationError('Mint quote ' + bolt11Quote.quote + ' has invalid amount'); + throw new MintQuoteValidationError( + 'Mint quote ' + bolt11Quote.quote + ' has invalid amount', + ); } const amount = Amount.from(rawAmount as AmountLike); if (amount.isZero()) { - throw new ProofValidationError('Mint quote ' + bolt11Quote.quote + ' has invalid amount'); + throw new MintQuoteValidationError( + 'Mint quote ' + bolt11Quote.quote + ' has invalid amount', + ); } const hasAmountPaid = bolt11Quote.amount_paid !== undefined; const hasAmountIssued = bolt11Quote.amount_issued !== undefined; if (hasAmountPaid !== hasAmountIssued) { - throw new ProofValidationError(`Mint quote ${bolt11Quote.quote} has incomplete accounting`); + throw new MintQuoteValidationError( + `Mint quote ${bolt11Quote.quote} has incomplete accounting`, + ); } let amountPaid: Amount; @@ -1069,13 +1077,13 @@ export class QuoteLifecycle { amountPaid = amount; amountIssued = amount; } else { - throw new ProofValidationError( + throw new MintQuoteValidationError( `Mint quote ${bolt11Quote.quote} lacks accounting and compatibility state`, ); } if (amountIssued.greaterThan(amountPaid)) { - throw new ProofValidationError( + throw new MintQuoteValidationError( `Mint quote ${bolt11Quote.quote} has amount_issued greater than amount_paid`, ); } @@ -1135,12 +1143,16 @@ export class QuoteLifecycle { const bolt11Quote = quote as MintMethodQuoteSnapshot<'bolt11'>; const rawAmount = (bolt11Quote as { amount?: unknown }).amount; if (rawAmount === undefined || rawAmount === null) { - throw new ProofValidationError('Mint quote ' + bolt11Quote.quote + ' has invalid amount'); + throw new MintQuoteValidationError( + 'Mint quote ' + bolt11Quote.quote + ' has invalid amount', + ); } const amount = Amount.from(rawAmount as AmountLike); if (amount.isZero()) { - throw new ProofValidationError('Mint quote ' + bolt11Quote.quote + ' has invalid amount'); + throw new MintQuoteValidationError( + 'Mint quote ' + bolt11Quote.quote + ' has invalid amount', + ); } const response = { ...bolt11Quote, amount }; @@ -1281,7 +1293,7 @@ export class QuoteLifecycle { ); } if (!isStatefulMintQuote(existing)) { - throw new Error( + throw new MintQuoteValidationError( `Cannot record legacy quote state for ${operation.method} mint quote ${operation.quoteId}`, ); } diff --git a/packages/core/test/unit/MintBolt11Handler.test.ts b/packages/core/test/unit/MintBolt11Handler.test.ts index 661e0a83..f2c07ba1 100644 --- a/packages/core/test/unit/MintBolt11Handler.test.ts +++ b/packages/core/test/unit/MintBolt11Handler.test.ts @@ -3,7 +3,11 @@ import { bytesToHex } from '@noble/curves/utils.js'; import { describe, it, beforeEach, expect, mock, type Mock } from 'bun:test'; import { OutputData, type MintQuoteBolt11Response, type Wallet } from '@cashu/cashu-ts'; import { MintBolt11Handler } from '../../infra/handlers/mint/MintBolt11Handler'; -import { MintOperationError } from '../../models/Error'; +import { + MintOperationError, + MintQuoteKeyError, + MintQuoteValidationError, +} from '../../models/Error'; import { EventBus } from '../../events/EventBus'; import type { CoreEvents } from '../../events/types'; import type { @@ -281,15 +285,18 @@ describe('MintBolt11Handler', () => { it('rejects an owned locking key before remote creation when it is not persisted', async () => { (keyRingService.getMintQuoteKeyPair as Mock).mockResolvedValueOnce(null); - await expect( - handler.createQuote({ + const error = await handler + .createQuote({ ...buildCreateQuoteContext(), createQuoteData: { amount: { amount: Amount.from(10), unit: 'sat' }, ownedPubkey: quotePubkey, }, - }), - ).rejects.toThrow('Missing NUT-20 mint quote key'); + }) + .catch((caught) => caught); + + expect(error).toBeInstanceOf(MintQuoteKeyError); + expect(error.message).toContain('Missing NUT-20 mint quote key'); expect(mintService.assertNutSupported).toHaveBeenCalledWith( mintUrl, @@ -298,6 +305,28 @@ describe('MintBolt11Handler', () => { ); expect(wallet.createLockedMintQuote).not.toHaveBeenCalled(); }); + + it('rejects a locked quote whose returned public key does not match the persisted key', async () => { + (wallet.createLockedMintQuote as Mock).mockResolvedValueOnce({ + ...quote, + pubkey: `03${'22'.repeat(32)}`, + }); + + const error = await handler + .createQuote({ + ...buildCreateQuoteContext(), + createQuoteData: { + amount: { amount: Amount.from(10), unit: 'sat' }, + locked: true, + }, + }) + .catch((caught) => caught); + + expect(error).toBeInstanceOf(MintQuoteValidationError); + expect(error.message).toBe( + 'Mint returned a BOLT11 quote with an unexpected NUT-20 public key', + ); + }); }); describe('execute', () => { @@ -321,18 +350,29 @@ describe('MintBolt11Handler', () => { ]); }); - it('preserves mint error codes and diagnostic details', async () => { - (wallet.mintProofsBolt11 as Mock).mockImplementation(async () => { - throw new MintOperationError(20007, `Quote ${quoteId} expired`); - }); + it('rethrows mint operation errors unchanged', async () => { + const originalError = new MintOperationError(20007, `Quote ${quoteId} expired`); + (wallet.mintProofsBolt11 as Mock).mockRejectedValueOnce(originalError); const error = await handler .execute(buildExecuteContext({ ...executingOperation, pubkey: quotePubkey })) .catch((caught) => caught); - expect(error).toBeInstanceOf(MintOperationError); + expect(error).toBe(originalError); expect(error.code).toBe(20007); - expect(error.message).toContain(`Quote ${quoteId} expired`); + expect(error.message).toBe(`Quote ${quoteId} expired`); + }); + + it('reports a missing locked-quote signing key as a domain validation error', async () => { + (keyRingService.getMintQuoteKeyPair as Mock).mockResolvedValueOnce(null); + + const error = await handler + .execute(buildExecuteContext({ ...executingOperation, pubkey: quotePubkey })) + .catch((caught) => caught); + + expect(error).toBeInstanceOf(MintQuoteKeyError); + expect(error.message).toContain('Missing NUT-20 mint quote key'); + expect(wallet.mintProofsBolt11).not.toHaveBeenCalled(); }); it('preserves non-protocol locked issuance errors as the cause', async () => { diff --git a/packages/core/test/unit/MintBolt12Handler.test.ts b/packages/core/test/unit/MintBolt12Handler.test.ts index 0ed2da91..eeedde66 100644 --- a/packages/core/test/unit/MintBolt12Handler.test.ts +++ b/packages/core/test/unit/MintBolt12Handler.test.ts @@ -6,6 +6,7 @@ import type { CoreEvents } from '../../events/types'; import type { MintAdapter } from '../../infra'; import { MintBolt12Handler } from '../../infra/handlers/mint/MintBolt12Handler'; import type { Logger } from '../../logging/Logger'; +import { MintQuoteKeyError, MintQuoteValidationError } from '../../models/Error'; import { mintQuoteFromBolt12Response } from '../../models/MintQuote'; import type { ProofRepository } from '../../repositories'; import type { KeyRingService } from '../../services/KeyRingService'; @@ -209,16 +210,19 @@ describe('MintBolt12Handler', () => { quote({ amount: undefined }), ); - await expect( - handler.createQuote({ + const error = await handler + .createQuote({ ...buildPrepareContext(), mintUrl, createQuoteData: { unit: 'sat', amount: { amount: Amount.from(10), unit: 'sat' }, }, - }), - ).rejects.toThrow('does not match requested amount'); + }) + .catch((caught) => caught); + + expect(error).toBeInstanceOf(MintQuoteValidationError); + expect(error.message).toContain('does not match requested amount'); }); it('rejects fixed-amount quotes when the mint returns a null response amount', async () => { @@ -328,9 +332,12 @@ describe('MintBolt12Handler', () => { it('requires the imported quote pubkey to exist in the keyring', async () => { (keyRingService.getMintQuoteKeyPair as Mock).mockImplementation(async () => null); - await expect(handler.prepare(buildPrepareContext({ importedQuote: quote() }))).rejects.toThrow( - 'Missing NUT-20 mint quote key', - ); + const error = await handler + .prepare(buildPrepareContext({ importedQuote: quote() })) + .catch((caught) => caught); + + expect(error).toBeInstanceOf(MintQuoteKeyError); + expect(error.message).toContain('Missing NUT-20 mint quote key'); }); it('marks amountless quotes ready when paid amount covers the operation amount', async () => { @@ -423,16 +430,21 @@ describe('MintBolt12Handler', () => { it('rejects execution when the remote quote pubkey changes', async () => { const changedPubkey = '02' + '22'.repeat(32); - await expect( - handler.execute( + const error = await handler + .execute( buildExecuteContext( quote({ amount_paid: Amount.from(10), pubkey: changedPubkey, }), ), - ), - ).rejects.toThrow(`BOLT12 mint quote ${quoteId} returned pubkey ${changedPubkey}`); + ) + .catch((caught) => caught); + + expect(error).toBeInstanceOf(MintQuoteValidationError); + expect(error.message).toContain( + `BOLT12 mint quote ${quoteId} returned pubkey ${changedPubkey}`, + ); expect(wallet.mintProofsBolt12).not.toHaveBeenCalled(); }); diff --git a/packages/core/test/unit/MintOnchainHandler.test.ts b/packages/core/test/unit/MintOnchainHandler.test.ts index 75b6cae8..7868d200 100644 --- a/packages/core/test/unit/MintOnchainHandler.test.ts +++ b/packages/core/test/unit/MintOnchainHandler.test.ts @@ -5,7 +5,11 @@ import type { CoreEvents } from '../../events/types'; import { MintOnchainHandler } from '../../infra/handlers/mint/MintOnchainHandler'; import type { MintAdapter } from '../../infra/MintAdapter'; import type { Logger } from '../../logging/Logger'; -import { MintOperationError } from '../../models/Error'; +import { + MintOperationError, + MintQuoteKeyError, + MintQuoteValidationError, +} from '../../models/Error'; import type { CreateMintQuoteContext, ExecuteContext, @@ -246,9 +250,10 @@ describe('MintOnchainHandler', () => { pubkey: '02'.padEnd(66, '2'), })); - await expect(handler.createQuote(buildCreateQuoteContext())).rejects.toThrow( - 'instead of requested pubkey', - ); + const error = await handler.createQuote(buildCreateQuoteContext()).catch((caught) => caught); + + expect(error).toBeInstanceOf(MintQuoteValidationError); + expect(error.message).toContain('instead of requested pubkey'); }); it('fetches the latest onchain quote through the mint adapter', async () => { @@ -283,9 +288,12 @@ describe('MintOnchainHandler', () => { it('fails onchain preparation when the quote key is missing', async () => { (keyRingService.getMintQuoteKeyPair as Mock).mockResolvedValueOnce(null); - await expect( - handler.prepare({ ...buildPrepareContext(), importedQuote: remoteQuote }), - ).rejects.toThrow('Missing NUT-20 mint quote key'); + const error = await handler + .prepare({ ...buildPrepareContext(), importedQuote: remoteQuote }) + .catch((caught) => caught); + + expect(error).toBeInstanceOf(MintQuoteKeyError); + expect(error.message).toContain('Missing NUT-20 mint quote key'); }); it('executes onchain mint proofs with the persisted quote key', async () => { diff --git a/packages/core/test/unit/MintOperationService.test.ts b/packages/core/test/unit/MintOperationService.test.ts index be8952d1..f3f697c0 100644 --- a/packages/core/test/unit/MintOperationService.test.ts +++ b/packages/core/test/unit/MintOperationService.test.ts @@ -44,7 +44,7 @@ import type { MintAdapter } from '../../infra/MintAdapter'; import type { Logger } from '../../logging/Logger'; import { serializeOutputData } from '../../utils'; import type { CoreProof } from '../../types'; -import { QuoteIdentityConflictError } from '../../models/Error'; +import { MintQuoteValidationError, QuoteIdentityConflictError } from '../../models/Error'; describe('MintOperationService', () => { const mintUrl = 'https://mint.test'; @@ -2632,6 +2632,25 @@ describe('MintOperationService', () => { expect(persistedDuringEvent).toEqual(['PAID']); }); + it('recordQuoteObservation rejects legacy state for reusable quotes with a domain error', async () => { + const onchainQuoteId = 'onchain-legacy-state'; + await persistOnchainQuote(onchainQuoteId); + const pendingOp: PendingMintOperation<'onchain'> = { + ...makePendingOp('pending-onchain-legacy-state'), + method: 'onchain', + quoteId: onchainQuoteId, + request: 'bc1qtest', + pubkey: '02'.padEnd(66, '1'), + }; + + const error = await quoteLifecycle + .recordMintQuoteObservation(pendingOp, 'PAID') + .catch((caught) => caught); + + expect(error).toBeInstanceOf(MintQuoteValidationError); + expect(error.message).toContain('Cannot record legacy quote state for onchain mint quote'); + }); + it('recordQuoteObservation cannot override newer ordered accounting', async () => { const initialExpiry = Math.floor(Date.now() / 1000) + 3600; const newerExpiry = initialExpiry + 3600; diff --git a/packages/core/test/unit/MintQuote.test.ts b/packages/core/test/unit/MintQuote.test.ts index 165fe372..be5d5039 100644 --- a/packages/core/test/unit/MintQuote.test.ts +++ b/packages/core/test/unit/MintQuote.test.ts @@ -5,6 +5,7 @@ import { } from '@cashu/cashu-ts'; import { describe, expect, it } from 'bun:test'; +import { MintQuoteValidationError } from '../../models/Error'; import { applyBolt11MintQuoteStateFallback, deriveBolt11MintQuoteState, @@ -65,7 +66,7 @@ describe('MintQuote model', () => { }); it('rejects contradictory BOLT11 accounting', () => { - expect(() => + const createQuote = () => mintQuoteFromBolt11Response('https://mint.test', { quote: 'quote-invalid-accounting', request: 'lnbc...', @@ -77,8 +78,10 @@ describe('MintQuote model', () => { amount_paid: Amount.from(100), amount_issued: Amount.from(101), updated_at: 55, - } satisfies MintQuoteBolt11Response), - ).toThrow('amount_issued greater than amount_paid'); + } satisfies MintQuoteBolt11Response); + + expect(createQuote).toThrow(MintQuoteValidationError); + expect(createQuote).toThrow('amount_issued greater than amount_paid'); }); it('uses canonical predicates for ready and terminal BOLT11 quotes', () => { diff --git a/packages/core/test/unit/QuoteLifecyclePolling.test.ts b/packages/core/test/unit/QuoteLifecyclePolling.test.ts index 9ea8909c..97307630 100644 --- a/packages/core/test/unit/QuoteLifecyclePolling.test.ts +++ b/packages/core/test/unit/QuoteLifecyclePolling.test.ts @@ -7,7 +7,12 @@ import { PollingTransport } from '../../infra/PollingTransport.ts'; import type { MeltHandlerProvider } from '../../infra/handlers/melt/index.ts'; import type { MintHandlerProvider } from '../../infra/handlers/mint/index.ts'; import { NullLogger } from '../../logging/NullLogger.ts'; -import { HttpResponseError, MintOperationError, NetworkError } from '../../models/Error.ts'; +import { + HttpResponseError, + MintOperationError, + MintQuoteValidationError, + NetworkError, +} from '../../models/Error.ts'; import { type MintQuote } from '../../models/MintQuote.ts'; import { cashuNormalizedBolt11Fixture, @@ -778,6 +783,10 @@ describe('QuoteLifecycle mint quote polling', () => { outcome.status === 'failed' ? outcome.failure.category : outcome.status, ), ).toEqual(['malformed-response', 'malformed-response']); + for (const outcome of result.outcomes) { + if (outcome.status !== 'failed') throw new Error('Expected a failed polling outcome'); + expect(outcome.failure.error).toBeInstanceOf(MintQuoteValidationError); + } expect(result.responseFailures).toEqual([]); });