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>
This commit is contained in:
minibits-cash
2026-09-29 09:10:41 +02:00
co-authored by Claude Opus 5.5
parent 6f6e723185
commit fd68398f3c
7 changed files with 164 additions and 115 deletions
+16
View File
@@ -212,3 +212,19 @@ test('mint unreachable: stays held for the next sweep, mint marked OFFLINE', asy
expect(tx.update).not.toHaveBeenCalled()
expect(mockRoot.mintsStore.findByUrl(MINT_URL).status).toBe('OFFLINE')
})
test('a hold from the running process (no restart) is resolved the same way', async () => {
const {proofsStore, reservation, tx} = interrupted('send-online-swap', {start: 40, count: 3, next: 43})
// Re-open as if the swap had just failed in this process: drop the startup hold,
// then hand it over the way SendOperationApi.execute does.
proofsStore.releaseInterruptedReservation(reservation.id)
proofsStore.holdInterruptedReservation(reservation)
mintSays('UNSPENT')
await run()
expect([state('in1'), state('in2')]).toEqual(['UNSPENT', 'UNSPENT'])
expect(openRows()).toBe(0)
expect(tx.status).toBe(TransactionStatus.REVERTED)
expect(proofsStore.interruptedReservations.size).toBe(0)
})
+43
View File
@@ -0,0 +1,43 @@
/**
* 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)
})
+15 -4
View File
@@ -20,6 +20,11 @@ import {
import { generateId } from '../utils/generateId'
import { INTERRUPTIBLE_OPERATION_TYPES, ProofReservation } from '../services/wallet/proofReservation'
const _heldEntry = (r: {transactionId: number; lockedProofs: Array<{secret: string}>}) => ({
transactionId: r.transactionId,
secrets: new Set(r.lockedProofs.map(p => p.secret)),
})
export const ProofsStoreModel = types
.model('ProofsStore', {
proofs: types.optional(types.map(ProofModel), {}),
@@ -682,10 +687,7 @@ import {
let recoveredCount = 0
for (const orphan of orphans) {
if (INTERRUPTIBLE_OPERATION_TYPES.has(orphan.operationType)) {
self.interruptedReservations.set(orphan.id, {
transactionId: orphan.transactionId,
secrets: new Set(orphan.lockedProofs.map(p => p.secret)),
})
self.interruptedReservations.set(orphan.id, _heldEntry(orphan))
log.warn('[recoverOrphanReservations] Holding interrupted operation for mint check', {
id: orphan.id,
transactionId: orphan.transactionId,
@@ -764,6 +766,15 @@ import {
return { revertedCount }
},
/**
* Hand a reservation from THIS process to the resolver: its request reached
* (or may have reached) the mint, but no definitive answer came back. Left
* open with its proofs PENDING until the mint is asked what happened.
*/
holdInterruptedReservation(reservation: ProofReservation): void {
self.interruptedReservations.set(reservation.id, _heldEntry(reservation))
},
/** The resolver settled this interrupted reservation; stop tracking it. */
releaseInterruptedReservation(reservationId: string): void {
self.interruptedReservations.delete(reservationId)
@@ -1,11 +1,9 @@
import {isAlive} from 'mobx-state-tree'
import {getEncodedToken, normalizeProofAmounts} from '@cashu/cashu-ts'
import {log} from '../../logService'
import {Database} from '../../sqlite'
import {CashuUtils} from '../../cashu/cashuUtils'
import {rootStoreInstance} from '../../../models'
import {Mint} from '../../../models/Mint'
import {Proof} from '../../../models/Proof'
import {
TransactionData,
TransactionStatus,
@@ -122,96 +120,6 @@ const handleInFlightByMintTask = async (mint: Mint): Promise<WalletTaskResult> =
break
}
case TransactionType.SEND: {
// Defensive: a stale/old-format in-flight request may lack the input
// proofs (e.g. persisted by an earlier version, or request migrated to
// null). Without them the swap can't be retried — drop it so it stops
// throwing on every sweep instead of recovering.
if (!Array.isArray(inFlight.request?.proofs)) {
log.warn('[handleInFlightByMintTask] SEND in-flight request missing proofs, dropping', {
tId: tx.id,
})
break
}
// Look up the MST nodes for the persisted input proofs so
// they can be reserved (and atomically transitioned to
// SPENT below). In the common case all proofs exist and
// are PENDING (left by the original send that died). Edge
// case: a prior retry could have committed and only the
// tx update failed — then they're already SPENT and
// commit + rollback are no-ops for those entries.
const lockedInputs = inFlight.request.proofs
.map((p: {secret: string}) => proofsStore.getBySecret(p.secret))
.filter((p: Proof | undefined): p is Proof => !!p && isAlive(p))
// rollbackTo: 'preserve' keeps each input at its actual
// pre-reservation state on failure (PENDING stays PENDING,
// SPENT stays SPENT — never un-spends).
const reservation = proofsStore.reserve(lockedInputs, {
transactionId: tx.id,
mintUrl,
unit,
operationType: 'in-flight-send-retry',
rollbackTo: 'preserve',
})
try {
const {returnedProofs, proofsToSend, swapFeePaid} = await walletStore.send(
mintUrl,
inFlight.request.amount,
unit,
inFlight.request.proofs,
tx.id,
{inFlightRequest: inFlight},
)
// Pre-compute everything that needs to land
// atomically. balanceAfter: locked inputs were
// PENDING (contribute 0 to UNSPENT); marking SPENT
// changes nothing. The returnedProofs (change) are
// added as UNSPENT, raising spendable.
const outputToken = getEncodedToken({
mint: mintUrl,
proofs: normalizeProofAmounts(proofsToSend),
unit,
})
const currentSpendable = proofsStore.getUnitBalance(unit)?.unitBalance ?? 0
const sumReturnedChange = CashuUtils.getProofsAmount(returnedProofs)
const balanceAfter = currentSpendable + sumReturnedChange
txData.push({status: TransactionStatus.PENDING, createdAt: new Date()})
// ATOMIC: inputs → SPENT, change → UNSPENT,
// proofsToSend → PENDING, tx → PENDING, reservation
// row deleted — single SQLite transaction.
proofsStore.commitReservation(reservation, {
toSpent: lockedInputs,
newProofs: [
{ proofs: returnedProofs, state: 'UNSPENT', tId: tx.id },
{ proofs: proofsToSend, state: 'PENDING', tId: tx.id },
],
transactionUpdate: {
id: tx.id,
status: TransactionStatus.PENDING,
data: JSON.stringify(txData),
outputToken,
balanceAfter,
fee: swapFeePaid > 0 ? swapFeePaid : tx.fee,
},
})
} catch (sendError: any) {
// Rollback restores each input to its pre-reservation
// state (atomic with reservation row deletion).
// inFlightRequest stays in place so the next sweep
// retries this entry.
proofsStore.rollbackReservation(reservation)
throw sendError
}
break
}
case TransactionType.TOPUP: {
const proofs = await walletStore.mintProofs(
mintUrl,
@@ -328,17 +236,25 @@ const handleInFlightByMintTask = async (mint: Mint): Promise<WalletTaskResult> =
break
}
// TRANSFER / TRANSFER_ONCHAIN (melt retry)
// NO-OP — solved by syncStateWithMintTask which recovers change from
// pending-yet-paid transfers. Request params (meltPreview) is stored in
// proofsCounter.meltCounterValues, not inFlightRequests.
// SEND / TRANSFER / TRANSFER_ONCHAIN — owned by the interrupted-operation
// resolver, not by replay.
//
// Melts need no replay for the reason mints do. A lost mint RESPONSE
// strands issued ecash (the mint counts it as issued, we never see it),
// so TOPUP replays the request against the mint's NUT-19 cache. A lost
// melt response strands nothing: the money is either gone (mint paid, and
// sync recovers the change) or still ours (mint did not, and sync returns
// the proofs). Replaying a melt would risk paying twice to fix nothing.
// Their records come from swaps (an online send, a transfer's preemptive
// swap), which run under a reservation. When a swap's outcome is unknown —
// the process died, or the request failed without a mint rejection — that
// reservation is held and the resolver settles it: it asks the mint, and
// restores the outputs via NUT-09 from the counter range recorded before
// the request. That works without NUT-19, after the cache ttl, and after
// the counter was reused; a replay needs all three to hold. Held
// transactions are skipped above, and the resolver deletes their record.
//
// Melts are not replayed either: a lost melt response strands nothing the
// melt_recovery record and refresh() cannot rebuild, and replaying one
// risks paying twice.
//
// Reaching here means a record left by an older version, whose
// reservation is gone; the NUT-19 cache has expired by now, so drop it.
case TransactionType.SEND:
case TransactionType.TRANSFER:
case TransactionType.TRANSFER_ONCHAIN: {
break
@@ -378,8 +378,32 @@ async function execute(prepared: PreparedSendData): Promise<PendingTransaction>
// ── Mint call ───────────────────────────────────────────────────
const p2pk = method.method === 'p2pk' ? method.options : undefined
let sendResult: {returnedProofs: CashuProof[]; proofsToSend: CashuProof[]; swapFeePaid: number}
const onCountersReserved = (info: ReservationCounters) =>
let swapRequestSent = false
const onCountersReserved = (info: ReservationCounters) => {
swapRequestSent = true
Database.setReservationCounters(reservation.id, info)
}
/** Settle the reservation for a failed swap; returns the error to throw. */
const abortSwap = (e: any): Error => {
if (WalletUtils.isSwapOutcomeUnknown(e, swapRequestSent)) {
// The mint may have executed the swap: its outputs would exist only
// there. Hand the reservation to the resolver, which asks the mint and
// restores them from the recorded counter range.
log.error('[SendOperationApi.execute] Swap outcome unknown, handing to resolver', {
transactionId: tx.id,
error: e.message,
})
proofsStore.holdInterruptedReservation(reservation)
WalletTask.resolveInterruptedQueue()
return new MintError(
'The mint did not confirm the swap. Nothing was sent; your ecash is being checked with the mint and will be restored automatically.',
{transactionId: tx.id, caller: 'SendOperationApi.execute', cause: e.message},
)
}
// Definitive (the mint rejected it, or it never left): proofs back to UNSPENT.
proofsStore.rollbackReservation(reservation)
return e
}
try {
sendResult = await walletStore.send(
mintUrl,
@@ -390,8 +414,11 @@ async function execute(prepared: PreparedSendData): Promise<PendingTransaction>
{p2pk: p2pk && p2pk.pubkey ? p2pk : undefined, onCountersReserved},
)
} catch (e: any) {
if (WalletUtils.shouldHealOutputsError(e)) {
if (!WalletUtils.shouldHealOutputsError(e)) throw abortSwap(e)
log.error('[SendOperationApi.execute]', 'Increasing proofsCounter outdated values and repeating send.')
swapRequestSent = false
try {
sendResult = await walletStore.send(
mintUrl,
sendAmount,
@@ -400,11 +427,8 @@ async function execute(prepared: PreparedSendData): Promise<PendingTransaction>
tx.id,
{p2pk: p2pk && p2pk.pubkey ? p2pk : undefined, increaseCounterBy: 10, onCountersReserved},
)
} else {
// Rollback restores the reservation (proofs back to UNSPENT, tx
// → REVERTED via the atomic reservation rollback).
proofsStore.rollbackReservation(reservation)
throw e
} catch (e2: any) {
throw abortSwap(e2)
}
}
@@ -365,6 +365,7 @@ async function prepare(input: PrepareTransferInput): Promise<PreparedTransferDat
rollbackTo: 'UNSPENT',
})
let swapRequestSent = false
try {
const swapResult = await walletStore.send(
mintUrl,
@@ -372,7 +373,12 @@ async function prepare(input: PrepareTransferInput): Promise<PreparedTransferDat
unit,
swapInputProofs,
transactionId,
{onCountersReserved: info => Database.setReservationCounters(swapReservation.id, info)},
{
onCountersReserved: info => {
swapRequestSent = true
Database.setReservationCounters(swapReservation.id, info)
},
},
)
const returnedSecrets = new Set(swapResult.returnedProofs.map(p => p.secret))
@@ -402,6 +408,22 @@ async function prepare(input: PrepareTransferInput): Promise<PreparedTransferDat
meltFeeReserve,
})
} catch (swapError: any) {
if (WalletUtils.isSwapOutcomeUnknown(swapError, swapRequestSent)) {
// The mint may have executed the swap. Neither rolling the inputs back
// (they may be spent) nor melting them is safe: hand the reservation
// to the resolver, which asks the mint and restores the outputs from
// the recorded counter range if the swap went through.
log.error('[TransferOperationApi.prepare] Preemptive swap outcome unknown, handing to resolver', {
transactionId,
error: swapError.message,
})
proofsStore.holdInterruptedReservation(swapReservation)
WalletTask.resolveInterruptedQueue()
throw new MintError(
'The payment was not sent. The mint did not confirm an internal step; your ecash is being checked with the mint and will be restored automatically.',
{transactionId, caller: 'TransferOperationApi.prepare', cause: swapError.message},
)
}
log.warn(
'[TransferOperationApi.prepare] Preemptive swap failed, continuing with original proofs',
{error: swapError.message},
+17
View File
@@ -61,10 +61,27 @@ const isTokenPendingError = function (e: any): boolean {
return e.code === CashuErrorCode.TOKEN_PENDING
}
/**
* True when a swap failed without the mint definitively rejecting it, after the
* request may already have been sent: the mint may have executed it, and its
* outputs would then exist only at the mint.
*
* `requestSent` is whether onCountersReserved fired — cashu-ts calls it right
* before the POST, so if it never fired the request never left. A mint REJECTION
* carries a NUT-00 code (copied onto e.code by WalletStore) and is definitive; a
* timeout, dropped connection, 5xx, or a local error after the response (e.g. while
* committing it) is not.
*/
const isSwapOutcomeUnknown = function (e: any, requestSent: boolean): boolean {
if (!requestSent) return false
return !(e instanceof AppError && e.code)
}
export const WalletUtils = {
formatError,
shouldHealOutputsError,
isTokenAlreadySpentError,
isTokenPendingError,
isSwapOutcomeUnknown,
}