mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-10-05 19:28:26 +00:00
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:
co-authored by
Claude Opus 5.5
parent
6f6e723185
commit
fd68398f3c
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)) {
|
||||
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<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},
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user