diff --git a/.changeset/canonical-mint-quote-claimability.md b/.changeset/canonical-mint-quote-claimability.md new file mode 100644 index 00000000..46fba788 --- /dev/null +++ b/.changeset/canonical-mint-quote-claimability.md @@ -0,0 +1,12 @@ +--- +'@cashu/coco-core': patch +'@cashu/coco-adapter-tests': patch +'@cashu/coco-sqlite': patch +'@cashu/coco-sqlite-bun': patch +'@cashu/coco-expo-sqlite': patch +'@cashu/coco-indexeddb': 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. Make +explicit mint execution retry-safe when background processing has already started or completed it. 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..f90bcdee --- /dev/null +++ b/FEATURE_TODO.md @@ -0,0 +1,43 @@ +# 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. + +## PR #400 stack reconciliation + +- [x] Refresh and pin PR #400 at `7bacc48df5e9012b3fcc1e43a05648eaa264f1b3`. +- [x] Rebase the PR #398 delta onto the pinned PR #400 head. +- [x] Transplant only the claimability delta onto `master` after PR #400 merged. +- [x] Remove competing BOLT11-specific runtime claimability predicates. +- [x] Preserve locked BOLT11 creation, signing, and exact-output recovery through the common seam. +- [x] Keep ownership contradictions ambiguity-preserving for future `needs_attention` recovery. +- [x] Restore transactional, monotonic repository observation and compatibility-state updates. +- [x] Fix explicit execution versus Background Watcher/processor coordination. +- [x] Run focused, adapter contract, build, typecheck, and formatting verification. +- [ ] Run Docker-backed live-mint integration tests (Docker is unavailable in this environment). +- [x] Review the combined stack against issues #387 and #365. + +## PR #403 execution-race follow-up + +- [x] Remove the stale pending-only precondition from the public Mint Ops interface. +- [x] Join active local execution and recover orphaned executing operations in the service. +- [x] Cover the public race and the authoritative execution state table. +- [x] Run focused and full core unit tests, typecheck, and build for the follow-up. +- [x] Re-run Docker-backed core integration tests with the required mint and auth environment. diff --git a/packages/adapter-tests/src/index.ts b/packages/adapter-tests/src/index.ts index 5e0fbfd6..586300b8 100644 --- a/packages/adapter-tests/src/index.ts +++ b/packages/adapter-tests/src/index.ts @@ -460,6 +460,7 @@ export async function runMintQuoteRepositoryContract( amountPaid: Amount.zero(), amountIssued: Amount.zero(), remoteUpdatedAt: null, + updatedAt: 10, }); await repositories.mintQuoteRepository.upsertMintQuote(quote); diff --git a/packages/core/Manager.ts b/packages/core/Manager.ts index 2d1e6670..7279a501 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/adapter.ts b/packages/core/adapter.ts index 1bdd08f9..f6b69c04 100644 --- a/packages/core/adapter.ts +++ b/packages/core/adapter.ts @@ -43,9 +43,6 @@ export { deriveBolt11MintQuoteState, getMintQuoteAmount, getMintQuoteRemoteState, - isBolt11MintQuoteIssued, - isBolt11MintQuotePaid, - isBolt11MintQuoteUnpaid, isMintQuotePending, isStatefulMintQuote, operationHistoryId, diff --git a/packages/core/api/MintOpsApi.ts b/packages/core/api/MintOpsApi.ts index 952a8ed3..74aa861f 100644 --- a/packages/core/api/MintOpsApi.ts +++ b/packages/core/api/MintOpsApi.ts @@ -58,17 +58,13 @@ export class MintOpsApi { - const operation = await this.resolveOperation(operationOrId); - if (operation.state !== 'pending') { - throw new Error( - `Cannot execute operation in state '${operation.state}'. Expected 'pending'.`, - ); - } - - return this.mintOperationService.execute(operation.id); + const operationId = typeof operationOrId === 'string' ? operationOrId : operationOrId.id; + return this.mintOperationService.execute(operationId); } /** Returns a mint operation by ID, or `null` when it does not exist. */ @@ -132,14 +128,6 @@ export class MintOpsApi { - if (typeof operationOrId === 'string') { - return this.requireOperation(operationOrId); - } - - return this.requireOperation(operationOrId.id); - } - private async requireOperation(operationId: string): Promise { const operation = await this.mintOperationService.getOperation(operationId); if (!operation) { diff --git a/packages/core/infra/handlers/mint/MintBolt11Handler.ts b/packages/core/infra/handlers/mint/MintBolt11Handler.ts index f2bd475f..ecb01678 100644 --- a/packages/core/infra/handlers/mint/MintBolt11Handler.ts +++ b/packages/core/infra/handlers/mint/MintBolt11Handler.ts @@ -22,15 +22,9 @@ import { MintQuoteValidationError, } from '../../../models/Error'; import type { KeyRingService } from '../../../services/KeyRingService'; -import { - deriveBolt11MintQuoteState, - isBolt11MintQuoteIssued, - isBolt11MintQuotePaid, - isBolt11MintQuoteUnpaid, - mintQuoteFromBolt11Response, - type MintQuote, -} from '../../../models/MintQuote'; +import { mintQuoteFromBolt11Response, type MintQuote } from '../../../models/MintQuote'; import { mintQuoteObservationFromBolt11Response } from '../../../models/MintQuoteObservationFactory'; +import { assessMintQuoteClaimability } from '../../../models/MintQuoteClaimability.ts'; export class MintBolt11Handler implements MintMethodHandler<'bolt11'> { constructor(private readonly keyRingService: KeyRingService) {} @@ -179,13 +173,32 @@ export class MintBolt11Handler implements MintMethodHandler<'bolt11'> { remoteQuote.pubkey !== ctx.operation.pubkey ) { return { - status: 'TERMINAL', + status: 'PENDING', error: 'Recovered BOLT11 mint operation has mismatched NUT-20 quote ownership', }; } - const canonicalRemoteQuote = mintQuoteFromBolt11Response(mintUrl, remoteQuote); - if (isBolt11MintQuotePaid(canonicalRemoteQuote)) { + 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 signingOptions = await this.getMintQuoteSigningOptions(ctx.operation.pubkey); @@ -212,11 +225,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', @@ -230,16 +238,6 @@ export class MintBolt11Handler implements MintMethodHandler<'bolt11'> { }; } } - } else if (isBolt11MintQuoteUnpaid(canonicalRemoteQuote)) { - return { - status: 'PENDING', - error: `Recovered: quote ${quoteId} is still UNPAID`, - }; - } else if (!isBolt11MintQuoteIssued(canonicalRemoteQuote)) { - return { - status: 'PENDING', - error: `Recovered: quote ${quoteId} has unresolved Mint Quote Accounting`, - }; } try { @@ -272,50 +270,27 @@ export class MintBolt11Handler implements MintMethodHandler<'bolt11'> { const quote = await ctx.mintAdapter.checkMintQuote(mintUrl, 'bolt11', quoteId); this.assertPendingQuoteMatchesOperation(quote, ctx.operation); - const canonicalQuote = mintQuoteFromBolt11Response(mintUrl, quote); - const remoteState = deriveBolt11MintQuoteState( - canonicalQuote.amountPaid, - canonicalQuote.amountIssued, - ); - ctx.logger?.info('Pending mint quote accounting', { + 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, - amountPaid: canonicalQuote.amountPaid.toString(), - amountIssued: canonicalQuote.amountIssued.toString(), - compatibilityState: remoteState, + status: assessment.status, }); - const observedRemoteStateAt = Date.now(); - - 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', + 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 6bdfb203..b33fece8 100644 --- a/packages/core/infra/handlers/mint/MintBolt12Handler.ts +++ b/packages/core/infra/handlers/mint/MintBolt12Handler.ts @@ -10,6 +10,7 @@ import { } 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, @@ -124,6 +125,15 @@ export class MintBolt12Handler implements MintMethodHandler<'bolt12'> { ctx.operation.quoteId, ); this.assertQuoteMatchesRequest(remoteQuote, ctx.operation.pubkey ?? '', ctx.operation.unit); + const assessment = assessMintQuoteClaimability( + mintQuoteObservationFromBolt12Response(ctx.operation.mintUrl, remoteQuote), + { requestedAmount: ctx.operation.amount }, + ); + if (assessment.status === 'invalid') { + throw new MintQuoteValidationError( + `BOLT12 mint quote ${ctx.operation.quoteId} is not claimable: ${assessment.status}`, + ); + } try { const proofs = await ctx.wallet.mintProofsBolt12( @@ -198,11 +208,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}`, }; } @@ -235,13 +254,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), @@ -291,12 +303,21 @@ export class MintBolt12Handler implements MintMethodHandler<'bolt12'> { }; } + const assessment = assessMintQuoteClaimability( + mintQuoteObservationFromBolt12Response(operation.mintUrl, remoteQuote, { + now: observedRemoteStateAt, + }), + { requestedAmount: operation.amount }, + ); return { observedRemoteStateAt, quoteSnapshot: remoteQuote, - category: this.getAvailableAmount(remoteQuote).greaterThanOrEqual(operation.amount) - ? 'ready' - : 'waiting', + category: + assessment.status === 'claimable' + ? 'ready' + : assessment.status === 'complete' + ? 'completed' + : 'waiting', }; } @@ -338,12 +359,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 MintQuoteValidationError( - `BOLT12 mint quote ${quote.quote} has amount_issued greater than amount_paid`, - ); - } } private assertQuoteAmount(quote: MintQuoteBolt12Response, expectedAmount?: Amount): void { @@ -401,10 +416,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; @@ -413,13 +424,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 1dc9531a..139f838d 100644 --- a/packages/core/infra/handlers/mint/MintOnchainHandler.ts +++ b/packages/core/infra/handlers/mint/MintOnchainHandler.ts @@ -18,6 +18,7 @@ import { type MintQuoteOnchainResponse, } from '../../../models/MintQuote'; import { mintQuoteObservationFromOnchainResponse } from '../../../models/MintQuoteObservationFactory'; +import { assessMintQuoteClaimability } from '../../../models/MintQuoteClaimability.ts'; import type { CreateMintQuoteContext, ExecuteContext, @@ -119,6 +120,15 @@ export class MintOnchainHandler implements MintMethodHandler<'onchain'> { ctx.operation.quoteId, ); this.assertQuoteMatchesRequest(remoteQuote, ctx.operation.pubkey ?? '', ctx.operation.unit); + const assessment = assessMintQuoteClaimability( + mintQuoteObservationFromOnchainResponse(ctx.operation.mintUrl, remoteQuote), + { requestedAmount: ctx.operation.amount }, + ); + if (assessment.status === 'invalid') { + throw new MintQuoteValidationError( + `Onchain mint quote ${ctx.operation.quoteId} is not claimable: ${assessment.status}`, + ); + } const proofs = await ctx.wallet.mintProofsOnchain( ctx.operation.amount, @@ -186,11 +196,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}`, }; } @@ -223,13 +242,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), @@ -279,12 +291,21 @@ export class MintOnchainHandler implements MintMethodHandler<'onchain'> { }; } + const assessment = assessMintQuoteClaimability( + mintQuoteObservationFromOnchainResponse(operation.mintUrl, remoteQuote, { + now: observedRemoteStateAt, + }), + { requestedAmount: operation.amount }, + ); return { observedRemoteStateAt, quoteSnapshot: remoteQuote, - category: this.getAvailableAmount(remoteQuote).greaterThanOrEqual(operation.amount) - ? 'ready' - : 'waiting', + category: + assessment.status === 'claimable' + ? 'ready' + : assessment.status === 'complete' + ? 'completed' + : 'waiting', }; } @@ -316,16 +337,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 MintQuoteValidationError( - `Onchain mint quote ${quote.quote} has amount_issued greater than amount_paid`, - ); - } } private async recoverSignedOutputs( @@ -369,12 +380,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; @@ -383,13 +388,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 a7454bb6..f53bd764 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; @@ -92,25 +93,6 @@ export function deriveBolt11MintQuoteState( : '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. * @@ -144,14 +126,14 @@ export function applyBolt11MintQuoteStateFallback( state: deriveBolt11MintQuoteState(amountPaid, amountIssued), amountPaid, amountIssued, - updatedAt: observedAt, + updatedAt: Math.max(quote.updatedAt, observedAt), }; } /** * Returns the deprecated BOLT11 state projection for compatibility consumers. * - * @deprecated Use `amountPaid` and `amountIssued`, or the canonical accounting predicates. + * @deprecated Use `amountPaid` and `amountIssued`, or the common Claimability assessment. */ export function getMintQuoteRemoteState( quote: MintQuote, @@ -175,16 +157,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 { - return quote.amountPaid.subtract(quote.amountIssued); + return assessMintQuoteClaimability(quote).remoteAvailable; } export function isMintQuotePending(quote: MintQuote): boolean { - if (isStatefulMintQuote(quote)) { - return !isBolt11MintQuoteIssued(quote); - } - - 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..e0a61fab --- /dev/null +++ b/packages/core/models/MintQuoteClaimability.ts @@ -0,0 +1,106 @@ +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.amountIssued.isZero() && !quote.amountIssued.equals(quote.amount)) || + (facts.requestedAmount !== undefined && !facts.requestedAmount.equals(quote.amount)) + ) { + return invalid(remoteAvailable); + } + + if ( + quote.amountIssued.equals(quote.amount) || + facts.finalizedAmount?.greaterThanOrEqual(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 1567638a..d943b3da 100644 --- a/packages/core/operations/mint/MintMethodHandler.ts +++ b/packages/core/operations/mint/MintMethodHandler.ts @@ -162,6 +162,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..06095925 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( @@ -341,25 +335,65 @@ export class MintOperationService { } async execute(operationId: string): Promise { - const operation = await this.mintOperationRepository.getById(operationId); - if (operation?.state === 'pending') { + while (true) { + const operation = await this.mintOperationRepository.getById(operationId); + if (!operation) { + throw new Error(`Operation ${operationId} not found`); + } + + if (isTerminalOperation(operation)) { + return operation; + } + + if (operation.state === 'executing') { + if (this.isOperationLocked(operationId)) { + await this.operationIdLock.waitForUnlock(operationId); + continue; + } + + try { + await this.recoverExecutingOperation(operation); + } catch (error) { + if (!(error instanceof OperationInProgressError)) { + throw error; + } + + await this.operationIdLock.waitForUnlock(operationId); + } + + const recovered = await this.mintOperationRepository.getById(operationId); + if (recovered?.state === 'executing') { + throw new Error(`Operation ${operationId} remains executing after recovery`); + } + continue; + } + + if (operation.state !== 'pending') { + throw new Error( + `Cannot execute operation ${operationId}: expected state 'pending' but found '${operation.state}'`, + ); + } + const quote = await this.quoteLifecycle.getMintQuote( operation.mintUrl, operation.method, operation.quoteId, ); - if (quote?.reusable) { - return this.claimReusableQuoteOperation(operation as PendingMintOperation); + if (quote) { + return this.claimPendingQuoteOperation(operation as PendingMintOperation, quote); } - } - return this.executeReadyOperation(operationId); + return this.executeReadyOperation(operationId); + } } private async executeReadyOperation(operationId: string): Promise { const releaseLock = await this.acquireOperationLockAfterWait(operationId); try { const operation = await this.mintOperationRepository.getById(operationId); + if (operation && isTerminalOperation(operation)) { + return operation; + } if (!operation || operation.state !== 'pending') { throw new Error( `Cannot execute operation ${operationId}: expected state 'pending' but found '${ @@ -367,6 +401,9 @@ export class MintOperationService { }'`, ); } + if (!(await this.mintService.isTrustedMint(operation.mintUrl))) { + throw new UnknownMintError(`Mint ${operation.mintUrl} is not trusted`); + } const pendingOp = operation as PendingMintOperation; const executing: ExecutingMintOperation = { @@ -600,10 +637,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 +757,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 +799,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 +828,13 @@ export class MintOperationService { const claimed: MintOperation[] = []; for (const quote of quotes) { - if (!quote.reusable) { - continue; - } - if (getMintQuoteAvailableAmount(quote).isZero()) { + if (!(await this.mintService.isTrustedMint(quote.mintUrl))) { + this.logger?.debug('Skipping pending mint quote for untrusted mint', { + mintUrl: quote.mintUrl, + method: quote.method, + }); continue; } - claimed.push( ...(await this.claimMintQuote(quote.mintUrl, quote.method, quote.quoteId, options)), ); @@ -786,22 +843,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 +874,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 +925,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 +954,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 +1183,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 +1199,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') { + 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 00ef8c91..11472b4b 100644 --- a/packages/core/quotes/QuoteLifecycle.ts +++ b/packages/core/quotes/QuoteLifecycle.ts @@ -11,7 +11,6 @@ import { applyBolt11MintQuoteStateFallback, deriveBolt11MintQuoteState, getMintQuoteAmount, - isBolt11MintQuoteIssued, mintQuoteFromBolt11Response, mintQuoteFromBolt12Response, mintQuoteFromOnchainResponse, @@ -62,6 +61,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([ @@ -1555,9 +1555,13 @@ export class QuoteLifecycle { } private assertMintQuoteCanPrepare(quote: MintQuote, context: string): void { - if (isStatefulMintQuote(quote) && isBolt11MintQuoteIssued(quote)) { + 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 4b3b7720..af6a98cf 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 { isBolt11MintQuotePaid, isStatefulMintQuote } 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); @@ -51,6 +47,7 @@ export class MintOperationProcessor { private offRequeue?: () => void; private offUntrusted?: () => void; private claimingQuotes = new Set(); + private quoteClaimsNeedingFollowUp = new Set(); private claimTasks = new Set>(); private handlers = new Map(); @@ -79,8 +76,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 +99,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 (!isStatefulMintQuote(quote) || !isBolt11MintQuotePaid(quote)) { - 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 +115,8 @@ export class MintOperationProcessor { operation.method, operation.quoteId, ); - if (quote?.reusable) { + if (quote) { this.scheduleQuoteClaim(operation.mintUrl, operation.method, operation.quoteId); - return; - } - - if (quote && isStatefulMintQuote(quote) && isBolt11MintQuotePaid(quote)) { - this.enqueue(mintUrl, operation.id, operation.method); } }); @@ -304,7 +280,8 @@ export class MintOperationProcessor { const key = `${mintUrl}::${method}::${quoteId}`; if (this.claimingQuotes.has(key)) { - this.logger?.debug('Reusable mint quote claim already in progress', { + this.quoteClaimsNeedingFollowUp.add(key); + this.logger?.debug('Mint quote claim already in progress', { mintUrl, method, quoteId, @@ -315,13 +292,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 +310,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, @@ -341,6 +318,10 @@ export class MintOperationProcessor { }); } finally { this.claimingQuotes.delete(key); + const needsFollowUp = this.quoteClaimsNeedingFollowUp.delete(key); + if (needsFollowUp && this.running) { + this.scheduleQuoteClaim(mintUrl, method, quoteId); + } } })(); @@ -355,7 +336,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 +419,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 bd97b767..3eb0e663 100644 --- a/packages/core/services/watchers/MintOperationWatcherService.ts +++ b/packages/core/services/watchers/MintOperationWatcherService.ts @@ -1,4 +1,3 @@ -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'; @@ -11,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}` @@ -22,17 +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; -} - -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.greaterThan(amountPaid) && amountIssued.greaterThanOrEqual(amount); } const mintQuoteWatchPolicies: { @@ -41,25 +30,14 @@ const mintQuoteWatchPolicies: { bolt11: { subscriptionKind: 'bolt11_mint_quote', getPayloadQuoteId: (payload) => payload.quote, - shouldRecordPayload: (payload) => - payload.amount_paid !== undefined && payload.amount_issued !== undefined, - shouldStopWatching: hasBolt11CompletedIssuance, }, 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, }, }; @@ -161,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, @@ -370,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( @@ -386,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); @@ -541,21 +518,21 @@ 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, + method: record.method, + errorName: err instanceof Error ? err.name : typeof err, + }); } } @@ -611,7 +588,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 f2c07ba1..71d7a614 100644 --- a/packages/core/test/unit/MintBolt11Handler.test.ts +++ b/packages/core/test/unit/MintBolt11Handler.test.ts @@ -162,6 +162,10 @@ describe('MintBolt11Handler', () => { const buildRecoverContext = (): RecoverExecutingContext<'bolt11'> => ({ operation: executingOperation, wallet, + localClaimabilityFacts: { + finalizedAmount: Amount.zero(), + reservedAmount: Amount.zero(), + }, mintAdapter, proofService, proofRepository, @@ -390,12 +394,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); @@ -420,9 +424,35 @@ describe('MintBolt11Handler', () => { expect(result).toEqual({ status: 'FINALIZED' }); const call = (wallet.mintProofsBolt11 as Mock).mock.calls[0]; expect(call?.[2]).toEqual({ privkey: bytesToHex(quoteSecretKey) }); + const customOutputs = call?.[3] as + | { type: 'custom'; data: Array<{ blindedMessage: { B_: string } }> } + | undefined; + expect(customOutputs?.data.map(({ blindedMessage }) => blindedMessage.B_)).toEqual([ + 'B_out_1', + 'B_out_2', + ]); expect(proofService.saveProofs).toHaveBeenCalledTimes(1); }); + it('keeps NUT-20 ownership contradictions pending for ambiguity-preserving recovery', async () => { + (mintAdapter.checkMintQuote as Mock).mockImplementation(async () => ({ + ...quote, + pubkey: `02${'22'.repeat(32)}`, + })); + + const result = await handler.recoverExecuting({ + ...buildRecoverContext(), + operation: { ...executingOperation, pubkey: quotePubkey }, + }); + + expect(result).toEqual({ + status: 'PENDING', + error: 'Recovered BOLT11 mint operation has mismatched NUT-20 quote ownership', + }); + expect(wallet.mintProofsBolt11).not.toHaveBeenCalled(); + expect(proofService.recoverProofsFromOutputData).not.toHaveBeenCalled(); + }); + it('preserves quote context in recovery errors and logs', async () => { (mintAdapter.checkMintQuote as Mock).mockRejectedValueOnce( new Error(`Quote ${quoteId} is temporarily unavailable`), @@ -503,7 +533,7 @@ describe('MintBolt11Handler', () => { }, ); - it('uses canonical accounting when the compatibility state is contradictory', async () => { + it('uses canonical accounting when compatibility state disagrees', async () => { (mintAdapter.checkMintQuote as Mock).mockResolvedValueOnce({ ...quote, state: 'UNPAID', @@ -511,12 +541,18 @@ describe('MintBolt11Handler', () => { 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)); - for (const [, meta] of (logger.info as Mock).mock.calls) { - expect(meta).toMatchObject({ mintUrl, quoteId }); - } + expect(logger.info).toHaveBeenCalledWith('Checking pending mint operation', { + mintUrl, + quoteId, + }); + expect(logger.info).toHaveBeenCalledWith('Pending mint quote claimability assessed', { + mintUrl, + status: 'claimable', + }); }); }); }); diff --git a/packages/core/test/unit/MintBolt12Handler.test.ts b/packages/core/test/unit/MintBolt12Handler.test.ts index eeedde66..620442a0 100644 --- a/packages/core/test/unit/MintBolt12Handler.test.ts +++ b/packages/core/test/unit/MintBolt12Handler.test.ts @@ -141,8 +141,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 = { @@ -427,6 +433,35 @@ describe('MintBolt12Handler', () => { expect(call[4].data[0].blindedMessage.B_).toBe('B_out_1'); }); + it('rejects contradictory fresh accounting before mint submission', async () => { + await expect( + handler.execute( + buildExecuteContext( + quote({ + amount_paid: Amount.from(9), + amount_issued: Amount.from(10), + }), + ), + ), + ).rejects.toThrow(`BOLT12 mint quote ${quoteId} is not claimable: invalid`); + + expect(wallet.mintProofsBolt12).not.toHaveBeenCalled(); + }); + + it('does not let a stale valid fresh balance veto service-authorized execution', async () => { + const result = await handler.execute( + buildExecuteContext( + quote({ + amount_paid: Amount.from(1), + amount_issued: Amount.zero(), + }), + ), + ); + + expect(result.status).toBe('ISSUED'); + expect(wallet.mintProofsBolt12).toHaveBeenCalledTimes(1); + }); + it('rejects execution when the remote quote pubkey changes', async () => { const changedPubkey = '02' + '22'.repeat(32); @@ -481,4 +516,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 7868d200..68056222 100644 --- a/packages/core/test/unit/MintOnchainHandler.test.ts +++ b/packages/core/test/unit/MintOnchainHandler.test.ts @@ -141,9 +141,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'> => ({ @@ -322,6 +328,47 @@ describe('MintOnchainHandler', () => { ); }); + it('rejects contradictory fresh accounting before mint submission', async () => { + const pending = await handler.prepare({ + ...buildPrepareContext(), + importedQuote: remoteQuote, + }); + (mintAdapter.checkMintQuote as Mock).mockResolvedValueOnce({ + ...remoteQuote, + amount_paid: Amount.from(9), + amount_issued: Amount.from(10), + }); + + await expect( + handler.execute({ + ...buildPrepareContext(), + operation: { ...pending, state: 'executing' }, + }), + ).rejects.toThrow(`Onchain mint quote ${quoteId} is not claimable: invalid`); + + expect(wallet.mintProofsOnchain).not.toHaveBeenCalled(); + }); + + it('does not let a stale valid fresh balance veto service-authorized execution', async () => { + const pending = await handler.prepare({ + ...buildPrepareContext(), + importedQuote: remoteQuote, + }); + (mintAdapter.checkMintQuote as Mock).mockResolvedValueOnce({ + ...remoteQuote, + amount_paid: Amount.from(1), + amount_issued: Amount.zero(), + }); + + const result = await handler.execute({ + ...buildPrepareContext(), + operation: { ...pending, state: 'executing' }, + }); + + expect(result.status).toBe('ISSUED'); + expect(wallet.mintProofsOnchain).toHaveBeenCalledTimes(1); + }); + it('recovers signed onchain outputs before retrying the mint', async () => { (proofService.recoverProofsFromOutputData as Mock).mockResolvedValueOnce([ { @@ -367,6 +414,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 f3f697c0..52357d27 100644 --- a/packages/core/test/unit/MintOperationService.test.ts +++ b/packages/core/test/unit/MintOperationService.test.ts @@ -6,11 +6,13 @@ import { type MintQuoteBolt12Response, type Proof, } from '@cashu/cashu-ts'; +import { MintOpsApi } from '../../api/MintOpsApi'; import { EventBus } from '../../events/EventBus'; import type { CoreEvents } from '../../events/types'; import { MintOperationService } from '../../operations/mint/MintOperationService'; import type { ExecutingMintOperation, + FailedMintOperation, FinalizedMintOperation, InitMintOperation, PendingMintOperation, @@ -748,16 +750,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 () => { @@ -1768,6 +1766,98 @@ 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('does not reclaim an overpaid atomic quote after local finalization', async () => { + await quoteRepo.upsertMintQuote( + mintQuoteFromBolt11Response(mintUrl, { + quote: quoteId, + request: 'lnbc1overpaid', + amount: Amount.from(10), + unit: 'sat', + expiry: Math.floor(Date.now() / 1000) + 3600, + state: 'PAID', + amount_paid: Amount.from(11), + amount_issued: Amount.zero(), + }), + ); + const operation = await service.prepare( + { mintUrl, method: 'bolt11', quoteId }, + Amount.from(10), + ); + + const firstClaim = await service.claimMintQuote(mintUrl, 'bolt11', quoteId); + const repeatedClaim = await service.claimMintQuote(mintUrl, 'bolt11', quoteId); + const startupClaims = await service.claimPendingMintQuotes(); + + expect(firstClaim).toHaveLength(1); + expect(firstClaim[0]?.id).toBe(operation.id); + expect(firstClaim[0]?.state).toBe('finalized'); + expect(repeatedClaim).toEqual([]); + expect(startupClaims).toEqual([]); + expect(handler.execute).toHaveBeenCalledTimes(1); + }); + + it('does not automatically or explicitly claim a pending quote after its mint is untrusted', async () => { + await persistQuote(); + const operation = await service.prepare( + { mintUrl, method: 'bolt11', quoteId }, + Amount.from(10), + ); + (mintService.isTrustedMint as Mock).mockResolvedValue(false); + + const automaticClaims = await service.claimPendingMintQuotes(); + + expect(automaticClaims).toEqual([]); + await expect(service.claimMintQuote(mintUrl, 'bolt11', quoteId)).rejects.toThrow( + `Mint ${mintUrl} is not trusted`, + ); + expect(handler.execute).not.toHaveBeenCalled(); + expect((await operationRepo.getById(operation.id))?.state).toBe('pending'); + }); + it('finalize is idempotent after finalize', async () => { await persistQuote(); @@ -1782,6 +1872,90 @@ describe('MintOperationService', () => { expect(ops.length).toBe(1); }); + it('public execute joins a background claim that already moved the operation to executing', async () => { + await persistQuote(); + const pending = await service.prepare({ mintUrl, method: 'bolt11', quoteId }, Amount.from(10)); + const api = new MintOpsApi(service); + const executionStarted = createDeferred(); + const releaseExecution = createDeferred(); + (handler.execute as Mock).mockImplementationOnce(async () => { + executionStarted.resolve(); + await releaseExecution.promise; + return { status: 'ISSUED', proofs: [makeProof('out-1')] }; + }); + + const backgroundClaim = service.claimMintQuote(mintUrl, 'bolt11', quoteId, { + autoClaimRemaining: false, + }); + await executionStarted.promise; + const explicitExecution = api.execute(pending.id); + releaseExecution.resolve(); + + const [claimed, executed] = await Promise.all([backgroundClaim, explicitExecution]); + + expect(claimed).toHaveLength(1); + const claimedOperation = claimed[0]; + if (!claimedOperation) { + throw new Error('Expected background claim to return the mint operation'); + } + expect(claimedOperation.state).toBe('finalized'); + expect(executed.state).toBe('finalized'); + expect(executed.id).toBe(pending.id); + expect(executed).toEqual(claimedOperation); + expect(handler.execute).toHaveBeenCalledTimes(1); + }); + + it('execute recovers an orphaned executing operation', async () => { + await persistQuote(); + const executing = makeExecutingOp('orphaned-executing'); + await operationRepo.create(executing); + (handler.recoverExecuting as Mock).mockResolvedValueOnce({ status: 'FINALIZED' }); + + const result = await service.execute(executing.id); + + expect(result.state).toBe('finalized'); + expect(handler.recoverExecuting).toHaveBeenCalledTimes(1); + expect(handler.execute).not.toHaveBeenCalled(); + }); + + it('execute rejects missing and init operations', async () => { + const init = makeInitOp('init-operation'); + await operationRepo.create(init); + + await expect(service.execute('missing-operation')).rejects.toThrow( + 'Operation missing-operation not found', + ); + await expect(service.execute(init.id)).rejects.toThrow( + "expected state 'pending' but found 'init'", + ); + + expect(handler.execute).not.toHaveBeenCalled(); + expect(handler.recoverExecuting).not.toHaveBeenCalled(); + }); + + it('execute returns persisted terminal outcomes without invoking the handler', async () => { + const finalized: FinalizedMintOperation = { + ...makePendingOp('finalized-operation'), + state: 'finalized', + }; + const failed: FailedMintOperation = { + ...makePendingOp('failed-operation'), + state: 'failed', + error: 'terminal failure', + terminalFailure: { + reason: 'terminal failure', + observedAt: Date.now(), + }, + }; + await operationRepo.create(finalized); + await operationRepo.create(failed); + + expect(await service.execute(finalized.id)).toEqual(finalized); + expect(await service.execute(failed.id)).toEqual(failed); + expect(handler.execute).not.toHaveBeenCalled(); + expect(handler.recoverExecuting).not.toHaveBeenCalled(); + }); + it('finalize leaves underfunded reusable onchain operations pending', async () => { const onchainQuoteId = 'onchain-quote-underfunded'; await persistOnchainQuote(onchainQuoteId, { paid: Amount.from(4), issued: Amount.zero() }); @@ -2116,14 +2290,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'); }); @@ -2143,19 +2313,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 () => { @@ -2496,12 +2666,22 @@ describe('MintOperationService', () => { }); (handler.checkPending as Mock).mockResolvedValueOnce({ - observedRemoteState: 'UNPAID', observedRemoteStateAt: observedAt, + quoteSnapshot: cashuNormalizedBolt11Fixture({ + quote: quoteId, + request: 'lnbc1test', + amount: Amount.from(10), + unit: 'sat', + expiry: pendingOp.expiry, + state: 'PAID', + amount_paid: Amount.from(10), + amount_issued: Amount.zero(), + updated_at: 10, + }), category: 'terminal', terminalFailure: { - reason: 'Quote response failed validation', - code: 'invalid_quote', + reason: 'Mint operation is missing NUT-20 quote pubkey', + code: 'missing_quote_pubkey', retryable: false, observedAt, }, @@ -2512,10 +2692,10 @@ describe('MintOperationService', () => { expect(result.category).toBe('terminal'); expect(stored?.state).toBe('failed'); - expect(stored?.error).toBe('Quote response failed validation'); + expect(stored?.error).toBe('Mint operation is missing NUT-20 quote pubkey'); expect(stored?.terminalFailure).toMatchObject({ - reason: 'Quote response failed validation', - code: 'invalid_quote', + reason: 'Mint operation is missing NUT-20 quote pubkey', + code: 'missing_quote_pubkey', retryable: false, observedAt, }); @@ -2524,10 +2704,42 @@ describe('MintOperationService', () => { expect(failedEvents[0]?.operationId).toBe(pendingOp.id); expect(failedEvents[0]?.operation.state).toBe('failed'); expect(failedEvents[0]?.operation.terminalFailure?.reason).toBe( - 'Quote response failed validation', + 'Mint operation is missing NUT-20 quote pubkey', ); }); + it('classifies pending work from the resolved quote after ignoring invalid accounting', async () => { + await persistQuote(); + const pendingOp = await service.prepare( + { mintUrl, method: 'bolt11', quoteId }, + Amount.from(10), + ); + (handler.checkPending as Mock).mockResolvedValueOnce({ + observedRemoteStateAt: Date.now(), + quoteSnapshot: cashuNormalizedBolt11Fixture({ + quote: quoteId, + request: 'lnbc1contradictory', + amount: Amount.from(10), + unit: 'sat', + expiry: pendingOp.expiry, + state: 'ISSUED', + amount_paid: Amount.from(9), + amount_issued: Amount.from(10), + updated_at: 20, + }), + category: 'waiting', + }); + + const result = await service.checkPendingOperation(pendingOp.id); + const storedOperation = await operationRepo.getById(pendingOp.id); + const storedQuote = await quoteRepo.getMintQuote(mintUrl, 'bolt11', quoteId); + + expect(result.category).toBe('ready'); + expect(storedOperation?.state).toBe('finalized'); + expect(storedQuote?.amountPaid.equals(Amount.from(10))).toBe(true); + expect(storedQuote?.request).not.toBe('lnbc1contradictory'); + }); + it('checkPendingOperation records onchain quote snapshots without protocol state', async () => { const onchainQuoteId = 'onchain-quote-pending-check'; await persistOnchainQuote(onchainQuoteId, { paid: Amount.zero(), issued: Amount.zero() }); diff --git a/packages/core/test/unit/MintOperationWatcherService.test.ts b/packages/core/test/unit/MintOperationWatcherService.test.ts index f4a6e39b..d7334ad1 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 }, }); @@ -447,7 +447,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()); @@ -478,7 +478,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(); @@ -742,7 +746,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()); @@ -772,13 +776,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()); @@ -807,7 +815,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(); @@ -851,7 +863,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({ @@ -909,7 +921,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' }; @@ -936,13 +948,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({ @@ -961,7 +973,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/MintOpsApi.test.ts b/packages/core/test/unit/MintOpsApi.test.ts index 6f3a1343..74953899 100644 --- a/packages/core/test/unit/MintOpsApi.test.ts +++ b/packages/core/test/unit/MintOpsApi.test.ts @@ -4,7 +4,6 @@ import { MintOpsApi } from '../../api/MintOpsApi.ts'; import type { MintOperationService } from '../../operations/mint/MintOperationService.ts'; import type { ExecutingMintOperation, - FinalizedMintOperation, MintOperation, PendingMintOperation, TerminalMintOperation, @@ -164,21 +163,43 @@ describe('MintOpsApi', () => { expect(mintOperationService.prepare).toHaveBeenCalledWith(quote, Amount.from(10)); }); - it('execute only allows pending operations', async () => { - const result = await api.execute(pendingOperation.id); + it('execute delegates every persisted operation state to the service by ID', async () => { + const states: MintOperation['state'][] = [ + 'init', + 'pending', + 'executing', + 'finalized', + 'failed', + ]; + + for (const state of states) { + const operation = { + ...pendingOperation, + id: `op-${state}`, + state, + } as MintOperation; + (mintOperationService.execute as unknown as ReturnType).mockResolvedValueOnce( + operation, + ); - expect(mintOperationService.getOperation).toHaveBeenCalledWith(pendingOperation.id); - expect(mintOperationService.execute).toHaveBeenCalledWith(pendingOperation.id); - expect(result.state).toBe('finalized'); + const result = await api.execute(operation); - (mintOperationService.getOperation as unknown as ReturnType).mockResolvedValueOnce( - { - ...pendingOperation, - state: 'executing', - } as MintOperation, + expect(mintOperationService.execute).toHaveBeenLastCalledWith(operation.id); + expect(result).toBe(operation); + } + + expect(mintOperationService.getOperation).not.toHaveBeenCalled(); + }); + + it('execute delegates missing operation errors to the service', async () => { + (mintOperationService.execute as unknown as ReturnType).mockRejectedValueOnce( + new Error('Operation missing-op not found'), ); - await expect(api.execute(pendingOperation.id)).rejects.toThrow("Expected 'pending'"); + await expect(api.execute('missing-op')).rejects.toThrow('Operation missing-op not found'); + + expect(mintOperationService.execute).toHaveBeenCalledWith('missing-op'); + expect(mintOperationService.getOperation).not.toHaveBeenCalled(); }); it('get and listByQuote delegate to the service', async () => { diff --git a/packages/core/test/unit/MintQuote.test.ts b/packages/core/test/unit/MintQuote.test.ts index be5d5039..84d930f8 100644 --- a/packages/core/test/unit/MintQuote.test.ts +++ b/packages/core/test/unit/MintQuote.test.ts @@ -12,9 +12,6 @@ import { getMintQuoteAvailableAmount, getMintQuoteAmount, getMintQuoteRemoteState, - isBolt11MintQuoteIssued, - isBolt11MintQuotePaid, - isBolt11MintQuoteUnpaid, isMintQuotePending, mintQuoteFromBolt11Response, mintQuoteFromBolt12Response, @@ -62,7 +59,7 @@ describe('MintQuote model', () => { 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); + expect(isMintQuotePending(quote)).toBe(false); }); it('rejects contradictory BOLT11 accounting', () => { @@ -84,7 +81,7 @@ describe('MintQuote model', () => { expect(createQuote).toThrow('amount_issued greater than amount_paid'); }); - it('uses canonical predicates for ready and terminal BOLT11 quotes', () => { + it('derives the compatibility state from canonical BOLT11 accounting', () => { const paid = mintQuoteFromBolt11Response('https://mint.test', { quote: 'quote-paid', request: 'lnbc...', @@ -105,42 +102,12 @@ describe('MintQuote model', () => { 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', @@ -163,6 +130,26 @@ describe('MintQuote model', () => { expect(orderedResult.remoteUpdatedAt).toBe(44); expect(unorderedResult.amountPaid.equals(Amount.from(50))).toBe(true); expect(unorderedResult.amountIssued.isZero()).toBe(true); + + const issued = mintQuoteFromBolt11Response('https://mint.test', { + quote: 'quote-issued', + request: 'lnbc...', + method: 'bolt11', + amount: Amount.from(100), + unit: 'sat', + expiry: 123, + amount_paid: Amount.from(100), + amount_issued: Amount.from(100), + updated_at: null, + state: 'ISSUED', + } satisfies MintQuoteBolt11Response); + const staleResult = applyBolt11MintQuoteStateFallback( + { ...issued, updatedAt: 50 }, + 'UNPAID', + 40, + ); + expect(staleResult.state).toBe('ISSUED'); + expect(staleResult.updatedAt).toBe(50); }); it('keeps BOLT12 offer amounts separate from mint operation amounts', () => { diff --git a/packages/core/test/unit/MintQuoteClaimability.test.ts b/packages/core/test/unit/MintQuoteClaimability.test.ts new file mode 100644 index 00000000..cebb152b --- /dev/null +++ b/packages/core/test/unit/MintQuoteClaimability.test.ts @@ -0,0 +1,337 @@ +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, + reservedAmount: amount, + }); + + expect(assessment.status).toBe('claimable'); + expect(assessment.remoteAvailable.equals(amount)).toBe(true); + expect(assessment.claimAmount?.equals(amount)).toBe(true); + }); + + it('treats a full finalized local BOLT11 operation as complete while remote issuance lags', () => { + const amount = Amount.from(10); + const quote = { ...makeBolt11Quote(), amountPaid: Amount.from(11) }; + + const assessment = assessMintQuoteClaimability(quote, { + finalizedAmount: amount, + reservedAmount: amount, + }); + + expect(assessment.status).toBe('complete'); + expect(assessment.remoteAvailable.equals(Amount.from(11))).toBe(true); + expect(assessment.claimAmount).toBeUndefined(); + }); + + 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('classifies partial atomic issuance as invalid', () => { + const quote = { + ...makeBolt11Quote(), + amountPaid: Amount.from(10), + amountIssued: Amount.from(5), + }; + + const assessment = assessMintQuoteClaimability(quote); + + expect(assessment.status).toBe('invalid'); + expect(assessment.claimAmount).toBeUndefined(); + }); + + it('claims exactly the fixed BOLT11 amount when remote payment exceeds it', () => { + const amount = Amount.from(10); + const quote = { + ...makeBolt11Quote(), + amountPaid: Amount.from(11), + }; + + const assessment = assessMintQuoteClaimability(quote); + + expect(assessment.status).toBe('claimable'); + expect(assessment.remoteAvailable.equals(Amount.from(11))).toBe(true); + expect(assessment.claimAmount?.equals(amount)).toBe(true); + }); + + it('treats an overpaid BOLT11 quote as complete after its fixed amount is issued', () => { + const amount = Amount.from(10); + const quote = { + ...makeBolt11Quote(), + amountPaid: Amount.from(11), + amountIssued: amount, + }; + + const assessment = assessMintQuoteClaimability(quote); + + expect(assessment.status).toBe('complete'); + expect(assessment.remoteAvailable.equals(Amount.from(1))).toBe(true); + expect(assessment.claimAmount).toBeUndefined(); + }); +}); diff --git a/packages/core/test/unit/MintQuoteProcessor.test.ts b/packages/core/test/unit/MintQuoteProcessor.test.ts index 6311d37c..40bd39f9 100644 --- a/packages/core/test/unit/MintQuoteProcessor.test.ts +++ b/packages/core/test/unit/MintQuoteProcessor.test.ts @@ -66,8 +66,8 @@ describe('MintOperationProcessor', () => { claimCalls.push({ mintUrl, method, quoteId }); return []; }, - async hasLocallyClaimableMintQuoteBalance() { - return true; + async getMintQuoteClaimability() { + return { status: 'claimable' }; }, async claimPendingMintQuotes() { startupClaimCalls++; @@ -111,7 +111,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', { @@ -121,31 +121,64 @@ describe('MintOperationProcessor', () => { quote: makeBolt11Quote('quote-1', true), }); - 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; @@ -171,12 +204,14 @@ describe('MintOperationProcessor', () => { quote: makeBolt11Quote('shared-quote', true), }); - 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', { @@ -208,11 +243,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( @@ -240,10 +275,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; @@ -272,14 +307,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, @@ -321,7 +356,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', { @@ -336,30 +371,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', { @@ -369,12 +428,41 @@ describe('MintOperationProcessor', () => { quote: makeBolt11Quote('quote-4', false), }); - 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('coalesces quote updates during an active assessment into one follow-up claim', async () => { + let releaseAssessment!: () => void; + const assessmentGate = new Promise((resolve) => { + releaseAssessment = resolve; + }); + let assessmentCalls = 0; + mockMintOperationService = { + ...mockMintOperationService, + async getMintQuoteClaimability() { + assessmentCalls++; + if (assessmentCalls === 1) { + await assessmentGate; + return { status: 'waiting' }; + } + 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++) { @@ -386,9 +474,14 @@ describe('MintOperationProcessor', () => { }); } - await sleep(TEST_PROCESS_INTERVAL + 20); + releaseAssessment(); + await processor.waitForCompletion(); - expect(finalizeCalls).toEqual(['mint-op-5']); + expect(assessmentCalls).toBe(2); + 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/core/test/unit/PollingTransport.test.ts b/packages/core/test/unit/PollingTransport.test.ts index 8f3d767b..9c058288 100644 --- a/packages/core/test/unit/PollingTransport.test.ts +++ b/packages/core/test/unit/PollingTransport.test.ts @@ -583,7 +583,7 @@ describe('PollingTransport mint quote batching', () => { transport.on(mintUrl, 'message', () => {}); subscribeToQuotes(transport, mintUrl, 'bolt11', 'cadence-sub', ['quote-a']); - await waitFor(() => startedAt.length === 2); + await waitFor(() => startedAt.length === 2, 2_000); expect(startedAt[1]! - startedAt[0]!).toBeGreaterThanOrEqual(18); transport.closeAll(); diff --git a/packages/indexeddb/src/repositories/MintQuoteRepository.ts b/packages/indexeddb/src/repositories/MintQuoteRepository.ts index 4ab571af..97a610f1 100644 --- a/packages/indexeddb/src/repositories/MintQuoteRepository.ts +++ b/packages/indexeddb/src/repositories/MintQuoteRepository.ts @@ -181,14 +181,22 @@ export class IdbMintQuoteRepository implements MintQuoteRepository { state: MintMethodRemoteState, observedAt = Date.now(), ): Promise { - 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; + await this.db.runTransaction('rw', ['coco_cashu_canonical_mint_quotes'], async (tx) => { + const table = tx.table( + 'coco_cashu_canonical_mint_quotes', + ); + const existing = await table.get([normalizeMintUrl(mintUrl), method, quoteId]); if (!existing) return; const quote = rowToMintQuote(existing); if (!isStatefulMintQuote(quote)) return; - await this.upsertMintQuote(applyBolt11MintQuoteStateFallback(quote, state, observedAt)); + const resolved = applyBolt11MintQuoteStateFallback(quote, state, observedAt); + await table.put({ + ...existing, + state: resolved.state, + amountPaid: serializeAmount(resolved.amountPaid), + amountIssued: serializeAmount(resolved.amountIssued), + updatedAt: resolved.updatedAt, + }); }); } diff --git a/packages/sql-storage/src/repositories/MintQuoteRepository.ts b/packages/sql-storage/src/repositories/MintQuoteRepository.ts index 0d2467b9..96a73735 100644 --- a/packages/sql-storage/src/repositories/MintQuoteRepository.ts +++ b/packages/sql-storage/src/repositories/MintQuoteRepository.ts @@ -230,23 +230,33 @@ export class SqliteMintQuoteRepository implements MintQuoteRepository { 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' + WHEN ? = 'ISSUED' OR amountIssued = amount THEN 'ISSUED' + WHEN ? IN ('PAID', 'ISSUED') OR amountPaid = amount 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 = ? + updatedAt = CASE WHEN updatedAt > ? THEN updatedAt ELSE ? END 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) + (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], + [ + state, + state, + state, + state, + observedAt, + observedAt, + normalizeMintUrl(mintUrl), + method, + quoteId, + ], ); }