-
Notifications
You must be signed in to change notification settings - Fork 19
fix(core): record melt quotes before settlement #275
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 5 commits
34c16d3
eb8cd7c
4107cb5
26a9ddf
8ec4aa2
af82ae8
a6f1216
7445e8a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the cached PAID quote already has a serialized 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, | ||
|
|
@@ -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); | ||
|
|
@@ -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 = { | ||
|
|
@@ -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); | ||
|
|
@@ -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( | ||
|
|
@@ -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'); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a cached
PAIDquote does not have its settlement change serialized (for example, legacy rows or an earlier paid observation withchangeomitted), this new cached-settlement path converts the missing field to[]. Finalization then marks the melt inputs spent, skipsunblindAndSaveChangeProofs, 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 👍 / 👎.