From fd68398f3c567cc832a6bd9ea22eca9a8e44a9ce Mon Sep 17 00:00:00 2001 From: minibits-cash Date: Tue, 29 Sep 2026 09:10:41 +0200 Subject: [PATCH] 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 --- __tests__/interruptedOperations.test.ts | 16 +++ __tests__/swapOutcomeUnknown.test.ts | 43 +++++++ src/models/ProofsStore.ts | 19 ++- .../wallet/operations/inFlightOperations.ts | 120 +++--------------- .../wallet/operations/sendOperationApi.ts | 40 ++++-- .../wallet/operations/transferOperationApi.ts | 24 +++- src/services/wallet/utils.ts | 17 +++ 7 files changed, 164 insertions(+), 115 deletions(-) create mode 100644 __tests__/swapOutcomeUnknown.test.ts diff --git a/__tests__/interruptedOperations.test.ts b/__tests__/interruptedOperations.test.ts index 1ae3915d..ee50972b 100644 --- a/__tests__/interruptedOperations.test.ts +++ b/__tests__/interruptedOperations.test.ts @@ -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) +}) diff --git a/__tests__/swapOutcomeUnknown.test.ts b/__tests__/swapOutcomeUnknown.test.ts new file mode 100644 index 00000000..e844195e --- /dev/null +++ b/__tests__/swapOutcomeUnknown.test.ts @@ -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) +}) diff --git a/src/models/ProofsStore.ts b/src/models/ProofsStore.ts index 1770cfd3..6eb4bfe9 100644 --- a/src/models/ProofsStore.ts +++ b/src/models/ProofsStore.ts @@ -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) diff --git a/src/services/wallet/operations/inFlightOperations.ts b/src/services/wallet/operations/inFlightOperations.ts index afbbd9f4..e5f295d1 100644 --- a/src/services/wallet/operations/inFlightOperations.ts +++ b/src/services/wallet/operations/inFlightOperations.ts @@ -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 = 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 = 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 diff --git a/src/services/wallet/operations/sendOperationApi.ts b/src/services/wallet/operations/sendOperationApi.ts index 253b2bda..6ebb6d7d 100644 --- a/src/services/wallet/operations/sendOperationApi.ts +++ b/src/services/wallet/operations/sendOperationApi.ts @@ -378,8 +378,32 @@ async function execute(prepared: PreparedSendData): Promise // ── 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 {p2pk: p2pk && p2pk.pubkey ? p2pk : undefined, onCountersReserved}, ) } catch (e: any) { - if (WalletUtils.shouldHealOutputsError(e)) { - log.error('[SendOperationApi.execute]', 'Increasing proofsCounter outdated values and repeating send.') + 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 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) } } diff --git a/src/services/wallet/operations/transferOperationApi.ts b/src/services/wallet/operations/transferOperationApi.ts index 109c043f..d6e9b669 100644 --- a/src/services/wallet/operations/transferOperationApi.ts +++ b/src/services/wallet/operations/transferOperationApi.ts @@ -365,6 +365,7 @@ async function prepare(input: PrepareTransferInput): Promise 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