mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-10-05 19:28:26 +00:00
Handle INTERRUPTIBLE_OPERATION_TYPES that are not auto rolled back, but checked against the mint
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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`]],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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',
|
||||||
|
])
|
||||||
|
|||||||
Reference in New Issue
Block a user