mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-10-05 19:28:26 +00:00
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:
co-authored by
Claude Opus 4.8
parent
8d91fad254
commit
14f8d1de8c
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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.'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)]],
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user