diff --git a/__tests__/meltChangeError.test.ts b/__tests__/meltChangeError.test.ts new file mode 100644 index 00000000..b4daf869 --- /dev/null +++ b/__tests__/meltChangeError.test.ts @@ -0,0 +1,104 @@ +/** + * The cashu-ts contract that melt-change recovery depends on (`MeltChangeError`). + * + * cashu-ts 4.10 raises `MeltChangeError` from `completeMelt` in one specific + * situation: the melt request SUCCEEDED — the mint executed the payment and the + * inputs are spent — but the NUT-08 change could not be reconstructed from the + * blank outputs. Its own docs are explicit: "The inputs are spent and the payment + * stands." It carries `outputData` and the merged `quote` precisely so the change + * can be rebuilt later. + * + * Why this file exists: `WalletStore.payLightningMelt` used to delete the melt + * recovery record for any error whose message did not mention a timeout or a + * network failure. `MeltChangeError`'s message mentions neither, so that heuristic + * deleted the record — one step before + * `TransferOperationApi._handleExecuteError` re-checks the quote, finds it PAID, + * and calls `recoverMeltQuoteChange`, which reads exactly that record. The user + * silently forfeited the change on a payment that had actually gone through. + * + * The wallet no longer message-sniffs, so what it now relies on is this error + * TYPE existing and being distinguishable. These tests pin that dependency: if + * cashu-ts v5 renames the class, drops the payload, or changes the message such + * that the old heuristic would have "worked", this fails and says why. + * + * @jest-environment node + */ +import {CTSError, MeltChangeError} from '@cashu/cashu-ts' +import type {OutputDataLike} from '@cashu/cashu-ts' + +/** Stand-ins with the right shape; nothing here needs real crypto. */ +const outputData = [ + {blindedMessage: {amount: '2', id: '00aa', B_: '02ff'}}, +] as unknown as OutputDataLike[] + +const quote = { + quote: 'quote-id-1', + amount: '21', + unit: 'sat', + state: 'PAID', +} as never + +describe('MeltChangeError is a distinguishable type', () => { + test('cashu-ts still exports it', () => { + expect(typeof MeltChangeError).toBe('function') + }) + + test('an instance is recognisable by instanceof — no message matching needed', () => { + const error = new MeltChangeError(outputData, quote) + + expect(error).toBeInstanceOf(MeltChangeError) + expect(error).toBeInstanceOf(Error) + }) + + test('it is a CTSError, so it travels the same path as other library errors', () => { + expect(new MeltChangeError(outputData, quote)).toBeInstanceOf(CTSError) + }) +}) + +describe('it carries what change recovery needs', () => { + test('outputData — the blank outputs the change proofs are rebuilt from', () => { + const error = new MeltChangeError(outputData, quote) + + expect(Array.isArray(error.outputData)).toBe(true) + expect(error.outputData).toHaveLength(1) + }) + + test('quote — merged from the preview and the mint response', () => { + const error = new MeltChangeError(outputData, quote) + + expect(error.quote).toBeDefined() + expect(error.quote.quote).toBe('quote-id-1') + }) + + test('an underlying cause is preserved for diagnosis', () => { + const cause = new Error('undefined key for amount 2') + const error = new MeltChangeError(outputData, quote, {cause}) + + expect((error as unknown as {cause?: Error}).cause).toBe(cause) + }) +}) + +describe('why the old message heuristic was wrong', () => { + // The exact predicate WalletStore.payLightningMelt used to decide whether the + // melt might still have gone through, and therefore whether to KEEP the record. + const oldHeuristicWouldKeepRecord = (e: Error) => + e.message.toLowerCase().includes('timeout') || + e.message.toLowerCase().includes('network request failed') + + test('a MeltChangeError does not look like a timeout or a network failure', () => { + const error = new MeltChangeError(outputData, quote) + + // So the old code fell through to the delete branch — for an error that means + // the payment SUCCEEDED. This assertion is the bug, pinned. + expect(oldHeuristicWouldKeepRecord(error)).toBe(false) + }) + + test('yet it is exactly the case where the record must survive', () => { + const error = new MeltChangeError(outputData, quote) + + // The payload is only useful to a reader that still has the record; the two + // are the same recovery. Keeping one and dropping the other is incoherent. + expect(error.outputData.length).toBeGreaterThan(0) + expect(error.quote).toBeDefined() + }) +}) diff --git a/src/models/WalletStore.ts b/src/models/WalletStore.ts index ada28092..1e18d59e 100644 --- a/src/models/WalletStore.ts +++ b/src/models/WalletStore.ts @@ -1336,12 +1336,25 @@ export const WalletStoreModel = types return meltResponse } catch (e: any) { - if(!e.message.toLowerCase().includes('timeout') && - !e.message.toLowerCase().includes('network request failed')) { - // remove only if it was not a timeout or network error - Database.removeMeltRecovery(transactionId) - } - + // The melt recovery record is deliberately NOT removed here. + // + // By the time completeMelt throws, the melt request may already have + // been executed by the mint — cashu-ts 4.10 makes that explicit with + // MeltChangeError, which is raised only AFTER the payment went through + // and means solely that the NUT-08 change could not be reconstructed. + // The inputs are spent and the payment stands. + // + // Callers handle exactly that: TransferOperationApi._handleExecuteError + // re-checks the quote and, when it comes back PAID, calls + // recoverMeltQuoteChange to rebuild the change from this record. Deleting + // it here — which the old `unless the message says timeout/network` test + // did for every other error, MeltChangeError included — destroyed the one + // input that recovery needs, one step before it was read, and the user + // silently forfeited the change. + // + // Removal belongs with whoever learns the quote's terminal state: + // recoverMeltQuoteChange on PAID/UNPAID, and the UNPAID paths in + // TransferOperationApi. A PENDING melt must keep it either way. let message = 'Lightning payment failed.' if (isOnionMint(mintUrl)) message += TorVPNSetupInstructions; throw new AppError( @@ -1533,11 +1546,11 @@ export const WalletStoreModel = types return meltResponse } catch (e: any) { - if(!e.message.toLowerCase().includes('timeout') && - !e.message.toLowerCase().includes('network request failed')) { - Database.removeMeltRecovery(transactionId) - } - + // Kept for the same reason as payLightningMelt: the mint may already + // have executed the melt, and the record is what change recovery reads. + // NUT-30 makes this sharper — an onchain melt is asynchronous by + // mandate, so "the request threw" says even less about whether the mint + // acted on it. let message = 'Onchain payment failed.' if (isOnionMint(mintUrl)) message += TorVPNSetupInstructions; throw new AppError( diff --git a/src/services/wallet/operations/transferOperationApi.ts b/src/services/wallet/operations/transferOperationApi.ts index c0594597..81629d14 100644 --- a/src/services/wallet/operations/transferOperationApi.ts +++ b/src/services/wallet/operations/transferOperationApi.ts @@ -923,6 +923,13 @@ async function refresh(transactionId: number): Promise { }) tx.update({status: TransactionStatus.REVERTED, data: JSON.stringify(txData)}) + // Terminal failure: no change will ever come back for this quote, so the + // melt recovery record written before submission is now dead weight. Without + // this, every async melt that resolves UNPAID left an orphaned row behind — + // the row is only ever cleaned on the PAID path (_unblindMeltChange) and by + // recoverMeltQuoteChange, neither of which this branch reaches. + Database.removeMeltRecovery(transactionId) + log.debug('[TransferOperationApi.refresh] Transaction reverted (UNPAID)', {transactionId}) EventEmitter.emit('ev_asyncMeltResult', { @@ -1080,6 +1087,13 @@ async function _handleExecuteError( } // ── UNPAID by mint ────────────────────────────────────────────────── + // The mint did not pay, so this quote will never return change to unblind and + // the melt recovery record is now dead weight. Dropped here rather than in + // WalletStore's catch, which cannot know the quote's terminal state and used to + // delete the record even when the melt had in fact succeeded. Covers all three + // exits below, since every one of them is reached only with state UNPAID. + Database.removeMeltRecovery(tx.id) + if (WalletUtils.isTokenAlreadySpentError(e)) { // Mint says one of our inputs is already spent. Sync will reconcile; // drop the reservation without restoring (proofs likely SPENT at mint).