Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/nice-spiders-promise.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cashu/coco": minor
---

Add changeAmount and effectiveFee to finalized melt operations for accurate settlement reporting
67 changes: 61 additions & 6 deletions packages/core/infra/handlers/MeltBolt11Handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
ExecuteContext,
ExecutionResult,
FinalizeContext,
FinalizeResult,
MeltMethodHandler,
MeltMethodMeta,
PendingCheckResult,
Expand All @@ -30,6 +31,26 @@ import {
} from './MeltBolt11Handler.utils.ts';

export class MeltBolt11Handler implements MeltMethodHandler<'bolt11'> {
// ============================================================================
// Helper Functions
// ============================================================================

/**
* Calculate change amount and effective fee from melt operation results.
* These values are derived from the actual melt settlement, not from the quote.
*
* changeAmount: Sum of amounts from change proofs returned by the mint
* effectiveFee: Actual fee paid = inputAmount - amount - changeAmount
*/
private calculateSettlementAmounts(
inputAmount: number,
meltAmount: number,
changeProofs?: SerializedBlindedSignature[],
): { changeAmount: number; effectiveFee: number } {
const changeAmount = changeProofs?.reduce((sum, p) => sum + p.amount, 0) ?? 0;
const effectiveFee = inputAmount - meltAmount - changeAmount;
return { changeAmount, effectiveFee };
}
// ============================================================================
// Prepare Phase
// ============================================================================
Expand Down Expand Up @@ -276,9 +297,16 @@ export class MeltBolt11Handler implements MeltMethodHandler<'bolt11'> {
const { mintUrl } = ctx.operation;

switch (state) {
case 'PAID':
case 'PAID': {
const { inputAmount, amount: meltAmount } = ctx.operation;
const { changeAmount, effectiveFee } = this.calculateSettlementAmounts(
inputAmount,
meltAmount,
change,
);
await this.finalizeOperation(ctx, change);
return buildPaidResult(ctx.operation);
return buildPaidResult(ctx.operation, changeAmount, effectiveFee);
}

case 'PENDING':
// Proofs stay inflight, finalize will be called later via checkPending -> finalize
Expand Down Expand Up @@ -362,9 +390,10 @@ export class MeltBolt11Handler implements MeltMethodHandler<'bolt11'> {
/**
* Finalize a pending melt operation that has succeeded.
* Called by MeltOperationService when checkPending returns 'finalize'.
* Returns settlement amounts for accurate accounting.
*/
async finalize(ctx: FinalizeContext<'bolt11'>): Promise<void> {
const { mintUrl, quoteId, id: operationId } = ctx.operation;
async finalize(ctx: FinalizeContext<'bolt11'>): Promise<FinalizeResult> {
const { mintUrl, quoteId, id: operationId, inputAmount, amount: meltAmount } = ctx.operation;

ctx.logger?.debug('Finalizing pending melt operation', { operationId, quoteId });

Expand All @@ -375,7 +404,23 @@ export class MeltBolt11Handler implements MeltMethodHandler<'bolt11'> {
throw new Error(`Cannot finalize: melt quote ${quoteId} is ${res.state}, expected PAID`);
}

// Calculate actual settlement amounts from the mint response
const { changeAmount, effectiveFee } = this.calculateSettlementAmounts(
inputAmount,
meltAmount,
res.change,
);

await this.finalizeOperation(ctx, res.change);

ctx.logger?.info('Pending melt operation finalized with settlement amounts', {
operationId,
quoteId,
changeAmount,
effectiveFee,
});

return { changeAmount, effectiveFee };
}

/**
Expand Down Expand Up @@ -508,11 +553,12 @@ export class MeltBolt11Handler implements MeltMethodHandler<'bolt11'> {
/**
* Recover an executing operation that was actually paid.
* Fetches change signatures and finalizes the operation.
* Returns execution result with actual settlement amounts.
*/
private async recoverExecutingPaidOperation(
ctx: RecoverExecutingContext<'bolt11'>,
): Promise<ExecutionResult<'bolt11'>> {
const { mintUrl, quoteId, id: operationId } = ctx.operation;
const { mintUrl, quoteId, id: operationId, inputAmount, amount: meltAmount } = ctx.operation;

ctx.logger?.debug('Recovering executing operation as paid, fetching change', {
operationId,
Expand All @@ -522,15 +568,24 @@ export class MeltBolt11Handler implements MeltMethodHandler<'bolt11'> {
// Fetch melt quote to get any change signatures
const res = await ctx.mintAdapter.checkMeltQuote(mintUrl, quoteId);

// Calculate actual settlement amounts from the mint response
const { changeAmount, effectiveFee } = this.calculateSettlementAmounts(
inputAmount,
meltAmount,
res.change,
);

// Finalize the operation (mark proofs spent, save change)
await this.finalizeOperation(ctx, res.change);

ctx.logger?.info('Recovered and finalized paid melt operation', {
operationId,
quoteId,
changeAmount,
effectiveFee,
});

return buildPaidResult(ctx.operation);
return buildPaidResult(ctx.operation, changeAmount, effectiveFee);
}

/**
Expand Down
10 changes: 9 additions & 1 deletion packages/core/infra/handlers/MeltBolt11Handler.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,18 @@ export function getSwapSendSecrets(swapOutputData: SerializedOutputData): string
*/
export function buildPaidResult<M extends MeltMethod>(
operation: ExecutingMeltOperation & MeltMethodMeta<M>,
changeAmount: number,
effectiveFee: number,
): ExecutionResult<M> {
return {
status: 'PAID',
finalized: { ...operation, state: 'finalized', updatedAt: Date.now() },
finalized: {
...operation,
state: 'finalized',
updatedAt: Date.now(),
changeAmount,
effectiveFee,
},
};
}

Expand Down
12 changes: 11 additions & 1 deletion packages/core/operations/melt/MeltMethodHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,16 @@ export interface FinalizeContext<M extends MeltMethod = MeltMethod> extends Base
operation: PendingMeltOperation & MeltMethodMeta<M>;
}

/**
* Data returned from the finalize method containing settlement information.
*/
export interface FinalizeResult {
/** Total amount returned as change by the mint */
changeAmount: number;
/** Actual fee impact after settlement */
effectiveFee: number;
}

export interface RollbackContext<M extends MeltMethod = MeltMethod> extends BaseHandlerDeps {
operation: PreparedOrLaterOperation & MeltMethodMeta<M>;
wallet: Wallet;
Expand Down Expand Up @@ -111,7 +121,7 @@ export type PendingCheckResult = 'finalize' | 'stay_pending' | 'rollback';
export interface MeltMethodHandler<M extends MeltMethod = MeltMethod> {
prepare(ctx: BasePrepareContext<M>): Promise<PreparedMeltOperation & MeltMethodMeta<M>>;
execute(ctx: ExecuteContext<M>): Promise<ExecutionResult<M>>;
finalize?(ctx: FinalizeContext<M>): Promise<void>;
finalize?(ctx: FinalizeContext<M>): Promise<FinalizeResult>;
rollback?(ctx: RollbackContext<M>): Promise<void>;
checkPending?(ctx: PendingContext<M>): Promise<PendingCheckResult>;
/**
Expand Down
18 changes: 17 additions & 1 deletion packages/core/operations/melt/MeltOperation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,10 +124,26 @@ export interface PendingMeltOperation extends MeltOperationBase, PreparedData {
}

/**
* Finalized state - sent proofs confirmed spent, operation finalized
* Finalized state - sent proofs confirmed spent, operation finalized.
* Contains actual settlement amounts after the melt is complete.
*/
export interface FinalizedMeltOperation extends MeltOperationBase, PreparedData {
state: 'finalized';

/**
* Total amount returned as change by the mint.
* This is the sum of change proofs received from the melt operation.
* May be 0 if no change was returned.
*/
changeAmount: number;

/**
* Actual fee impact after settlement.
* Calculated as: inputAmount - amount - changeAmount
* (total input proofs value - melt amount - change returned)
* This represents the actual cost paid for the melt, which may differ from fee_reserve.
*/
effectiveFee: number;
Comment on lines +137 to +146
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

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

The FinalizedMeltOperation interface now requires changeAmount and effectiveFee fields (non-optional). Existing finalized operations in storage from before this change will not have these fields. Consider either: (1) making these fields optional with changeAmount?: number, (2) adding a data migration to populate these fields for existing operations, or (3) adding fallback values when reading from storage. Without one of these approaches, loading pre-existing finalized operations will fail or have undefined values that violate the type contract.

Suggested change
*/
changeAmount: number;
/**
* Actual fee impact after settlement.
* Calculated as: inputAmount - amount - changeAmount
* (total input proofs value - melt amount - change returned)
* This represents the actual cost paid for the melt, which may differ from fee_reserve.
*/
effectiveFee: number;
*
* Optional for backward compatibility with finalized operations
* persisted before this field was introduced.
*/
changeAmount?: number;
/**
* Actual fee impact after settlement.
* Calculated as: inputAmount - amount - changeAmount
* (total input proofs value - melt amount - change returned)
* This represents the actual cost paid for the melt, which may differ from fee_reserve.
*
* Optional for backward compatibility with finalized operations
* persisted before this field was introduced.
*/
effectiveFee?: number;

Copilot uses AI. Check for mistakes.
}

/**
Expand Down
27 changes: 22 additions & 5 deletions packages/core/operations/melt/MeltOperationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
} from '../../models/Error';
import type { MintAdapter } from '@core/infra';
import type { MeltHandlerProvider } from '../../infra/handlers';
import type { FinalizeResult } from '../../infra/handlers/MeltMethodHandler';

/**
* MeltOperationService orchestrates melt sagas while delegating
Expand Down Expand Up @@ -300,7 +301,7 @@ export class MeltOperationService {
}
}

async finalize(operationId: string): Promise<void> {
async finalize(operationId: string): Promise<FinalizeResult> {
const releaseLock = await this.acquireOperationLock(operationId);
try {
const operation = await this.meltOperationRepository.getById(operationId);
Expand All @@ -309,13 +310,18 @@ export class MeltOperationService {
}
if (operation.state === 'finalized') {
this.logger?.debug('Operation already finalized', { operationId });
return;
// Return settlement amounts if already finalized
const finalizedOp = operation as FinalizedMeltOperation;
return {
changeAmount: finalizedOp.changeAmount,
effectiveFee: finalizedOp.effectiveFee,
Comment on lines +316 to +317
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

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

For backward compatibility with operations finalized before this PR, consider adding fallback values: changeAmount: finalizedOp.changeAmount ?? 0, effectiveFee: finalizedOp.effectiveFee ?? (finalizedOp as any).fee_reserve ?? 0. This ensures the return value always matches FinalizeResult even if the stored operation lacks these fields.

Suggested change
changeAmount: finalizedOp.changeAmount,
effectiveFee: finalizedOp.effectiveFee,
// Fallbacks for backward compatibility with operations finalized before changeAmount/effectiveFee were added
changeAmount: finalizedOp.changeAmount ?? 0,
effectiveFee: finalizedOp.effectiveFee ?? (finalizedOp as any).fee_reserve ?? 0,

Copilot uses AI. Check for mistakes.
};
}
if (operation.state === 'rolled_back' || operation.state === 'rolling_back') {
this.logger?.debug('Operation was rolled back or is rolling back, skipping finalization', {
operationId,
});
return;
return { changeAmount: 0, effectiveFee: 0 };
}

if (operation.state !== 'pending') {
Expand All @@ -324,7 +330,7 @@ export class MeltOperationService {

const pendingOp = operation as PendingMeltOperation;
const handler = this.handlerProvider.get(pendingOp.method);
await handler.finalize?.({
const finalizeResult = await handler.finalize?.({
...this.buildDeps(),
operation: pendingOp,
});
Expand All @@ -333,6 +339,8 @@ export class MeltOperationService {
...pendingOp,
state: 'finalized',
updatedAt: Date.now(),
changeAmount: finalizeResult?.changeAmount ?? 0,
effectiveFee: finalizeResult?.effectiveFee ?? pendingOp.fee_reserve,
};

await this.meltOperationRepository.update(finalized);
Expand All @@ -342,7 +350,16 @@ export class MeltOperationService {
operation: finalized,
});

this.logger?.info('Melt operation finalized', { operationId });
this.logger?.info('Melt operation finalized', {
operationId,
changeAmount: finalized.changeAmount,
effectiveFee: finalized.effectiveFee,
});

return {
changeAmount: finalized.changeAmount,
effectiveFee: finalized.effectiveFee,
};
} finally {
releaseLock();
}
Expand Down
41 changes: 29 additions & 12 deletions packages/core/test/unit/MeltOperationService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type {
import type {
MeltMethodHandler,
PendingCheckResult,
FinalizeResult,
} from '../../operations/melt/MeltMethodHandler.ts';
import {
UnknownMintError,
Expand Down Expand Up @@ -102,6 +103,17 @@ describe('MeltOperationService', () => {
...overrides,
});

const makeFinalizedOp = (
id: string,
overrides?: Partial<FinalizedMeltOperation>,
): FinalizedMeltOperation => ({
...makePreparedOp(id),
state: 'finalized',
changeAmount: 0,
effectiveFee: 1,
...overrides,
});

beforeEach(() => {
meltOperationRepository = new MemoryMeltOperationRepository();
proofRepository = new MemoryProofRepository();
Expand All @@ -117,12 +129,13 @@ describe('MeltOperationService', () => {
),
execute: mock(async ({ operation }) => ({
status: 'PAID',
finalized: {
...operation,
state: 'finalized',
} as FinalizedMeltOperation,
finalized: makeFinalizedOp(operation.id, {
mintUrl: operation.mintUrl,
method: operation.method,
methodData: operation.methodData,
}),
})),
finalize: mock(async () => {}),
finalize: mock(async () => ({ changeAmount: 0, effectiveFee: 1 } as FinalizeResult)),
rollback: mock(async () => {}),
checkPending: mock(async () => 'stay_pending' as PendingCheckResult),
recoverExecuting: mock(async ({ operation }) => ({
Expand Down Expand Up @@ -297,31 +310,35 @@ describe('MeltOperationService', () => {
});

describe('finalize', () => {
it('finalizes pending operation and emits event', async () => {
it('finalizes pending operation and emits event with settlement amounts', async () => {
const pending = makePendingOp('op-7');
await meltOperationRepository.create(pending);

const events: any[] = [];
eventBus.on('melt-op:finalized', (payload) => void events.push(payload));

await service.finalize('op-7');
const result = await service.finalize('op-7');

expect(handler.finalize).toHaveBeenCalled();
expect(result).toEqual({ changeAmount: 0, effectiveFee: 1 });
expect(events.length).toBe(1);
const stored = await meltOperationRepository.getById('op-7');
expect(stored?.state).toBe('finalized');
// Verify the finalized operation has the settlement amounts
const finalizedOp = stored as FinalizedMeltOperation;
expect(finalizedOp.changeAmount).toBe(0);
expect(finalizedOp.effectiveFee).toBe(1);
});

it('returns early if already finalized', async () => {
const finalized = {
...makePreparedOp('op-8'),
state: 'finalized' as const,
};
const finalized = makeFinalizedOp('op-8');
await meltOperationRepository.create(finalized);

await service.finalize('op-8');
const result = await service.finalize('op-8');

expect(handler.finalize).not.toHaveBeenCalled();
// Returns the existing settlement amounts
expect(result).toEqual({ changeAmount: 0, effectiveFee: 1 });
});
});

Expand Down