Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
7 changes: 7 additions & 0 deletions .changeset/melt-observation-before-settlement.md
Original file line number Diff line number Diff line change
@@ -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.
38 changes: 35 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,32 @@ 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;
}

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<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 +596,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 && 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`);
Expand Down Expand Up @@ -649,7 +681,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
59 changes: 56 additions & 3 deletions packages/core/operations/melt/MeltOperationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,29 @@ export class MeltOperationService {
return this.recoveryLock !== null;
}

private async resolvePendingSettlementQuote(
op: PendingMeltOperation,
canonicalQuote?: MeltQuote,
): Promise<MeltQuote> {
if (canonicalQuote) {
if (canonicalQuote.state === 'PAID' && !Array.isArray(canonicalQuote.change)) {
return this.quoteLifecycle.refreshMeltQuote(op.mintUrl, op.method, op.quoteId);
}
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' && Array.isArray(persistedQuote.change)) {
return persistedQuote;
}

return this.quoteLifecycle.refreshMeltQuote(op.mintUrl, op.method, op.quoteId);
}

async init(
mintUrl: string,
method: MeltMethod,
Expand Down Expand Up @@ -430,7 +453,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 +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 = {
Expand Down Expand Up @@ -496,7 +527,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 +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(
Expand Down Expand Up @@ -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');
Expand Down
47 changes: 47 additions & 0 deletions packages/core/quotes/QuoteLifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
101 changes: 101 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,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<any>).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<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
Loading
Loading