Files
minibits_wallet/__tests__/interruptedReservations.test.ts
minibits-cashandClaude Opus 5.5 57e8291278 Resolve unconfirmed melts in-session, block re-paying an unresolved quote
A melt whose request and follow-up quote check both failed (mint unreachable)
left its reservation open but unowned: inputs stayed locked until the next app
restart and the user saw "Lightning payment failed". The reservation is now held
and handed to the interrupted-operation resolver right away. It retries on each
performChecks until the mint answers, then rolls back (tx REVERTED) or hands over
to refresh. The user is told the payment status is unknown and the ecash is
locked until it is confirmed.

TransferOperationApi.prepare now refuses a transfer while another attempt for the
same melt quote or invoice (payment hash) is PENDING, EXECUTING, or held by the
resolver. The screen's Pay button re-uses the same quote after a failure. A mint
settles a quote once, so this could not pay twice, but after the mint rejects the
second melt, _handleExecuteError reads the shared quote state and would settle the
second attempt's never-spent inputs as SPENT. The check runs before the draft is
created and clears once the earlier attempt is resolved.

Sentry noise: the "handing to resolver" logs and the resolver's per-sweep retry
log are now warn. The thrown MintError (the AppError constructor logs it) and the
failing mint call already report each incident once.

Verified on the simulator: network cut mid-melt, retry refused, resolved to
REVERTED with the balance restored once back online.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 11:19:36 +02:00

209 lines
8.4 KiB
TypeScript

