diff --git a/.changeset/melt-observation-before-settlement.md b/.changeset/melt-observation-before-settlement.md new file mode 100644 index 00000000..03c6feaa --- /dev/null +++ b/.changeset/melt-observation-before-settlement.md @@ -0,0 +1,7 @@ +--- +'@cashu/coco-core': patch +--- + +Record pending melt quote observations before settling local melt operations, and use cached PAID +observations during recovery only when serialized settlement change and method-specific settlement +metadata are available. diff --git a/packages/core/infra/handlers/melt/BaseQuoteMeltHandler.ts b/packages/core/infra/handlers/melt/BaseQuoteMeltHandler.ts index 1de088bc..40e5f0c8 100644 --- a/packages/core/infra/handlers/melt/BaseQuoteMeltHandler.ts +++ b/packages/core/infra/handlers/melt/BaseQuoteMeltHandler.ts @@ -147,6 +147,32 @@ export abstract class BaseQuoteMeltHandler implements Melt return { changeAmount, effectiveFee }; } + private getPersistedSettlementResponse(quote?: MeltQuote): QuoteMeltResponse | null { + if (!quote || quote.state !== 'PAID') { + return null; + } + + if (!Array.isArray(quote.change)) { + return null; + } + + const change = quote.change; + + if (quote.method === 'onchain') { + return { + state: quote.state, + change, + outpoint: quote.outpoint ?? null, + } as QuoteMeltResponse; + } + + return { + state: quote.state, + change, + payment_preimage: quote.payment_preimage ?? null, + } as QuoteMeltResponse; + } + /** * Returns the amount of proofs that were actually sent to the melt call. * For swap melts this excludes proofs kept locally after the pre-swap. @@ -570,8 +596,14 @@ export abstract class BaseQuoteMeltHandler implements Melt ctx.logger?.debug('Finalizing pending melt operation', { operationId, quoteId }); - // Fetch current melt quote state from mint to get change signatures - const res = await this.checkMeltQuote(ctx); + const persistedSettlement = this.getPersistedSettlementResponse(ctx.canonicalQuote); + if (ctx.canonicalQuote && ctx.canonicalQuote.state !== 'PAID') { + throw new Error( + `Cannot finalize: melt quote ${quoteId} is ${ctx.canonicalQuote.state}, expected PAID`, + ); + } + + const res = persistedSettlement ?? (await this.checkMeltQuote(ctx)); if (res.state !== 'PAID') { throw new Error(`Cannot finalize: melt quote ${quoteId} is ${res.state}, expected PAID`); @@ -649,7 +681,7 @@ export abstract class BaseQuoteMeltHandler implements Melt ctx.logger?.debug('Checking pending melt operation', { operationId, quoteId }); - const state = await this.checkMeltQuoteState(ctx); + const state = ctx.canonicalQuote?.state ?? (await this.checkMeltQuoteState(ctx)); ctx.logger?.debug('Pending melt quote state', { operationId, quoteId, state }); diff --git a/packages/core/operations/melt/MeltMethodHandler.ts b/packages/core/operations/melt/MeltMethodHandler.ts index 7bc08bb1..62319dc0 100644 --- a/packages/core/operations/melt/MeltMethodHandler.ts +++ b/packages/core/operations/melt/MeltMethodHandler.ts @@ -136,10 +136,12 @@ export interface ExecuteContext extends BaseH export interface PendingContext extends BaseHandlerDeps { operation: PendingMeltOperation & MeltMethodMeta; wallet: Wallet; + canonicalQuote?: MeltQuote; } export interface FinalizeContext extends BaseHandlerDeps { operation: PendingMeltOperation & MeltMethodMeta; + canonicalQuote?: MeltQuote; } export type FinalizeResult = { diff --git a/packages/core/operations/melt/MeltOperationService.ts b/packages/core/operations/melt/MeltOperationService.ts index b1fe931f..77628205 100644 --- a/packages/core/operations/melt/MeltOperationService.ts +++ b/packages/core/operations/melt/MeltOperationService.ts @@ -106,6 +106,29 @@ export class MeltOperationService { return this.recoveryLock !== null; } + private async resolvePendingSettlementQuote( + op: PendingMeltOperation, + canonicalQuote?: MeltQuote, + ): Promise { + if (canonicalQuote) { + if (canonicalQuote.state === 'PAID' && !Array.isArray(canonicalQuote.change)) { + return this.quoteLifecycle.refreshMeltQuote(op.mintUrl, op.method, op.quoteId); + } + return canonicalQuote; + } + + const persistedQuote = await this.quoteLifecycle.getMeltQuote( + op.mintUrl, + op.method, + op.quoteId, + ); + if (persistedQuote?.state === 'PAID' && Array.isArray(persistedQuote.change)) { + return persistedQuote; + } + + return this.quoteLifecycle.refreshMeltQuote(op.mintUrl, op.method, op.quoteId); + } + async init( mintUrl: string, method: MeltMethod, @@ -430,7 +453,10 @@ export class MeltOperationService { } } - async finalize(operationId: string): Promise { + async finalize( + operationId: string, + options: { canonicalQuote?: MeltQuote } = {}, + ): Promise { const releaseLock = await this.acquireOperationLock(operationId); try { const operation = await this.meltOperationRepository.getById(operationId); @@ -459,9 +485,14 @@ export class MeltOperationService { const pendingOp = operation as PendingMeltOperation; const handler = this.handlerProvider.get(pendingOp.method); + const canonicalQuote = await this.resolvePendingSettlementQuote( + pendingOp, + options.canonicalQuote, + ); const finalizeResult = await handler.finalize?.({ ...this.buildDeps(), operation: pendingOp, + canonicalQuote, }); const finalized: FinalizedMeltOperation = { @@ -496,7 +527,11 @@ export class MeltOperationService { } } - async rollback(operationId: string, reason = 'Rolled back'): Promise { + async rollback( + operationId: string, + reason = 'Rolled back', + options: { canonicalQuote?: MeltQuote } = {}, + ): Promise { const releaseLock = await this.acquireOperationLock(operationId); try { const operation = await this.meltOperationRepository.getById(operationId); @@ -528,10 +563,13 @@ export class MeltOperationService { // This prevents releasing proofs that are still inflight with the Lightning network. if (operation.state === 'pending') { const pendingOp = operation as PendingMeltOperation; + // Re-read the quote while holding the operation lock; a pre-lock snapshot may be stale. + const canonicalQuote = await this.resolvePendingSettlementQuote(pendingOp); const decision = await handler.checkPending?.({ ...this.buildDeps(), operation: pendingOp, wallet, + canonicalQuote, }); if (decision !== 'rollback') { throw new Error( @@ -663,17 +701,32 @@ export class MeltOperationService { }'`, ); } + const persistedQuote = await this.quoteLifecycle.getMeltQuote( + op.mintUrl, + op.method, + op.quoteId, + ); + if (persistedQuote?.state === 'PAID') { + await this.finalize(op.id, { canonicalQuote: persistedQuote }); + return 'finalize'; + } + const handler = this.handlerProvider.get(op.method); const { wallet } = await this.walletService.getWalletWithActiveKeysetId(op.mintUrl, op.unit); + const quote = await this.quoteLifecycle.refreshMeltQuoteById({ + mintUrl: op.mintUrl, + quoteId: op.quoteId, + }); const decision: PendingCheckResult = (await handler.checkPending?.({ ...this.buildDeps(), operation: op, wallet, + canonicalQuote: quote, })) ?? 'stay_pending'; if (decision === 'finalize') { - await this.finalize(op.id); + await this.finalize(op.id, { canonicalQuote: quote }); return 'finalize'; } else if (decision === 'rollback') { await this.rollback(op.id, 'Rollback requested by handler'); diff --git a/packages/core/quotes/QuoteLifecycle.ts b/packages/core/quotes/QuoteLifecycle.ts index 02ce6f3e..9b3ea28f 100644 --- a/packages/core/quotes/QuoteLifecycle.ts +++ b/packages/core/quotes/QuoteLifecycle.ts @@ -155,6 +155,44 @@ function getMeltQuoteChange(existing: MeltQuote | null, incoming: MeltQuote): bo ); } +function mergePaidMeltQuoteSettlement(existing: MeltQuote, incoming: MeltQuote): MeltQuote | null { + if ( + existing.state !== 'PAID' || + incoming.state !== 'PAID' || + existing.method !== incoming.method + ) { + return null; + } + + let changed = false; + let merged = { + ...existing, + lastObservedRemoteState: incoming.lastObservedRemoteState ?? existing.lastObservedRemoteState, + lastObservedRemoteStateAt: + incoming.lastObservedRemoteStateAt ?? existing.lastObservedRemoteStateAt, + updatedAt: incoming.updatedAt, + } as MeltQuote; + + if (!Array.isArray(existing.change) && Array.isArray(incoming.change)) { + merged = { ...merged, change: incoming.change } as MeltQuote; + changed = true; + } + + if (existing.method === 'onchain' && incoming.method === 'onchain') { + if (existing.outpoint == null && incoming.outpoint != null) { + merged = { ...merged, outpoint: incoming.outpoint } as MeltQuote; + changed = true; + } + } else if (existing.method !== 'onchain' && incoming.method !== 'onchain') { + if (existing.payment_preimage == null && incoming.payment_preimage != null) { + merged = { ...merged, payment_preimage: incoming.payment_preimage } as MeltQuote; + changed = true; + } + } + + return changed ? merged : null; +} + export interface QuoteLifecycleDeps { mintHandlerProvider: MintHandlerProvider; meltHandlerProvider: MeltHandlerProvider; @@ -718,6 +756,15 @@ export class QuoteLifecycle { ); if (existing?.state === 'PAID') { + const enrichedQuote = mergePaidMeltQuoteSettlement(existing, canonicalQuote); + if (enrichedQuote) { + const persisted = await this.persistCanonicalMeltQuote(enrichedQuote); + return { + quote: persisted, + remoteQuoteChanged: true, + }; + } + return { quote: existing, remoteQuoteChanged: false, diff --git a/packages/core/test/unit/MeltBolt11Handler.test.ts b/packages/core/test/unit/MeltBolt11Handler.test.ts index 90d31ff9..1451fca4 100644 --- a/packages/core/test/unit/MeltBolt11Handler.test.ts +++ b/packages/core/test/unit/MeltBolt11Handler.test.ts @@ -11,6 +11,7 @@ import { } from '../../infra/handlers/melt/QuoteMeltHandler.utils'; import type { Logger } from '../../logging/Logger'; import { MintOperationError, ProofValidationError } from '../../models/Error'; +import { meltQuoteFromBolt11Response } from '../../models/MeltQuote'; import type { BasePrepareContext, CreateMeltQuoteContext, @@ -903,6 +904,41 @@ describe('MeltBolt11Handler', () => { // ============================================================================ describe('finalize', () => { + it('should finalize a pending operation from persisted canonical settlement fields', async () => { + const operation = makePendingOp('op-canonical-finalize', { + needsSwap: false, + inputProofSecrets: ['input-1'], + }); + const changeSignatures: SerializedBlindedSignature[] = [ + { id: keysetId, amount: Amount.from(5), C_: 'C_change' }, + ]; + const quote = meltQuoteFromBolt11Response(mintUrl, { + quote: operation.quoteId, + request: invoice, + amount: operation.amount, + unit: operation.unit, + fee_reserve: operation.fee_reserve, + expiry: Math.floor(Date.now() / 1000) + 3600, + state: 'PAID', + payment_preimage: 'preimage-canonical', + change: changeSignatures, + }); + + const result = await handler.finalize({ + ...buildFinalizeContext(operation), + canonicalQuote: quote, + }); + + expect(mintAdapter.checkMeltQuote).not.toHaveBeenCalled(); + expect(proofService.setProofState).toHaveBeenCalledWith(mintUrl, ['input-1'], 'spent'); + expect(proofService.unblindAndSaveChangeProofs).toHaveBeenCalled(); + expect(result).toEqual({ + changeAmount: Amount.from(5), + effectiveFee: Amount.from(5), + finalizedData: { preimage: 'preimage-canonical' }, + }); + }); + it('should finalize a pending operation by fetching change', async () => { const operation = makePendingOp('op-1', { needsSwap: false, @@ -933,6 +969,71 @@ describe('MeltBolt11Handler', () => { }); }); + it('should fetch full settlement when a PAID canonical quote omits change', async () => { + const operation = makePendingOp('op-partial-canonical', { + needsSwap: false, + inputProofSecrets: ['input-1'], + }); + const changeSignatures: SerializedBlindedSignature[] = [ + { id: keysetId, amount: Amount.from(5), C_: 'C_change' }, + ]; + const quote = meltQuoteFromBolt11Response(mintUrl, { + quote: operation.quoteId, + request: invoice, + amount: operation.amount, + unit: operation.unit, + fee_reserve: operation.fee_reserve, + expiry: Math.floor(Date.now() / 1000) + 3600, + state: 'PAID', + payment_preimage: 'preimage-partial', + }); + (mintAdapter.checkMeltQuote as Mock).mockImplementation(() => + Promise.resolve({ + state: 'PAID', + change: changeSignatures, + payment_preimage: 'preimage-remote', + }), + ); + + const result = await handler.finalize({ + ...buildFinalizeContext(operation), + canonicalQuote: quote, + }); + + expect(mintAdapter.checkMeltQuote).toHaveBeenCalledWith(mintUrl, operation.quoteId); + expect(proofService.unblindAndSaveChangeProofs).toHaveBeenCalled(); + expect(result).toEqual({ + changeAmount: Amount.from(5), + effectiveFee: Amount.from(5), + finalizedData: { preimage: 'preimage-remote' }, + }); + }); + + it('should not re-check the mint when a supplied canonical quote is not PAID', async () => { + const operation = makePendingOp('op-canonical-pending'); + const quote = meltQuoteFromBolt11Response(mintUrl, { + quote: operation.quoteId, + request: invoice, + amount: operation.amount, + unit: operation.unit, + fee_reserve: operation.fee_reserve, + expiry: Math.floor(Date.now() / 1000) + 3600, + state: 'PENDING', + payment_preimage: null, + }); + (mintAdapter.checkMeltQuote as Mock).mockImplementation(() => + Promise.resolve({ state: 'PAID', change: [], payment_preimage: 'preimage-remote' }), + ); + + await expect( + handler.finalize({ + ...buildFinalizeContext(operation), + canonicalQuote: quote, + }), + ).rejects.toThrow('Cannot finalize: melt quote quote-123 is PENDING, expected PAID'); + expect(mintAdapter.checkMeltQuote).not.toHaveBeenCalled(); + }); + it('should throw if quote is not PAID', async () => { const operation = makePendingOp('op-1'); (mintAdapter.checkMeltQuote as Mock).mockImplementation(() => diff --git a/packages/core/test/unit/MeltOnchainHandler.test.ts b/packages/core/test/unit/MeltOnchainHandler.test.ts index 8a05600d..edfb6a9c 100644 --- a/packages/core/test/unit/MeltOnchainHandler.test.ts +++ b/packages/core/test/unit/MeltOnchainHandler.test.ts @@ -1,6 +1,7 @@ import { Amount } from '@cashu/cashu-ts'; import { describe, expect, it, mock } from 'bun:test'; import { MeltOnchainHandler } from '../../infra/handlers/melt/MeltOnchainHandler.ts'; +import { meltQuoteFromOnchainResponse } from '../../models/MeltQuote.ts'; import type { BasePrepareContext, CreateMeltQuoteContext, @@ -237,4 +238,26 @@ describe('MeltOnchainHandler', () => { expect(deps.mintAdapter.checkMeltQuoteOnchain).toHaveBeenCalledWith(mintUrl, quoteId); expect(result.finalizedData).toBeUndefined(); }); + + it('finalizes pending onchain melt quotes from persisted canonical settlement fields', async () => { + const handler = new MeltOnchainHandler(); + const deps = baseDeps(); + const quote = meltQuoteFromOnchainResponse(mintUrl, { + ...remoteQuote, + state: 'PAID', + outpoint: 'txid:0', + change: [], + }); + + const result = await handler.finalize({ + ...deps, + operation: buildPendingOperation(), + wallet: {}, + canonicalQuote: quote, + } as unknown as FinalizeContext<'onchain'>); + + expect(deps.mintAdapter.checkMeltQuoteOnchain).not.toHaveBeenCalled(); + expect(deps.proofService.setProofState).toHaveBeenCalledWith(mintUrl, ['secret-1'], 'spent'); + expect(result.finalizedData).toEqual({ outpoint: 'txid:0' }); + }); }); diff --git a/packages/core/test/unit/MeltOperationService.test.ts b/packages/core/test/unit/MeltOperationService.test.ts index e1af3874..e165d11e 100644 --- a/packages/core/test/unit/MeltOperationService.test.ts +++ b/packages/core/test/unit/MeltOperationService.test.ts @@ -28,7 +28,11 @@ import type { PendingCheckResult, FinalizeResult, } from '../../operations/melt/MeltMethodHandler.ts'; -import { meltQuoteFromBolt11Response, type MeltQuote } from '../../models/MeltQuote.ts'; +import { + meltQuoteFromBolt11Response, + meltQuoteFromOnchainResponse, + type MeltQuote, +} from '../../models/MeltQuote.ts'; import { UnknownMintError, ProofValidationError, @@ -562,6 +566,45 @@ describe('MeltOperationService', () => { ).resolves.toMatchObject({ state: 'PAID', payment_preimage: 'preimage-123' }); }); + it('enriches terminal PAID melt quotes when stored settlement change is missing', async () => { + await persistMeltQuote('quote-terminal-paid-enrich', 'PAID'); + const changeSignatures = [{ id: keysetId, amount: Amount.from(5), C_: 'C_change' }]; + (handler.fetchRemoteQuote as Mock).mockImplementationOnce( + async ({ quote }: { quote: MeltQuote<'bolt11'> }) => + meltQuoteFromBolt11Response(quote.mintUrl, { + quote: quote.quoteId, + request: quote.request, + amount: quote.amount, + unit: quote.unit, + fee_reserve: quote.fee_reserve, + expiry: quote.expiry, + state: 'PAID', + payment_preimage: 'preimage-remote', + change: changeSignatures, + }), + ); + + const events: Array = []; + eventBus.on('melt-quote:updated', (event) => { + events.push(event); + }); + + const refreshed = await quoteLifecycle.refreshMeltQuoteById({ + mintUrl, + quoteId: 'quote-terminal-paid-enrich', + }); + + expect(refreshed.state).toBe('PAID'); + if (refreshed.method === 'onchain') throw new Error('Expected BOLT melt quote'); + expect(refreshed.payment_preimage).toBe('preimage-123'); + expect(refreshed.change).toHaveLength(1); + expect(refreshed.change?.[0]?.amount.equals(Amount.from(5))).toBe(true); + expect(events.map((event) => event.quote.quoteId)).toEqual(['quote-terminal-paid-enrich']); + await expect( + quoteLifecycle.getMeltQuote(mintUrl, 'bolt11', 'quote-terminal-paid-enrich'), + ).resolves.toMatchObject({ state: 'PAID', payment_preimage: 'preimage-123' }); + }); + it('ignores later PAID melt quote observations after terminal settlement', async () => { await persistMeltQuote('quote-terminal-paid-repeat', 'PAID'); (handler.fetchRemoteQuote as Mock).mockImplementationOnce( @@ -945,8 +988,41 @@ describe('MeltOperationService', () => { const pending = makePendingOp('op-7'); await meltOperationRepository.create(pending); + const order: string[] = []; + (handler.fetchRemoteQuote as Mock).mockImplementationOnce( + async ({ quote }: { quote: MeltQuote<'bolt11'> }) => + meltQuoteFromBolt11Response(quote.mintUrl, { + quote: quote.quoteId, + request: quote.request, + amount: quote.amount, + unit: quote.unit, + fee_reserve: quote.fee_reserve, + expiry: quote.expiry, + state: 'PAID', + payment_preimage: 'preimage-finalize', + change: [], + }), + ); + (handler.finalize as Mock).mockImplementationOnce( + async ({ canonicalQuote }: { canonicalQuote?: MeltQuote }) => { + order.push(`finalize:${canonicalQuote?.state ?? 'missing'}`); + return { + changeAmount: Amount.from(0), + effectiveFee: Amount.from(1), + finalizedData: { preimage: 'preimage-finalize' }, + } as FinalizeResult; + }, + ); + const events: any[] = []; + eventBus.on('melt-quote:updated', async () => { + const stored = await meltOperationRepository.getById(pending.id); + order.push(`quote-updated:${stored?.state ?? 'missing'}`); + }); eventBus.on('melt-op:finalized', (payload) => void events.push(payload)); + eventBus.on('melt-op:finalized', () => { + order.push('operation-finalized'); + }); const result = await service.finalize('op-7'); @@ -954,7 +1030,14 @@ describe('MeltOperationService', () => { expect(result).toEqual({ changeAmount: Amount.from(0), effectiveFee: Amount.from(1), - finalizedData: { preimage: 'preimage-123' }, + finalizedData: { preimage: 'preimage-finalize' }, + }); + expect(order).toEqual(['quote-updated:pending', 'finalize:PAID', 'operation-finalized']); + await expect( + meltQuoteRepository.getMeltQuote(mintUrl, 'bolt11', pending.quoteId), + ).resolves.toMatchObject({ + state: 'PAID', + payment_preimage: 'preimage-finalize', }); expect(events.length).toBe(1); const stored = await meltOperationRepository.getById('op-7'); @@ -963,7 +1046,7 @@ describe('MeltOperationService', () => { const finalizedOp = stored as FinalizedMeltOperation; expect(finalizedOp.changeAmount).toEqual(Amount.from(0)); expect(finalizedOp.effectiveFee).toEqual(Amount.from(1)); - expect(finalizedOp.finalizedData?.preimage).toBe('preimage-123'); + expect(finalizedOp.finalizedData?.preimage).toBe('preimage-finalize'); }); it('returns early if already finalized', async () => { @@ -1033,9 +1116,505 @@ describe('MeltOperationService', () => { 'Cannot rollback pending operation: quote state is not UNPAID', ); }); + + it('records the remote quote observation before rolling back a pending operation', async () => { + await persistMeltQuote('quote-direct-rollback', 'PENDING'); + const pending = makePendingOp('op-direct-rollback', { + quoteId: 'quote-direct-rollback', + }); + await meltOperationRepository.create(pending); + + const order: string[] = []; + (handler.fetchRemoteQuote as Mock).mockImplementationOnce( + async ({ quote }: { quote: MeltQuote<'bolt11'> }) => + meltQuoteFromBolt11Response(quote.mintUrl, { + quote: quote.quoteId, + request: quote.request, + amount: quote.amount, + unit: quote.unit, + fee_reserve: quote.fee_reserve, + expiry: quote.expiry, + state: 'UNPAID', + payment_preimage: null, + }), + ); + (handler.checkPending as Mock).mockImplementationOnce( + async ({ canonicalQuote }: { canonicalQuote?: MeltQuote }) => { + order.push(`decision:${canonicalQuote?.state ?? 'missing'}`); + return canonicalQuote?.state === 'UNPAID' ? 'rollback' : 'stay_pending'; + }, + ); + + eventBus.on('melt-quote:updated', async () => { + const stored = await meltOperationRepository.getById(pending.id); + order.push(`quote-updated:${stored?.state ?? 'missing'}`); + }); + eventBus.on('melt-op:rolled-back', () => { + order.push('operation-rolled-back'); + }); + + await service.rollback(pending.id); + + expect(order).toEqual(['quote-updated:pending', 'decision:UNPAID', 'operation-rolled-back']); + await expect( + meltQuoteRepository.getMeltQuote(mintUrl, 'bolt11', pending.quoteId), + ).resolves.toMatchObject({ + state: 'UNPAID', + }); + await expect(meltOperationRepository.getById(pending.id)).resolves.toMatchObject({ + state: 'rolled_back', + }); + }); }); describe('checkPendingOperation', () => { + it('finalizes from a cached PAID quote with serialized change without refreshing', async () => { + const pending = makePendingOp('op-cached-paid-finalize'); + await meltOperationRepository.create(pending); + await meltQuoteRepository.upsertMeltQuote( + meltQuoteFromBolt11Response(mintUrl, { + quote: pending.quoteId, + request: invoice, + amount: pending.amount, + unit: pending.unit, + fee_reserve: pending.fee_reserve, + expiry: Math.floor(Date.now() / 1000) + 3600, + state: 'PAID', + payment_preimage: 'preimage-cached', + change: [], + }), + ); + + (handler.finalize as Mock).mockImplementationOnce( + async ({ canonicalQuote }: { canonicalQuote?: MeltQuote }) => { + expect(canonicalQuote).toMatchObject({ + state: 'PAID', + payment_preimage: 'preimage-cached', + change: [], + }); + return { + changeAmount: Amount.from(0), + effectiveFee: Amount.from(1), + finalizedData: { preimage: 'preimage-cached' }, + } as FinalizeResult; + }, + ); + + const result = await service.checkPendingOperation(pending.id); + + expect(result).toBe('finalize'); + expect(handler.fetchRemoteQuote).not.toHaveBeenCalled(); + expect(handler.checkPending).not.toHaveBeenCalled(); + expect(walletService.getWalletWithActiveKeysetId).not.toHaveBeenCalled(); + await expect(meltOperationRepository.getById(pending.id)).resolves.toMatchObject({ + state: 'finalized', + finalizedData: { preimage: 'preimage-cached' }, + }); + }); + + it('refreshes a cached PAID quote with omitted change before finalizing', async () => { + const pending = makePendingOp('op-cached-paid-missing-change'); + const changeSignatures = [{ id: keysetId, amount: Amount.from(5), C_: 'C_change' }]; + await meltOperationRepository.create(pending); + await meltQuoteRepository.upsertMeltQuote( + meltQuoteFromBolt11Response(mintUrl, { + quote: pending.quoteId, + request: invoice, + amount: pending.amount, + unit: pending.unit, + fee_reserve: pending.fee_reserve, + expiry: Math.floor(Date.now() / 1000) + 3600, + state: 'PAID', + payment_preimage: 'preimage-cached', + }), + ); + (handler.fetchRemoteQuote as Mock).mockImplementationOnce( + async ({ quote }: { quote: MeltQuote<'bolt11'> }) => + meltQuoteFromBolt11Response(quote.mintUrl, { + quote: quote.quoteId, + request: quote.request, + amount: quote.amount, + unit: quote.unit, + fee_reserve: quote.fee_reserve, + expiry: quote.expiry, + state: 'PAID', + payment_preimage: 'preimage-remote', + change: changeSignatures, + }), + ); + (handler.finalize as Mock).mockImplementationOnce( + async ({ canonicalQuote }: { canonicalQuote?: MeltQuote }) => { + expect(canonicalQuote).toMatchObject({ + state: 'PAID', + payment_preimage: 'preimage-cached', + }); + expect(canonicalQuote?.change).toHaveLength(1); + expect(canonicalQuote?.change?.[0]?.amount.equals(Amount.from(5))).toBe(true); + return { + changeAmount: Amount.from(5), + effectiveFee: Amount.from(6), + finalizedData: { preimage: 'preimage-cached' }, + } as FinalizeResult; + }, + ); + + const result = await service.checkPendingOperation(pending.id); + const storedQuote = await meltQuoteRepository.getMeltQuote( + mintUrl, + 'bolt11', + pending.quoteId, + ); + + expect(result).toBe('finalize'); + expect(handler.fetchRemoteQuote).toHaveBeenCalled(); + expect(handler.checkPending).not.toHaveBeenCalled(); + expect(walletService.getWalletWithActiveKeysetId).not.toHaveBeenCalled(); + expect(storedQuote?.change).toHaveLength(1); + expect(storedQuote?.change?.[0]?.amount.equals(Amount.from(5))).toBe(true); + await expect(meltOperationRepository.getById(pending.id)).resolves.toMatchObject({ + state: 'finalized', + changeAmount: Amount.from(5), + }); + }); + + it('copies a remote BOLT preimage when enriching a cached PAID quote before finalizing', async () => { + const pending = makePendingOp('op-cached-paid-missing-preimage'); + const changeSignatures = [{ id: keysetId, amount: Amount.from(5), C_: 'C_change' }]; + await meltOperationRepository.create(pending); + await meltQuoteRepository.upsertMeltQuote( + meltQuoteFromBolt11Response(mintUrl, { + quote: pending.quoteId, + request: invoice, + amount: pending.amount, + unit: pending.unit, + fee_reserve: pending.fee_reserve, + expiry: Math.floor(Date.now() / 1000) + 3600, + state: 'PAID', + payment_preimage: null, + }), + ); + (handler.fetchRemoteQuote as Mock).mockImplementationOnce( + async ({ quote }: { quote: MeltQuote<'bolt11'> }) => + meltQuoteFromBolt11Response(quote.mintUrl, { + quote: quote.quoteId, + request: quote.request, + amount: quote.amount, + unit: quote.unit, + fee_reserve: quote.fee_reserve, + expiry: quote.expiry, + state: 'PAID', + payment_preimage: 'preimage-remote', + change: changeSignatures, + }), + ); + (handler.finalize as Mock).mockImplementationOnce( + async ({ canonicalQuote }: { canonicalQuote?: MeltQuote }) => { + expect(canonicalQuote).toMatchObject({ + state: 'PAID', + payment_preimage: 'preimage-remote', + }); + expect(canonicalQuote?.change).toHaveLength(1); + return { + changeAmount: Amount.from(5), + effectiveFee: Amount.from(6), + finalizedData: { preimage: 'preimage-remote' }, + } as FinalizeResult; + }, + ); + + const result = await service.checkPendingOperation(pending.id); + const storedQuote = await meltQuoteRepository.getMeltQuote( + mintUrl, + 'bolt11', + pending.quoteId, + ); + + expect(result).toBe('finalize'); + expect(handler.fetchRemoteQuote).toHaveBeenCalled(); + expect(storedQuote).toMatchObject({ state: 'PAID', payment_preimage: 'preimage-remote' }); + await expect(meltOperationRepository.getById(pending.id)).resolves.toMatchObject({ + state: 'finalized', + finalizedData: { preimage: 'preimage-remote' }, + }); + }); + + it('copies a remote onchain outpoint when enriching a cached PAID quote', async () => { + const pending = makePendingOp('op-cached-paid-missing-outpoint', { + method: 'onchain', + methodData: { address: 'bc1ptest', amountSats: Amount.from(21), feeIndex: 1 }, + quoteId: 'onchain-paid-quote', + amount: Amount.from(21), + fee_reserve: Amount.from(1), + inputAmount: Amount.from(27), + }); + const feeOptions = [{ fee_index: 1, fee_reserve: Amount.from(1), estimated_blocks: 12 }]; + const changeSignatures = [{ id: keysetId, amount: Amount.from(5), C_: 'C_change' }]; + await meltOperationRepository.create(pending); + await meltQuoteRepository.upsertMeltQuote( + meltQuoteFromOnchainResponse(mintUrl, { + quote: pending.quoteId, + request: 'bc1ptest', + amount: pending.amount, + unit: pending.unit, + fee_options: feeOptions, + selected_fee_index: null, + expiry: Math.floor(Date.now() / 1000) + 3600, + state: 'PAID', + outpoint: null, + }), + ); + (handler.fetchRemoteQuote as Mock).mockImplementationOnce( + async ({ quote }: { quote: MeltQuote<'onchain'> }) => + meltQuoteFromOnchainResponse(quote.mintUrl, { + quote: quote.quoteId, + request: quote.request, + amount: quote.amount, + unit: quote.unit, + fee_options: quote.fee_options, + selected_fee_index: null, + expiry: quote.expiry, + state: 'PAID', + outpoint: 'txid:vout', + change: changeSignatures, + }), + ); + (handler.finalize as Mock).mockImplementationOnce( + async ({ canonicalQuote }: { canonicalQuote?: MeltQuote }) => { + expect(canonicalQuote).toMatchObject({ + state: 'PAID', + outpoint: 'txid:vout', + }); + expect(canonicalQuote?.change).toHaveLength(1); + return { + changeAmount: Amount.from(5), + effectiveFee: Amount.from(1), + finalizedData: { outpoint: 'txid:vout' }, + } as FinalizeResult; + }, + ); + + const result = await service.checkPendingOperation(pending.id); + const storedQuote = await meltQuoteRepository.getMeltQuote( + mintUrl, + 'onchain', + pending.quoteId, + ); + + expect(result).toBe('finalize'); + expect(handler.fetchRemoteQuote).toHaveBeenCalled(); + expect(storedQuote).toMatchObject({ state: 'PAID', outpoint: 'txid:vout' }); + await expect(meltOperationRepository.getById(pending.id)).resolves.toMatchObject({ + state: 'finalized', + finalizedData: { outpoint: 'txid:vout' }, + }); + }); + + it('records the remote quote observation before finalizing the operation', async () => { + const pending = makePendingOp('op-observed-finalize'); + await meltOperationRepository.create(pending); + + const order: string[] = []; + (handler.fetchRemoteQuote as Mock).mockImplementationOnce( + async ({ quote }: { quote: MeltQuote<'bolt11'> }) => + meltQuoteFromBolt11Response(quote.mintUrl, { + quote: quote.quoteId, + request: quote.request, + amount: quote.amount, + unit: quote.unit, + fee_reserve: quote.fee_reserve, + expiry: quote.expiry, + state: 'PAID', + payment_preimage: 'preimage-observed', + change: [], + }), + ); + (handler.checkPending as Mock).mockImplementationOnce( + async ({ canonicalQuote }: { canonicalQuote?: MeltQuote }) => { + order.push(`decision:${canonicalQuote?.state ?? 'missing'}`); + return canonicalQuote?.state === 'PAID' ? 'finalize' : 'stay_pending'; + }, + ); + + eventBus.on('melt-quote:updated', async () => { + const stored = await meltOperationRepository.getById(pending.id); + order.push(`quote-updated:${stored?.state ?? 'missing'}`); + }); + eventBus.on('melt-op:finalized', () => { + order.push('operation-finalized'); + }); + + const result = await service.checkPendingOperation(pending.id); + + expect(result).toBe('finalize'); + expect(order).toEqual(['quote-updated:pending', 'decision:PAID', 'operation-finalized']); + await expect( + meltQuoteRepository.getMeltQuote(mintUrl, 'bolt11', pending.quoteId), + ).resolves.toMatchObject({ + state: 'PAID', + payment_preimage: 'preimage-observed', + }); + await expect(meltOperationRepository.getById(pending.id)).resolves.toMatchObject({ + state: 'finalized', + }); + }); + + it('records the remote quote observation before leaving the operation pending', async () => { + const pending = makePendingOp('op-observed-pending'); + await meltOperationRepository.create(pending); + + const order: string[] = []; + (handler.fetchRemoteQuote as Mock).mockImplementationOnce( + async ({ quote }: { quote: MeltQuote<'bolt11'> }) => + meltQuoteFromBolt11Response(quote.mintUrl, { + quote: quote.quoteId, + request: quote.request, + amount: quote.amount, + unit: quote.unit, + fee_reserve: quote.fee_reserve, + expiry: quote.expiry, + state: 'PENDING', + payment_preimage: null, + }), + ); + (handler.checkPending as Mock).mockImplementationOnce( + async ({ canonicalQuote }: { canonicalQuote?: MeltQuote }) => { + order.push(`decision:${canonicalQuote?.state ?? 'missing'}`); + return 'stay_pending'; + }, + ); + + eventBus.on('melt-quote:updated', async () => { + const stored = await meltOperationRepository.getById(pending.id); + order.push(`quote-updated:${stored?.state ?? 'missing'}`); + }); + + const result = await service.checkPendingOperation(pending.id); + + expect(result).toBe('stay_pending'); + expect(order).toEqual(['quote-updated:pending', 'decision:PENDING']); + await expect( + meltQuoteRepository.getMeltQuote(mintUrl, 'bolt11', pending.quoteId), + ).resolves.toMatchObject({ + state: 'PENDING', + }); + await expect(meltOperationRepository.getById(pending.id)).resolves.toMatchObject({ + state: 'pending', + }); + }); + + it('records the remote quote observation before rolling back the operation', async () => { + await persistMeltQuote('quote-observed-rollback', 'PENDING'); + const pending = makePendingOp('op-observed-rollback', { + quoteId: 'quote-observed-rollback', + }); + await meltOperationRepository.create(pending); + + const order: string[] = []; + (handler.fetchRemoteQuote as Mock).mockImplementation( + async ({ quote }: { quote: MeltQuote<'bolt11'> }) => + meltQuoteFromBolt11Response(quote.mintUrl, { + quote: quote.quoteId, + request: quote.request, + amount: quote.amount, + unit: quote.unit, + fee_reserve: quote.fee_reserve, + expiry: quote.expiry, + state: 'UNPAID', + payment_preimage: null, + }), + ); + (handler.checkPending as Mock).mockImplementation( + async ({ canonicalQuote }: { canonicalQuote?: MeltQuote }) => { + order.push( + `decision:${canonicalQuote?.quoteId ?? 'missing'}:${canonicalQuote?.state ?? 'missing'}`, + ); + return 'rollback'; + }, + ); + + eventBus.on('melt-quote:updated', async () => { + const stored = await meltOperationRepository.getById(pending.id); + order.push(`quote-updated:${stored?.state ?? 'missing'}`); + }); + eventBus.on('melt-op:rolled-back', () => { + order.push('operation-rolled-back'); + }); + + const result = await service.checkPendingOperation(pending.id); + + expect(result).toBe('rollback'); + expect(order).toEqual([ + 'quote-updated:pending', + 'decision:quote-observed-rollback:UNPAID', + 'decision:quote-observed-rollback:UNPAID', + 'operation-rolled-back', + ]); + expect(handler.checkPending).toHaveBeenCalledTimes(2); + await expect( + meltQuoteRepository.getMeltQuote(mintUrl, 'bolt11', pending.quoteId), + ).resolves.toMatchObject({ + state: 'UNPAID', + }); + await expect(meltOperationRepository.getById(pending.id)).resolves.toMatchObject({ + state: 'rolled_back', + }); + }); + + it('re-checks the quote under rollback lock after a pending check requests rollback', async () => { + await persistMeltQuote('quote-rollback-race', 'PENDING'); + const pending = makePendingOp('op-rollback-race', { + quoteId: 'quote-rollback-race', + }); + await meltOperationRepository.create(pending); + + (handler.fetchRemoteQuote as Mock) + .mockImplementationOnce(async ({ quote }: { quote: MeltQuote<'bolt11'> }) => + meltQuoteFromBolt11Response(quote.mintUrl, { + quote: quote.quoteId, + request: quote.request, + amount: quote.amount, + unit: quote.unit, + fee_reserve: quote.fee_reserve, + expiry: quote.expiry, + state: 'UNPAID', + payment_preimage: null, + }), + ) + .mockImplementationOnce(async ({ quote }: { quote: MeltQuote<'bolt11'> }) => + meltQuoteFromBolt11Response(quote.mintUrl, { + quote: quote.quoteId, + request: quote.request, + amount: quote.amount, + unit: quote.unit, + fee_reserve: quote.fee_reserve, + expiry: quote.expiry, + state: 'PAID', + payment_preimage: 'preimage-race', + change: [], + }), + ); + (handler.checkPending as Mock).mockImplementation( + async ({ canonicalQuote }: { canonicalQuote?: MeltQuote }) => + canonicalQuote?.state === 'UNPAID' ? 'rollback' : 'finalize', + ); + + await expect(service.checkPendingOperation(pending.id)).rejects.toThrow( + 'Cannot rollback pending operation: quote state is not UNPAID (decision: finalize)', + ); + + expect(handler.fetchRemoteQuote).toHaveBeenCalledTimes(2); + expect(handler.rollback).not.toHaveBeenCalled(); + await expect( + meltQuoteRepository.getMeltQuote(mintUrl, 'bolt11', pending.quoteId), + ).resolves.toMatchObject({ + state: 'PAID', + payment_preimage: 'preimage-race', + }); + await expect(meltOperationRepository.getById(pending.id)).resolves.toMatchObject({ + state: 'pending', + }); + }); + it('delegates to finalize when handler returns finalize', async () => { const pending = makePendingOp('op-11'); await meltOperationRepository.create(pending); @@ -1046,7 +1625,9 @@ describe('MeltOperationService', () => { const result = await service.checkPendingOperation('op-11'); expect(result).toBe('finalize'); - expect((service as any).finalize).toHaveBeenCalledWith('op-11'); + expect((service as any).finalize).toHaveBeenCalledWith('op-11', { + canonicalQuote: expect.objectContaining({ quoteId: 'quote-1', state: 'PENDING' }), + }); }); });