From 14f8d1de8cc2b8fbf7758caf1b5a67485b3910ab Mon Sep 17 00:00:00 2001 From: minibits-cash Date: Fri, 5 Jun 2026 09:50:56 +0200 Subject: [PATCH] feat(inflight): move inFlightRequests to SQLite (off-MST) [M2] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relocate per-transaction in-flight request data (params for NUT-19 idempotent retry) from the MST MintProofsCounter to a dedicated SQLite table, so retries work with no MST loaded — completing the off-MST set needed for background NWC. - schema/migration v29: inflight_requests (txId PK, mintUrl, keysetId, request JSON); added to cleanAll. - inFlightRepo: add (INSERT OR REPLACE = set semantics) / get / getInFlightRequestsByMint / remove / seed (ON CONFLICT DO NOTHING). - WalletStore: write/remove via Database (receive/send/mint paths). - inFlightOperations: enumerate via a flat Database.getInFlightRequestsByMint query instead of the mint.proofsCountersWithInFlightRequests nested loop; removeInFlightRequest via Database; queue guard uses the DB count. - Mint model: remove inFlightRequests map, InFlightRequestModel, all the in-flight actions/views (counter + mint level). The InFlightRequest TYPE is kept (WalletStore option signatures). MintProofsCounter is now just {keyset, unit, counter}. migrateSnapshot strips inFlightRequests AND meltCounterValues from old snapshots. - one-time seed (rootStoreModelVersion 34->35): _runMigrations reads the raw pre-upgrade snapshot for any in-flight requests; idempotent. - tests: __tests__/inFlightRequests.test.ts. Full suite green (15 suites / 163 tests). With M1+M2, the MintProofsCounter sub-model now carries only the counter (itself SQLite-authoritative) — a candidate to collapse later. Co-Authored-By: Claude Opus 4.8 --- __tests__/inFlightRequests.test.ts | 142 ++++++++++++++++++ src/models/Mint.ts | 121 +-------------- src/models/RootStore.ts | 2 +- src/models/WalletStore.ts | 18 +-- src/models/helpers/setupRootStore.ts | 28 +++- src/services/db/inFlightRepo.ts | 120 +++++++++++++++ src/services/db/index.ts | 13 ++ src/services/db/instance.ts | 1 + src/services/db/migrations.ts | 11 +- src/services/db/schema.ts | 22 +++ .../wallet/operations/inFlightOperations.ts | 33 ++-- 11 files changed, 362 insertions(+), 149 deletions(-) create mode 100644 __tests__/inFlightRequests.test.ts create mode 100644 src/services/db/inFlightRepo.ts diff --git a/__tests__/inFlightRequests.test.ts b/__tests__/inFlightRequests.test.ts new file mode 100644 index 00000000..7cd32a9f --- /dev/null +++ b/__tests__/inFlightRequests.test.ts @@ -0,0 +1,142 @@ +/** + * In-flight request tests (inFlightRequests → SQLite migration). + * + * Per-transaction request params stored so an op whose mint response was lost + * can be retried against the mint's idempotent endpoint. add() overwrites + * (set semantics), the per-mint query drives the recovery sweep, the row is + * deleted on success/terminal failure, and the upgrade seed is idempotent. + * + * Mirrors the production SQL against node:sqlite (the native driver needs a + * device), like meltRecovery.test.ts. + * + * @jest-environment node + */ +import {DatabaseSync} from 'node:sqlite' + +const NOW = '2026-06-05T00:00:00.000Z' + +const CREATE_INFLIGHT = `CREATE TABLE inflight_requests ( + transactionId INTEGER PRIMARY KEY NOT NULL, + mintUrl TEXT, + keysetId TEXT, + request TEXT NOT NULL, + createdAt TEXT +)` + +const MINT = 'https://mint.test' + +// ── Mirrored repo primitives (exact production SQL) ───────────────────────── + +function addInFlightRequest( + db: DatabaseSync, + transactionId: number, + mintUrl: string | null, + keysetId: string | null, + request: object, +) { + db.prepare( + `INSERT OR REPLACE INTO inflight_requests (transactionId, mintUrl, keysetId, request, createdAt) + VALUES (?, ?, ?, ?, ?)`, + ).run(transactionId, mintUrl, keysetId, JSON.stringify(request), NOW) +} + +function getInFlightRequest(db: DatabaseSync, transactionId: number) { + const row = db + .prepare(`SELECT transactionId, mintUrl, keysetId, request, createdAt FROM inflight_requests WHERE transactionId = ?`) + .get(transactionId) as {transactionId: number; mintUrl: string | null; keysetId: string | null; request: string; createdAt: string | null} | undefined + if (!row) return undefined + return {...row, request: JSON.parse(row.request)} +} + +function getInFlightRequestsByMint(db: DatabaseSync, mintUrl: string) { + const rows = db + .prepare(`SELECT transactionId, mintUrl, keysetId, request, createdAt FROM inflight_requests WHERE mintUrl = ?`) + .all(mintUrl) as Array<{transactionId: number; mintUrl: string | null; keysetId: string | null; request: string; createdAt: string | null}> + return rows.map(r => ({...r, request: JSON.parse(r.request)})) +} + +function removeInFlightRequest(db: DatabaseSync, transactionId: number) { + db.prepare(`DELETE FROM inflight_requests WHERE transactionId = ?`).run(transactionId) +} + +function seedInFlightRequest( + db: DatabaseSync, + transactionId: number, + mintUrl: string | null, + keysetId: string | null, + request: object, +) { + db.prepare( + `INSERT INTO inflight_requests (transactionId, mintUrl, keysetId, request, createdAt) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(transactionId) DO NOTHING`, + ).run(transactionId, mintUrl, keysetId, JSON.stringify(request), NOW) +} + +function freshDb(): DatabaseSync { + const db = new DatabaseSync(':memory:') + db.exec(CREATE_INFLIGHT) + return db +} + +// ── Tests ─────────────────────────────────────────────────────────────────── + +describe('In-flight requests (inflight_requests)', () => { + test('stores and reads back a request (JSON round-trip)', () => { + const db = freshDb() + const request = {token: 'cashuA...', options: {keysetId: 'k1'}} + + addInFlightRequest(db, 101, MINT, 'k1', request) + const rec = getInFlightRequest(db, 101)! + + expect(rec.transactionId).toBe(101) + expect(rec.mintUrl).toBe(MINT) + expect(rec.keysetId).toBe('k1') + expect(rec.request).toEqual(request) + db.close() + }) + + test('returns undefined when no entry exists', () => { + const db = freshDb() + expect(getInFlightRequest(db, 999)).toBeUndefined() + db.close() + }) + + test('add OVERWRITES an existing entry (set semantics)', () => { + const db = freshDb() + addInFlightRequest(db, 101, MINT, 'k1', {v: 'first'}) + addInFlightRequest(db, 101, MINT, 'k1', {v: 'second'}) + + expect(getInFlightRequest(db, 101)!.request).toEqual({v: 'second'}) + db.close() + }) + + test('getInFlightRequestsByMint returns all rows for a mint', () => { + const db = freshDb() + addInFlightRequest(db, 101, MINT, 'k1', {v: 1}) + addInFlightRequest(db, 102, MINT, 'k1', {v: 2}) + addInFlightRequest(db, 103, 'https://other.test', 'k9', {v: 3}) + + const forMint = getInFlightRequestsByMint(db, MINT) + expect(forMint.map(r => r.transactionId).sort()).toEqual([101, 102]) + expect(getInFlightRequestsByMint(db, 'https://other.test')).toHaveLength(1) + db.close() + }) + + test('remove deletes the entry', () => { + const db = freshDb() + addInFlightRequest(db, 101, MINT, 'k1', {v: 1}) + removeInFlightRequest(db, 101) + expect(getInFlightRequest(db, 101)).toBeUndefined() + expect(getInFlightRequestsByMint(db, MINT)).toHaveLength(0) + db.close() + }) + + test('seed is idempotent — does not overwrite an existing entry', () => { + const db = freshDb() + addInFlightRequest(db, 101, MINT, 'k1', {v: 'live'}) + seedInFlightRequest(db, 101, MINT, 'k1', {v: 'snapshot'}) + expect(getInFlightRequest(db, 101)!.request).toEqual({v: 'live'}) + db.close() + }) +}) diff --git a/src/models/Mint.ts b/src/models/Mint.ts index 877e7e5f..0817d062 100644 --- a/src/models/Mint.ts +++ b/src/models/Mint.ts @@ -43,46 +43,15 @@ export type InFlightRequest = { request: TRequest } -const InFlightRequestModel = types.model('InFlightRequest', { - transactionId: types.number, - request: types.frozen(), // or replace `any` with your actual request type -}) - // === Migration function === +// inFlightRequests and meltCounterValues moved to SQLite (inflight_requests / +// melt_recovery tables). Strip both from any old snapshot so applySnapshot does +// not choke on the removed fields. A MintProofsCounter snapshot is now just +// {keyset, unit, counter}. const migrateSnapshot = (snapshot: any): any => { if (!snapshot) return snapshot - - // 1. Convert old inFlightRequests array → map (if needed) - if (Array.isArray(snapshot.inFlightRequests)) { - const oldArray = snapshot.inFlightRequests as Array<{ transactionId: number; request: any }> - const newMap: Record = {} - - oldArray.forEach(item => { - if (item && typeof item.transactionId === 'number') { - newMap[item.transactionId.toString()] = { - transactionId: item.transactionId, - request: item.request ?? null, - } - } - }) - - snapshot = { - ...snapshot, - inFlightRequests: newMap, - } - } else if (snapshot.inFlightRequests == null) { - // Ensure it's an object (empty map) - snapshot = { ...snapshot, inFlightRequests: {} } - } - - // 2. meltCounterValues moved to SQLite (melt_recovery table). Strip it from - // any old snapshot so applySnapshot doesn't choke on the removed field. - if (snapshot.meltCounterValues !== undefined) { - const {meltCounterValues, ...rest} = snapshot - snapshot = rest - } - - return snapshot + const {inFlightRequests, meltCounterValues, ...rest} = snapshot + return rest } @@ -139,45 +108,9 @@ export const MintProofsCounterModel = types keyset: types.string, unit: types.optional(types.frozen(), 'sat'), counter: types.optional(types.number, 0), - - // In-flight mint requests - inFlightRequests: types.map(InFlightRequestModel), }) .preProcessSnapshot(migrateSnapshot) .actions(self => ({ - // === In-flight mint requests (unchanged) === - addInFlightRequest(transactionId: number, request: any) { - self.inFlightRequests.set(transactionId.toString(), { - transactionId, - request, - }) - log.trace('[addInFlightRequest]', { transactionId, request }) - }, - - removeInFlightRequest(transactionId: number) { - if (!isAlive(self)) { - log.error('[removeInFlightRequest]', 'ProofsCounter is not alive') - return - } - const key = transactionId.toString() - if (self.inFlightRequests.has(key)) { - self.inFlightRequests.delete(key) - log.trace('[removeInFlightRequest]', { transactionId }) - } - }, - - clearAllInFlightRequests() { - if (!isAlive(self)) { - log.error('[clearAllInFlightRequests]', 'ProofsCounter is not alive') - return - } - const count = self.inFlightRequests.size - if (count > 0) { - self.inFlightRequests.clear() - log.info('[clearAllInFlightRequests]', `Cleared ${count} in-flight request(s)`) - } - }, - // === Counter mutations (write through to the SQLite authority) === increaseProofsCounter(numberOfProofs: number) { self.counter += numberOfProofs @@ -207,21 +140,6 @@ export const MintProofsCounterModel = types } }, })) - .views(self => ({ - // === In-flight requests === - inFlightRequestExists(transactionId: number): boolean { - return self.inFlightRequests.has(transactionId.toString()) - }, - getInFlightRequest(transactionId: number): InFlightRequest | undefined { - return self.inFlightRequests.get(transactionId.toString()) - }, - get inFlightRequestCount(): number { - return self.inFlightRequests.size - }, - get allInFlightRequests(): Instance[] { - return Array.from(self.inFlightRequests.values()) - }, - })) // The derivation counter is mastered in SQLite (mint_counters), hydrated // into this model as an in-memory cache on startup/resume. Strip it from // every persisted snapshot so the MMKV whole-tree save can never write a @@ -590,35 +508,8 @@ export const MintModel = types log.trace('[getMintFeeReserve]', {feeReserve}) return feeReserve }, - removeAllInFlightRequests() { - log.trace('[removeAllInFlightRequests] Removing all inFlight requests', {mintUrl: self.mintUrl}) - for(const counter of self.proofsCounters) { - counter.clearAllInFlightRequests() - } - }, })) .views(self => ({ - findInFlightRequestByTId: (transactionId: number) => { - const inFlightCounters = self.proofsCounters.filter(c => c.inFlightRequests && c.inFlightRequests.size > 0) - let inFlightRequest: InFlightRequest | undefined = undefined - - for (const counter of inFlightCounters) { - const request = counter.getInFlightRequest(transactionId) - if(request) { - inFlightRequest = request - break - } - } - - return inFlightRequest - }, - get proofsCountersWithInFlightRequests() { - const counters = self.proofsCounters.filter(c => c.inFlightRequests && c.inFlightRequests.size > 0) - return counters || [] - }, - get allInFlightRequests() { - return self.proofsCounters.flatMap((counter) => counter.allInFlightRequests) - }, get balances(): MintBalance | undefined { const mintBalance: MintBalance | undefined = getRootStore(self).proofsStore.getMintBalance(self.mintUrl) return mintBalance diff --git a/src/models/RootStore.ts b/src/models/RootStore.ts index d72d8b0f..6f0ab7c9 100644 --- a/src/models/RootStore.ts +++ b/src/models/RootStore.ts @@ -11,7 +11,7 @@ import {NwcStoreModel} from './NwcStore' import {AuthStoreModel} from './AuthStore' import { log } from '../services' -export const rootStoreModelVersion = 34 // Update this if model changes require migrations defined in setupRootStore.ts +export const rootStoreModelVersion = 35 // Update this if model changes require migrations defined in setupRootStore.ts /** * A RootStore model. */ diff --git a/src/models/WalletStore.ts b/src/models/WalletStore.ts index 3e51d1f6..386475e8 100644 --- a/src/models/WalletStore.ts +++ b/src/models/WalletStore.ts @@ -476,7 +476,7 @@ export const WalletStoreModel = types // @ts-ignore if(cashuWallet.getMintInfo().nuts['19'] && !options?.inFlightRequest) { - currentCounter.addInFlightRequest(transactionId, receiveParams) + Database.addInFlightRequest(transactionId, mintUrl, cashuWallet.keysetId, receiveParams) } let reservedCounters: OperationCounters | undefined @@ -495,7 +495,7 @@ export const WalletStoreModel = types log.trace('[WalletStore.receive]', {proofs}) - currentCounter.removeInFlightRequest(transactionId) + Database.removeInFlightRequest(transactionId) // Update our counter to match what the wallet used (v3.x) if (reservedCounters) { @@ -524,7 +524,7 @@ export const WalletStoreModel = types if(!e.message.toLowerCase().includes('timeout') && !e.message.toLowerCase().includes('network request failed')) { // remove in-flight request only if it was not a timeout or network error - currentCounter.removeInFlightRequest(transactionId) + Database.removeInFlightRequest(transactionId) } throw new AppError( Err.MINT_ERROR, @@ -591,7 +591,7 @@ export const WalletStoreModel = types // @ts-ignore if(cashuWallet.getMintInfo().nuts['19'] && !options?.inFlightRequest) { - currentCounter.addInFlightRequest(transactionId, sendParams) + Database.addInFlightRequest(transactionId, mintUrl, cashuWallet.keysetId, sendParams) } let reservedCounters: OperationCounters | undefined @@ -610,7 +610,7 @@ export const WalletStoreModel = types } ) - currentCounter.removeInFlightRequest(transactionId) + Database.removeInFlightRequest(transactionId) // Update our counter to match what the wallet used (v3.x) if (reservedCounters) { @@ -642,7 +642,7 @@ export const WalletStoreModel = types if(!e.message.toLowerCase().includes('timeout') && !e.message.toLowerCase().includes('network request failed')) { // remove in-flight request only if it was not a timeout or network error - currentCounter.removeInFlightRequest(transactionId) + Database.removeInFlightRequest(transactionId) } let message = 'Swap to prepare ecash to send has failed.' @@ -829,7 +829,7 @@ export const WalletStoreModel = types // @ts-ignore if(cashuWallet.getMintInfo().nuts['19'] && !options?.inFlightRequest) { - currentCounter.addInFlightRequest(transactionId, mintParams) + Database.addInFlightRequest(transactionId, mintUrl, cashuWallet.keysetId, mintParams) } let reservedCounters: OperationCounters | undefined @@ -848,7 +848,7 @@ export const WalletStoreModel = types } ) - currentCounter.removeInFlightRequest(transactionId) + Database.removeInFlightRequest(transactionId) // Update our counter to match what the wallet used (v3.x) if (reservedCounters) { @@ -869,7 +869,7 @@ export const WalletStoreModel = types if(!e.message.toLowerCase().includes('timeout') && !e.message.toLowerCase().includes('network request failed')) { // remove in-flight request only if it was not a timeout or network error - currentCounter.removeInFlightRequest(transactionId) + Database.removeInFlightRequest(transactionId) } let message = 'Error on request to mint new ecash.' diff --git a/src/models/helpers/setupRootStore.ts b/src/models/helpers/setupRootStore.ts index 461e1fad..06122eec 100644 --- a/src/models/helpers/setupRootStore.ts +++ b/src/models/helpers/setupRootStore.ts @@ -17,7 +17,7 @@ import { import * as Sentry from '@sentry/react-native' import type { RootStore } from '../RootStore' import { Database, MMKVStorage } from '../../services' -import type { MeltRecoverySeed } from '../../services/db' +import type { MeltRecoverySeed, InFlightRequestSeed } from '../../services/db' import { log } from '../../services/logService' import { rootStoreModelVersion } from '../RootStore' import AppError, { Err } from '../../utils/AppError' @@ -198,6 +198,32 @@ async function _runMigrations(rootStore: RootStore, restoredState: any) { } } + if(currentVersion < 35) { + // inFlightRequests moved to SQLite (inflight_requests). Same as the + // melt seed above: read from the RAW pre-upgrade snapshot to carry + // over any request in-flight at upgrade time (usually none). Idempotent. + const seeds: InFlightRequestSeed[] = [] + for (const mint of restoredState?.mintsStore?.mints ?? []) { + for (const counter of mint?.proofsCounters ?? []) { + const ifr = counter?.inFlightRequests ?? {} + for (const key of Object.keys(ifr)) { + const entry = ifr[key] + if (entry?.request && typeof entry.transactionId === 'number') { + seeds.push({ + transactionId: entry.transactionId, + mintUrl: mint.mintUrl, + keysetId: counter.keyset, + request: entry.request, + }) + } + } + } + } + if (seeds.length > 0) { + Database.seedInFlightRequests(seeds) + } + } + // Set once, after all steps succeed: if any step throws, the version is // NOT bumped and the whole migration retries on the next launch. rootStore.setVersion(rootStoreModelVersion) diff --git a/src/services/db/inFlightRepo.ts b/src/services/db/inFlightRepo.ts new file mode 100644 index 00000000..db1003b7 --- /dev/null +++ b/src/services/db/inFlightRepo.ts @@ -0,0 +1,120 @@ +import {getInstance} from './instance' +import {dbError} from './errors' +import {log} from '../logService' + +// ───────────────────────────────────────────────────────────────────────────── +// In-flight mint/swap requests. +// +// Per-transaction request params for an operation that has hit the mint but +// whose response may be lost (network failure). Written before the network call +// so the op can be safely retried against the mint's idempotent (NUT-19 cached) +// endpoint. Previously held on the MST MintProofsCounter; moved here so retries +// work with no MST loaded (off-MST background). +// +// A row exists only while a request is in-flight; it is deleted on success or +// terminal failure. Keyed by transactionId. +// ───────────────────────────────────────────────────────────────────────────── + +export type InFlightRequestRecord = { + transactionId: number + mintUrl: string | null + keysetId: string | null + request: any + createdAt: string | null +} + +/** A single in-flight entry for the one-time seed from the MST/MMKV snapshot. */ +export type InFlightRequestSeed = { + transactionId: number + mintUrl?: string + keysetId?: string + request: any +} + +const rowToRecord = (row: any): InFlightRequestRecord => ({ + transactionId: row.transactionId, + mintUrl: row.mintUrl, + keysetId: row.keysetId, + request: JSON.parse(row.request), + createdAt: row.createdAt, +}) + +/** + * Store (or replace) the in-flight request for a transaction. Overwrites an + * existing row — matching the previous addInFlightRequest set() semantics. + */ +export const addInFlightRequest = function ( + transactionId: number, + mintUrl: string | undefined, + keysetId: string | undefined, + request: any, +): void { + try { + getInstance().execute( + `INSERT OR REPLACE INTO inflight_requests (transactionId, mintUrl, keysetId, request, createdAt) + VALUES (?, ?, ?, ?, ?)`, + [transactionId, mintUrl ?? null, keysetId ?? null, JSON.stringify(request), new Date().toISOString()], + ) + } catch (e: any) { + throw dbError('In-flight request could not be saved to the database', e) + } +} + +/** Read the in-flight request for a transaction, or undefined. */ +export const getInFlightRequest = function (transactionId: number): InFlightRequestRecord | undefined { + try { + const {rows} = getInstance().execute( + `SELECT transactionId, mintUrl, keysetId, request, createdAt FROM inflight_requests WHERE transactionId = ?`, + [transactionId], + ) + const row = rows?.item(0) + return row ? rowToRecord(row) : undefined + } catch (e: any) { + throw dbError('In-flight request could not be retrieved from the database', e) + } +} + +/** All in-flight requests for a mint (drives the per-mint recovery sweep). */ +export const getInFlightRequestsByMint = function (mintUrl: string): InFlightRequestRecord[] { + try { + const {rows} = getInstance().execute( + `SELECT transactionId, mintUrl, keysetId, request, createdAt FROM inflight_requests WHERE mintUrl = ?`, + [mintUrl], + ) + return (rows?._array ?? []).map(rowToRecord) + } catch (e: any) { + throw dbError('In-flight requests could not be retrieved from the database', e) + } +} + +/** Delete the in-flight request for a transaction (success/terminal failure). */ +export const removeInFlightRequest = function (transactionId: number): void { + try { + getInstance().execute(`DELETE FROM inflight_requests WHERE transactionId = ?`, [transactionId]) + } catch (e: any) { + throw dbError('In-flight request could not be removed from the database', e) + } +} + +/** + * One-time, idempotent copy of MST/MMKV-resident in-flight requests into SQLite. + * Used by the upgrade migration to carry over a request in-flight at upgrade. + */ +export const seedInFlightRequests = function (seeds: InFlightRequestSeed[]): {seeded: number} { + if (!seeds || seeds.length === 0) return {seeded: 0} + try { + const now = new Date().toISOString() + getInstance().executeBatch( + seeds.map(s => [ + `INSERT INTO inflight_requests (transactionId, mintUrl, keysetId, request, createdAt) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(transactionId) DO NOTHING`, + [s.transactionId, s.mintUrl ?? null, s.keysetId ?? null, JSON.stringify(s.request), now], + ]), + ) + log.info('[seedInFlightRequests]', 'Seeded in-flight requests into SQLite', {count: seeds.length}) + return {seeded: seeds.length} + } catch (e: any) { + throw dbError('In-flight requests could not be seeded into the database', e) + } +} diff --git a/src/services/db/index.ts b/src/services/db/index.ts index 8b0c729a..fd8961dc 100644 --- a/src/services/db/index.ts +++ b/src/services/db/index.ts @@ -57,6 +57,13 @@ import { removeMeltRecovery, seedMeltRecoveries, } from './meltRecoveryRepo' +import { + addInFlightRequest, + getInFlightRequest, + getInFlightRequestsByMint, + removeInFlightRequest, + seedInFlightRequests, +} from './inFlightRepo' export type {TransactionSearchFilters} from './transactionsRepo' export type { @@ -66,6 +73,7 @@ export type { } from './reservationsRepo' export type {CounterRecord, CounterSeed} from './countersRepo' export type {MeltRecoveryRecord, MeltRecoverySeed} from './meltRecoveryRepo' +export type {InFlightRequestRecord, InFlightRequestSeed} from './inFlightRepo' export const Database = { getInstance, @@ -111,4 +119,9 @@ export const Database = { getMeltRecovery, removeMeltRecovery, seedMeltRecoveries, + addInFlightRequest, + getInFlightRequest, + getInFlightRequestsByMint, + removeInFlightRequest, + seedInFlightRequests, } diff --git a/src/services/db/instance.ts b/src/services/db/instance.ts index 5c99dcab..b134f07c 100644 --- a/src/services/db/instance.ts +++ b/src/services/db/instance.ts @@ -64,6 +64,7 @@ export const cleanAll = function () { ['DROP TABLE IF EXISTS reservations'], ['DROP TABLE IF EXISTS mint_counters'], ['DROP TABLE IF EXISTS melt_recovery'], + ['DROP TABLE IF EXISTS inflight_requests'], ] as SQLBatchTuple[] try { diff --git a/src/services/db/migrations.ts b/src/services/db/migrations.ts index 41463bee..66613f6d 100644 --- a/src/services/db/migrations.ts +++ b/src/services/db/migrations.ts @@ -1,10 +1,10 @@ import {DbConnection, SQLBatchTuple} from './connection' -import {createTable, PROOFS_COLUMNS, PROOFS_COLUMN_NAMES, RESERVATIONS_COLUMNS, MINT_COUNTERS_COLUMNS, MELT_RECOVERY_COLUMNS} from './schema' +import {createTable, PROOFS_COLUMNS, PROOFS_COLUMN_NAMES, RESERVATIONS_COLUMNS, MINT_COUNTERS_COLUMNS, MELT_RECOVERY_COLUMNS, INFLIGHT_REQUESTS_COLUMNS} from './schema' import {dbError} from './errors' import {log} from '../logService' /** Bump this when a schema change requires a migration, then add an entry below. */ -export const _dbVersion = 28 +export const _dbVersion = 29 type Migration = {version: number; queries: SQLBatchTuple[]} @@ -86,6 +86,13 @@ const MIGRATIONS: Migration[] = [ version: 28, queries: [[createTable('melt_recovery', MELT_RECOVERY_COLUMNS)]], }, + { + // Add per-transaction in-flight request table. Empty on creation; any + // in-flight requests from the MST/MMKV snapshot are copied by a one-time JS + // seed (see setupRootStore._runMigrations). + version: 29, + queries: [[createTable('inflight_requests', INFLIGHT_REQUESTS_COLUMNS)]], + }, ] /** diff --git a/src/services/db/schema.ts b/src/services/db/schema.ts index 926ddb0c..e9a0fb76 100644 --- a/src/services/db/schema.ts +++ b/src/services/db/schema.ts @@ -109,6 +109,25 @@ export const MELT_RECOVERY_COLUMNS = ` createdAt TEXT ` +/** + * In-flight mint/swap request data for idempotent retry (NUT-19). + * + * Holds the op's request params per transaction, written before the network + * call so a lost response can be safely retried against the mint's cached + * (idempotent) endpoint. Previously kept on the MST MintProofsCounter; moved + * here so retries work with no MST loaded (off-MST background). + * + * Keyed by transactionId. A row exists only while a request is in-flight; it is + * deleted on success or terminal failure. + */ +export const INFLIGHT_REQUESTS_COLUMNS = ` + transactionId INTEGER PRIMARY KEY NOT NULL, + mintUrl TEXT, + keysetId TEXT, + request TEXT NOT NULL, + createdAt TEXT +` + /** Build a CREATE TABLE statement from a column block. */ export const createTable = ( name: string, @@ -135,4 +154,7 @@ export const createSchemaQueries: SQLBatchTuple[] = [ // Per-transaction melt recovery data (serialized meltPreview). A row exists // only while an outgoing lightning payment is in-flight (see meltRecoveryRepo). [createTable('melt_recovery', MELT_RECOVERY_COLUMNS)], + // Per-transaction in-flight mint/swap request data for idempotent retry + // (see inFlightRepo). A row exists only while a request is in-flight. + [createTable('inflight_requests', INFLIGHT_REQUESTS_COLUMNS)], ] diff --git a/src/services/wallet/operations/inFlightOperations.ts b/src/services/wallet/operations/inFlightOperations.ts index 0de8cfb7..8bd2d006 100644 --- a/src/services/wallet/operations/inFlightOperations.ts +++ b/src/services/wallet/operations/inFlightOperations.ts @@ -1,6 +1,7 @@ import {isAlive} from 'mobx-state-tree' import {getEncodedToken, normalizeProofAmounts} from '@cashu/cashu-ts' import {log} from '../../logService' +import {Database} from '../../sqlite' import {CashuUtils} from '../../cashu/cashuUtils' import {rootStoreInstance} from '../../../models' import {Mint} from '../../../models/Mint' @@ -30,15 +31,12 @@ const { */ const handleInFlightByMintTask = async (mint: Mint): Promise => { const mintUrl = mint.mintUrl - const countersWithInFlight = mint.proofsCountersWithInFlightRequests || [] + const inFlightRequests = Database.getInFlightRequestsByMint(mintUrl) + const totalRequests = inFlightRequests.length - log.trace('[handleInFlightByMintTask] start', { - mintUrl, - counters: countersWithInFlight?.length, - totalRequests: mint.allInFlightRequests?.length ?? 0, - }) + log.trace('[handleInFlightByMintTask] start', {mintUrl, totalRequests}) - if (countersWithInFlight.length === 0) { + if (totalRequests === 0) { return { taskFunction: HANDLE_INFLIGHT_BY_MINT_TASK, mintUrl, @@ -48,17 +46,11 @@ const handleInFlightByMintTask = async (mint: Mint): Promise = const errors: string[] = [] - for (const counter of countersWithInFlight) { - for (const inFlight of counter.allInFlightRequests) { - - if (!isAlive(inFlight)) { - log.error('[handleInFlightByMintTask]', 'InFlightRequest is not alive', {mintUrl}) - continue - } + for (const inFlight of inFlightRequests) { const tx = transactionsStore.findById(inFlight.transactionId) if (!tx) { - counter.removeInFlightRequest(inFlight.transactionId) + Database.removeInFlightRequest(inFlight.transactionId) continue } @@ -66,7 +58,7 @@ const handleInFlightByMintTask = async (mint: Mint): Promise = // the tx): nothing to recover. Drop the lingering in-flight request so it isn't // retried on every sweep. if (tx.status === TransactionStatus.COMPLETED || tx.status === TransactionStatus.REVERTED) { - counter.removeInFlightRequest(inFlight.transactionId) + Database.removeInFlightRequest(inFlight.transactionId) continue } @@ -264,7 +256,7 @@ const handleInFlightByMintTask = async (mint: Mint): Promise = log.error('[handleInFlightByMintTask] Unknown tx type', {type: tx.type, tId: tx.id}) } - counter.removeInFlightRequest(inFlight.transactionId) + Database.removeInFlightRequest(inFlight.transactionId) } catch (e: any) { log.error(`[handleInFlightByMintTask] ${tx.type} failed`, { @@ -274,10 +266,9 @@ const handleInFlightByMintTask = async (mint: Mint): Promise = }) errors.push(`${tx.type} tId=${tx.id}: ${e.message}`) } - } } - const totalProcessed = mint.allInFlightRequests?.length ?? 0 + const totalProcessed = totalRequests return { taskFunction: HANDLE_INFLIGHT_BY_MINT_TASK, @@ -295,8 +286,8 @@ const handleInFlightQueue = async function (): Promise { for (const mint of mintsStore.allMints) { - if (mint.proofsCountersWithInFlightRequests.length === 0) { - log.trace('No proofCounters with inFlight requests, skipping...') + if (Database.getInFlightRequestsByMint(mint.mintUrl).length === 0) { + log.trace('No inFlight requests for mint, skipping...') continue }