diff --git a/__tests__/dbUpgradePath.test.ts b/__tests__/dbUpgradePath.test.ts index 541404ca..3c1d9f14 100644 --- a/__tests__/dbUpgradePath.test.ts +++ b/__tests__/dbUpgradePath.test.ts @@ -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. diff --git a/__tests__/interruptedReservations.test.ts b/__tests__/interruptedReservations.test.ts new file mode 100644 index 00000000..100e57f2 --- /dev/null +++ b/__tests__/interruptedReservations.test.ts @@ -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) + }, + ) +}) diff --git a/__tests__/proofReservation.test.ts b/__tests__/proofReservation.test.ts index ef4b92e9..323b2253 100644 --- a/__tests__/proofReservation.test.ts +++ b/__tests__/proofReservation.test.ts @@ -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() diff --git a/__tests__/swapCounterRecording.test.ts b/__tests__/swapCounterRecording.test.ts new file mode 100644 index 00000000..990b52d6 --- /dev/null +++ b/__tests__/swapCounterRecording.test.ts @@ -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 = {} +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) +}) diff --git a/src/models/ProofsStore.ts b/src/models/ProofsStore.ts index b982ba67..537031ee 100644 --- a/src/models/ProofsStore.ts +++ b/src/models/ProofsStore.ts @@ -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(), + })) // ───────────────────── 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) }, })) diff --git a/src/models/WalletStore.ts b/src/models/WalletStore.ts index e44f766c..20e0e6ff 100644 --- a/src/models/WalletStore.ts +++ b/src/models/WalletStore.ts @@ -745,7 +745,13 @@ export const WalletStoreModel = types options?: { increaseCounterBy?: number, inFlightRequest?: InFlightRequest - p2pk?: { pubkey: string; locktime?: number; refundKeys?: Array } + p2pk?: { pubkey: string; locktime?: number; refundKeys?: Array } + /** + * 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) } } ) diff --git a/src/models/helpers/setupRootStore.ts b/src/models/helpers/setupRootStore.ts index 5dba3580..75fdd5d7 100644 --- a/src/models/helpers/setupRootStore.ts +++ b/src/models/helpers/setupRootStore.ts @@ -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() diff --git a/src/services/db/index.ts b/src/services/db/index.ts index b1f0e466..4f64ef61 100644 --- a/src/services/db/index.ts +++ b/src/services/db/index.ts @@ -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, diff --git a/src/services/db/migrations.ts b/src/services/db/migrations.ts index 09987dd0..84a241fe 100644 --- a/src/services/db/migrations.ts +++ b/src/services/db/migrations.ts @@ -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`]], + }, ] /** diff --git a/src/services/db/reservationsRepo.ts b/src/services/db/reservationsRepo.ts index 28525ec2..e04aac92 100644 --- a/src/services/db/reservationsRepo.ts +++ b/src/services/db/reservationsRepo.ts @@ -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), }) } diff --git a/src/services/db/schema.ts b/src/services/db/schema.ts index 6bd0d324..003d575e 100644 --- a/src/services/db/schema.ts +++ b/src/services/db/schema.ts @@ -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 ` diff --git a/src/services/wallet/operations/sendOperationApi.ts b/src/services/wallet/operations/sendOperationApi.ts index fa6994d2..253b2bda 100644 --- a/src/services/wallet/operations/sendOperationApi.ts +++ b/src/services/wallet/operations/sendOperationApi.ts @@ -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 // ── 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 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 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 diff --git a/src/services/wallet/operations/transferOperationApi.ts b/src/services/wallet/operations/transferOperationApi.ts index d3476774..109c043f 100644 --- a/src/services/wallet/operations/transferOperationApi.ts +++ b/src/services/wallet/operations/transferOperationApi.ts @@ -372,6 +372,7 @@ async function prepare(input: PrepareTransferInput): Promise Database.setReservationCounters(swapReservation.id, info)}, ) const returnedSecrets = new Set(swapResult.returnedProofs.map(p => p.secret)) diff --git a/src/services/wallet/proofReservation.ts b/src/services/wallet/proofReservation.ts index b34dafd1..b9b66a46 100644 --- a/src/services/wallet/proofReservation.ts +++ b/src/services/wallet/proofReservation.ts @@ -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 = new Set([ + 'transfer-swap', + 'transfer-melt', + 'transfer-melt-after-swap', + 'send-online-swap', +])