Handle INTERRUPTIBLE_OPERATION_TYPES that are not auto rolled back, but checked against the mint

This commit is contained in:
minibits-cash
2026-09-28 22:28:41 +02:00
parent dd44910e75
commit 7edaaa2257
14 changed files with 366 additions and 15 deletions
+2
View File
@@ -164,6 +164,8 @@ describe('upgrading an existing database (the device path)', () => {
// v33 / v34: the mint-identity columns. // v33 / v34: the mint-identity columns.
expect(columns(db, 'onchain_mint_quotes')).toContain('mintId') expect(columns(db, 'onchain_mint_quotes')).toContain('mintId')
expect(columns(db, 'reservations')).toContain('mintId') expect(columns(db, 'reservations')).toContain('mintId')
// v36: the swap output counter range.
expect(columns(db, 'reservations')).toContain('counters')
expect(columns(db, 'transactions')).toContain('mintId') expect(columns(db, 'transactions')).toContain('mintId')
// v34 also rebuilt the child tables WITHOUT their duplicated mint reference. // v34 also rebuilt the child tables WITHOUT their duplicated mint reference.
+123
View File
@@ -0,0 +1,123 @@
/**
* 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.interruptedReservationIds]).toEqual([swap.id])
// 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.interruptedReservationIds.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.interruptedReservationIds.has(swap.id)).toBe(true)
},
)
})
+30
View File
@@ -396,6 +396,36 @@ describe('Proof reservations', () => {
}) })
}) })
describe('swap counter range', () => {
test('is null until recorded, then read back with the reservation', () => {
const db = freshDb()
insertProof(db, 'sA', 100, 'UNSPENT')
openReservation(
db,
{
id: 'r8',
transactionId: 15,
mintUrl: MINT,
unit: 'sat',
operationType: 'transfer-swap',
lockedProofs: [{secret: 'sA', originalState: 'UNSPENT', originalTId: null}],
},
['sA'],
)
expect(Database.getOpenReservations()[0].counters).toBeNull()
Database.setReservationCounters('r8', {keysetId: 'keyset1', start: 42, count: 7, next: 49})
expect(Database.getOpenReservations()[0].counters).toEqual({
keysetId: 'keyset1',
start: 42,
count: 7,
next: 49,
})
})
})
describe('orphan recovery', () => { describe('orphan recovery', () => {
test('getOpenReservations returns all rows; rollback restores state', () => { test('getOpenReservations returns all rows; rollback restores state', () => {
const db = freshDb() const db = freshDb()
+79
View File
@@ -0,0 +1,79 @@
/**
* A swap's output counter range must be known BEFORE its request is sent.
*
* If the process dies (or the response is lost) after the mint executed a swap, its
* outputs exist only at the mint. The wallet advances its stored counter only on
* success, and anything else using the keyset afterwards reuses that range — so the
* range is recorded on the reservation from cashu-ts's onCountersReserved, and
* recovery restores the outputs from it (NUT-09).
*
* That only works if cashu-ts fires onCountersReserved before it POSTs /v1/swap.
* This pins that ordering against the real library, so an upgrade that moves the
* callback after the request fails here rather than silently on a device.
*
* @jest-environment node
*/
import {deriveKeysetId, getPubKeyFromPrivKey, KeyChain, Wallet} from '@cashu/cashu-ts'
import type {MintKeys, MintKeyset, OperationCounters} from '@cashu/cashu-ts'
import {bytesToHex} from '@noble/curves/utils.js'
const MINT_URL = 'https://mint.test/sat'
const AMOUNTS = [1, 2, 4, 8, 16, 32, 64]
const MINT_INFO = {
name: 'test mint',
pubkey: '02'.padEnd(66, 'a'),
version: 'test/1.0',
description: '',
contact: [],
nuts: {'4': {methods: [], disabled: false}, '5': {methods: [], disabled: false}},
} as any
const keys: Record<string, string> = {}
AMOUNTS.forEach((_, i) => {
const priv = new Uint8Array(32)
priv[31] = 0x33
priv[30] = i + 1
keys[String(AMOUNTS[i])] = bytesToHex(getPubKeyFromPrivKey(priv))
})
const keysetId = deriveKeysetId(keys, {unit: 'sat', input_fee_ppk: 0, versionByte: 0})
const meta: MintKeyset = {id: keysetId, unit: 'sat', active: true, input_fee_ppk: 0}
const mintKeys: MintKeys = {id: keysetId, unit: 'sat', keys} as MintKeys
test('onCountersReserved fires before the swap request is sent', async () => {
const events: string[] = []
let reserved: OperationCounters | undefined
const fakeMint = {
mintUrl: MINT_URL,
getInfo: jest.fn(async () => MINT_INFO),
setMintInfo: jest.fn(),
// The response never arrives — the case recovery exists for.
swap: jest.fn(async () => {
events.push('swap-request')
throw new Error('Network request failed')
}),
} as any
const wallet = new Wallet(fakeMint, {unit: 'sat', bip39seed: new Uint8Array(64).fill(7)})
wallet.loadMintFromCache(MINT_INFO, KeyChain.mintToCacheDTO(MINT_URL, [meta], [mintKeys]))
await wallet.counters.advanceToAtLeast(keysetId, 42)
const proofs = [{id: keysetId, amount: 64, secret: 'input-1', C: '02' + '11'.repeat(32)}] as any
await expect(
wallet.send(5, proofs, {
includeFees: false,
onCountersReserved: info => {
events.push('counters-reserved')
reserved = info
},
}),
).rejects.toThrow()
expect(events).toEqual(['counters-reserved', 'swap-request'])
// The range starts at the wallet's counter and covers every output of the swap.
expect(reserved).toMatchObject({keysetId, start: 42})
expect(reserved!.count).toBeGreaterThan(0)
expect(reserved!.next).toBe(42 + reserved!.count)
})
+32 -6
View File
@@ -17,7 +17,7 @@ import {
import { MintUnit } from '../services/wallet/currency' import { MintUnit } from '../services/wallet/currency'
import { CashuProof } from '../services/cashu/cashuUtils' import { CashuProof } from '../services/cashu/cashuUtils'
import { generateId } from '../utils/generateId' import { generateId } from '../utils/generateId'
import { ProofReservation } from '../services/wallet/proofReservation' import { INTERRUPTIBLE_OPERATION_TYPES, ProofReservation } from '../services/wallet/proofReservation'
export const ProofsStoreModel = types export const ProofsStoreModel = types
.model('ProofsStore', { .model('ProofsStore', {
@@ -28,6 +28,13 @@ import {
pendingByMintSecrets: types.array(types.string), pendingByMintSecrets: types.array(types.string),
}) })
.actions(withSetPropAction) .actions(withSetPropAction)
// Reservations a previous process left open mid-way through an interruptible
// operation (see INTERRUPTIBLE_OPERATION_TYPES). Collected at startup, drained by
// the interrupted-operation resolver. In memory only: the rows themselves are in
// SQLite, and the next launch rebuilds this list from them.
.volatile(() => ({
interruptedReservationIds: new Set<string>(),
}))
// ───────────────────── VIEWS ───────────────────── // ───────────────────── VIEWS ─────────────────────
.views(self => ({ .views(self => ({
@@ -642,20 +649,33 @@ import {
/** /**
* Detect orphan reservations (rows left behind by a process that died * Detect orphan reservations (rows left behind by a process that died
* before it could commit or rollback) and roll each one back. * before it could commit or rollback) and roll each one back — except
* interruptible ones, whose proofs the mint may already have consumed.
* Those stay open with their proofs PENDING and are handed to the
* interrupted-operation resolver, which asks the mint before settling.
* *
* Intended to run once at startup, after proofs have been loaded from * Intended to run once at startup, after proofs have been loaded from
* the database. Idempotent. * the database. Idempotent.
*/ */
recoverOrphanReservations(): { recoveredCount: number } { recoverOrphanReservations(): { recoveredCount: number; heldCount: number } {
const orphans = Database.getOpenReservations() const orphans = Database.getOpenReservations()
if (orphans.length === 0) return { recoveredCount: 0 } if (orphans.length === 0) return { recoveredCount: 0, heldCount: 0 }
log.warn( log.warn(
`[recoverOrphanReservations] Found ${orphans.length} orphan reservations — rolling back`, `[recoverOrphanReservations] Found ${orphans.length} orphan reservations`,
) )
let recoveredCount = 0
for (const orphan of orphans) { for (const orphan of orphans) {
if (INTERRUPTIBLE_OPERATION_TYPES.has(orphan.operationType)) {
self.interruptedReservationIds.add(orphan.id)
log.warn('[recoverOrphanReservations] Holding interrupted operation for mint check', {
id: orphan.id,
transactionId: orphan.transactionId,
operationType: orphan.operationType,
})
continue
}
try { try {
Database.rollbackReservation(orphan.id, orphan.lockedProofs) Database.rollbackReservation(orphan.id, orphan.lockedProofs)
// Mirror into MST: restore BOTH state and tId from the // Mirror into MST: restore BOTH state and tId from the
@@ -669,6 +689,7 @@ import {
} }
} }
} }
recoveredCount++
} catch (e: any) { } catch (e: any) {
log.error('[recoverOrphanReservations] rollback failed', { log.error('[recoverOrphanReservations] rollback failed', {
id: orphan.id, id: orphan.id,
@@ -677,7 +698,12 @@ import {
} }
} }
return { recoveredCount: orphans.length } return { recoveredCount, heldCount: self.interruptedReservationIds.size }
},
/** The resolver settled this interrupted reservation; stop tracking it. */
releaseInterruptedReservation(reservationId: string): void {
self.interruptedReservationIds.delete(reservationId)
}, },
})) }))
+7
View File
@@ -746,6 +746,12 @@ export const WalletStoreModel = types
increaseCounterBy?: number, increaseCounterBy?: number,
inFlightRequest?: InFlightRequest<SendParams> inFlightRequest?: InFlightRequest<SendParams>
p2pk?: { pubkey: string; locktime?: number; refundKeys?: Array<string> } p2pk?: { pubkey: string; locktime?: number; refundKeys?: Array<string> }
/**
* Receives the output counter range once cashu-ts allocates it, before
* the swap request is sent. Callers record it on their reservation so a
* swap whose response is lost can have its outputs restored (NUT-09).
*/
onCountersReserved?: (info: OperationCounters) => void
} }
) { ) {
@@ -804,6 +810,7 @@ export const WalletStoreModel = types
onCountersReserved: (info: OperationCounters) => { onCountersReserved: (info: OperationCounters) => {
reservedCounters = info reservedCounters = info
log.debug('[WalletStore.send] Counters reserved', info) log.debug('[WalletStore.send] Counters reserved', info)
options?.onCountersReserved?.(info)
} }
} }
) )
+3 -3
View File
@@ -138,9 +138,9 @@ export async function setupRootStore(rootStore: RootStore, opts: SetupRootStoreO
// this runs on demand via proofsStore.ensureProofsLoaded() before the // this runs on demand via proofsStore.ensureProofsLoaded() before the
// first mutating command, and fully on the next foreground app open. // first mutating command, and fully on the next foreground app open.
if(!opts.skipProofs) { if(!opts.skipProofs) {
const { recoveredCount } = proofsStore.recoverOrphanReservations() const { recoveredCount, heldCount } = proofsStore.recoverOrphanReservations()
if (recoveredCount > 0) { if (recoveredCount > 0 || heldCount > 0) {
log.warn(`[setupRootStore] Rolled back ${recoveredCount} orphan proof reservations`) log.warn('[setupRootStore] Orphan proof reservations', {rolledBack: recoveredCount, heldForMintCheck: heldCount})
} }
} }
const orphansRecovered = performance.now() const orphansRecovered = performance.now()
+3
View File
@@ -46,6 +46,7 @@ import {
commitReservation, commitReservation,
rollbackReservation, rollbackReservation,
getOpenReservations, getOpenReservations,
setReservationCounters,
backfillReservationMintIds, backfillReservationMintIds,
} from './reservationsRepo' } from './reservationsRepo'
import { import {
@@ -94,6 +95,7 @@ export type {TransactionSearchFilters, NwcTransactionQuery} from './transactions
export type { export type {
LockedProofSnapshot, LockedProofSnapshot,
ReservationRow, ReservationRow,
ReservationCounters,
ReservationTransactionUpdate, ReservationTransactionUpdate,
} from './reservationsRepo' } from './reservationsRepo'
export type {CounterRecord, CounterSeed} from './countersRepo' export type {CounterRecord, CounterSeed} from './countersRepo'
@@ -147,6 +149,7 @@ export const Database = {
commitReservation, commitReservation,
rollbackReservation, rollbackReservation,
getOpenReservations, getOpenReservations,
setReservationCounters,
backfillReservationMintIds, backfillReservationMintIds,
getCounters, getCounters,
getCounter, getCounter,
+10
View File
@@ -275,6 +275,16 @@ export const MIGRATIONS: Migration[] = [
[createTable('mint_keysets', MINT_KEYSETS_COLUMNS)], [createTable('mint_keysets', MINT_KEYSETS_COLUMNS)],
], ],
}, },
{
// Record the counter range a swap derives its outputs from, on the reservation
// that owns the swap. A swap whose response is lost (process killed, network
// drop) leaves its outputs signed at the mint and nowhere else; the wallet's
// counter is only advanced on success, so without this the range is unknown
// once anything else has used the keyset. Recovery restores the outputs from
// it (NUT-09). Nullable: rows opened before v36 simply have no range.
version: 36,
queries: [[`ALTER TABLE reservations ADD COLUMN counters TEXT`]],
},
] ]
/** /**
+42
View File
@@ -48,9 +48,19 @@ export type ReservationRow = {
unit: string unit: string
operationType: string operationType: string
lockedProofs: LockedProofSnapshot[] lockedProofs: LockedProofSnapshot[]
/** Counter range a swap under this reservation derived its outputs from (v36+). */
counters: ReservationCounters | null
createdAt: Date createdAt: Date
} }
/** Derivation-counter range used by a swap's outputs: [start, start + count). */
export type ReservationCounters = {
keysetId: string
start: number
count: number
next: number
}
/** /**
* Open a reservation: insert the reservation row and move the locked proofs to * Open a reservation: insert the reservation row and move the locked proofs to
* PENDING — all in a single SQLite transaction (via executeBatch). * PENDING — all in a single SQLite transaction (via executeBatch).
@@ -363,6 +373,37 @@ export const backfillReservationMintIds = function (
} }
} }
/**
* Record the counter range a swap is about to derive its outputs from. Called
* synchronously from cashu-ts's onCountersReserved, which fires after the counters
* are allocated and BEFORE the swap request is sent — so the range is durable
* even if the process dies with the request in flight.
*/
export const setReservationCounters = function (
reservationId: string,
counters: ReservationCounters,
): void {
try {
const {keysetId, start, count, next} = counters
getInstance().execute(`UPDATE reservations SET counters = ? WHERE id = ?`, [
JSON.stringify({keysetId, start, count, next}),
reservationId,
])
} catch (e: any) {
throw dbError('Could not record reservation counters', e)
}
}
const _parseCounters = function (row: any): ReservationCounters | null {
if (!row.counters) return null
try {
return JSON.parse(row.counters)
} catch {
log.warn('[getOpenReservations] Could not parse counters JSON', {id: row.id})
return null
}
}
export const getOpenReservations = function (): ReservationRow[] { export const getOpenReservations = function (): ReservationRow[] {
try { try {
const db = getInstance() const db = getInstance()
@@ -386,6 +427,7 @@ export const getOpenReservations = function (): ReservationRow[] {
unit: row.unit, unit: row.unit,
operationType: row.operationType, operationType: row.operationType,
lockedProofs, lockedProofs,
counters: _parseCounters(row),
createdAt: new Date(row.createdAt), createdAt: new Date(row.createdAt),
}) })
} }
+7
View File
@@ -102,6 +102,12 @@ export const DBVERSION_COLUMNS = `
* *
* Nullable for the same reason as onchain_mint_quotes: ALTER TABLE cannot backfill * Nullable for the same reason as onchain_mint_quotes: ALTER TABLE cannot backfill
* from MST/MMKV. See the v38 seed in setupRootStore. * from MST/MMKV. See the v38 seed in setupRootStore.
*
* `counters` (JSON {keysetId, start, count, next}) is the derivation-counter range
* a swap under this reservation used for its outputs, written BEFORE the request is
* sent. If the process dies after the mint executed the swap, those outputs exist
* only at the mint; the range is what lets recovery restore them (NUT-09). Null for
* operations that derive no outputs, and on rows opened before v36.
*/ */
export const RESERVATIONS_COLUMNS = ` export const RESERVATIONS_COLUMNS = `
id TEXT PRIMARY KEY NOT NULL, id TEXT PRIMARY KEY NOT NULL,
@@ -111,6 +117,7 @@ export const RESERVATIONS_COLUMNS = `
unit TEXT NOT NULL, unit TEXT NOT NULL,
operationType TEXT NOT NULL, operationType TEXT NOT NULL,
lockedProofs TEXT NOT NULL, lockedProofs TEXT NOT NULL,
counters TEXT,
createdAt TEXT NOT NULL createdAt TEXT NOT NULL
` `
@@ -50,7 +50,7 @@ import {
getInactiveKeysetIds, getInactiveKeysetIds,
prioritizeFromInactiveKeysets, prioritizeFromInactiveKeysets,
} from '../sendTask' } from '../sendTask'
import {Database, ReservationRow} from '../../sqlite' import {Database, ReservationCounters, ReservationRow} from '../../sqlite'
import {ProofReservation} from '../proofReservation' import {ProofReservation} from '../proofReservation'
import {poller} from '../../../utils/poller' import {poller} from '../../../utils/poller'
import AppError, {Err} from '../../../utils/AppError' import AppError, {Err} from '../../../utils/AppError'
@@ -378,6 +378,8 @@ 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) =>
Database.setReservationCounters(reservation.id, info)
try { try {
sendResult = await walletStore.send( sendResult = await walletStore.send(
mintUrl, mintUrl,
@@ -385,7 +387,7 @@ async function execute(prepared: PreparedSendData): Promise<PendingTransaction>
unit, unit,
lockedProofs, lockedProofs,
tx.id, tx.id,
{p2pk: p2pk && p2pk.pubkey ? p2pk : undefined}, {p2pk: p2pk && p2pk.pubkey ? p2pk : undefined, onCountersReserved},
) )
} catch (e: any) { } catch (e: any) {
if (WalletUtils.shouldHealOutputsError(e)) { if (WalletUtils.shouldHealOutputsError(e)) {
@@ -396,7 +398,7 @@ async function execute(prepared: PreparedSendData): Promise<PendingTransaction>
unit, unit,
lockedProofs, lockedProofs,
tx.id, tx.id,
{p2pk: p2pk && p2pk.pubkey ? p2pk : undefined, increaseCounterBy: 10}, {p2pk: p2pk && p2pk.pubkey ? p2pk : undefined, increaseCounterBy: 10, onCountersReserved},
) )
} else { } else {
// Rollback restores the reservation (proofs back to UNSPENT, tx // Rollback restores the reservation (proofs back to UNSPENT, tx
@@ -372,6 +372,7 @@ async function prepare(input: PrepareTransferInput): Promise<PreparedTransferDat
unit, unit,
swapInputProofs, swapInputProofs,
transactionId, transactionId,
{onCountersReserved: info => 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))
+21 -2
View File
@@ -10,8 +10,9 @@ import {MintUnit} from './currency'
* completes. * completes.
* *
* If the process dies between open and commit/rollback, the reservation row in * If the process dies between open and commit/rollback, the reservation row in
* SQLite is detected at the next startup and rolled back automatically by * SQLite is detected at the next startup by `proofsStore.recoverOrphanReservations()`
* `proofsStore.recoverOrphanReservations()`. * and rolled back — unless its type is in INTERRUPTIBLE_OPERATION_TYPES, in which
* case it is held open for the interrupted-operation resolver.
* *
* The shape is intentionally a plain data record (no methods) so it can be * The shape is intentionally a plain data record (no methods) so it can be
* captured by closures, passed across MST action boundaries, and serialised * captured by closures, passed across MST action boundaries, and serialised
@@ -53,3 +54,21 @@ export type ProofReservation = {
*/ */
lockedProofs: LockedProofSnapshot[] lockedProofs: LockedProofSnapshot[]
} }
/**
* Reservations whose operation sends the locked proofs to the mint while the row
* is open: a swap (transfer's preemptive swap, an online send) or a melt.
*
* If the process dies with one of these open, the mint may already have consumed
* the proofs — a blind rollback to UNSPENT would then show spent ecash as balance
* and, for a swap, abandon outputs that exist only at the mint. Startup therefore
* leaves them open (proofs stay PENDING) for the interrupted-operation resolver,
* which asks the mint what happened before settling them. Every other reservation
* type is rolled back at startup as before.
*/
export const INTERRUPTIBLE_OPERATION_TYPES: ReadonlySet<string> = new Set([
'transfer-swap',
'transfer-melt',
'transfer-melt-after-swap',
'send-online-swap',
])