feat(inflight): move inFlightRequests to SQLite (off-MST) [M2]

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 <noreply@anthropic.com>
This commit is contained in:
minibits-cash
2026-06-05 09:50:56 +02:00
co-authored by Claude Opus 4.8
parent 8d91fad254
commit 14f8d1de8c
11 changed files with 362 additions and 149 deletions
+142
View File
@@ -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()
})
})
+6 -115
View File
@@ -43,46 +43,15 @@ export type InFlightRequest<TRequest = any> = {
request: TRequest
}
const InFlightRequestModel = types.model('InFlightRequest', {
transactionId: types.number,
request: types.frozen<any>(), // 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<string, any> = {}
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<MintUnit>(), '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<typeof InFlightRequestModel>[] {
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
+1 -1
View File
@@ -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.
*/
+9 -9
View File
@@ -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.'
+27 -1
View File
@@ -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)
+120
View File
@@ -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)
}
}
+13
View File
@@ -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,
}
+1
View File
@@ -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 {
+9 -2
View File
@@ -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)]],
},
]
/**
+22
View File
@@ -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)],
]
@@ -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<WalletTaskResult> => {
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<WalletTaskResult> =
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<WalletTaskResult> =
// 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<WalletTaskResult> =
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<WalletTaskResult> =
})
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<void> {
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
}