mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-10-05 11:18:24 +00:00
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>
44 lines
1.9 KiB
TypeScript
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)
|
|
})
|