mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-10-05 11:18:24 +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(tx.update).not.toHaveBeenCalled()
|
||||||
expect(mockRoot.mintsStore.findByUrl(MINT_URL).status).toBe('OFFLINE')
|
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 { generateId } from '../utils/generateId'
|
||||||
import { INTERRUPTIBLE_OPERATION_TYPES, ProofReservation } from '../services/wallet/proofReservation'
|
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
|
export const ProofsStoreModel = types
|
||||||
.model('ProofsStore', {
|
.model('ProofsStore', {
|
||||||
proofs: types.optional(types.map(ProofModel), {}),
|
proofs: types.optional(types.map(ProofModel), {}),
|
||||||
@@ -682,10 +687,7 @@ import {
|
|||||||
let recoveredCount = 0
|
let recoveredCount = 0
|
||||||
for (const orphan of orphans) {
|
for (const orphan of orphans) {
|
||||||
if (INTERRUPTIBLE_OPERATION_TYPES.has(orphan.operationType)) {
|
if (INTERRUPTIBLE_OPERATION_TYPES.has(orphan.operationType)) {
|
||||||
self.interruptedReservations.set(orphan.id, {
|
self.interruptedReservations.set(orphan.id, _heldEntry(orphan))
|
||||||
transactionId: orphan.transactionId,
|
|
||||||
secrets: new Set(orphan.lockedProofs.map(p => p.secret)),
|
|
||||||
})
|
|
||||||
log.warn('[recoverOrphanReservations] Holding interrupted operation for mint check', {
|
log.warn('[recoverOrphanReservations] Holding interrupted operation for mint check', {
|
||||||
id: orphan.id,
|
id: orphan.id,
|
||||||
transactionId: orphan.transactionId,
|
transactionId: orphan.transactionId,
|
||||||
@@ -764,6 +766,15 @@ import {
|
|||||||
return { revertedCount }
|
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. */
|
/** The resolver settled this interrupted reservation; stop tracking it. */
|
||||||
releaseInterruptedReservation(reservationId: string): void {
|
releaseInterruptedReservation(reservationId: string): void {
|
||||||
self.interruptedReservations.delete(reservationId)
|
self.interruptedReservations.delete(reservationId)
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import {isAlive} from 'mobx-state-tree'
|
|
||||||
import {getEncodedToken, normalizeProofAmounts} from '@cashu/cashu-ts'
|
import {getEncodedToken, normalizeProofAmounts} from '@cashu/cashu-ts'
|
||||||
import {log} from '../../logService'
|
import {log} from '../../logService'
|
||||||
import {Database} from '../../sqlite'
|
import {Database} from '../../sqlite'
|
||||||
import {CashuUtils} from '../../cashu/cashuUtils'
|
import {CashuUtils} from '../../cashu/cashuUtils'
|
||||||
import {rootStoreInstance} from '../../../models'
|
import {rootStoreInstance} from '../../../models'
|
||||||
import {Mint} from '../../../models/Mint'
|
import {Mint} from '../../../models/Mint'
|
||||||
import {Proof} from '../../../models/Proof'
|
|
||||||
import {
|
import {
|
||||||
TransactionData,
|
TransactionData,
|
||||||
TransactionStatus,
|
TransactionStatus,
|
||||||
@@ -122,96 +120,6 @@ const handleInFlightByMintTask = async (mint: Mint): Promise<WalletTaskResult> =
|
|||||||
break
|
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: {
|
case TransactionType.TOPUP: {
|
||||||
const proofs = await walletStore.mintProofs(
|
const proofs = await walletStore.mintProofs(
|
||||||
mintUrl,
|
mintUrl,
|
||||||
@@ -328,17 +236,25 @@ const handleInFlightByMintTask = async (mint: Mint): Promise<WalletTaskResult> =
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// TRANSFER / TRANSFER_ONCHAIN (melt retry)
|
// SEND / TRANSFER / TRANSFER_ONCHAIN — owned by the interrupted-operation
|
||||||
// NO-OP — solved by syncStateWithMintTask which recovers change from
|
// resolver, not by replay.
|
||||||
// pending-yet-paid transfers. Request params (meltPreview) is stored in
|
|
||||||
// proofsCounter.meltCounterValues, not inFlightRequests.
|
|
||||||
//
|
//
|
||||||
// Melts need no replay for the reason mints do. A lost mint RESPONSE
|
// Their records come from swaps (an online send, a transfer's preemptive
|
||||||
// strands issued ecash (the mint counts it as issued, we never see it),
|
// swap), which run under a reservation. When a swap's outcome is unknown —
|
||||||
// so TOPUP replays the request against the mint's NUT-19 cache. A lost
|
// the process died, or the request failed without a mint rejection — that
|
||||||
// melt response strands nothing: the money is either gone (mint paid, and
|
// reservation is held and the resolver settles it: it asks the mint, and
|
||||||
// sync recovers the change) or still ours (mint did not, and sync returns
|
// restores the outputs via NUT-09 from the counter range recorded before
|
||||||
// the proofs). Replaying a melt would risk paying twice to fix nothing.
|
// 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:
|
||||||
case TransactionType.TRANSFER_ONCHAIN: {
|
case TransactionType.TRANSFER_ONCHAIN: {
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -378,8 +378,32 @@ async function execute(prepared: PreparedSendData): Promise<PendingTransaction>
|
|||||||
// ── Mint call ───────────────────────────────────────────────────
|
// ── Mint call ───────────────────────────────────────────────────
|
||||||
const p2pk = method.method === 'p2pk' ? method.options : undefined
|
const p2pk = method.method === 'p2pk' ? method.options : undefined
|
||||||
let sendResult: {returnedProofs: CashuProof[]; proofsToSend: CashuProof[]; swapFeePaid: number}
|
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)
|
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 {
|
try {
|
||||||
sendResult = await walletStore.send(
|
sendResult = await walletStore.send(
|
||||||
mintUrl,
|
mintUrl,
|
||||||
@@ -390,8 +414,11 @@ async function execute(prepared: PreparedSendData): Promise<PendingTransaction>
|
|||||||
{p2pk: p2pk && p2pk.pubkey ? p2pk : undefined, onCountersReserved},
|
{p2pk: p2pk && p2pk.pubkey ? p2pk : undefined, onCountersReserved},
|
||||||
)
|
)
|
||||||
} catch (e: any) {
|
} 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.')
|
|
||||||
|
log.error('[SendOperationApi.execute]', 'Increasing proofsCounter outdated values and repeating send.')
|
||||||
|
swapRequestSent = false
|
||||||
|
try {
|
||||||
sendResult = await walletStore.send(
|
sendResult = await walletStore.send(
|
||||||
mintUrl,
|
mintUrl,
|
||||||
sendAmount,
|
sendAmount,
|
||||||
@@ -400,11 +427,8 @@ async function execute(prepared: PreparedSendData): Promise<PendingTransaction>
|
|||||||
tx.id,
|
tx.id,
|
||||||
{p2pk: p2pk && p2pk.pubkey ? p2pk : undefined, increaseCounterBy: 10, onCountersReserved},
|
{p2pk: p2pk && p2pk.pubkey ? p2pk : undefined, increaseCounterBy: 10, onCountersReserved},
|
||||||
)
|
)
|
||||||
} else {
|
} catch (e2: any) {
|
||||||
// Rollback restores the reservation (proofs back to UNSPENT, tx
|
throw abortSwap(e2)
|
||||||
// → REVERTED via the atomic reservation rollback).
|
|
||||||
proofsStore.rollbackReservation(reservation)
|
|
||||||
throw e
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -365,6 +365,7 @@ async function prepare(input: PrepareTransferInput): Promise<PreparedTransferDat
|
|||||||
rollbackTo: 'UNSPENT',
|
rollbackTo: 'UNSPENT',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
let swapRequestSent = false
|
||||||
try {
|
try {
|
||||||
const swapResult = await walletStore.send(
|
const swapResult = await walletStore.send(
|
||||||
mintUrl,
|
mintUrl,
|
||||||
@@ -372,7 +373,12 @@ async function prepare(input: PrepareTransferInput): Promise<PreparedTransferDat
|
|||||||
unit,
|
unit,
|
||||||
swapInputProofs,
|
swapInputProofs,
|
||||||
transactionId,
|
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))
|
const returnedSecrets = new Set(swapResult.returnedProofs.map(p => p.secret))
|
||||||
@@ -402,6 +408,22 @@ async function prepare(input: PrepareTransferInput): Promise<PreparedTransferDat
|
|||||||
meltFeeReserve,
|
meltFeeReserve,
|
||||||
})
|
})
|
||||||
} catch (swapError: any) {
|
} 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(
|
log.warn(
|
||||||
'[TransferOperationApi.prepare] Preemptive swap failed, continuing with original proofs',
|
'[TransferOperationApi.prepare] Preemptive swap failed, continuing with original proofs',
|
||||||
{error: swapError.message},
|
{error: swapError.message},
|
||||||
|
|||||||
@@ -61,10 +61,27 @@ const isTokenPendingError = function (e: any): boolean {
|
|||||||
return e.code === CashuErrorCode.TOKEN_PENDING
|
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 = {
|
export const WalletUtils = {
|
||||||
formatError,
|
formatError,
|
||||||
shouldHealOutputsError,
|
shouldHealOutputsError,
|
||||||
isTokenAlreadySpentError,
|
isTokenAlreadySpentError,
|
||||||
isTokenPendingError,
|
isTokenPendingError,
|
||||||
|
isSwapOutcomeUnknown,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user