Files
minibits_wallet/__tests__/swapOutcomeUnknown.test.ts
T
minibits-cashandClaude Opus 5.5 fd68398f3c Hand swaps with an unknown outcome to the resolver, drop SEND in-flight replay
A preemptive swap or online send swap that failed after its request may have
reached the mint (timeout, dropped connection, 5xx, or a local error while
handling the response) was rolled back: inputs back to UNSPENT although the mint
may have spent them, and its outputs lost. Such failures now hold the
reservation and queue the interrupted-operation resolver, which asks the mint and
restores the outputs from the recorded counter range. A definitive mint rejection,
or a request that never left, still rolls back as before.

The SEND in-flight replay is removed: every SEND record comes from an online send
swap, which the resolver now owns.

Each lifecycle phase now has exactly one owner:
In-flight replay: a lost response for RECEIVE, TOPUP or TOPUP_ONCHAIN, where no reservation exists.
Resolver: any swap or melt with an unknown outcome, whether the app is still running or was killed.
PENDING sync, refresh() and the pending queue: settlement after the mint has accepted the request.
Startup: rollback of safe reservation types, and revertAbandonedDrafts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 09:10:41 +02:00

44 lines
1.9 KiB
TypeScript

/**
* When a failed swap must be handed to the resolver instead of rolled back.
*
* Rolling back returns the inputs to UNSPENT; if the mint in fact executed the swap
* they are spent and its outputs exist only at the mint (the 2026-09-24 incident).
* Only a failure that provably never reached the mint, or that the mint definitively
* rejected, may roll back.
*
* @jest-environment node
*/
jest.mock('../src/services/logService', () => ({
log: {debug: jest.fn(), error: jest.fn(), info: jest.fn(), trace: jest.fn(), warn: jest.fn()},
}))
jest.mock('../src/services/nostrService', () => ({NostrClient: {getFirstTagValue: jest.fn()}}))
import AppError, {Err} from '../src/utils/AppError'
import {WalletUtils, CashuErrorCode} from '../src/services/wallet/utils'
const mintRejection = new AppError(Err.MINT_ERROR, 'Swap to prepare ecash to send has failed.', {
code: CashuErrorCode.TOKEN_ALREADY_SPENT,
message: 'Token already spent',
})
const networkFailure = new AppError(Err.MINT_ERROR, 'Swap to prepare ecash to send has failed.', {
message: 'Request timed out after 60000ms',
})
test('request never sent: safe to roll back, whatever the error', () => {
expect(WalletUtils.isSwapOutcomeUnknown(networkFailure, false)).toBe(false)
expect(WalletUtils.isSwapOutcomeUnknown(new Error('Not enough funds available for swap'), false)).toBe(false)
})
test('sent, and the mint rejected it with a protocol code: definitive, roll back', () => {
expect(WalletUtils.isSwapOutcomeUnknown(mintRejection, true)).toBe(false)
})
test('sent, then a timeout / dropped connection: outcome unknown', () => {
expect(WalletUtils.isSwapOutcomeUnknown(networkFailure, true)).toBe(true)
})
test('sent, then a local failure while handling the response: outcome unknown', () => {
// e.g. commitReservation throwing after the mint already executed the swap
expect(WalletUtils.isSwapOutcomeUnknown(new TypeError('Cannot read properties of undefined'), true)).toBe(true)
})