Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/melt-observation-before-settlement.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@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.
34 changes: 31 additions & 3 deletions packages/core/infra/handlers/melt/BaseQuoteMeltHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,28 @@ export abstract class BaseQuoteMeltHandler<M extends MeltMethod> implements Melt
return { changeAmount, effectiveFee };
}

private getPersistedSettlementResponse(quote?: MeltQuote<M>): QuoteMeltResponse<M> | null {
if (!quote || quote.state !== 'PAID') {
return null;
}

const change = quote.change ?? [];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Fetch full settlement before assuming zero change

When a cached PAID quote does not have its settlement change serialized (for example, legacy rows or an earlier paid observation with change omitted), this new cached-settlement path converts the missing field to []. Finalization then marks the melt inputs spent, skips unblindAndSaveChangeProofs, and computes the whole excess as fees instead of fetching the full quote as the old path did, so any actual mint change for that pending melt is lost locally.

Useful? React with 👍 / 👎.


if (quote.method === 'onchain') {
return {
state: quote.state,
change,
outpoint: quote.outpoint ?? null,
} as QuoteMeltResponse<M>;
}

return {
state: quote.state,
change,
payment_preimage: quote.payment_preimage ?? null,
} as QuoteMeltResponse<M>;
}

/**
* 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.
Expand Down Expand Up @@ -570,8 +592,14 @@ export abstract class BaseQuoteMeltHandler<M extends MeltMethod> 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 && !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`);
Expand Down Expand Up @@ -649,7 +677,7 @@ export abstract class BaseQuoteMeltHandler<M extends MeltMethod> 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 });

Expand Down
2 changes: 2 additions & 0 deletions packages/core/operations/melt/MeltMethodHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,10 +136,12 @@ export interface ExecuteContext<M extends MeltMethod = MeltMethod> extends BaseH
export interface PendingContext<M extends MeltMethod = MeltMethod> extends BaseHandlerDeps {
operation: PendingMeltOperation & MeltMethodMeta<M>;
wallet: Wallet;
canonicalQuote?: MeltQuote<M>;
}

export interface FinalizeContext<M extends MeltMethod = MeltMethod> extends BaseHandlerDeps {
operation: PendingMeltOperation & MeltMethodMeta<M>;
canonicalQuote?: MeltQuote<M>;
}

export type FinalizeResult<M extends MeltMethod = MeltMethod> = {
Expand Down
56 changes: 53 additions & 3 deletions packages/core/operations/melt/MeltOperationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,26 @@ export class MeltOperationService {
return this.recoveryLock !== null;
}

private async resolvePendingSettlementQuote(
op: PendingMeltOperation,
canonicalQuote?: MeltQuote,
): Promise<MeltQuote> {
if (canonicalQuote) {
return canonicalQuote;
Comment on lines +114 to +117

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Refresh paid quotes that lack settlement metadata

When the cached PAID quote already has a serialized change array but still lacks the method-specific settlement field (payment_preimage for BOLT or outpoint for onchain), this branch treats it as complete and returns it without a remote refresh. The new cached-PAID fast path means mergePaidMeltQuoteSettlement never gets a chance to copy that metadata later, so the pending operation can be finalized permanently without the preimage/outpoint even though a full quote check would now provide it.

Useful? React with 👍 / 👎.

}

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,
Expand Down Expand Up @@ -430,7 +450,10 @@ export class MeltOperationService {
}
}

async finalize(operationId: string): Promise<FinalizeResult> {
async finalize(
operationId: string,
options: { canonicalQuote?: MeltQuote } = {},
): Promise<FinalizeResult> {
const releaseLock = await this.acquireOperationLock(operationId);
try {
const operation = await this.meltOperationRepository.getById(operationId);
Expand Down Expand Up @@ -459,9 +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,
});

const finalized: FinalizedMeltOperation = {
Expand Down Expand Up @@ -496,7 +524,11 @@ export class MeltOperationService {
}
}

async rollback(operationId: string, reason = 'Rolled back'): Promise<void> {
async rollback(
operationId: string,
reason = 'Rolled back',
options: { canonicalQuote?: MeltQuote } = {},
): Promise<void> {
const releaseLock = await this.acquireOperationLock(operationId);
try {
const operation = await this.meltOperationRepository.getById(operationId);
Expand Down Expand Up @@ -528,10 +560,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(
Expand Down Expand Up @@ -663,17 +698,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');
Expand Down
90 changes: 90 additions & 0 deletions packages/core/test/unit/MeltBolt11Handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -933,6 +969,60 @@ describe('MeltBolt11Handler', () => {
});
});

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'],
});
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 result = await handler.finalize({
...buildFinalizeContext(operation),
canonicalQuote: quote,
});

expect(mintAdapter.checkMeltQuote).not.toHaveBeenCalled();
expect(result).toEqual({
changeAmount: Amount.from(0),
effectiveFee: Amount.from(10),
finalizedData: { preimage: 'preimage-partial' },
});
});

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<any>).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<any>).mockImplementation(() =>
Expand Down
23 changes: 23 additions & 0 deletions packages/core/test/unit/MeltOnchainHandler.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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' });
});
});
Loading
Loading