From 34c16d3da34790fb87703dcb407d5ce9129e7a99 Mon Sep 17 00:00:00 2001 From: Egge Date: Mon, 29 Jun 2026 06:57:04 +0200 Subject: [PATCH 1/8] fix(core): record melt quotes before settlement --- .../melt-observation-before-settlement.md | 5 + .../handlers/melt/BaseQuoteMeltHandler.ts | 26 ++- .../core/operations/melt/MeltMethodHandler.ts | 2 + .../operations/melt/MeltOperationService.ts | 22 ++- .../core/test/unit/MeltBolt11Handler.test.ts | 75 +++++++++ .../core/test/unit/MeltOnchainHandler.test.ts | 23 +++ .../test/unit/MeltOperationService.test.ts | 155 +++++++++++++++++- 7 files changed, 300 insertions(+), 8 deletions(-) create mode 100644 .changeset/melt-observation-before-settlement.md diff --git a/.changeset/melt-observation-before-settlement.md b/.changeset/melt-observation-before-settlement.md new file mode 100644 index 00000000..04108123 --- /dev/null +++ b/.changeset/melt-observation-before-settlement.md @@ -0,0 +1,5 @@ +--- +'@cashu/coco-core': patch +--- + +Record pending melt quote observations before settling local melt operations. diff --git a/packages/core/infra/handlers/melt/BaseQuoteMeltHandler.ts b/packages/core/infra/handlers/melt/BaseQuoteMeltHandler.ts index 1de088bc..cb84afc3 100644 --- a/packages/core/infra/handlers/melt/BaseQuoteMeltHandler.ts +++ b/packages/core/infra/handlers/melt/BaseQuoteMeltHandler.ts @@ -147,6 +147,26 @@ export abstract class BaseQuoteMeltHandler implements Melt return { changeAmount, effectiveFee }; } + private getPersistedSettlementResponse(quote?: MeltQuote): QuoteMeltResponse | null { + if (!quote || quote.state !== 'PAID' || quote.change === undefined) { + return null; + } + + if (quote.method === 'onchain') { + return { + state: quote.state, + change: quote.change, + outpoint: quote.outpoint ?? null, + } as QuoteMeltResponse; + } + + return { + state: quote.state, + change: quote.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 +590,8 @@ 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 res = + this.getPersistedSettlementResponse(ctx.canonicalQuote) ?? (await this.checkMeltQuote(ctx)); if (res.state !== 'PAID') { throw new Error(`Cannot finalize: melt quote ${quoteId} is ${res.state}, expected PAID`); @@ -649,7 +669,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..049c4557 100644 --- a/packages/core/operations/melt/MeltOperationService.ts +++ b/packages/core/operations/melt/MeltOperationService.ts @@ -430,7 +430,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); @@ -462,6 +465,7 @@ export class MeltOperationService { const finalizeResult = await handler.finalize?.({ ...this.buildDeps(), operation: pendingOp, + canonicalQuote: options.canonicalQuote, }); const finalized: FinalizedMeltOperation = { @@ -496,7 +500,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); @@ -532,6 +540,7 @@ export class MeltOperationService { ...this.buildDeps(), operation: pendingOp, wallet, + canonicalQuote: options.canonicalQuote, }); if (decision !== 'rollback') { throw new Error( @@ -665,18 +674,23 @@ export class MeltOperationService { } 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'); + await this.rollback(op.id, 'Rollback requested by handler', { canonicalQuote: quote }); return 'rollback'; } else { this.logger?.debug('Pending melt remains pending', { operationId: op.id }); diff --git a/packages/core/test/unit/MeltBolt11Handler.test.ts b/packages/core/test/unit/MeltBolt11Handler.test.ts index 90d31ff9..352c7f68 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,45 @@ describe('MeltBolt11Handler', () => { }); }); + it('should fetch remote settlement when the canonical quote is missing change data', async () => { + const operation = makePendingOp('op-partial-canonical', { + needsSwap: false, + inputProofSecrets: ['input-1'], + }); + 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', + }); + const changeSignatures: SerializedBlindedSignature[] = [ + { id: keysetId, amount: Amount.from(5), C_: 'C_change' }, + ]; + (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(result).toEqual({ + changeAmount: Amount.from(5), + effectiveFee: Amount.from(5), + finalizedData: { preimage: 'preimage-remote' }, + }); + }); + 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..2fe5fd9c 100644 --- a/packages/core/test/unit/MeltOperationService.test.ts +++ b/packages/core/test/unit/MeltOperationService.test.ts @@ -1036,6 +1036,157 @@ describe('MeltOperationService', () => { }); describe('checkPendingOperation', () => { + 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).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).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('delegates to finalize when handler returns finalize', async () => { const pending = makePendingOp('op-11'); await meltOperationRepository.create(pending); @@ -1046,7 +1197,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' }), + }); }); }); From eb8cd7c099e64d310235ba0a74e2b5c98fee0e30 Mon Sep 17 00:00:00 2001 From: Egge Date: Mon, 29 Jun 2026 13:42:24 +0200 Subject: [PATCH 2/8] fix(core): use cached paid melt quotes during recovery --- .../melt-observation-before-settlement.md | 3 +- .../operations/melt/MeltOperationService.ts | 10 +++++ .../test/unit/MeltOperationService.test.ts | 43 +++++++++++++++++++ 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/.changeset/melt-observation-before-settlement.md b/.changeset/melt-observation-before-settlement.md index 04108123..bbef6685 100644 --- a/.changeset/melt-observation-before-settlement.md +++ b/.changeset/melt-observation-before-settlement.md @@ -2,4 +2,5 @@ '@cashu/coco-core': patch --- -Record pending melt quote observations before settling local melt operations. +Record pending melt quote observations before settling local melt operations, and use cached PAID +observations during recovery without refreshing the remote quote first. diff --git a/packages/core/operations/melt/MeltOperationService.ts b/packages/core/operations/melt/MeltOperationService.ts index 049c4557..dd0eeaff 100644 --- a/packages/core/operations/melt/MeltOperationService.ts +++ b/packages/core/operations/melt/MeltOperationService.ts @@ -672,6 +672,16 @@ 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({ diff --git a/packages/core/test/unit/MeltOperationService.test.ts b/packages/core/test/unit/MeltOperationService.test.ts index 2fe5fd9c..a1654351 100644 --- a/packages/core/test/unit/MeltOperationService.test.ts +++ b/packages/core/test/unit/MeltOperationService.test.ts @@ -1036,6 +1036,49 @@ describe('MeltOperationService', () => { }); describe('checkPendingOperation', () => { + it('finalizes from a cached PAID quote without refreshing the remote quote', 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', + }); + 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('records the remote quote observation before finalizing the operation', async () => { const pending = makePendingOp('op-observed-finalize'); await meltOperationRepository.create(pending); From 4107cb5472a366247b1c374491fb2e547e1a7d92 Mon Sep 17 00:00:00 2001 From: Egge Date: Mon, 29 Jun 2026 14:08:42 +0200 Subject: [PATCH 3/8] fix(core): treat paid melt quotes as terminal --- .../melt-observation-before-settlement.md | 4 ++-- .../handlers/melt/BaseQuoteMeltHandler.ts | 8 +++++--- .../core/test/unit/MeltBolt11Handler.test.ts | 20 +++++-------------- .../test/unit/MeltOperationService.test.ts | 1 - 4 files changed, 12 insertions(+), 21 deletions(-) diff --git a/.changeset/melt-observation-before-settlement.md b/.changeset/melt-observation-before-settlement.md index bbef6685..1d926a19 100644 --- a/.changeset/melt-observation-before-settlement.md +++ b/.changeset/melt-observation-before-settlement.md @@ -2,5 +2,5 @@ '@cashu/coco-core': patch --- -Record pending melt quote observations before settling local melt operations, and use cached PAID -observations during recovery without refreshing the remote quote first. +Record pending melt quote observations before settling local melt operations, and treat cached PAID +observations as terminal during recovery without refreshing the remote quote first. diff --git a/packages/core/infra/handlers/melt/BaseQuoteMeltHandler.ts b/packages/core/infra/handlers/melt/BaseQuoteMeltHandler.ts index cb84afc3..3d56e23e 100644 --- a/packages/core/infra/handlers/melt/BaseQuoteMeltHandler.ts +++ b/packages/core/infra/handlers/melt/BaseQuoteMeltHandler.ts @@ -148,21 +148,23 @@ export abstract class BaseQuoteMeltHandler implements Melt } private getPersistedSettlementResponse(quote?: MeltQuote): QuoteMeltResponse | null { - if (!quote || quote.state !== 'PAID' || quote.change === undefined) { + if (!quote || quote.state !== 'PAID') { return null; } + const change = quote.change ?? []; + if (quote.method === 'onchain') { return { state: quote.state, - change: quote.change, + change, outpoint: quote.outpoint ?? null, } as QuoteMeltResponse; } return { state: quote.state, - change: quote.change, + change, payment_preimage: quote.payment_preimage ?? null, } as QuoteMeltResponse; } diff --git a/packages/core/test/unit/MeltBolt11Handler.test.ts b/packages/core/test/unit/MeltBolt11Handler.test.ts index 352c7f68..d357b1b1 100644 --- a/packages/core/test/unit/MeltBolt11Handler.test.ts +++ b/packages/core/test/unit/MeltBolt11Handler.test.ts @@ -969,7 +969,7 @@ describe('MeltBolt11Handler', () => { }); }); - it('should fetch remote settlement when the canonical quote is missing change data', async () => { + it('should treat a PAID canonical quote without change as terminal no-change settlement', async () => { const operation = makePendingOp('op-partial-canonical', { needsSwap: false, inputProofSecrets: ['input-1'], @@ -984,27 +984,17 @@ describe('MeltBolt11Handler', () => { state: 'PAID', payment_preimage: 'preimage-partial', }); - const changeSignatures: SerializedBlindedSignature[] = [ - { id: keysetId, amount: Amount.from(5), C_: 'C_change' }, - ]; - (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(mintAdapter.checkMeltQuote).not.toHaveBeenCalled(); expect(result).toEqual({ - changeAmount: Amount.from(5), - effectiveFee: Amount.from(5), - finalizedData: { preimage: 'preimage-remote' }, + changeAmount: Amount.from(0), + effectiveFee: Amount.from(10), + finalizedData: { preimage: 'preimage-partial' }, }); }); diff --git a/packages/core/test/unit/MeltOperationService.test.ts b/packages/core/test/unit/MeltOperationService.test.ts index a1654351..838abf55 100644 --- a/packages/core/test/unit/MeltOperationService.test.ts +++ b/packages/core/test/unit/MeltOperationService.test.ts @@ -1049,7 +1049,6 @@ describe('MeltOperationService', () => { expiry: Math.floor(Date.now() / 1000) + 3600, state: 'PAID', payment_preimage: 'preimage-cached', - change: [], }), ); From 26a9ddf0ac1c634ef067a01b2281b64ae7ca06a0 Mon Sep 17 00:00:00 2001 From: Egge Date: Mon, 29 Jun 2026 14:18:23 +0200 Subject: [PATCH 4/8] fix(core): persist melt quote observations before explicit settlement --- .../handlers/melt/BaseQuoteMeltHandler.ts | 10 +- .../operations/melt/MeltOperationService.ts | 32 ++++++- .../core/test/unit/MeltBolt11Handler.test.ts | 25 +++++ .../test/unit/MeltOperationService.test.ts | 93 ++++++++++++++++++- 4 files changed, 154 insertions(+), 6 deletions(-) diff --git a/packages/core/infra/handlers/melt/BaseQuoteMeltHandler.ts b/packages/core/infra/handlers/melt/BaseQuoteMeltHandler.ts index 3d56e23e..796845c7 100644 --- a/packages/core/infra/handlers/melt/BaseQuoteMeltHandler.ts +++ b/packages/core/infra/handlers/melt/BaseQuoteMeltHandler.ts @@ -592,8 +592,14 @@ export abstract class BaseQuoteMeltHandler implements Melt ctx.logger?.debug('Finalizing pending melt operation', { operationId, quoteId }); - const res = - this.getPersistedSettlementResponse(ctx.canonicalQuote) ?? (await this.checkMeltQuote(ctx)); + const persistedSettlement = this.getPersistedSettlementResponse(ctx.canonicalQuote); + if (ctx.canonicalQuote && !persistedSettlement) { + 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`); diff --git a/packages/core/operations/melt/MeltOperationService.ts b/packages/core/operations/melt/MeltOperationService.ts index dd0eeaff..67a6c1ab 100644 --- a/packages/core/operations/melt/MeltOperationService.ts +++ b/packages/core/operations/melt/MeltOperationService.ts @@ -106,6 +106,26 @@ export class MeltOperationService { return this.recoveryLock !== null; } + private async resolvePendingSettlementQuote( + op: PendingMeltOperation, + canonicalQuote?: MeltQuote, + ): Promise { + if (canonicalQuote) { + return canonicalQuote; + } + + const persistedQuote = await this.quoteLifecycle.getMeltQuote( + op.mintUrl, + op.method, + op.quoteId, + ); + if (persistedQuote?.state === 'PAID') { + return persistedQuote; + } + + return this.quoteLifecycle.refreshMeltQuote(op.mintUrl, op.method, op.quoteId); + } + async init( mintUrl: string, method: MeltMethod, @@ -462,10 +482,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: options.canonicalQuote, + canonicalQuote, }); const finalized: FinalizedMeltOperation = { @@ -536,11 +560,15 @@ export class MeltOperationService { // This prevents releasing proofs that are still inflight with the Lightning network. if (operation.state === 'pending') { const pendingOp = operation as PendingMeltOperation; + const canonicalQuote = await this.resolvePendingSettlementQuote( + pendingOp, + options.canonicalQuote, + ); const decision = await handler.checkPending?.({ ...this.buildDeps(), operation: pendingOp, wallet, - canonicalQuote: options.canonicalQuote, + canonicalQuote, }); if (decision !== 'rollback') { throw new Error( diff --git a/packages/core/test/unit/MeltBolt11Handler.test.ts b/packages/core/test/unit/MeltBolt11Handler.test.ts index d357b1b1..f6376d72 100644 --- a/packages/core/test/unit/MeltBolt11Handler.test.ts +++ b/packages/core/test/unit/MeltBolt11Handler.test.ts @@ -998,6 +998,31 @@ describe('MeltBolt11Handler', () => { }); }); + 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/MeltOperationService.test.ts b/packages/core/test/unit/MeltOperationService.test.ts index 838abf55..018d850d 100644 --- a/packages/core/test/unit/MeltOperationService.test.ts +++ b/packages/core/test/unit/MeltOperationService.test.ts @@ -945,8 +945,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 +987,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 +1003,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,6 +1073,55 @@ 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', () => { From 8ec4aa294cd57fad36a0dd238b8cd999f863f748 Mon Sep 17 00:00:00 2001 From: Egge Date: Mon, 29 Jun 2026 14:41:42 +0200 Subject: [PATCH 5/8] fix(core): recheck melt quote before rollback --- .../operations/melt/MeltOperationService.ts | 8 +-- .../test/unit/MeltOperationService.test.ts | 57 ++++++++++++++++++- 2 files changed, 59 insertions(+), 6 deletions(-) diff --git a/packages/core/operations/melt/MeltOperationService.ts b/packages/core/operations/melt/MeltOperationService.ts index 67a6c1ab..7f0905ed 100644 --- a/packages/core/operations/melt/MeltOperationService.ts +++ b/packages/core/operations/melt/MeltOperationService.ts @@ -560,10 +560,8 @@ export class MeltOperationService { // This prevents releasing proofs that are still inflight with the Lightning network. if (operation.state === 'pending') { const pendingOp = operation as PendingMeltOperation; - const canonicalQuote = await this.resolvePendingSettlementQuote( - pendingOp, - options.canonicalQuote, - ); + // 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, @@ -728,7 +726,7 @@ export class MeltOperationService { await this.finalize(op.id, { canonicalQuote: quote }); return 'finalize'; } else if (decision === 'rollback') { - await this.rollback(op.id, 'Rollback requested by handler', { canonicalQuote: quote }); + await this.rollback(op.id, 'Rollback requested by handler'); return 'rollback'; } else { this.logger?.debug('Pending melt remains pending', { operationId: op.id }); diff --git a/packages/core/test/unit/MeltOperationService.test.ts b/packages/core/test/unit/MeltOperationService.test.ts index 018d850d..5e5ca64c 100644 --- a/packages/core/test/unit/MeltOperationService.test.ts +++ b/packages/core/test/unit/MeltOperationService.test.ts @@ -1268,7 +1268,7 @@ describe('MeltOperationService', () => { await meltOperationRepository.create(pending); const order: string[] = []; - (handler.fetchRemoteQuote as Mock).mockImplementationOnce( + (handler.fetchRemoteQuote as Mock).mockImplementation( async ({ quote }: { quote: MeltQuote<'bolt11'> }) => meltQuoteFromBolt11Response(quote.mintUrl, { quote: quote.quoteId, @@ -1318,6 +1318,61 @@ describe('MeltOperationService', () => { }); }); + 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); From af82ae8fed43582ff018b84dcac808057224048a Mon Sep 17 00:00:00 2001 From: Egge Date: Mon, 29 Jun 2026 15:28:00 +0200 Subject: [PATCH 6/8] fix(core): fetch incomplete paid melt settlements --- .../melt-observation-before-settlement.md | 4 +- .../handlers/melt/BaseQuoteMeltHandler.ts | 8 +- .../operations/melt/MeltOperationService.ts | 5 +- packages/core/quotes/QuoteLifecycle.ts | 25 +++++ .../core/test/unit/MeltBolt11Handler.test.ts | 21 +++- .../test/unit/MeltOperationService.test.ts | 104 +++++++++++++++++- 6 files changed, 156 insertions(+), 11 deletions(-) diff --git a/.changeset/melt-observation-before-settlement.md b/.changeset/melt-observation-before-settlement.md index 1d926a19..ff662a3d 100644 --- a/.changeset/melt-observation-before-settlement.md +++ b/.changeset/melt-observation-before-settlement.md @@ -2,5 +2,5 @@ '@cashu/coco-core': patch --- -Record pending melt quote observations before settling local melt operations, and treat cached PAID -observations as terminal during recovery without refreshing the remote quote first. +Record pending melt quote observations before settling local melt operations, and use cached PAID +observations during recovery only when serialized settlement change is available. diff --git a/packages/core/infra/handlers/melt/BaseQuoteMeltHandler.ts b/packages/core/infra/handlers/melt/BaseQuoteMeltHandler.ts index 796845c7..40e5f0c8 100644 --- a/packages/core/infra/handlers/melt/BaseQuoteMeltHandler.ts +++ b/packages/core/infra/handlers/melt/BaseQuoteMeltHandler.ts @@ -152,7 +152,11 @@ export abstract class BaseQuoteMeltHandler implements Melt return null; } - const change = quote.change ?? []; + if (!Array.isArray(quote.change)) { + return null; + } + + const change = quote.change; if (quote.method === 'onchain') { return { @@ -593,7 +597,7 @@ export abstract class BaseQuoteMeltHandler implements Melt ctx.logger?.debug('Finalizing pending melt operation', { operationId, quoteId }); const persistedSettlement = this.getPersistedSettlementResponse(ctx.canonicalQuote); - if (ctx.canonicalQuote && !persistedSettlement) { + if (ctx.canonicalQuote && ctx.canonicalQuote.state !== 'PAID') { throw new Error( `Cannot finalize: melt quote ${quoteId} is ${ctx.canonicalQuote.state}, expected PAID`, ); diff --git a/packages/core/operations/melt/MeltOperationService.ts b/packages/core/operations/melt/MeltOperationService.ts index 7f0905ed..77628205 100644 --- a/packages/core/operations/melt/MeltOperationService.ts +++ b/packages/core/operations/melt/MeltOperationService.ts @@ -111,6 +111,9 @@ export class MeltOperationService { 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; } @@ -119,7 +122,7 @@ export class MeltOperationService { op.method, op.quoteId, ); - if (persistedQuote?.state === 'PAID') { + if (persistedQuote?.state === 'PAID' && Array.isArray(persistedQuote.change)) { return persistedQuote; } diff --git a/packages/core/quotes/QuoteLifecycle.ts b/packages/core/quotes/QuoteLifecycle.ts index 02ce6f3e..5499d7f9 100644 --- a/packages/core/quotes/QuoteLifecycle.ts +++ b/packages/core/quotes/QuoteLifecycle.ts @@ -155,6 +155,15 @@ function getMeltQuoteChange(existing: MeltQuote | null, incoming: MeltQuote): bo ); } +function shouldEnrichPaidMeltQuoteSettlement(existing: MeltQuote, incoming: MeltQuote): boolean { + return ( + existing.state === 'PAID' && + incoming.state === 'PAID' && + !Array.isArray(existing.change) && + Array.isArray(incoming.change) + ); +} + export interface QuoteLifecycleDeps { mintHandlerProvider: MintHandlerProvider; meltHandlerProvider: MeltHandlerProvider; @@ -718,6 +727,22 @@ export class QuoteLifecycle { ); if (existing?.state === 'PAID') { + if (shouldEnrichPaidMeltQuoteSettlement(existing, canonicalQuote)) { + const persisted = await this.persistCanonicalMeltQuote({ + ...existing, + change: canonicalQuote.change, + lastObservedRemoteState: + canonicalQuote.lastObservedRemoteState ?? existing.lastObservedRemoteState, + lastObservedRemoteStateAt: + canonicalQuote.lastObservedRemoteStateAt ?? existing.lastObservedRemoteStateAt, + updatedAt: canonicalQuote.updatedAt, + } as MeltQuote); + 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 f6376d72..1451fca4 100644 --- a/packages/core/test/unit/MeltBolt11Handler.test.ts +++ b/packages/core/test/unit/MeltBolt11Handler.test.ts @@ -969,11 +969,14 @@ describe('MeltBolt11Handler', () => { }); }); - it('should treat a PAID canonical quote without change as terminal no-change settlement', async () => { + 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, @@ -984,17 +987,25 @@ describe('MeltBolt11Handler', () => { 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).not.toHaveBeenCalled(); + expect(mintAdapter.checkMeltQuote).toHaveBeenCalledWith(mintUrl, operation.quoteId); + expect(proofService.unblindAndSaveChangeProofs).toHaveBeenCalled(); expect(result).toEqual({ - changeAmount: Amount.from(0), - effectiveFee: Amount.from(10), - finalizedData: { preimage: 'preimage-partial' }, + changeAmount: Amount.from(5), + effectiveFee: Amount.from(5), + finalizedData: { preimage: 'preimage-remote' }, }); }); diff --git a/packages/core/test/unit/MeltOperationService.test.ts b/packages/core/test/unit/MeltOperationService.test.ts index 5e5ca64c..d0a0582b 100644 --- a/packages/core/test/unit/MeltOperationService.test.ts +++ b/packages/core/test/unit/MeltOperationService.test.ts @@ -562,6 +562,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( @@ -1125,7 +1164,7 @@ describe('MeltOperationService', () => { }); describe('checkPendingOperation', () => { - it('finalizes from a cached PAID quote without refreshing the remote quote', async () => { + 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( @@ -1138,6 +1177,7 @@ describe('MeltOperationService', () => { expiry: Math.floor(Date.now() / 1000) + 3600, state: 'PAID', payment_preimage: 'preimage-cached', + change: [], }), ); @@ -1146,6 +1186,7 @@ describe('MeltOperationService', () => { expect(canonicalQuote).toMatchObject({ state: 'PAID', payment_preimage: 'preimage-cached', + change: [], }); return { changeAmount: Amount.from(0), @@ -1167,6 +1208,67 @@ describe('MeltOperationService', () => { }); }); + 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('records the remote quote observation before finalizing the operation', async () => { const pending = makePendingOp('op-observed-finalize'); await meltOperationRepository.create(pending); From a6f1216dc6e2e436b70106054a7d70e91fc65c44 Mon Sep 17 00:00:00 2001 From: Egge Date: Mon, 29 Jun 2026 15:38:47 +0200 Subject: [PATCH 7/8] format --- packages/core/test/unit/MeltOperationService.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/core/test/unit/MeltOperationService.test.ts b/packages/core/test/unit/MeltOperationService.test.ts index d0a0582b..48d0c1f0 100644 --- a/packages/core/test/unit/MeltOperationService.test.ts +++ b/packages/core/test/unit/MeltOperationService.test.ts @@ -1255,7 +1255,11 @@ describe('MeltOperationService', () => { ); const result = await service.checkPendingOperation(pending.id); - const storedQuote = await meltQuoteRepository.getMeltQuote(mintUrl, 'bolt11', pending.quoteId); + const storedQuote = await meltQuoteRepository.getMeltQuote( + mintUrl, + 'bolt11', + pending.quoteId, + ); expect(result).toBe('finalize'); expect(handler.fetchRemoteQuote).toHaveBeenCalled(); From 7445e8afeae726ed03b8c21cc103b418d61320e3 Mon Sep 17 00:00:00 2001 From: Egge Date: Mon, 29 Jun 2026 16:08:29 +0200 Subject: [PATCH 8/8] fix(core): preserve melt settlement metadata --- .../melt-observation-before-settlement.md | 3 +- packages/core/quotes/QuoteLifecycle.ts | 56 ++++--- .../test/unit/MeltOperationService.test.ts | 138 +++++++++++++++++- 3 files changed, 178 insertions(+), 19 deletions(-) diff --git a/.changeset/melt-observation-before-settlement.md b/.changeset/melt-observation-before-settlement.md index ff662a3d..03c6feaa 100644 --- a/.changeset/melt-observation-before-settlement.md +++ b/.changeset/melt-observation-before-settlement.md @@ -3,4 +3,5 @@ --- Record pending melt quote observations before settling local melt operations, and use cached PAID -observations during recovery only when serialized settlement change is available. +observations during recovery only when serialized settlement change and method-specific settlement +metadata are available. diff --git a/packages/core/quotes/QuoteLifecycle.ts b/packages/core/quotes/QuoteLifecycle.ts index 5499d7f9..9b3ea28f 100644 --- a/packages/core/quotes/QuoteLifecycle.ts +++ b/packages/core/quotes/QuoteLifecycle.ts @@ -155,13 +155,42 @@ function getMeltQuoteChange(existing: MeltQuote | null, incoming: MeltQuote): bo ); } -function shouldEnrichPaidMeltQuoteSettlement(existing: MeltQuote, incoming: MeltQuote): boolean { - return ( - existing.state === 'PAID' && - incoming.state === 'PAID' && - !Array.isArray(existing.change) && - Array.isArray(incoming.change) - ); +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 { @@ -727,16 +756,9 @@ export class QuoteLifecycle { ); if (existing?.state === 'PAID') { - if (shouldEnrichPaidMeltQuoteSettlement(existing, canonicalQuote)) { - const persisted = await this.persistCanonicalMeltQuote({ - ...existing, - change: canonicalQuote.change, - lastObservedRemoteState: - canonicalQuote.lastObservedRemoteState ?? existing.lastObservedRemoteState, - lastObservedRemoteStateAt: - canonicalQuote.lastObservedRemoteStateAt ?? existing.lastObservedRemoteStateAt, - updatedAt: canonicalQuote.updatedAt, - } as MeltQuote); + const enrichedQuote = mergePaidMeltQuoteSettlement(existing, canonicalQuote); + if (enrichedQuote) { + const persisted = await this.persistCanonicalMeltQuote(enrichedQuote); return { quote: persisted, remoteQuoteChanged: true, diff --git a/packages/core/test/unit/MeltOperationService.test.ts b/packages/core/test/unit/MeltOperationService.test.ts index 48d0c1f0..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, @@ -1273,6 +1277,138 @@ describe('MeltOperationService', () => { }); }); + 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);