From 2675637afffd24fa6ebc3afaa303686e35e2d1ff Mon Sep 17 00:00:00 2001 From: Egge Date: Wed, 29 Jul 2026 15:02:50 +0000 Subject: [PATCH] refactor(core): centralize mint quote claimability --- .../canonical-mint-quote-claimability.md | 10 + CONTEXT.md | 6 +- FEATURE_TODO.md | 21 ++ packages/adapter-tests/src/index.ts | 32 ++ packages/core/Manager.ts | 13 +- .../infra/handlers/mint/MintBolt11Handler.ts | 96 +++--- .../infra/handlers/mint/MintBolt12Handler.ts | 71 +++-- .../infra/handlers/mint/MintOnchainHandler.ts | 77 ++--- packages/core/models/MintQuote.ts | 15 +- packages/core/models/MintQuoteClaimability.ts | 104 ++++++ .../core/operations/mint/MintMethodHandler.ts | 4 + .../operations/mint/MintOperationService.ts | 197 +++++++----- packages/core/quotes/QuoteLifecycle.ts | 7 +- .../watchers/MintOperationProcessor.ts | 64 +--- .../watchers/MintOperationWatcherService.ts | 62 ++-- packages/core/test/unit/Manager.test.ts | 6 +- .../core/test/unit/MintBolt11Handler.test.ts | 24 +- .../core/test/unit/MintBolt12Handler.test.ts | 22 +- .../core/test/unit/MintOnchainHandler.test.ts | 20 +- .../test/unit/MintOperationService.test.ts | 68 +++- .../unit/MintOperationWatcherService.test.ts | 38 ++- .../test/unit/MintQuoteClaimability.test.ts | 298 ++++++++++++++++++ .../core/test/unit/MintQuoteProcessor.test.ts | 195 ++++++++---- .../src/repositories/MintQuoteRepository.ts | 2 +- 24 files changed, 1070 insertions(+), 382 deletions(-) create mode 100644 .changeset/canonical-mint-quote-claimability.md create mode 100644 FEATURE_TODO.md create mode 100644 packages/core/models/MintQuoteClaimability.ts create mode 100644 packages/core/test/unit/MintQuoteClaimability.test.ts diff --git a/.changeset/canonical-mint-quote-claimability.md b/.changeset/canonical-mint-quote-claimability.md new file mode 100644 index 00000000..894ecde5 --- /dev/null +++ b/.changeset/canonical-mint-quote-claimability.md @@ -0,0 +1,10 @@ +--- +'@cashu/coco-core': patch +'@cashu/coco-adapter-tests': patch +'@cashu/coco-sqlite': patch +'@cashu/coco-sqlite-bun': patch +'@cashu/coco-expo-sqlite': patch +--- + +Centralize Mint Quote Claimability on canonical accounting so atomic BOLT11 and balance-based +BOLT12/on-chain callers share readiness, reservation, scheduling, and recovery behavior. diff --git a/CONTEXT.md b/CONTEXT.md index 5c27d9fd..f21f07c0 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -78,9 +78,9 @@ older `PENDING` observation. _Avoid_: Payment status, melt lifecycle **Mint Quote Claimability**: -Whether a mint quote currently has paid value that coco can claim into proofs. BOLT11 mint quotes -are claimable when their state is `PAID`; reusable mint quotes are claimable when their paid amount -exceeds their issued amount. +Whether Mint Quote Accounting leaves paid value that Coco may claim into proofs after accounting +for completed local issuance and Mint Quote Reservations. Fixed BOLT11 quotes are all-or-nothing; +BOLT12 and on-chain quotes can remain claimable across partial balance claims. _Avoid_: Mint quote paid state, payment status **Mint Quote Accounting**: diff --git a/FEATURE_TODO.md b/FEATURE_TODO.md new file mode 100644 index 00000000..07f5e7ad --- /dev/null +++ b/FEATURE_TODO.md @@ -0,0 +1,21 @@ +# Issue #387 — Centralize Mint Quote Claimability + +- [x] Fetch #387, parent #294, blockers, domain glossary, and relevant ADRs. +- [x] Confirm #300 and #390 are closed and move `ready-for-agent` to #387. +- [x] Create a fresh worktree from `origin/master` at `1601cf7`. +- [x] Inventory all Mint Quote Claimability callers and existing tests. +- [x] Test-drive one pure Claimability interface for atomic and balance policies. +- [x] Migrate model helpers, operation orchestration, watchers, handlers, lifecycle, and manager. +- [x] Rewrite superseded tests around the common assessment and preserve method-specific coverage. +- [x] Rewrite only the `Mint Quote Claimability` glossary entry in `CONTEXT.md`. +- [x] Run core unit tests, typecheck, build, formatting, and affected adapter suites. +- [x] Review against repository standards and issue #387. + +## Scope boundaries + +- Do not introduce Mint Issuance Attempts, Mint Batches, or the Mint Issuance Engine. +- Do not redesign the full `MintMethodHandler` interface. +- Do not change Melt Quote semantics. +- Keep BOLT11 `state` only for compatibility projection and import behavior. +- Keep Mint Quote expiry out of Claimability and caller decisions. +- Preserve Quote Observation persistence before Quote-backed Operation advancement. diff --git a/packages/adapter-tests/src/index.ts b/packages/adapter-tests/src/index.ts index b896ee87..62b00a61 100644 --- a/packages/adapter-tests/src/index.ts +++ b/packages/adapter-tests/src/index.ts @@ -618,6 +618,38 @@ export async function runMintOperationRepositoryContract( } }); + it('selects pending mint quotes from canonical accounting instead of compatibility state', async () => { + const { repositories, dispose } = await options.createRepositories(); + try { + const amount = Amount.from(3); + await repositories.mintQuoteRepository.upsertMintQuote( + createDummyMintQuote({ + quoteId: 'accounting-waiting', + quote: 'accounting-waiting', + state: 'ISSUED', + amountPaid: Amount.zero(), + amountIssued: Amount.zero(), + }), + ); + await repositories.mintQuoteRepository.upsertMintQuote( + createDummyMintQuote({ + quoteId: 'accounting-complete', + quote: 'accounting-complete', + state: 'UNPAID', + amountPaid: amount, + amountIssued: amount, + }), + ); + + const pending = await repositories.mintQuoteRepository.getPendingMintQuotes('bolt11'); + + expect(pending).toHaveLength(1); + expect(pending[0]?.quoteId).toBe('accounting-waiting'); + } finally { + await dispose(); + } + }); + it('looks up canonical mint quotes by identity without method', async () => { const { repositories, dispose } = await options.createRepositories(); try { diff --git a/packages/core/Manager.ts b/packages/core/Manager.ts index 99d987f8..d926dc9f 100644 --- a/packages/core/Manager.ts +++ b/packages/core/Manager.ts @@ -79,6 +79,7 @@ import { isStatefulMintQuote, mintQuoteToMethodSnapshot, } from './models/MintQuote.ts'; +import { assessMintQuoteClaimability } from './models/MintQuoteClaimability.ts'; /** * Configuration options for initializing the Coco Cashu manager @@ -629,7 +630,7 @@ export class Manager { continue; } - if (quote.state === 'ISSUED') { + if (assessMintQuoteClaimability(quote).status === 'complete') { skipped.push(quote.quote); continue; } @@ -765,12 +766,18 @@ export class Manager { for (const operation of pendingOperations) { if (mintUrl && operation.mintUrl !== mintUrl) continue; - const quote = await this.quoteLifecycle.getMintQuote( + const assessment = await this.mintOperationService.getMintQuoteClaimability( operation.mintUrl, operation.method, operation.quoteId, + { + requestedAmount: operation.amount, + targetOperationId: operation.id, + }, ); - if (!quote || !isStatefulMintQuote(quote) || quote.state !== 'PAID') continue; + if (!assessment || (assessment.status !== 'claimable' && assessment.status !== 'complete')) { + continue; + } const trusted = await this.mintService.isTrustedMint(operation.mintUrl); if (!trusted) { diff --git a/packages/core/infra/handlers/mint/MintBolt11Handler.ts b/packages/core/infra/handlers/mint/MintBolt11Handler.ts index 66522aeb..b511472b 100644 --- a/packages/core/infra/handlers/mint/MintBolt11Handler.ts +++ b/packages/core/infra/handlers/mint/MintBolt11Handler.ts @@ -18,6 +18,7 @@ import { deserializeOutputData, mapProofToCoreProof, serializeOutputData } from import { Amount, type MintQuoteBolt11Response } from '@cashu/cashu-ts'; import { mintQuoteFromBolt11Response, type MintQuote } from '../../../models/MintQuote'; import { mintQuoteObservationFromBolt11Response } from '../../../models/MintQuoteObservationFactory'; +import { assessMintQuoteClaimability } from '../../../models/MintQuoteClaimability.ts'; export class MintBolt11Handler implements MintMethodHandler<'bolt11'> { async createQuote(ctx: CreateMintQuoteContext<'bolt11'>): Promise> { @@ -126,7 +127,27 @@ export class MintBolt11Handler implements MintMethodHandler<'bolt11'> { }; } - if (remoteQuote.state === 'PAID') { + const quote = mintQuoteObservationFromBolt11Response(mintUrl, remoteQuote); + const assessment = assessMintQuoteClaimability(quote, { + ...ctx.localClaimabilityFacts, + requestedAmount: ctx.operation.amount, + }); + + if (assessment.status === 'invalid') { + return { + status: 'TERMINAL', + error: `Recovered: quote ${quoteId} has invalid claimability accounting`, + }; + } + + if (assessment.status === 'waiting') { + return { + status: 'PENDING', + error: `Recovered: quote ${quoteId} is not yet claimable`, + }; + } + + if (assessment.status === 'claimable') { const outputData = deserializeOutputData(ctx.operation.outputData); try { const proofs = await ctx.wallet.mintProofsBolt11( @@ -152,11 +173,6 @@ export class MintBolt11Handler implements MintMethodHandler<'bolt11'> { if (err instanceof MintOperationError) { if (err.code === 20002) { // Quote already issued; fall through to proof recovery - } else if (err.code === 20007) { - return { - status: 'TERMINAL', - error: `Recovered: quote ${quoteId} expired while executing mint`, - }; } else { return { status: 'PENDING', @@ -170,16 +186,6 @@ export class MintBolt11Handler implements MintMethodHandler<'bolt11'> { }; } } - } else if (remoteQuote.state === 'UNPAID') { - return { - status: 'PENDING', - error: `Recovered: quote ${quoteId} is still UNPAID`, - }; - } else if (remoteQuote.state !== 'ISSUED') { - return { - status: 'PENDING', - error: `Recovered: quote ${quoteId} remains in remote state ${remoteQuote.state}`, - }; } try { @@ -211,32 +217,42 @@ export class MintBolt11Handler implements MintMethodHandler<'bolt11'> { ctx.logger?.info('Checking pending mint operation', { mintUrl, quoteId }); const quote = await ctx.mintAdapter.checkMintQuote(mintUrl, 'bolt11', quoteId); - ctx.logger?.info('Pending mint quote state', { mintUrl, quoteId, state: quote.state }); const observedRemoteStateAt = Date.now(); + const canonicalQuote = mintQuoteObservationFromBolt11Response(mintUrl, quote, { + now: observedRemoteStateAt, + }); + const assessment = assessMintQuoteClaimability(canonicalQuote, { + requestedAmount: ctx.operation.amount, + }); + ctx.logger?.info('Pending mint quote claimability assessed', { + mintUrl, + quoteId, + status: assessment.status, + }); - 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 (assessment.status === 'invalid') { + return { + observedRemoteStateAt, + quoteSnapshot: quote, + category: 'terminal', + terminalFailure: { + reason: `BOLT11 mint quote ${quoteId} has invalid claimability accounting`, + code: 'invalid_quote', + retryable: false, + observedAt: observedRemoteStateAt, + }, + }; } + + return { + observedRemoteStateAt, + quoteSnapshot: quote, + category: + assessment.status === 'claimable' + ? 'ready' + : assessment.status === 'complete' + ? 'completed' + : 'waiting', + }; } } diff --git a/packages/core/infra/handlers/mint/MintBolt12Handler.ts b/packages/core/infra/handlers/mint/MintBolt12Handler.ts index 4f7d95cc..58f11344 100644 --- a/packages/core/infra/handlers/mint/MintBolt12Handler.ts +++ b/packages/core/infra/handlers/mint/MintBolt12Handler.ts @@ -6,6 +6,7 @@ import { bytesToHex } from '@noble/curves/utils.js'; import { MintOperationError } from '../../../models/Error'; import { mintQuoteFromBolt12Response, type MintQuote } from '../../../models/MintQuote'; import { mintQuoteObservationFromBolt12Response } from '../../../models/MintQuoteObservationFactory'; +import { assessMintQuoteClaimability } from '../../../models/MintQuoteClaimability.ts'; import type { CreateMintQuoteContext, ExecuteContext, @@ -194,11 +195,20 @@ export class MintBolt12Handler implements MintMethodHandler<'bolt12'> { }; } - const available = this.getAvailableAmount(remoteQuote); - if (available.lessThan(operation.amount)) { + const assessment = assessMintQuoteClaimability( + mintQuoteObservationFromBolt12Response(operation.mintUrl, remoteQuote), + { ...ctx.localClaimabilityFacts, requestedAmount: operation.amount }, + ); + if (assessment.status === 'invalid') { + return { + status: 'TERMINAL', + error: `Recovered: BOLT12 quote ${operation.quoteId} has invalid claimability accounting`, + }; + } + if (assessment.status !== 'claimable') { return { status: 'PENDING', - error: `Recovered: BOLT12 quote ${operation.quoteId} has ${available} available, requested ${operation.amount}`, + error: `Recovered: BOLT12 quote ${operation.quoteId} has ${assessment.remoteAvailable} remotely available, requested ${operation.amount}`, }; } @@ -231,13 +241,6 @@ export class MintBolt12Handler implements MintMethodHandler<'bolt12'> { ); } - if (this.isExpiredMintError(error)) { - return { - status: 'TERMINAL', - error: `Recovered: BOLT12 quote ${operation.quoteId} expired while executing mint`, - }; - } - return { status: 'PENDING', error: error instanceof Error ? error.message : String(error), @@ -287,12 +290,35 @@ export class MintBolt12Handler implements MintMethodHandler<'bolt12'> { }; } + const assessment = assessMintQuoteClaimability( + mintQuoteObservationFromBolt12Response(operation.mintUrl, remoteQuote, { + now: observedRemoteStateAt, + }), + { requestedAmount: operation.amount }, + ); + if (assessment.status === 'invalid') { + return { + observedRemoteStateAt, + quoteSnapshot: remoteQuote, + category: 'terminal', + terminalFailure: { + reason: `BOLT12 mint quote ${operation.quoteId} has invalid claimability accounting`, + code: 'invalid_quote', + retryable: false, + observedAt: observedRemoteStateAt, + }, + }; + } + return { observedRemoteStateAt, quoteSnapshot: remoteQuote, - category: this.getAvailableAmount(remoteQuote).greaterThanOrEqual(operation.amount) - ? 'ready' - : 'waiting', + category: + assessment.status === 'claimable' + ? 'ready' + : assessment.status === 'complete' + ? 'completed' + : 'waiting', }; } @@ -334,12 +360,6 @@ export class MintBolt12Handler implements MintMethodHandler<'bolt12'> { assertSameUnit(quote.unit, expectedUnit, `BOLT12 mint quote ${quote.quote}`); this.assertQuoteAmount(quote, expectedAmount); - - if (Amount.from(quote.amount_paid).lessThan(Amount.from(quote.amount_issued))) { - throw new Error( - `BOLT12 mint quote ${quote.quote} has amount_issued greater than amount_paid`, - ); - } } private assertQuoteAmount(quote: MintQuoteBolt12Response, expectedAmount?: Amount): void { @@ -397,10 +417,6 @@ export class MintBolt12Handler implements MintMethodHandler<'bolt12'> { } } - private getAvailableAmount(quote: MintQuoteBolt12Response): Amount { - return Amount.from(quote.amount_paid).subtract(Amount.from(quote.amount_issued)); - } - private isAlreadyIssuedError(error: unknown): boolean { if (error instanceof MintOperationError && (error.code === 20002 || error.code === 11003)) { return true; @@ -409,13 +425,4 @@ export class MintBolt12Handler implements MintMethodHandler<'bolt12'> { const message = error instanceof Error ? error.message : String(error); return /already (issued|signed)|outputs? already/i.test(message); } - - private isExpiredMintError(error: unknown): boolean { - if (error instanceof MintOperationError && error.code === 20007) { - return true; - } - - const message = error instanceof Error ? error.message : String(error); - return /expired/i.test(message); - } } diff --git a/packages/core/infra/handlers/mint/MintOnchainHandler.ts b/packages/core/infra/handlers/mint/MintOnchainHandler.ts index ff5b313c..2b73ee18 100644 --- a/packages/core/infra/handlers/mint/MintOnchainHandler.ts +++ b/packages/core/infra/handlers/mint/MintOnchainHandler.ts @@ -14,6 +14,7 @@ import { type MintQuoteOnchainResponse, } from '../../../models/MintQuote'; import { mintQuoteObservationFromOnchainResponse } from '../../../models/MintQuoteObservationFactory'; +import { assessMintQuoteClaimability } from '../../../models/MintQuoteClaimability.ts'; import type { CreateMintQuoteContext, ExecuteContext, @@ -182,11 +183,20 @@ export class MintOnchainHandler implements MintMethodHandler<'onchain'> { }; } - const available = this.getAvailableAmount(remoteQuote); - if (available.lessThan(operation.amount)) { + const assessment = assessMintQuoteClaimability( + mintQuoteObservationFromOnchainResponse(operation.mintUrl, remoteQuote), + { ...ctx.localClaimabilityFacts, requestedAmount: operation.amount }, + ); + if (assessment.status === 'invalid') { + return { + status: 'TERMINAL', + error: `Recovered: onchain quote ${operation.quoteId} has invalid claimability accounting`, + }; + } + if (assessment.status !== 'claimable') { return { status: 'PENDING', - error: `Recovered: onchain quote ${operation.quoteId} has ${available} available, requested ${operation.amount}`, + error: `Recovered: onchain quote ${operation.quoteId} has ${assessment.remoteAvailable} remotely available, requested ${operation.amount}`, }; } @@ -219,13 +229,6 @@ export class MintOnchainHandler implements MintMethodHandler<'onchain'> { ); } - if (this.isExpiredMintError(error)) { - return { - status: 'TERMINAL', - error: `Recovered: onchain quote ${operation.quoteId} expired while executing mint`, - }; - } - return { status: 'PENDING', error: error instanceof Error ? error.message : String(error), @@ -275,12 +278,35 @@ export class MintOnchainHandler implements MintMethodHandler<'onchain'> { }; } + const assessment = assessMintQuoteClaimability( + mintQuoteObservationFromOnchainResponse(operation.mintUrl, remoteQuote, { + now: observedRemoteStateAt, + }), + { requestedAmount: operation.amount }, + ); + if (assessment.status === 'invalid') { + return { + observedRemoteStateAt, + quoteSnapshot: remoteQuote, + category: 'terminal', + terminalFailure: { + reason: `Onchain mint quote ${operation.quoteId} has invalid claimability accounting`, + code: 'invalid_quote', + retryable: false, + observedAt: observedRemoteStateAt, + }, + }; + } + return { observedRemoteStateAt, quoteSnapshot: remoteQuote, - category: this.getAvailableAmount(remoteQuote).greaterThanOrEqual(operation.amount) - ? 'ready' - : 'waiting', + category: + assessment.status === 'claimable' + ? 'ready' + : assessment.status === 'complete' + ? 'completed' + : 'waiting', }; } @@ -312,16 +338,6 @@ export class MintOnchainHandler implements MintMethodHandler<'onchain'> { } assertSameUnit(quote.unit, expectedUnit, `Onchain mint quote ${quote.quote}`); - - if ( - Amount.from(quote.amount_paid ?? Amount.zero()).lessThan( - Amount.from(quote.amount_issued ?? Amount.zero()), - ) - ) { - throw new Error( - `Onchain mint quote ${quote.quote} has amount_issued greater than amount_paid`, - ); - } } private async recoverSignedOutputs( @@ -365,12 +381,6 @@ export class MintOnchainHandler implements MintMethodHandler<'onchain'> { } } - private getAvailableAmount(quote: MintQuoteOnchainResponse): Amount { - return Amount.from(quote.amount_paid ?? Amount.zero()).subtract( - Amount.from(quote.amount_issued ?? Amount.zero()), - ); - } - private isAlreadyIssuedError(error: unknown): boolean { if (error instanceof MintOperationError && (error.code === 20002 || error.code === 11003)) { return true; @@ -379,13 +389,4 @@ export class MintOnchainHandler implements MintMethodHandler<'onchain'> { const message = error instanceof Error ? error.message : String(error); return /already (issued|signed)|outputs? already/i.test(message); } - - private isExpiredMintError(error: unknown): boolean { - if (error instanceof MintOperationError && error.code === 20007) { - return true; - } - - const message = error instanceof Error ? error.message : String(error); - return /expired/i.test(message); - } } diff --git a/packages/core/models/MintQuote.ts b/packages/core/models/MintQuote.ts index 4172648f..55e24cde 100644 --- a/packages/core/models/MintQuote.ts +++ b/packages/core/models/MintQuote.ts @@ -16,6 +16,7 @@ import type { MintMethodQuoteSnapshot, MintMethodRemoteState, } from '../operations/mint/MintMethodHandler'; +import { assessMintQuoteClaimability } from './MintQuoteClaimability.ts'; export type MintQuoteOnchainResponse = CashuMintQuoteOnchainResponse; @@ -100,20 +101,14 @@ export function getMintQuoteAmount(quote: MintQuote): Amount | undefined { return undefined; } +/** Returns mint-reported availability without local issuance or reservation facts. */ export function getMintQuoteAvailableAmount(quote: MintQuote): Amount { - if (quote.reusable) { - return quote.amountPaid.subtract(quote.amountIssued); - } - - return quote.state === 'PAID' ? quote.amount : Amount.zero(); + return assessMintQuoteClaimability(quote).remoteAvailable; } export function isMintQuotePending(quote: MintQuote): boolean { - if (isStatefulMintQuote(quote)) { - return quote.state !== 'ISSUED'; - } - - return true; + const { status } = assessMintQuoteClaimability(quote); + return status === 'waiting' || status === 'claimable'; } function assertValidMintQuoteAccounting( diff --git a/packages/core/models/MintQuoteClaimability.ts b/packages/core/models/MintQuoteClaimability.ts new file mode 100644 index 00000000..8bd326e4 --- /dev/null +++ b/packages/core/models/MintQuoteClaimability.ts @@ -0,0 +1,104 @@ +import { Amount } from '@cashu/cashu-ts'; + +import type { MintQuote } from './MintQuote.ts'; + +export type MintQuoteClaimabilityStatus = 'waiting' | 'claimable' | 'complete' | 'invalid'; + +export interface MintQuoteClaimabilityAssessment { + status: MintQuoteClaimabilityStatus; + remoteAvailable: Amount; + claimAmount?: Amount; +} + +export interface MintQuoteClaimabilityFacts { + finalizedAmount?: Amount; + reservedAmount?: Amount; + requestedAmount?: Amount; +} + +function invalid(remoteAvailable: Amount): MintQuoteClaimabilityAssessment { + return { status: 'invalid', remoteAvailable }; +} + +function waiting(remoteAvailable: Amount): MintQuoteClaimabilityAssessment { + return { status: 'waiting', remoteAvailable }; +} + +function assessAtomicClaimability( + quote: MintQuote<'bolt11'>, + facts: MintQuoteClaimabilityFacts, + remoteAvailable: Amount, +): MintQuoteClaimabilityAssessment { + if ( + quote.amountPaid.greaterThan(quote.amount) || + (!quote.amountIssued.isZero() && !quote.amountIssued.equals(quote.amount)) || + (facts.requestedAmount !== undefined && !facts.requestedAmount.equals(quote.amount)) + ) { + return invalid(remoteAvailable); + } + + if (quote.amountIssued.equals(quote.amount)) { + return { status: 'complete', remoteAvailable }; + } + + if (quote.amountPaid.lessThan(quote.amount)) { + return waiting(remoteAvailable); + } + + return { + status: 'claimable', + remoteAvailable, + claimAmount: quote.amount, + }; +} + +function assessBalanceClaimability( + quote: MintQuote<'bolt12' | 'onchain'>, + facts: MintQuoteClaimabilityFacts, + remoteAvailable: Amount, +): MintQuoteClaimabilityAssessment { + const finalizedAmount = facts.finalizedAmount ?? Amount.zero(); + const effectiveIssued = finalizedAmount.greaterThan(quote.amountIssued) + ? finalizedAmount + : quote.amountIssued; + const availableAfterFinalized = quote.amountPaid.lessThan(effectiveIssued) + ? Amount.zero() + : quote.amountPaid.subtract(effectiveIssued); + const reservedAmount = facts.reservedAmount ?? Amount.zero(); + const locallyAvailable = availableAfterFinalized.lessThan(reservedAmount) + ? Amount.zero() + : availableAfterFinalized.subtract(reservedAmount); + const claimAmount = facts.requestedAmount ?? locallyAvailable; + + if (claimAmount.isZero() || claimAmount.greaterThan(locallyAvailable)) { + return waiting(remoteAvailable); + } + + return { status: 'claimable', remoteAvailable, claimAmount }; +} + +/** + * Assesses canonical Mint Quote Accounting for one local claim. + * + * Quote expiry and deprecated BOLT11 compatibility state are deliberately absent from the facts + * consumed by this module. Atomic-versus-balance policy is private to this implementation. + */ +export function assessMintQuoteClaimability( + quote: MintQuote, + facts: MintQuoteClaimabilityFacts = {}, +): MintQuoteClaimabilityAssessment { + if (quote.amountIssued.greaterThan(quote.amountPaid)) { + return invalid(Amount.zero()); + } + + const remoteAvailable = quote.amountPaid.subtract(quote.amountIssued); + if (facts.requestedAmount?.isZero()) { + return invalid(remoteAvailable); + } + + if (quote.method === 'bolt11') { + return assessAtomicClaimability(quote, facts, remoteAvailable); + } + + return assessBalanceClaimability(quote, facts, remoteAvailable); +} diff --git a/packages/core/operations/mint/MintMethodHandler.ts b/packages/core/operations/mint/MintMethodHandler.ts index b26c507b..fccbd546 100644 --- a/packages/core/operations/mint/MintMethodHandler.ts +++ b/packages/core/operations/mint/MintMethodHandler.ts @@ -156,6 +156,10 @@ export interface RecoverExecutingContext< > extends BaseHandlerDeps { operation: ExecutingMintOperation; wallet: Wallet; + localClaimabilityFacts: { + finalizedAmount: Amount; + reservedAmount: Amount; + }; } export interface PendingContext extends BaseHandlerDeps { diff --git a/packages/core/operations/mint/MintOperationService.ts b/packages/core/operations/mint/MintOperationService.ts index 436ac58c..d0eef1f5 100644 --- a/packages/core/operations/mint/MintOperationService.ts +++ b/packages/core/operations/mint/MintOperationService.ts @@ -40,11 +40,11 @@ import type { MintAdapter } from '../../infra'; import type { MintHandlerProvider } from '../../infra/handlers/mint'; import { MintScopedLock } from '../MintScopedLock'; import { OperationIdLock } from '../OperationIdLock'; +import { getMintQuoteAmount, type MintQuote } from '../../models/MintQuote'; import { - getMintQuoteAvailableAmount, - getMintQuoteAmount, - type MintQuote, -} from '../../models/MintQuote'; + assessMintQuoteClaimability, + type MintQuoteClaimabilityAssessment, +} from '../../models/MintQuoteClaimability.ts'; import type { MintQuoteRef } from '../../models/QuoteIdentity'; import type { QuoteLifecycle } from '../../quotes/QuoteLifecycle'; @@ -204,19 +204,13 @@ export class MintOperationService { `Mint quote ${quote.quoteId} amount ${fixedAmount} does not match requested amount ${intent.amount}`, ); } - if (!fixedAmount && !quote.reusable) { - throw new Error( - `Mint quote ${quote.quoteId} for ${method} at ${mintUrl} does not have a fixed amount`, - ); - } - if (quote.unit !== intent.unit) { throw new Error( `Mint quote ${quote.quoteId} unit ${quote.unit} does not match requested unit ${intent.unit}`, ); } - if (!quote.reusable) { + if (fixedAmount) { const existing = await this.getOperationByQuote(quote.mintUrl, method, quote.quoteId); if (existing) { throw new Error( @@ -348,8 +342,8 @@ export class MintOperationService { operation.method, operation.quoteId, ); - if (quote?.reusable) { - return this.claimReusableQuoteOperation(operation as PendingMintOperation); + if (quote) { + return this.claimPendingQuoteOperation(operation as PendingMintOperation, quote); } } @@ -600,10 +594,16 @@ export class MintOperationService { executing.mintUrl, executing.unit, ); + const siblings = await this.mintOperationRepository.getByQuoteId( + executing.mintUrl, + executing.method, + executing.quoteId, + ); const result = await handler.recoverExecuting({ ...this.buildDeps(), operation: executing as any, wallet, + localClaimabilityFacts: this.getLocalClaimabilityFacts(siblings, executing.id), }); switch (result.status) { @@ -714,12 +714,21 @@ export class MintOperationService { `Cannot claim mint quote ${quoteId}: quote for ${method} at ${mintUrl} was not found`, ); } - if (!quote.reusable) { + const siblings = await this.mintOperationRepository.getByQuoteId(mintUrl, method, quoteId); + const assessment = this.assessQuoteClaimability(quote, siblings); + const claimable = assessment.claimAmount ?? Amount.zero(); + if (assessment.status === 'complete') { + const completed: MintOperation[] = []; + for (const operation of siblings) { + if (operation.state === 'pending') { + completed.push(await this.executeReadyOperation(operation.id)); + } + } + return completed; + } + if (assessment.status !== 'claimable' || claimable.isZero()) { return []; } - - const claimable = await this.getLocallyClaimableQuoteAmount(quote); - const siblings = await this.mintOperationRepository.getByQuoteId(mintUrl, method, quoteId); let selectedAmount = Amount.zero(); const selected: PendingMintOperation[] = []; const autoClaimRemaining = options.autoClaimRemaining ?? true; @@ -747,11 +756,16 @@ export class MintOperationService { if (autoClaimRemaining && !remaining.isZero()) { const refreshedQuote = (await this.quoteLifecycle.getMintQuote(mintUrl, method, quoteId)) ?? quote; - if (refreshedQuote.reusable) { - const currentClaimable = await this.getLocallyClaimableQuoteAmount(refreshedQuote); - const autoClaimAmount = remaining.lessThan(currentClaimable) + const refreshedSiblings = await this.mintOperationRepository.getByQuoteId( + mintUrl, + method, + quoteId, + ); + const currentAssessment = this.assessQuoteClaimability(refreshedQuote, refreshedSiblings); + if (currentAssessment.status === 'claimable' && currentAssessment.claimAmount) { + const autoClaimAmount = remaining.lessThan(currentAssessment.claimAmount) ? remaining - : currentClaimable; + : currentAssessment.claimAmount; if (!autoClaimAmount.isZero()) { const autoClaim = await this.createAutoClaimOperation(refreshedQuote, autoClaimAmount); @@ -771,13 +785,6 @@ export class MintOperationService { const claimed: MintOperation[] = []; for (const quote of quotes) { - if (!quote.reusable) { - continue; - } - if (getMintQuoteAvailableAmount(quote).isZero()) { - continue; - } - claimed.push( ...(await this.claimMintQuote(quote.mintUrl, quote.method, quote.quoteId, options)), ); @@ -786,22 +793,25 @@ export class MintOperationService { return claimed; } - /** @internal Used by the mint operation processor to suppress no-op reusable quote claims. */ - async hasLocallyClaimableMintQuoteBalance( + /** @internal Used by background schedulers to assess a canonical quote with local operation facts. */ + async getMintQuoteClaimability( mintUrl: string, method: MintMethod, quoteId: string, - ): Promise { + options: { requestedAmount?: Amount; targetOperationId?: string } = {}, + ): Promise { const quote = await this.quoteLifecycle.getMintQuote(mintUrl, method, quoteId); - if (!quote || !quote.reusable) { - return false; + if (!quote) { + return undefined; } - return !(await this.getLocallyClaimableQuoteAmount(quote)).isZero(); + const siblings = await this.mintOperationRepository.getByQuoteId(mintUrl, method, quoteId); + return this.assessQuoteClaimability(quote, siblings, options); } - private async claimReusableQuoteOperation( + private async claimPendingQuoteOperation( operation: PendingMintOperation, + initialQuote: MintQuote, ): Promise { const releaseQuoteLock = await this.mintScopedLock.acquire( this.quoteLockKey(operation.mintUrl, operation.method, operation.quoteId), @@ -814,25 +824,32 @@ export class MintOperationService { } const pending = current as PendingMintOperation; - const quote = await this.quoteLifecycle.getMintQuote( + const quote = + (await this.quoteLifecycle.getMintQuote( + pending.mintUrl, + pending.method, + pending.quoteId, + )) ?? initialQuote; + + const siblings = await this.mintOperationRepository.getByQuoteId( pending.mintUrl, pending.method, pending.quoteId, ); - if (!quote) { - throw new Error( - `Cannot claim operation ${pending.id}: mint quote ${pending.quoteId} for ${pending.method} at ${pending.mintUrl} was not found`, - ); + const assessment = this.assessQuoteClaimability(quote, siblings, { + requestedAmount: pending.amount, + targetOperationId: pending.id, + }); + if (assessment.status === 'invalid') { + throw new Error(`Mint quote ${pending.quoteId} has invalid claimability accounting`); } - - const claimable = await this.getLocallyClaimableQuoteAmount(quote, pending.id); - if (pending.amount.greaterThan(claimable)) { - this.logger?.info('Reusable mint quote is not sufficiently funded for operation', { + if (assessment.status === 'waiting') { + this.logger?.info('Mint quote is not sufficiently funded for operation', { operationId: pending.id, mintUrl: pending.mintUrl, quoteId: pending.quoteId, requestedAmount: pending.amount.toString(), - claimableAmount: claimable.toString(), + claimableAmount: assessment.claimAmount?.toString() ?? '0', }); return pending; } @@ -858,33 +875,27 @@ export class MintOperationService { return this.prepareInitOperation(initOperation.id); } - private async getLocallyClaimableQuoteAmount( + private assessQuoteClaimability( quote: MintQuote, + siblings: MintOperation[], + options: { requestedAmount?: Amount; targetOperationId?: string } = {}, + ): MintQuoteClaimabilityAssessment { + const localFacts = this.getLocalClaimabilityFacts(siblings, options.targetOperationId); + + return assessMintQuoteClaimability(quote, { + ...localFacts, + requestedAmount: options.requestedAmount, + }); + } + + private getLocalClaimabilityFacts( + siblings: MintOperation[], targetOperationId?: string, - ): Promise { - let remoteAvailable = getMintQuoteAvailableAmount(quote); - const siblings = await this.mintOperationRepository.getByQuoteId( - quote.mintUrl, - quote.method, - quote.quoteId, + ): { finalizedAmount: Amount; reservedAmount: Amount } { + const finalizedAmount = siblings.reduce( + (total, operation) => (operation.state === 'finalized' ? total.add(operation.amount) : total), + Amount.zero(), ); - if (quote.reusable) { - const locallyIssued = siblings.reduce((total, operation) => { - if (operation.state !== 'finalized') { - return total; - } - - return total.add(operation.amount); - }, Amount.zero()); - const effectiveIssued = locallyIssued.greaterThan(quote.amountIssued) - ? locallyIssued - : quote.amountIssued; - - remoteAvailable = quote.amountPaid.lessThan(effectiveIssued) - ? Amount.zero() - : quote.amountPaid.subtract(effectiveIssued); - } - const locallyReserved = siblings.reduce((total, operation) => { if (operation.state !== 'executing' || operation.id === targetOperationId) { return total; @@ -893,11 +904,10 @@ export class MintOperationService { return total.add(operation.amount); }, Amount.zero()); - if (remoteAvailable.lessThan(locallyReserved)) { - return Amount.zero(); - } - - return remoteAvailable.subtract(locallyReserved); + return { + finalizedAmount, + reservedAmount: locallyReserved, + }; } private quoteLockKey(mintUrl: string, method: MintMethod, quoteId: string): string { @@ -1123,14 +1133,15 @@ export class MintOperationService { const handler = this.handlerProvider.get(op.method); const { wallet } = await this.walletService.getWalletWithActiveKeysetId(op.mintUrl, op.unit); - const result = await handler.checkPending({ + let result = await handler.checkPending({ ...this.buildDeps(), operation: op as PendingMintOperation, wallet, }); + let canonicalQuote: MintQuote | undefined; if (result.quoteSnapshot) { - await this.quoteLifecycle.recordMintQuoteSnapshot( + canonicalQuote = await this.quoteLifecycle.recordMintQuoteSnapshot( op.mintUrl, op.method, result.quoteSnapshot as MintMethodQuoteSnapshot, @@ -1138,13 +1149,45 @@ export class MintOperationService { } if (result.observedRemoteState !== undefined) { - await this.quoteLifecycle.recordMintQuoteObservation( + canonicalQuote = await this.quoteLifecycle.recordMintQuoteObservation( op, result.observedRemoteState, result.observedRemoteStateAt, ); } + if (canonicalQuote && (result.category !== 'terminal' || result.quoteSnapshot !== undefined)) { + const siblings = await this.mintOperationRepository.getByQuoteId( + op.mintUrl, + op.method, + op.quoteId, + ); + const assessment = this.assessQuoteClaimability(canonicalQuote, siblings, { + requestedAmount: op.amount, + targetOperationId: op.id, + }); + result = { + ...result, + category: + assessment.status === 'claimable' + ? 'ready' + : assessment.status === 'complete' + ? 'completed' + : assessment.status === 'invalid' + ? 'terminal' + : 'waiting', + terminalFailure: + assessment.status === 'invalid' + ? { + reason: `Mint quote ${op.quoteId} has invalid claimability accounting`, + code: 'invalid_quote', + retryable: false, + observedAt: result.observedRemoteStateAt, + } + : undefined, + }; + } + if (result.category === 'terminal' && result.terminalFailure) { await this.failPendingOperation(op, result.terminalFailure); } diff --git a/packages/core/quotes/QuoteLifecycle.ts b/packages/core/quotes/QuoteLifecycle.ts index 6ebb4d64..e57c3ca8 100644 --- a/packages/core/quotes/QuoteLifecycle.ts +++ b/packages/core/quotes/QuoteLifecycle.ts @@ -58,6 +58,7 @@ import type { MintQuotePollingResult, } from './MintQuotePolling.ts'; import { resolveMintQuoteObservation } from './MintQuoteObservation.ts'; +import { assessMintQuoteClaimability } from '../models/MintQuoteClaimability.ts'; const BUILT_IN_MINT_METHODS = new Set(['bolt11', 'bolt12', 'onchain']); const DEFINITIVE_BATCH_FAILURE_CATEGORIES = new Set([ @@ -1550,9 +1551,13 @@ export class QuoteLifecycle { } private assertMintQuoteCanPrepare(quote: MintQuote, context: string): void { - if (isStatefulMintQuote(quote) && quote.state === 'ISSUED') { + const assessment = assessMintQuoteClaimability(quote); + if (assessment.status === 'complete') { throw new Error(`Cannot prepare ${context}: quote is terminal`); } + if (assessment.status === 'invalid') { + throw new Error(`Cannot prepare ${context}: quote accounting is invalid`); + } } private assertMeltQuoteCanPrepare(quote: MeltQuote, context: string): void { diff --git a/packages/core/services/watchers/MintOperationProcessor.ts b/packages/core/services/watchers/MintOperationProcessor.ts index c2225310..3d136aa4 100644 --- a/packages/core/services/watchers/MintOperationProcessor.ts +++ b/packages/core/services/watchers/MintOperationProcessor.ts @@ -2,7 +2,6 @@ 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 type { QuoteLifecycle } from '../../quotes/QuoteLifecycle.ts'; interface QueueItem { @@ -17,11 +16,8 @@ interface OperationHandler { process(mintUrl: string, operationId: string): Promise; } -class Bolt11MintOperationHandler implements OperationHandler { - constructor( - private mintOperations: MintOperationService, - private logger?: Logger, - ) {} +class DefaultMintOperationHandler implements OperationHandler { + constructor(private mintOperations: MintOperationService) {} async process(_mintUrl: string, operationId: string): Promise { await this.mintOperations.finalize(operationId); @@ -79,8 +75,10 @@ export class MintOperationProcessor { this.initialEnqueueDelayMs = options?.initialEnqueueDelayMs ?? 500; this.autoClaimMintQuotes = options?.autoClaimMintQuotes ?? true; - // Register default handler for bolt11 mint operations - this.registerHandler('bolt11', new Bolt11MintOperationHandler(mintOperations, this.logger)); + const defaultHandler = new DefaultMintOperationHandler(mintOperations); + for (const method of ['bolt11', 'bolt12', 'onchain']) { + this.registerHandler(method, defaultHandler); + } } registerHandler(method: string, handler: OperationHandler): void { @@ -100,31 +98,13 @@ export class MintOperationProcessor { // Subscribe to canonical quote updates and resolve all affected local operations. this.offQuoteUpdated = this.bus.on( 'mint-quote:updated', - async ({ mintUrl, method, quoteId, quote }) => { - if (quote.reusable) { - this.scheduleQuoteClaim(mintUrl, method, quoteId); - return; - } - - if (getMintQuoteRemoteState(quote) !== 'PAID') { - return; - } - - const operations = await this.mintOperations.getOperationsForQuote( - mintUrl, - method, - quoteId, - ); - for (const operation of operations) { - if (operation.state === 'pending') { - this.enqueue(mintUrl, operation.id, operation.method); - } - } + async ({ mintUrl, method, quoteId }) => { + this.scheduleQuoteClaim(mintUrl, method, quoteId); }, ); - // Subscribe to pending operations so operations created after a PAID quote enqueue immediately - this.offPending = this.bus.on('mint-op:pending', async ({ mintUrl, operation }) => { + // Subscribe to pending operations so newly created operations reassess their canonical quote. + this.offPending = this.bus.on('mint-op:pending', async ({ operation }) => { if (operation.state !== 'pending') { return; } @@ -134,13 +114,8 @@ export class MintOperationProcessor { operation.method, operation.quoteId, ); - if (quote?.reusable) { + if (quote) { this.scheduleQuoteClaim(operation.mintUrl, operation.method, operation.quoteId); - return; - } - - if (quote && getMintQuoteRemoteState(quote) === 'PAID') { - this.enqueue(mintUrl, operation.id, operation.method); } }); @@ -304,7 +279,7 @@ export class MintOperationProcessor { const key = `${mintUrl}::${method}::${quoteId}`; if (this.claimingQuotes.has(key)) { - this.logger?.debug('Reusable mint quote claim already in progress', { + this.logger?.debug('Mint quote claim already in progress', { mintUrl, method, quoteId, @@ -315,13 +290,13 @@ export class MintOperationProcessor { this.claimingQuotes.add(key); const task = (async () => { try { - const hasClaimableBalance = await this.mintOperations.hasLocallyClaimableMintQuoteBalance( + const assessment = await this.mintOperations.getMintQuoteClaimability( mintUrl, method, quoteId, ); - if (!hasClaimableBalance) { - this.logger?.debug('Reusable mint quote has no locally claimable balance', { + if (assessment?.status !== 'claimable' && assessment?.status !== 'complete') { + this.logger?.debug('Mint quote has no locally claimable value', { mintUrl, method, quoteId, @@ -333,7 +308,7 @@ export class MintOperationProcessor { autoClaimRemaining: true, }); } catch (error) { - this.logger?.warn('Failed to check or claim reusable mint quote', { + this.logger?.warn('Failed to check or claim mint quote', { mintUrl, method, quoteId, @@ -355,7 +330,7 @@ export class MintOperationProcessor { try { await this.mintOperations.claimPendingMintQuotes({ autoClaimRemaining: true }); } catch (error) { - this.logger?.warn('Failed to claim pending reusable mint quotes on startup', { + this.logger?.warn('Failed to claim pending mint quotes on startup', { error: error instanceof Error ? error.message : String(error), }); } @@ -438,11 +413,6 @@ export class MintOperationProcessor { const { mintUrl, operationId } = item; if (err instanceof MintOperationError) { - if (err.code === 20007) { - this.logger?.warn('Mint operation quote expired', { mintUrl, operationId }); - return; - } - if (err.code === 20002) { this.logger?.info('Mint operation quote already issued', { mintUrl, operationId }); return; diff --git a/packages/core/services/watchers/MintOperationWatcherService.ts b/packages/core/services/watchers/MintOperationWatcherService.ts index 8eca69c0..dbbf5fe5 100644 --- a/packages/core/services/watchers/MintOperationWatcherService.ts +++ b/packages/core/services/watchers/MintOperationWatcherService.ts @@ -10,6 +10,7 @@ import type { } from '@core/operations/mint'; import type { SubscriptionKind } from '@core/infra/SubscriptionProtocol.ts'; import { mintQuoteToMethodSnapshot, type MintQuote } from '../../models/MintQuote.ts'; +import { assessMintQuoteClaimability } from '../../models/MintQuoteClaimability.ts'; import type { QuoteLifecycle } from '../../quotes/QuoteLifecycle.ts'; type QuoteKey = string; // `${mintUrl}::${method}::${quoteId}` @@ -21,9 +22,6 @@ function toKey(mintUrl: string, method: string, quoteId: string): QuoteKey { interface MintQuoteWatchPolicy { subscriptionKind: SubscriptionKind; getPayloadQuoteId(payload: MintMethodQuoteSnapshot): string | undefined; - shouldRecordPayload(payload: MintMethodQuoteSnapshot): boolean; - shouldStopWatching(payload: MintMethodQuoteSnapshot): boolean; - keepWatchingWithoutOperationInterest?: boolean; } const mintQuoteWatchPolicies: { @@ -32,24 +30,14 @@ const mintQuoteWatchPolicies: { bolt11: { subscriptionKind: 'bolt11_mint_quote', getPayloadQuoteId: (payload) => payload.quote, - shouldRecordPayload: (payload) => payload.state === 'PAID' || payload.state === 'ISSUED', - shouldStopWatching: (payload) => payload.state === 'ISSUED', }, onchain: { subscriptionKind: 'onchain_mint_quote', getPayloadQuoteId: (payload) => payload.quote, - shouldRecordPayload: (payload) => - payload.amount_paid !== undefined && payload.amount_issued !== undefined, - shouldStopWatching: () => false, - keepWatchingWithoutOperationInterest: true, }, bolt12: { subscriptionKind: 'bolt12_mint_quote', getPayloadQuoteId: (payload) => payload.quote, - shouldRecordPayload: (payload) => - payload.amount_paid !== undefined && payload.amount_issued !== undefined, - shouldStopWatching: () => false, - keepWatchingWithoutOperationInterest: true, }, }; @@ -151,15 +139,16 @@ export class MintOperationWatcherService { const policy = this.getPolicy(quote.method); if (!policy) return; - const snapshot = mintQuoteToMethodSnapshot(quote); const key = toKey(quote.mintUrl, quote.method, quote.quoteId); - if (policy.shouldStopWatching(snapshot)) { + if (assessMintQuoteClaimability(quote).status === 'complete') { await this.stopWatching(key); return; } try { - await this.watchMintQuotes([{ ...quote, snapshot }], { canonical: true }); + await this.watchMintQuotes([{ ...quote, snapshot: mintQuoteToMethodSnapshot(quote) }], { + canonical: true, + }); } catch (err) { this.logger?.error('Failed to start watching canonical mint quote', { mintUrl: quote.mintUrl, @@ -360,7 +349,10 @@ export class MintOperationWatcherService { operationIdsByKey.set(key, operationIds); } - await this.watchMintQuotes(Array.from(uniqueByQuote.values()), { operationIdsByKey }); + await this.watchMintQuotes(Array.from(uniqueByQuote.values()), { + canonical: true, + operationIdsByKey, + }); } private async watchMintQuotes( @@ -376,11 +368,6 @@ export class MintOperationWatcherService { if (!policy) continue; const key = toKey(quote.mintUrl, quote.method, quote.quoteId); - if (quote.snapshot && policy.shouldStopWatching(quote.snapshot)) { - await this.stopWatching(key); - continue; - } - const existing = this.watchRecordByKey.get(key); if (existing?.stop) { this.addInterest(existing, key, interest); @@ -531,21 +518,22 @@ export class MintOperationWatcherService { if (!quoteId) return; const key = toKey(mintUrl, record.method, quoteId); - if (policy.shouldRecordPayload(methodPayload)) { - try { - await this.quoteLifecycle.recordMintQuoteSnapshot(mintUrl, record.method, methodPayload); - } catch (err) { - this.logger?.error('Failed to persist mint quote update from remote update', { - mintUrl, - quoteId, - method: record.method, - err, - }); + try { + const quote = await this.quoteLifecycle.recordMintQuoteSnapshot( + mintUrl, + record.method, + methodPayload, + ); + if (assessMintQuoteClaimability(quote).status === 'complete') { + await this.stopWatching(key); } - } - - if (policy.shouldStopWatching(methodPayload)) { - await this.stopWatching(key); + } catch (err) { + this.logger?.error('Failed to persist mint quote update from remote update', { + mintUrl, + quoteId, + method: record.method, + err, + }); } } @@ -601,7 +589,7 @@ export class MintOperationWatcherService { return false; } - return this.getPolicy(record.method)?.keepWatchingWithoutOperationInterest !== true; + return true; } private removeWatchRecord(key: QuoteKey): void { diff --git a/packages/core/test/unit/Manager.test.ts b/packages/core/test/unit/Manager.test.ts index ee0b40fa..fe36db5f 100644 --- a/packages/core/test/unit/Manager.test.ts +++ b/packages/core/test/unit/Manager.test.ts @@ -1455,7 +1455,7 @@ describe('initializeCoco', () => { expect(clearCache).toHaveBeenCalledWith('https://mint.test'); }); - it('requeues pending mint operations backed by paid canonical quotes', async () => { + it('requeues pending mint operations from canonical accounting', async () => { const manager = await initializeCoco({ ...baseConfig, watchers: { @@ -1477,7 +1477,9 @@ describe('initializeCoco', () => { amount: operation.amount, unit: operation.unit, expiry: Math.floor(Date.now() / 1000) + 3600, - state: 'PAID', + state: 'UNPAID', + amount_paid: operation.amount, + amount_issued: Amount.zero(), }), ); diff --git a/packages/core/test/unit/MintBolt11Handler.test.ts b/packages/core/test/unit/MintBolt11Handler.test.ts index ff003026..235dc42e 100644 --- a/packages/core/test/unit/MintBolt11Handler.test.ts +++ b/packages/core/test/unit/MintBolt11Handler.test.ts @@ -143,6 +143,10 @@ describe('MintBolt11Handler', () => { const buildRecoverContext = (): RecoverExecutingContext<'bolt11'> => ({ operation: executingOperation, wallet, + localClaimabilityFacts: { + finalizedAmount: Amount.zero(), + reservedAmount: Amount.zero(), + }, mintAdapter, proofService, proofRepository, @@ -213,12 +217,12 @@ describe('MintBolt11Handler', () => { }); describe('recoverExecuting', () => { - it('returns a terminal result when the mint quote expired during execution', async () => { + it('keeps ordinary mint errors pending during recovery', async () => { const result = await handler.recoverExecuting(buildRecoverContext()); expect(result).toEqual({ - status: 'TERMINAL', - error: `Recovered: quote ${quoteId} expired while executing mint`, + status: 'PENDING', + error: 'Quote expired', }); expect((wallet.mintProofsBolt11 as Mock).mock.calls.length).toBe(1); expect((proofService.saveProofs as Mock).mock.calls.length).toBe(0); @@ -267,10 +271,20 @@ describe('MintBolt11Handler', () => { }); describe('checkPending', () => { - it('returns the observed remote state with a normalized ready category', async () => { + it('uses canonical accounting when compatibility state disagrees', async () => { + ( + mintAdapter.checkMintQuote as Mock< + (mintUrl: string, method: 'bolt11', quoteId: string) => Promise + > + ).mockImplementation(async () => ({ + ...quote, + state: 'UNPAID', + })); + const result = await handler.checkPending(buildPendingContext()); - expect(result.observedRemoteState).toBe('PAID'); + expect(result.observedRemoteState).toBeUndefined(); + expect(result.quoteSnapshot).toEqual(expect.objectContaining({ state: 'UNPAID' })); expect(result.category).toBe('ready'); expect(result.observedRemoteStateAt).toEqual(expect.any(Number)); }); diff --git a/packages/core/test/unit/MintBolt12Handler.test.ts b/packages/core/test/unit/MintBolt12Handler.test.ts index 0ed2da91..c3cc7465 100644 --- a/packages/core/test/unit/MintBolt12Handler.test.ts +++ b/packages/core/test/unit/MintBolt12Handler.test.ts @@ -140,8 +140,14 @@ describe('MintBolt12Handler', () => { const buildRecoverContext = ( remoteQuote: MintQuoteBolt12Response, - ): RecoverExecutingContext<'bolt12'> => - buildExecuteContext(remoteQuote) as RecoverExecutingContext<'bolt12'>; + localClaimabilityFacts = { + finalizedAmount: Amount.zero(), + reservedAmount: Amount.zero(), + }, + ): RecoverExecutingContext<'bolt12'> => ({ + ...buildExecuteContext(remoteQuote), + localClaimabilityFacts, + }); beforeEach(() => { wallet = { @@ -469,4 +475,16 @@ describe('MintBolt12Handler', () => { expect(wallet.mintProofsBolt12).toHaveBeenCalled(); expect(proofService.saveProofs).toHaveBeenCalled(); }); + + it('does not retry balance already consumed by finalized local operations', async () => { + const result = await handler.recoverExecuting( + buildRecoverContext(quote({ amount_paid: Amount.from(10) }), { + finalizedAmount: Amount.from(10), + reservedAmount: Amount.zero(), + }), + ); + + expect(result.status).toBe('PENDING'); + 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..d86b4aca 100644 --- a/packages/core/test/unit/MintOnchainHandler.test.ts +++ b/packages/core/test/unit/MintOnchainHandler.test.ts @@ -137,9 +137,15 @@ describe('MintOnchainHandler', () => { outputData: serializeOutputData({ keep: [output], send: [] }), }); - const buildRecoverContext = (): RecoverExecutingContext<'onchain'> => ({ + const buildRecoverContext = ( + localClaimabilityFacts = { + finalizedAmount: Amount.zero(), + reservedAmount: Amount.zero(), + }, + ): RecoverExecutingContext<'onchain'> => ({ ...buildPrepareContext(), operation: buildExecutingOperation(), + localClaimabilityFacts, }); const buildPendingContext = (): PendingContext<'onchain'> => ({ @@ -359,6 +365,18 @@ describe('MintOnchainHandler', () => { expect(wallet.mintProofsOnchain).not.toHaveBeenCalled(); }); + it('subtracts other in-flight reservations before retrying onchain recovery', async () => { + const result = await handler.recoverExecuting( + buildRecoverContext({ + finalizedAmount: Amount.zero(), + reservedAmount: Amount.from(10), + }), + ); + + expect(result.status).toBe('PENDING'); + expect(wallet.mintProofsOnchain).not.toHaveBeenCalled(); + }); + it('attempts restore again after an already-issued retry result', async () => { (wallet.mintProofsOnchain as Mock).mockImplementationOnce(async () => { throw new MintOperationError(20002, 'already issued'); diff --git a/packages/core/test/unit/MintOperationService.test.ts b/packages/core/test/unit/MintOperationService.test.ts index 76632879..18c09e63 100644 --- a/packages/core/test/unit/MintOperationService.test.ts +++ b/packages/core/test/unit/MintOperationService.test.ts @@ -748,16 +748,12 @@ describe('MintOperationService', () => { ); const stored = await quoteRepo.getMintQuote(mintUrl, 'onchain', onchainQuoteId); - const hasClaimableBalance = await service.hasLocallyClaimableMintQuoteBalance( - mintUrl, - 'onchain', - onchainQuoteId, - ); + const assessment = await service.getMintQuoteClaimability(mintUrl, 'onchain', onchainQuoteId); expect(stored?.amountPaid.equals(Amount.from(10))).toBe(true); expect(stored?.amountIssued.equals(Amount.zero())).toBe(true); expect(stored?.remoteUpdatedAt).toBe(11); - expect(hasClaimableBalance).toBe(true); + expect(assessment?.status).toBe('claimable'); }); it('refreshMintQuote updates reusable onchain quote data before emitting', async () => { @@ -1761,6 +1757,50 @@ describe('MintOperationService', () => { expect(failedEvents).toHaveLength(0); }); + it('claimMintQuote issues atomic accounting even when compatibility state is stale', async () => { + await persistQuote(); + const canonical = await quoteRepo.getMintQuote(mintUrl, 'bolt11', quoteId); + if (!canonical || canonical.method !== 'bolt11') { + throw new Error('Expected canonical BOLT11 quote'); + } + await quoteRepo.upsertMintQuote({ ...canonical, state: 'UNPAID' }); + const operation = await service.prepare(canonical, canonical.amount); + + const claimed = await service.claimMintQuote(mintUrl, 'bolt11', quoteId, { + autoClaimRemaining: false, + }); + + expect(claimed).toHaveLength(1); + expect(claimed[0]?.id).toBe(operation.id); + expect(claimed[0]?.state).toBe('finalized'); + }); + + it('claimMintQuote advances a pending atomic operation from complete accounting', async () => { + await persistQuote(); + const operation = await service.prepare( + { mintUrl, method: 'bolt11', quoteId }, + Amount.from(10), + ); + const canonical = await quoteRepo.getMintQuote(mintUrl, 'bolt11', quoteId); + if (!canonical || canonical.method !== 'bolt11') { + throw new Error('Expected canonical BOLT11 quote'); + } + await quoteRepo.upsertMintQuote({ + ...canonical, + state: 'UNPAID', + amountIssued: canonical.amount, + }); + (handler.execute as Mock).mockResolvedValueOnce({ + status: 'ALREADY_ISSUED', + }); + + const claimed = await service.claimMintQuote(mintUrl, 'bolt11', quoteId); + + expect(claimed).toHaveLength(1); + expect(claimed[0]?.id).toBe(operation.id); + expect(claimed[0]?.state).toBe('finalized'); + }); + it('finalize is idempotent after finalize', async () => { await persistQuote(); @@ -2109,14 +2149,10 @@ describe('MintOperationService', () => { }); useAutoClaimOnchainHandler(Amount.from(10)); - const hasClaimableBalance = await service.hasLocallyClaimableMintQuoteBalance( - mintUrl, - 'onchain', - onchainQuoteId, - ); + const assessment = await service.getMintQuoteClaimability(mintUrl, 'onchain', onchainQuoteId); const claimed = await service.claimMintQuote(mintUrl, 'onchain', onchainQuoteId); - expect(hasClaimableBalance).toBe(true); + expect(assessment?.status).toBe('claimable'); expect(claimed).toHaveLength(1); expect(claimed[0]?.state).toBe('finalized'); }); @@ -2136,19 +2172,19 @@ describe('MintOperationService', () => { expiry, }); - const onchainClaimable = await service.hasLocallyClaimableMintQuoteBalance( + const onchainAssessment = await service.getMintQuoteClaimability( mintUrl, 'onchain', onchainQuoteId, ); - const bolt12Claimable = await service.hasLocallyClaimableMintQuoteBalance( + const bolt12Assessment = await service.getMintQuoteClaimability( mintUrl, 'bolt12', bolt12QuoteId, ); - expect(onchainClaimable).toBe(true); - expect(bolt12Claimable).toBe(true); + expect(onchainAssessment?.status).toBe('claimable'); + expect(bolt12Assessment?.status).toBe('claimable'); }); it('claimMintQuote prepares and issues reusable balance after expiry', async () => { diff --git a/packages/core/test/unit/MintOperationWatcherService.test.ts b/packages/core/test/unit/MintOperationWatcherService.test.ts index 32e92fbe..b758290e 100644 --- a/packages/core/test/unit/MintOperationWatcherService.test.ts +++ b/packages/core/test/unit/MintOperationWatcherService.test.ts @@ -340,8 +340,8 @@ describe('MintOperationWatcherService', () => { await watcher.stop(); }); - it('watches canonical mint quotes created after startup', async () => { - const quote = makeBolt11Quote(); + it('watches canonical quotes from accounting when compatibility state disagrees', async () => { + const quote = { ...makeBolt11Quote(), state: 'ISSUED' as const }; const watcher = makeWatcher({ options: { watchExistingPendingOnStart: false, watchExistingPendingQuotesOnStart: false }, }); @@ -439,7 +439,7 @@ describe('MintOperationWatcherService', () => { await watcher.stop(); }); - it('keeps watching expired unpaid subscription updates', async () => { + it('records expired unpaid subscription updates and keeps watching', async () => { const operation = makePendingOperation(); const recordMintQuoteSnapshot = mock(async () => makeBolt11Quote()); @@ -470,7 +470,11 @@ describe('MintOperationWatcherService', () => { state: 'UNPAID', }); - expect(recordMintQuoteSnapshot).not.toHaveBeenCalled(); + expect(recordMintQuoteSnapshot).toHaveBeenCalledWith( + mintUrl, + 'bolt11', + expect.objectContaining({ quote: quoteId, state: 'UNPAID' }), + ); expect(unsubscribe).not.toHaveBeenCalled(); await watcher.stop(); @@ -685,7 +689,7 @@ describe('MintOperationWatcherService', () => { await watcher.stop(); }); - it('drops incomplete onchain payloads without stopping the watch', async () => { + it('passes incomplete onchain payloads to canonical validation without stopping the watch', async () => { const operation = makeOnchainOperation(); const recordMintQuoteSnapshot = mock(async () => makeOnchainQuote()); @@ -715,13 +719,17 @@ describe('MintOperationWatcherService', () => { amount_paid: Amount.from(10), }); - expect(recordMintQuoteSnapshot).not.toHaveBeenCalled(); + expect(recordMintQuoteSnapshot).toHaveBeenCalledWith( + mintUrl, + 'onchain', + expect.objectContaining({ quote: quoteId }), + ); expect(unsubscribe).not.toHaveBeenCalled(); await watcher.stop(); }); - it('keeps watching an incomplete onchain payload after expiry', async () => { + it('passes incomplete onchain payloads after expiry to canonical validation', async () => { const operation = makeOnchainOperation(); const recordMintQuoteSnapshot = mock(async () => makeOnchainQuote()); @@ -750,7 +758,11 @@ describe('MintOperationWatcherService', () => { expiry: Math.floor(Date.now() / 1000) - 1, }); - expect(recordMintQuoteSnapshot).not.toHaveBeenCalled(); + expect(recordMintQuoteSnapshot).toHaveBeenCalledWith( + mintUrl, + 'onchain', + expect.objectContaining({ quote: quoteId }), + ); expect(unsubscribe).not.toHaveBeenCalled(); await watcher.stop(); @@ -794,7 +806,7 @@ describe('MintOperationWatcherService', () => { await watcher.stop(); }); - it('keeps reusable onchain quote watches after operation finalization', async () => { + it('keeps onchain balance quote watches after operation finalization', async () => { const operation = makeOnchainOperation(); const watcher = makeWatcher({ @@ -852,7 +864,7 @@ describe('MintOperationWatcherService', () => { expect(unsubscribe).toHaveBeenCalledTimes(1); }); - it('keeps watching when one of multiple operation interests finalizes', async () => { + it('keeps canonical quote watching when all operation interests finalize', async () => { const first = makePendingOperation(); const second = { ...makePendingOperation(), id: 'mint-op-2' }; @@ -879,13 +891,13 @@ describe('MintOperationWatcherService', () => { operationId: second.id, operation: { ...second, state: 'finalized' }, }); - expect(unsubscribe).toHaveBeenCalledTimes(1); + expect(unsubscribe).not.toHaveBeenCalled(); await watcher.stop(); expect(unsubscribe).toHaveBeenCalledTimes(1); }); - it('stops operation-specific watch interest when an operation fails', async () => { + it('keeps canonical quote watching when an operation fails', async () => { const operation = makePendingOperation(); const watcher = makeWatcher({ @@ -904,7 +916,7 @@ describe('MintOperationWatcherService', () => { operation: makeFailedOperation(operation), }); - expect(unsubscribe).toHaveBeenCalledTimes(1); + expect(unsubscribe).not.toHaveBeenCalled(); await watcher.stop(); expect(unsubscribe).toHaveBeenCalledTimes(1); diff --git a/packages/core/test/unit/MintQuoteClaimability.test.ts b/packages/core/test/unit/MintQuoteClaimability.test.ts new file mode 100644 index 00000000..6e9b67d4 --- /dev/null +++ b/packages/core/test/unit/MintQuoteClaimability.test.ts @@ -0,0 +1,298 @@ +import { Amount } from '@cashu/cashu-ts'; +import { describe, expect, it } from 'bun:test'; + +import type { MintQuote } from '../../models/MintQuote.ts'; +import { assessMintQuoteClaimability } from '../../models/MintQuoteClaimability.ts'; + +const mintUrl = 'https://mint.test'; +const futureExpiry = Math.floor(Date.now() / 1000) + 3600; + +function makeBolt11Quote(expiry: number | null = futureExpiry): MintQuote<'bolt11'> { + const amount = Amount.from(10); + return { + mintUrl, + method: 'bolt11', + quoteId: 'bolt11-quote', + quote: 'bolt11-quote', + request: 'lnbc1test', + amount, + unit: 'sat', + expiry, + state: 'ISSUED', + reusable: false, + amountPaid: Amount.zero(), + amountIssued: Amount.zero(), + remoteUpdatedAt: null, + quoteData: { amount }, + createdAt: 1, + updatedAt: 1, + }; +} + +function makeBalanceQuote( + method: 'bolt12' | 'onchain', + expiry: number | null = futureExpiry, +): MintQuote<'bolt12' | 'onchain'> { + return { + mintUrl, + method, + quoteId: `${method}-quote`, + quote: `${method}-quote`, + request: method === 'bolt12' ? 'lno1test' : 'bc1qtest', + unit: 'sat', + expiry, + reusable: true, + amountPaid: Amount.zero(), + amountIssued: Amount.zero(), + remoteUpdatedAt: null, + quoteData: { pubkey: '02'.padEnd(66, '1') }, + createdAt: 1, + updatedAt: 1, + } as MintQuote<'bolt12' | 'onchain'>; +} + +describe('assessMintQuoteClaimability', () => { + it.each([ + ['bolt11', makeBolt11Quote()], + ['bolt12', makeBalanceQuote('bolt12')], + ['onchain', makeBalanceQuote('onchain')], + ] as const)('classifies zero %s accounting as waiting', (_method, quote) => { + const assessment = assessMintQuoteClaimability(quote); + + expect(assessment.status).toBe('waiting'); + expect(assessment.remoteAvailable.equals(Amount.zero())).toBe(true); + expect(assessment.claimAmount).toBeUndefined(); + }); + + it.each([ + ['bolt11', makeBolt11Quote], + ['bolt12', (expiry: number | null) => makeBalanceQuote('bolt12', expiry)], + ['onchain', (expiry: number | null) => makeBalanceQuote('onchain', expiry)], + ] as const)('ignores %s expiry for identical accounting', (_method, makeQuote) => { + const beforeExpiry = { + ...makeQuote(futureExpiry), + amountPaid: Amount.from(10), + } as MintQuote; + const afterExpiry = { + ...makeQuote(1), + amountPaid: Amount.from(10), + } as MintQuote; + + const before = assessMintQuoteClaimability(beforeExpiry); + const after = assessMintQuoteClaimability(afterExpiry); + + expect(after.status).toBe(before.status); + expect(after.remoteAvailable.equals(before.remoteAvailable)).toBe(true); + expect(after.claimAmount?.equals(before.claimAmount ?? Amount.zero())).toBe(true); + }); + + it.each([ + ['bolt11', { ...makeBolt11Quote(), amountPaid: Amount.from(10) }], + ['bolt12', { ...makeBalanceQuote('bolt12'), amountPaid: Amount.from(10) }], + ['onchain', { ...makeBalanceQuote('onchain'), amountPaid: Amount.from(10) }], + ] as const)('classifies a zero %s claim request as invalid', (_method, quote) => { + const assessment = assessMintQuoteClaimability(quote as MintQuote, { + requestedAmount: Amount.zero(), + }); + + expect(assessment.status).toBe('invalid'); + expect(assessment.claimAmount).toBeUndefined(); + }); + + it.each([ + ['bolt11', makeBolt11Quote()], + ['bolt12', makeBalanceQuote('bolt12')], + ['onchain', makeBalanceQuote('onchain')], + ] as const)('classifies paid, unissued %s accounting as claimable', (_method, quote) => { + const paidQuote = { ...quote, amountPaid: Amount.from(10) } as MintQuote; + + const assessment = assessMintQuoteClaimability(paidQuote); + + expect(assessment.status).toBe('claimable'); + expect(assessment.remoteAvailable.equals(Amount.from(10))).toBe(true); + expect(assessment.claimAmount?.equals(Amount.from(10))).toBe(true); + }); + + it('keeps a partially paid BOLT11 quote waiting for its full fixed amount', () => { + const quote = { + ...makeBolt11Quote(), + amountPaid: Amount.from(9), + state: 'PAID' as const, + }; + + const assessment = assessMintQuoteClaimability(quote); + + expect(assessment.status).toBe('waiting'); + expect(assessment.remoteAvailable.equals(Amount.from(9))).toBe(true); + expect(assessment.claimAmount).toBeUndefined(); + }); + + it('classifies a fully issued BOLT11 quote as complete', () => { + const quote = { + ...makeBolt11Quote(), + amountPaid: Amount.from(10), + amountIssued: Amount.from(10), + state: 'UNPAID' as const, + }; + + const assessment = assessMintQuoteClaimability(quote); + + expect(assessment.status).toBe('complete'); + expect(assessment.remoteAvailable.equals(Amount.zero())).toBe(true); + expect(assessment.claimAmount).toBeUndefined(); + }); + + it.each(['bolt12', 'onchain'] as const)( + 'accounts for finalized local %s operations while remote issuance lags', + (method) => { + const quote = { + ...makeBalanceQuote(method), + amountPaid: Amount.from(20), + amountIssued: Amount.from(5), + } as MintQuote<'bolt12' | 'onchain'>; + + const assessment = assessMintQuoteClaimability(quote, { + finalizedAmount: Amount.from(12), + }); + + expect(assessment.status).toBe('claimable'); + expect(assessment.remoteAvailable.equals(Amount.from(15))).toBe(true); + expect(assessment.claimAmount?.equals(Amount.from(8))).toBe(true); + }, + ); + + it.each(['bolt12', 'onchain'] as const)( + 'subtracts in-flight %s Mint Quote Reservations from the claim amount', + (method) => { + const quote = { + ...makeBalanceQuote(method), + amountPaid: Amount.from(20), + amountIssued: Amount.from(5), + } as MintQuote<'bolt12' | 'onchain'>; + + const assessment = assessMintQuoteClaimability(quote, { + reservedAmount: Amount.from(6), + }); + + expect(assessment.status).toBe('claimable'); + expect(assessment.remoteAvailable.equals(Amount.from(15))).toBe(true); + expect(assessment.claimAmount?.equals(Amount.from(9))).toBe(true); + }, + ); + + it('returns the requested balance claim when local availability is sufficient', () => { + const quote = { + ...makeBalanceQuote('onchain'), + amountPaid: Amount.from(20), + amountIssued: Amount.from(5), + } as MintQuote<'onchain'>; + + const assessment = assessMintQuoteClaimability(quote, { + requestedAmount: Amount.from(7), + }); + + expect(assessment.status).toBe('claimable'); + expect(assessment.remoteAvailable.equals(Amount.from(15))).toBe(true); + expect(assessment.claimAmount?.equals(Amount.from(7))).toBe(true); + }); + + it.each(['bolt12', 'onchain'] as const)( + 'keeps a %s request waiting when the local balance is insufficient', + (method) => { + const quote = { + ...makeBalanceQuote(method), + amountPaid: Amount.from(10), + amountIssued: Amount.from(4), + } as MintQuote<'bolt12' | 'onchain'>; + + const assessment = assessMintQuoteClaimability(quote, { + reservedAmount: Amount.from(2), + requestedAmount: Amount.from(5), + }); + + expect(assessment.status).toBe('waiting'); + expect(assessment.remoteAvailable.equals(Amount.from(6))).toBe(true); + expect(assessment.claimAmount).toBeUndefined(); + }, + ); + + it.each(['bolt12', 'onchain'] as const)( + 'keeps a fully drawn %s balance waiting for future funding', + (method) => { + const quote = { + ...makeBalanceQuote(method), + amountPaid: Amount.from(10), + amountIssued: Amount.from(10), + } as MintQuote<'bolt12' | 'onchain'>; + + const assessment = assessMintQuoteClaimability(quote); + + expect(assessment.status).toBe('waiting'); + expect(assessment.remoteAvailable.equals(Amount.zero())).toBe(true); + expect(assessment.claimAmount).toBeUndefined(); + }, + ); + + it('classifies a non-fixed BOLT11 claim request as invalid', () => { + const quote = { ...makeBolt11Quote(), amountPaid: Amount.from(10) }; + + const assessment = assessMintQuoteClaimability(quote, { + requestedAmount: Amount.from(9), + finalizedAmount: Amount.from(10), + reservedAmount: Amount.from(10), + }); + + expect(assessment.status).toBe('invalid'); + expect(assessment.remoteAvailable.equals(Amount.from(10))).toBe(true); + expect(assessment.claimAmount).toBeUndefined(); + }); + + it('does not apply balance reservation arithmetic to an atomic BOLT11 claim', () => { + const amount = Amount.from(10); + const quote = { ...makeBolt11Quote(), amountPaid: amount }; + + const assessment = assessMintQuoteClaimability(quote, { + requestedAmount: amount, + finalizedAmount: amount, + reservedAmount: amount, + }); + + expect(assessment.status).toBe('claimable'); + expect(assessment.remoteAvailable.equals(amount)).toBe(true); + expect(assessment.claimAmount?.equals(amount)).toBe(true); + }); + + it.each([ + ['bolt11', makeBolt11Quote()], + ['bolt12', makeBalanceQuote('bolt12')], + ['onchain', makeBalanceQuote('onchain')], + ] as const)('classifies contradictory %s accounting as invalid', (_method, quote) => { + const contradictory = { + ...quote, + amountPaid: Amount.from(5), + amountIssued: Amount.from(6), + } as MintQuote; + + const assessment = assessMintQuoteClaimability(contradictory); + + expect(assessment.status).toBe('invalid'); + expect(assessment.remoteAvailable.equals(Amount.zero())).toBe(true); + expect(assessment.claimAmount).toBeUndefined(); + }); + + it.each([ + ['partial issuance', 10, 5], + ['payment above the fixed amount', 11, 0], + ] as const)('classifies atomic %s as invalid', (_case, paid, issued) => { + const quote = { + ...makeBolt11Quote(), + amountPaid: Amount.from(paid), + amountIssued: Amount.from(issued), + }; + + const assessment = assessMintQuoteClaimability(quote); + + expect(assessment.status).toBe('invalid'); + expect(assessment.claimAmount).toBeUndefined(); + }); +}); diff --git a/packages/core/test/unit/MintQuoteProcessor.test.ts b/packages/core/test/unit/MintQuoteProcessor.test.ts index 219db46a..908b3d4d 100644 --- a/packages/core/test/unit/MintQuoteProcessor.test.ts +++ b/packages/core/test/unit/MintQuoteProcessor.test.ts @@ -45,8 +45,8 @@ describe('MintOperationProcessor', () => { claimCalls.push({ mintUrl, method, quoteId }); return []; }, - async hasLocallyClaimableMintQuoteBalance() { - return true; + async getMintQuoteClaimability() { + return { status: 'claimable' }; }, async claimPendingMintQuotes() { startupClaimCalls++; @@ -102,7 +102,7 @@ describe('MintOperationProcessor', () => { expect(processor.isRunning()).toBe(false); }); - it('processes PAID operations from mint-quote:updated', async () => { + it('claims accounting-ready BOLT11 quotes from mint-quote:updated', async () => { await processor.start(); await bus.emit('mint-quote:updated', { @@ -118,31 +118,64 @@ describe('MintOperationProcessor', () => { } as any, }); - await sleep(TEST_PROCESS_INTERVAL * 2 + 50); + await processor.waitForCompletion(); - expect(finalizeCalls).toEqual(['mint-op-1']); + expect(claimCalls).toEqual([ + { mintUrl: 'https://mint.test', method: 'bolt11', quoteId: 'quote-1' }, + ]); + expect(finalizeCalls).toEqual([]); }); - it('processes all pending operations that share a paid quote', async () => { + it('advances complete BOLT11 quotes from mint-quote:updated', async () => { mockMintOperationService = { - async getOperationsForQuote() { - return [ - { - id: 'mint-op-a', - state: 'pending', - mintUrl: 'https://mint.test', - method: 'bolt11', - }, - { - id: 'mint-op-b', - state: 'pending', - mintUrl: 'https://mint.test', - method: 'bolt11', - }, - ]; + ...mockMintOperationService, + async getMintQuoteClaimability() { + return { status: 'complete' }; }, - async finalize(operationId: string) { - finalizeCalls.push(operationId); + } as unknown as MintOperationService; + processor = new MintOperationProcessor( + mockMintOperationService, + mockQuoteLifecycle, + bus, + undefined, + { + processIntervalMs: TEST_PROCESS_INTERVAL, + baseRetryDelayMs: TEST_RETRY_DELAY, + maxRetries: 3, + initialEnqueueDelayMs: TEST_INITIAL_DELAY, + }, + ); + await processor.start(); + + await bus.emit('mint-quote:updated', { + mintUrl: 'https://mint.test', + method: 'bolt11', + quoteId: 'complete-quote', + quote: { + mintUrl: 'https://mint.test', + method: 'bolt11', + quoteId: 'complete-quote', + } as any, + }); + + await processor.waitForCompletion(); + + expect(claimCalls).toEqual([ + { mintUrl: 'https://mint.test', method: 'bolt11', quoteId: 'complete-quote' }, + ]); + }); + + it('delegates sibling selection to one common quote claim', async () => { + mockMintOperationService = { + async getMintQuoteClaimability() { + return { status: 'claimable' }; + }, + async claimMintQuote(mintUrl: string, method: string, quoteId: string) { + claimCalls.push({ mintUrl, method, quoteId }); + return []; + }, + async claimPendingMintQuotes() { + return []; }, } as unknown as MintOperationService; @@ -174,12 +207,14 @@ describe('MintOperationProcessor', () => { } as any, }); - await sleep(TEST_PROCESS_INTERVAL * 2 + 50); + await processor.waitForCompletion(); - expect(finalizeCalls).toEqual(['mint-op-a', 'mint-op-b']); + expect(claimCalls).toEqual([ + { mintUrl: 'https://mint.test', method: 'bolt11', quoteId: 'shared-quote' }, + ]); }); - it('claims reusable onchain quotes with locally claimable balance from mint-quote:updated', async () => { + it('claims onchain balance quotes with locally claimable value from mint-quote:updated', async () => { await processor.start(); await bus.emit('mint-quote:updated', { @@ -211,11 +246,11 @@ describe('MintOperationProcessor', () => { expect(finalizeCalls).toEqual([]); }); - it('skips reusable onchain quote claims with no locally claimable balance', async () => { + it('skips onchain balance quotes with no locally claimable value', async () => { mockMintOperationService = { ...mockMintOperationService, - async hasLocallyClaimableMintQuoteBalance() { - return false; + async getMintQuoteClaimability() { + return { status: 'waiting' }; }, } as unknown as MintOperationService; processor = new MintOperationProcessor( @@ -243,10 +278,10 @@ describe('MintOperationProcessor', () => { expect(claimCalls).toEqual([]); }); - it('logs and skips reusable onchain quote claims when claimability check fails', async () => { + it('logs and skips onchain balance quotes when claimability assessment fails', async () => { mockMintOperationService = { ...mockMintOperationService, - async hasLocallyClaimableMintQuoteBalance() { + async getMintQuoteClaimability() { throw new Error('claimability check failed'); }, } as unknown as MintOperationService; @@ -275,14 +310,14 @@ describe('MintOperationProcessor', () => { expect(claimCalls).toEqual([]); }); - it('claims pending reusable mint quotes on startup', async () => { + it('claims pending mint quotes through the common startup path', async () => { await processor.start(); await processor.waitForCompletion(); expect(startupClaimCalls).toBe(1); }); - it('can disable reusable mint quote auto-claiming', async () => { + it('can disable automatic mint quote claiming', async () => { processor = new MintOperationProcessor( mockMintOperationService, mockQuoteLifecycle, @@ -324,7 +359,7 @@ describe('MintOperationProcessor', () => { expect(claimCalls).toEqual([]); }); - it('processes already-paid pending operations from mint-op:pending', async () => { + it('claims an accounting-ready quote from mint-op:pending', async () => { await processor.start(); await bus.emit('mint-op:pending', { @@ -339,30 +374,54 @@ describe('MintOperationProcessor', () => { } as any, }); - await sleep(TEST_PROCESS_INTERVAL + 20); + await processor.waitForCompletion(); - expect(finalizeCalls).toEqual(['mint-op-2']); + expect(claimCalls).toEqual([ + { mintUrl: 'https://mint.test', method: 'bolt11', quoteId: 'quote-2' }, + ]); + expect(finalizeCalls).toEqual([]); }); - it('processes explicit mint-op:requeue events', async () => { - await processor.start(); + it.each(['bolt11', 'bolt12', 'onchain'] as const)( + 'processes explicit %s mint-op:requeue events through the default handler', + async (method) => { + await processor.start(); - await bus.emit('mint-op:requeue', { - mintUrl: 'https://mint.test', - operationId: 'mint-op-3', - operation: { - id: 'mint-op-3', + await bus.emit('mint-op:requeue', { mintUrl: 'https://mint.test', - method: 'bolt11', - } as any, - }); + operationId: `mint-op-${method}`, + operation: { + id: `mint-op-${method}`, + mintUrl: 'https://mint.test', + method, + } as any, + }); - await sleep(TEST_PROCESS_INTERVAL + 20); + await sleep(TEST_PROCESS_INTERVAL + 20); - expect(finalizeCalls).toEqual(['mint-op-3']); - }); + expect(finalizeCalls).toEqual([`mint-op-${method}`]); + }, + ); - it('ignores non-PAID quote updates', async () => { + it('skips quote updates with no locally claimable value', async () => { + mockMintOperationService = { + ...mockMintOperationService, + async getMintQuoteClaimability() { + return { status: 'waiting' }; + }, + } as unknown as MintOperationService; + processor = new MintOperationProcessor( + mockMintOperationService, + mockQuoteLifecycle, + bus, + undefined, + { + processIntervalMs: TEST_PROCESS_INTERVAL, + baseRetryDelayMs: TEST_RETRY_DELAY, + maxRetries: 3, + initialEnqueueDelayMs: TEST_INITIAL_DELAY, + }, + ); await processor.start(); await bus.emit('mint-quote:updated', { @@ -378,12 +437,36 @@ describe('MintOperationProcessor', () => { } as any, }); - await sleep(TEST_PROCESS_INTERVAL + 20); + await processor.waitForCompletion(); + expect(claimCalls).toEqual([]); expect(finalizeCalls).toEqual([]); }); - it('deduplicates repeated enqueue requests for the same operation', async () => { + it('deduplicates concurrent claim requests for the same quote', async () => { + let releaseAssessment!: () => void; + const assessmentGate = new Promise((resolve) => { + releaseAssessment = resolve; + }); + mockMintOperationService = { + ...mockMintOperationService, + async getMintQuoteClaimability() { + await assessmentGate; + return { status: 'claimable' }; + }, + } as unknown as MintOperationService; + processor = new MintOperationProcessor( + mockMintOperationService, + mockQuoteLifecycle, + bus, + undefined, + { + processIntervalMs: TEST_PROCESS_INTERVAL, + baseRetryDelayMs: TEST_RETRY_DELAY, + maxRetries: 3, + initialEnqueueDelayMs: TEST_INITIAL_DELAY, + }, + ); await processor.start(); for (let i = 0; i < 3; i++) { @@ -401,9 +484,13 @@ describe('MintOperationProcessor', () => { }); } - await sleep(TEST_PROCESS_INTERVAL + 20); + releaseAssessment(); + await processor.waitForCompletion(); - expect(finalizeCalls).toEqual(['mint-op-5']); + expect(claimCalls).toEqual([ + { mintUrl: 'https://mint.test', method: 'bolt11', quoteId: 'quote-5' }, + ]); + expect(finalizeCalls).toEqual([]); }); it('retries network errors with exponential backoff', async () => { diff --git a/packages/sql-storage/src/repositories/MintQuoteRepository.ts b/packages/sql-storage/src/repositories/MintQuoteRepository.ts index adbc75eb..5b03b192 100644 --- a/packages/sql-storage/src/repositories/MintQuoteRepository.ts +++ b/packages/sql-storage/src/repositories/MintQuoteRepository.ts @@ -246,7 +246,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);