mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-10-05 11:18:24 +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.
|
||||
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.
|
||||
|
||||
@@ -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', () => {
|
||||
test('getOpenReservations returns all rows; rollback restores state', () => {
|
||||
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 { 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)
|
||||
},
|
||||
}))
|
||||
|
||||
|
||||
@@ -746,6 +746,12 @@ export const WalletStoreModel = types
|
||||
increaseCounterBy?: number,
|
||||
inFlightRequest?: InFlightRequest<SendParams>
|
||||
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)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`]],
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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',
|
||||
])
|
||||
|
||||
Reference in New Issue
Block a user