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.
expect(columns(db, 'onchain_mint_quotes')).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')
// 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', () => {
test('getOpenReservations returns all rows; rollback restores state', () => {
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 { CashuProof } from '../services/cashu/cashuUtils'
import { generateId } from '../utils/generateId'
import { ProofReservation } from '../services/wallet/proofReservation'
import { INTERRUPTIBLE_OPERATION_TYPES, ProofReservation } from '../services/wallet/proofReservation'
export const ProofsStoreModel = types
.model('ProofsStore', {
@@ -28,6 +28,13 @@ import {
pendingByMintSecrets: types.array(types.string),
})
.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(self => ({
@@ -642,20 +649,33 @@ import {
/**
* 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
* the database. Idempotent.
*/
recoverOrphanReservations(): { recoveredCount: number } {
recoverOrphanReservations(): { recoveredCount: number; heldCount: number } {
const orphans = Database.getOpenReservations()
if (orphans.length === 0) return { recoveredCount: 0 }
if (orphans.length === 0) return { recoveredCount: 0, heldCount: 0 }
log.warn(
`[recoverOrphanReservations] Found ${orphans.length} orphan reservations — rolling back`,
`[recoverOrphanReservations] Found ${orphans.length} orphan reservations`,
)
let recoveredCount = 0
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 {
Database.rollbackReservation(orphan.id, orphan.lockedProofs)
// Mirror into MST: restore BOTH state and tId from the
@@ -669,6 +689,7 @@ import {
}
}
}
recoveredCount++
} catch (e: any) {
log.error('[recoverOrphanReservations] rollback failed', {
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)
},
}))
+8 -1
View File
@@ -745,7 +745,13 @@ export const WalletStoreModel = types
options?: {
increaseCounterBy?: number,
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) => {
reservedCounters = 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
// first mutating command, and fully on the next foreground app open.
if(!opts.skipProofs) {
const { recoveredCount } = proofsStore.recoverOrphanReservations()
if (recoveredCount > 0) {
log.warn(`[setupRootStore] Rolled back ${recoveredCount} orphan proof reservations`)
const { recoveredCount, heldCount } = proofsStore.recoverOrphanReservations()
if (recoveredCount > 0 || heldCount > 0) {
log.warn('[setupRootStore] Orphan proof reservations', {rolledBack: recoveredCount, heldForMintCheck: heldCount})
}
}
const orphansRecovered = performance.now()
+3
View File
@@ -46,6 +46,7 @@ import {
commitReservation,
rollbackReservation,
getOpenReservations,
setReservationCounters,
backfillReservationMintIds,
} from './reservationsRepo'
import {
@@ -94,6 +95,7 @@ export type {TransactionSearchFilters, NwcTransactionQuery} from './transactions
export type {
LockedProofSnapshot,
ReservationRow,
ReservationCounters,
ReservationTransactionUpdate,
} from './reservationsRepo'
export type {CounterRecord, CounterSeed} from './countersRepo'
@@ -147,6 +149,7 @@ export const Database = {
commitReservation,
rollbackReservation,
getOpenReservations,
setReservationCounters,
backfillReservationMintIds,
getCounters,
getCounter,
+10
View File
@@ -275,6 +275,16 @@ export const MIGRATIONS: Migration[] = [
[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
operationType: string
lockedProofs: LockedProofSnapshot[]
/** Counter range a swap under this reservation derived its outputs from (v36+). */
counters: ReservationCounters | null
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
* 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[] {
try {
const db = getInstance()
@@ -386,6 +427,7 @@ export const getOpenReservations = function (): ReservationRow[] {
unit: row.unit,
operationType: row.operationType,
lockedProofs,
counters: _parseCounters(row),
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
* 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 = `
id TEXT PRIMARY KEY NOT NULL,
@@ -111,6 +117,7 @@ export const RESERVATIONS_COLUMNS = `
unit TEXT NOT NULL,
operationType TEXT NOT NULL,
lockedProofs TEXT NOT NULL,
counters TEXT,
createdAt TEXT NOT NULL
`
@@ -50,7 +50,7 @@ import {
getInactiveKeysetIds,
prioritizeFromInactiveKeysets,
} from '../sendTask'
import {Database, ReservationRow} from '../../sqlite'
import {Database, ReservationCounters, ReservationRow} from '../../sqlite'
import {ProofReservation} from '../proofReservation'
import {poller} from '../../../utils/poller'
import AppError, {Err} from '../../../utils/AppError'
@@ -378,6 +378,8 @@ 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) =>
Database.setReservationCounters(reservation.id, info)
try {
sendResult = await walletStore.send(
mintUrl,
@@ -385,7 +387,7 @@ async function execute(prepared: PreparedSendData): Promise<PendingTransaction>
unit,
lockedProofs,
tx.id,
{p2pk: p2pk && p2pk.pubkey ? p2pk : undefined},
{p2pk: p2pk && p2pk.pubkey ? p2pk : undefined, onCountersReserved},
)
} catch (e: any) {
if (WalletUtils.shouldHealOutputsError(e)) {
@@ -396,7 +398,7 @@ async function execute(prepared: PreparedSendData): Promise<PendingTransaction>
unit,
lockedProofs,
tx.id,
{p2pk: p2pk && p2pk.pubkey ? p2pk : undefined, increaseCounterBy: 10},
{p2pk: p2pk && p2pk.pubkey ? p2pk : undefined, increaseCounterBy: 10, onCountersReserved},
)
} else {
// Rollback restores the reservation (proofs back to UNSPENT, tx
@@ -372,6 +372,7 @@ async function prepare(input: PrepareTransferInput): Promise<PreparedTransferDat
unit,
swapInputProofs,
transactionId,
{onCountersReserved: info => Database.setReservationCounters(swapReservation.id, info)},
)
const returnedSecrets = new Set(swapResult.returnedProofs.map(p => p.secret))
+21 -2
View File
@@ -10,8 +10,9 @@ import {MintUnit} from './currency'
* completes.
*
* 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
* `proofsStore.recoverOrphanReservations()`.
* SQLite is detected at the next startup by `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
* captured by closures, passed across MST action boundaries, and serialised
@@ -53,3 +54,21 @@ export type ProofReservation = {
*/
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',
])