/**
* Startup orphan recovery holds interruptible reservations instead of rolling them back.
*
* A transfer's preemptive swap, an online send's swap and a melt all send the locked
* proofs to the mint while the reservation is open. If the process dies there, the
* mint may already have consumed them: rolling back to UNSPENT would show spent ecash
* as balance and abandon a swap's outputs (the 2026-09-24 incident). Those rows must
* stay open, proofs PENDING, for the resolver — everything else rolls back as before.
*
* @jest-environment node
*/
jest.mock('../src/services/nostrService', () => ({
NostrClient: {getFirstTagValue: jest.fn()},
}))
jest.mock('../src/services/logService', () => ({
log: {debug: jest.fn(), error: jest.fn(), info: jest.fn(), trace: jest.fn(), warn: jest.fn()},
}))
import {types} from 'mobx-state-tree'
import {MintsStoreModel} from '../src/models/MintsStore'
import {ProofsStoreModel} from '../src/models/ProofsStore'
import {Database} from '../src/services/db'
const TestRoot = types.model('RootStore', {
mintsStore: types.optional(MintsStoreModel, {}),
proofsStore: types.optional(ProofsStoreModel, {}),
})
const MINT_URL = 'https://mint.test'
const proof = (secret: string, amount: number) => ({
id: 'keyset1',
amount,
secret,
C: 'C' + secret,
unit: 'sat',
tId: 1,
mintUrl: MINT_URL,
state: 'UNSPENT',
})
/** A store + database holding four UNSPENT proofs, with two reservations left open. */
function crashedMidOperations() {
Database.getInstance().executeBatch([['DELETE FROM proofs'], ['DELETE FROM reservations']])
const proofs = [proof('swapIn1', 64), proof('swapIn2', 32), proof('offline1', 8), proof('free', 4)]
const root = TestRoot.create({
mintsStore: {mints: [{id: 'mint1111', mintUrl: MINT_URL, units: ['sat']}]},
proofsStore: {proofs: Object.fromEntries(proofs.map(p => [p.secret, p])) as any},
})
const {proofsStore} = root
Database.addOrUpdateProofs([...proofsStore.proofs.values()] as any, 'UNSPENT')
const opts = {mintUrl: MINT_URL, unit: 'sat' as const, rollbackTo: 'UNSPENT' as const}
const swap = proofsStore.reserve(
[proofsStore.getBySecret('swapIn1')!, proofsStore.getBySecret('swapIn2')!],
{...opts, transactionId: 20, operationType: 'transfer-swap'},
)
const offline = proofsStore.reserve([proofsStore.getBySecret('offline1')!], {
...opts,
transactionId: 21,
operationType: 'send-offline',
})
return {proofsStore, swap, offline}
}
const dbState = (secret: string) =>
Database.getInstance().execute('SELECT state FROM proofs WHERE secret = ?', [secret]).rows?.item(0)?.state
describe('recoverOrphanReservations', () => {
test('rolls back a non-interruptible orphan as before', () => {
const {proofsStore, offline} = crashedMidOperations()
proofsStore.recoverOrphanReservations()
expect(proofsStore.getBySecret('offline1')!.state).toBe('UNSPENT')
expect(dbState('offline1')).toBe('UNSPENT')
expect(Database.getOpenReservations().map(r => r.id)).not.toContain(offline.id)
})
test('holds an interruptible orphan open with its proofs PENDING', () => {
const {proofsStore, swap} = crashedMidOperations()
const result = proofsStore.recoverOrphanReservations()
expect(result).toEqual({recoveredCount: 1, heldCount: 1})
for (const secret of ['swapIn1', 'swapIn2']) {
expect(proofsStore.getBySecret(secret)!.state).toBe('PENDING')
expect(dbState(secret)).toBe('PENDING')
}
expect(Database.getOpenReservations().map(r => r.id)).toEqual([swap.id])
expect([...proofsStore.interruptedReservations.keys()]).toEqual([swap.id])
expect(proofsStore.isHeldByInterruptedOperation('swapIn1')).toBe(true)
expect(proofsStore.isHeldByInterruptedOperation('free')).toBe(false)
expect(proofsStore.isTransactionInterrupted(20)).toBe(true)
// Held proofs are not spendable.
expect(proofsStore.getMintBalance(MINT_URL)!.balances.sat).toBe(4 + 8)
})
test('is idempotent and release stops tracking', () => {
const {proofsStore, swap} = crashedMidOperations()
proofsStore.recoverOrphanReservations()
expect(proofsStore.recoverOrphanReservations()).toEqual({recoveredCount: 0, heldCount: 1})
proofsStore.releaseInterruptedReservation(swap.id)
expect(proofsStore.interruptedReservations.size).toBe(0)
})
test.each(['transfer-swap', 'transfer-melt', 'transfer-melt-after-swap', 'send-online-swap'])(
'%s is held',
operationType => {
const {proofsStore, swap} = crashedMidOperations()
Database.getInstance().execute('UPDATE reservations SET operationType = ? WHERE id = ?', [
operationType,
swap.id,
])
proofsStore.recoverOrphanReservations()
expect(proofsStore.interruptedReservations.has(swap.id)).toBe(true)
},
)
})
describe('revertAbandonedDrafts', () => {
const insertTx = (id: number, type: string, status: string, quote: string | null = null) =>
Database.getInstance().execute(
`INSERT INTO transactions (id, type, amount, fee, unit, mint, status, quote, data, createdAt)
VALUES (?, ?, 1, 0, 'sat', ?, ?, ?, ?, ?)`,
[id, type, MINT_URL, status, quote, JSON.stringify([{status: 'DRAFT'}]), new Date().toISOString()],
)
const txRow = (id: number) =>
Database.getInstance().execute('SELECT status, data FROM transactions WHERE id = ?', [id]).rows?.item(0)
function setup() {
const {proofsStore, swap} = crashedMidOperations()
Database.getInstance().execute('DELETE FROM transactions')
insertTx(40, 'TRANSFER', 'DRAFT', 'quote-40') // died after its preemptive swap committed
insertTx(41, 'TRANSFER', 'DRAFT') // Nostr invoice waiting for the user: no quote yet
insertTx(20, 'TRANSFER', 'DRAFT', 'quote-20') // owns the held transfer-swap reservation
insertTx(43, 'TOPUP', 'PREPARED', 'quote-43') // invoice issued, waiting for payment
insertTx(44, 'TRANSFER', 'EXECUTING', 'quote-44') // a mint call may have happened
insertTx(45, 'SEND', 'PREPARED')
// The swap's outputs, committed PENDING under tx 40 before the melt reserved them.
const out = proofsStore.getBySecret('free')!
out.setProp('state', 'PENDING')
out.setProp('tId', 40)
Database.addOrUpdateProofs([out], 'PENDING')
proofsStore.recoverOrphanReservations()
return {proofsStore, swap}
}
test('reverts abandoned transfers and sends, releasing their PENDING proofs', () => {
const {proofsStore} = setup()
expect(proofsStore.revertAbandonedDrafts()).toEqual({revertedCount: 2})
for (const id of [40, 45]) {
expect(txRow(id).status).toBe('REVERTED')
expect(JSON.parse(txRow(id).data).at(-1)).toMatchObject({status: 'REVERTED', interrupted: true})
}
expect(JSON.parse(txRow(40).data).at(-1).releasedAmount).toBe(4)
expect(proofsStore.getBySecret('free')!.state).toBe('UNSPENT')
expect(dbState('free')).toBe('UNSPENT')
})
test('leaves alone what may still be live or waiting', () => {
const {proofsStore} = setup()
proofsStore.revertAbandonedDrafts()
expect(txRow(41).status).toBe('DRAFT') // Nostr invoice
expect(txRow(20).status).toBe('DRAFT') // held for the resolver
expect(txRow(43).status).toBe('PREPARED') // topup
expect(txRow(44).status).toBe('EXECUTING')
expect(proofsStore.getBySecret('swapIn1')!.state).toBe('PENDING')
})
})
describe('getTransactionsByQuoteOrPaymentId', () => {
const insert = (id: number, status: string, quote: string | null, paymentId: string | null) =>
Database.getInstance().execute(
`INSERT INTO transactions (id, type, amount, fee, unit, mint, status, quote, paymentId, data, createdAt)
VALUES (?, 'TRANSFER', 1, 0, 'sat', ?, ?, ?, ?, '[]', ?)`,
[id, MINT_URL, status, quote, paymentId, new Date().toISOString()],
)
test('matches the same quote, or the same invoice under a different quote', () => {
Database.getInstance().execute('DELETE FROM transactions')
insert(50, 'ERROR', 'q1', 'hash1') // first attempt, e.g. held for the resolver
insert(51, 'PENDING', 'q2', 'hash1') // same invoice, new quote
insert(52, 'PENDING', 'q3', 'hash3') // unrelated
insert(53, 'DRAFT', 'q1', null)
const ids = (quote: string, hash?: string) =>
Database.getTransactionsByQuoteOrPaymentId(quote, hash).map(t => t.id).sort()
expect(ids('q1', 'hash1')).toEqual([50, 51, 53])
expect(ids('q1')).toEqual([50, 53]) // no payment hash (onchain): quote only
expect(ids('q9', 'hash9')).toEqual([])
})
})