From fba0291ed2be9998aecd54c9c2c61f79cac000b4 Mon Sep 17 00:00:00 2001 From: Provokke Date: Sat, 7 Feb 2026 02:09:48 +1100 Subject: [PATCH 1/2] feat: add changeAmount and effectiveFee to finalized melt operations --- .../core/infra/handlers/MeltBolt11Handler.ts | 67 +++++++++++++++++-- .../infra/handlers/MeltBolt11Handler.utils.ts | 10 ++- .../core/operations/melt/MeltMethodHandler.ts | 12 +++- .../core/operations/melt/MeltOperation.ts | 18 ++++- .../operations/melt/MeltOperationService.ts | 27 ++++++-- .../test/unit/MeltOperationService.test.ts | 41 ++++++++---- 6 files changed, 149 insertions(+), 26 deletions(-) diff --git a/packages/core/infra/handlers/MeltBolt11Handler.ts b/packages/core/infra/handlers/MeltBolt11Handler.ts index 785603b8..38e9177e 100644 --- a/packages/core/infra/handlers/MeltBolt11Handler.ts +++ b/packages/core/infra/handlers/MeltBolt11Handler.ts @@ -4,6 +4,7 @@ import type { ExecuteContext, ExecutionResult, FinalizeContext, + FinalizeResult, MeltMethodHandler, MeltMethodMeta, PendingCheckResult, @@ -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 // ============================================================================ @@ -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 @@ -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 { - const { mintUrl, quoteId, id: operationId } = ctx.operation; + async finalize(ctx: FinalizeContext<'bolt11'>): Promise { + const { mintUrl, quoteId, id: operationId, inputAmount, amount: meltAmount } = ctx.operation; ctx.logger?.debug('Finalizing pending melt operation', { operationId, quoteId }); @@ -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 }; } /** @@ -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> { - 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, @@ -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); } /** diff --git a/packages/core/infra/handlers/MeltBolt11Handler.utils.ts b/packages/core/infra/handlers/MeltBolt11Handler.utils.ts index ed7d3aa7..cfb589e1 100644 --- a/packages/core/infra/handlers/MeltBolt11Handler.utils.ts +++ b/packages/core/infra/handlers/MeltBolt11Handler.utils.ts @@ -71,10 +71,18 @@ export function getSwapSendSecrets(swapOutputData: SerializedOutputData): string */ export function buildPaidResult( operation: ExecutingMeltOperation & MeltMethodMeta, + changeAmount: number, + effectiveFee: number, ): ExecutionResult { return { status: 'PAID', - finalized: { ...operation, state: 'finalized', updatedAt: Date.now() }, + finalized: { + ...operation, + state: 'finalized', + updatedAt: Date.now(), + changeAmount, + effectiveFee, + }, }; } diff --git a/packages/core/operations/melt/MeltMethodHandler.ts b/packages/core/operations/melt/MeltMethodHandler.ts index 36dc5801..d438faec 100644 --- a/packages/core/operations/melt/MeltMethodHandler.ts +++ b/packages/core/operations/melt/MeltMethodHandler.ts @@ -75,6 +75,16 @@ export interface FinalizeContext extends Base operation: PendingMeltOperation & MeltMethodMeta; } +/** + * 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 extends BaseHandlerDeps { operation: PreparedOrLaterOperation & MeltMethodMeta; wallet: Wallet; @@ -111,7 +121,7 @@ export type PendingCheckResult = 'finalize' | 'stay_pending' | 'rollback'; export interface MeltMethodHandler { prepare(ctx: BasePrepareContext): Promise>; execute(ctx: ExecuteContext): Promise>; - finalize?(ctx: FinalizeContext): Promise; + finalize?(ctx: FinalizeContext): Promise; rollback?(ctx: RollbackContext): Promise; checkPending?(ctx: PendingContext): Promise; /** diff --git a/packages/core/operations/melt/MeltOperation.ts b/packages/core/operations/melt/MeltOperation.ts index 2e24259a..b585b89f 100644 --- a/packages/core/operations/melt/MeltOperation.ts +++ b/packages/core/operations/melt/MeltOperation.ts @@ -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; } /** diff --git a/packages/core/operations/melt/MeltOperationService.ts b/packages/core/operations/melt/MeltOperationService.ts index 1b07e17f..6e743720 100644 --- a/packages/core/operations/melt/MeltOperationService.ts +++ b/packages/core/operations/melt/MeltOperationService.ts @@ -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 @@ -300,7 +301,7 @@ export class MeltOperationService { } } - async finalize(operationId: string): Promise { + async finalize(operationId: string): Promise { const releaseLock = await this.acquireOperationLock(operationId); try { const operation = await this.meltOperationRepository.getById(operationId); @@ -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, + }; } 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') { @@ -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, }); @@ -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); @@ -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(); } diff --git a/packages/core/test/unit/MeltOperationService.test.ts b/packages/core/test/unit/MeltOperationService.test.ts index 7dd07846..421f5a02 100644 --- a/packages/core/test/unit/MeltOperationService.test.ts +++ b/packages/core/test/unit/MeltOperationService.test.ts @@ -21,6 +21,7 @@ import type { import type { MeltMethodHandler, PendingCheckResult, + FinalizeResult, } from '../../operations/melt/MeltMethodHandler.ts'; import { UnknownMintError, @@ -102,6 +103,17 @@ describe('MeltOperationService', () => { ...overrides, }); + const makeFinalizedOp = ( + id: string, + overrides?: Partial, + ): FinalizedMeltOperation => ({ + ...makePreparedOp(id), + state: 'finalized', + changeAmount: 0, + effectiveFee: 1, + ...overrides, + }); + beforeEach(() => { meltOperationRepository = new MemoryMeltOperationRepository(); proofRepository = new MemoryProofRepository(); @@ -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 }) => ({ @@ -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 }); }); }); From a2b9ad8e8fceb7be9d5394f1638f0f6d409e5df2 Mon Sep 17 00:00:00 2001 From: Provokke Date: Sat, 7 Feb 2026 02:24:57 +1100 Subject: [PATCH 2/2] chore: add changeset for melt settlement amounts --- .changeset/nice-spiders-promise.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/nice-spiders-promise.md diff --git a/.changeset/nice-spiders-promise.md b/.changeset/nice-spiders-promise.md new file mode 100644 index 00000000..b9059907 --- /dev/null +++ b/.changeset/nice-spiders-promise.md @@ -0,0 +1,5 @@ +--- +"@cashu/coco": minor +--- + +Add changeAmount and effectiveFee to finalized melt operations for accurate settlement reporting