mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-10-05 19:28:26 +00:00
feat(melt): move meltCounterValues to SQLite (off-MST, durable) [M1]
Relocate per-transaction melt recovery data (the serialized meltPreview) from the MST MintProofsCounter (debounced MMKV) to a dedicated SQLite table, so it can be written synchronously and read with no MST loaded. Why: meltPreview is recovery-critical — it unblinds the change of a paid- but-unconfirmed melt. It was persisted only via the batched whole-tree MMKV snapshot, so a crash right after the payment was submitted could lose it and the change ecash. It's also a prerequisite for off-MST background melt (NWC pay_invoice). - schema/migration v28: new melt_recovery table (txId PK, mintUrl, keysetId, meltPreview JSON); added to cleanAll. - meltRecoveryRepo: add (ON CONFLICT DO NOTHING — first preview wins, matching the old "already tracked" guard) / get / remove / seed. - WalletStore: write the preview synchronously via Database.addMeltRecovery BEFORE completeMelt; remove on terminal success/failure. - meltOperations / transferOperationApi: read/remove via Database instead of the counter model; drop the now-needless counter fetch in those blocks. - Mint model: remove meltCounterValues map, MeltCounterValueModel, the melt actions/views, the dead counterAtMelt field, and serializeMeltPreview (moved to cashuUtils). migrateSnapshot now STRIPS meltCounterValues from old snapshots so applySnapshot tolerates the removed field. - one-time seed (rootStoreModelVersion 33->34): _runMigrations reads the RAW pre-upgrade snapshot (the model no longer holds it) and copies any in-flight meltPreview into SQLite. Idempotent. - tests: __tests__/meltRecovery.test.ts (JSON round-trip, first-wins, remove, isolation, idempotent seed). Full suite green (14 suites / 157 tests). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
2d294c2200
commit
8d91fad254
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Melt recovery tests (meltCounterValues → SQLite migration).
|
||||
*
|
||||
* Verifies the SQL-level semantics of meltRecoveryRepo: a per-transaction
|
||||
* serialized meltPreview is stored before a melt is submitted so a paid-but-
|
||||
* unconfirmed melt can be recovered and its change unblinded. The first stored
|
||||
* preview for a transaction wins (idempotent), and the row is removed on
|
||||
* terminal success/failure.
|
||||
*
|
||||
* Mirrors the production SQL against node:sqlite, like proofReservation.test.ts
|
||||
* and counters.test.ts (the native driver needs a device).
|
||||
*
|
||||
* @jest-environment node
|
||||
*/
|
||||
import {DatabaseSync} from 'node:sqlite'
|
||||
|
||||
const NOW = '2026-06-05T00:00:00.000Z'
|
||||
|
||||
const CREATE_MELT_RECOVERY = `CREATE TABLE melt_recovery (
|
||||
transactionId INTEGER PRIMARY KEY NOT NULL,
|
||||
mintUrl TEXT,
|
||||
keysetId TEXT,
|
||||
meltPreview TEXT NOT NULL,
|
||||
createdAt TEXT
|
||||
)`
|
||||
|
||||
const MINT = 'https://mint.test'
|
||||
|
||||
// A representative StoredMeltPreview (shape from cashuUtils).
|
||||
const previewFor = (keysetId: string, secret = 'aa') => ({
|
||||
keysetId,
|
||||
outputData: [
|
||||
{
|
||||
blindedMessage: {amount: '2', id: keysetId, B_: 'B_' + secret},
|
||||
blindingFactor: 'deadbeef',
|
||||
secret,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// ── Mirrored repo primitives (exact production SQL) ─────────────────────────
|
||||
|
||||
function addMeltRecovery(
|
||||
db: DatabaseSync,
|
||||
transactionId: number,
|
||||
mintUrl: string | null,
|
||||
keysetId: string | null,
|
||||
meltPreview: object,
|
||||
) {
|
||||
db.prepare(
|
||||
`INSERT INTO melt_recovery (transactionId, mintUrl, keysetId, meltPreview, createdAt)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(transactionId) DO NOTHING`,
|
||||
).run(transactionId, mintUrl, keysetId, JSON.stringify(meltPreview), NOW)
|
||||
}
|
||||
|
||||
function getMeltRecovery(db: DatabaseSync, transactionId: number) {
|
||||
const row = db
|
||||
.prepare(`SELECT transactionId, mintUrl, keysetId, meltPreview, createdAt FROM melt_recovery WHERE transactionId = ?`)
|
||||
.get(transactionId) as
|
||||
| {transactionId: number; mintUrl: string | null; keysetId: string | null; meltPreview: string; createdAt: string | null}
|
||||
| undefined
|
||||
if (!row) return undefined
|
||||
return {...row, meltPreview: JSON.parse(row.meltPreview)}
|
||||
}
|
||||
|
||||
function removeMeltRecovery(db: DatabaseSync, transactionId: number) {
|
||||
db.prepare(`DELETE FROM melt_recovery WHERE transactionId = ?`).run(transactionId)
|
||||
}
|
||||
|
||||
function rowCount(db: DatabaseSync): number {
|
||||
const {n} = db.prepare('SELECT COUNT(*) AS n FROM melt_recovery').get() as {n: number}
|
||||
return n
|
||||
}
|
||||
|
||||
function freshDb(): DatabaseSync {
|
||||
const db = new DatabaseSync(':memory:')
|
||||
db.exec(CREATE_MELT_RECOVERY)
|
||||
return db
|
||||
}
|
||||
|
||||
// ── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Melt recovery (melt_recovery)', () => {
|
||||
test('stores and reads back a meltPreview (JSON round-trip)', () => {
|
||||
const db = freshDb()
|
||||
const preview = previewFor('k1')
|
||||
|
||||
addMeltRecovery(db, 101, MINT, 'k1', preview)
|
||||
const rec = getMeltRecovery(db, 101)!
|
||||
|
||||
expect(rec.transactionId).toBe(101)
|
||||
expect(rec.mintUrl).toBe(MINT)
|
||||
expect(rec.keysetId).toBe('k1')
|
||||
expect(rec.meltPreview).toEqual(preview)
|
||||
db.close()
|
||||
})
|
||||
|
||||
test('returns undefined when no entry exists', () => {
|
||||
const db = freshDb()
|
||||
expect(getMeltRecovery(db, 999)).toBeUndefined()
|
||||
db.close()
|
||||
})
|
||||
|
||||
test('the FIRST stored preview wins (ON CONFLICT DO NOTHING)', () => {
|
||||
const db = freshDb()
|
||||
addMeltRecovery(db, 101, MINT, 'k1', previewFor('k1', 'first'))
|
||||
// A second attempt for the same tx must not overwrite.
|
||||
addMeltRecovery(db, 101, MINT, 'k1', previewFor('k1', 'second'))
|
||||
|
||||
const rec = getMeltRecovery(db, 101)!
|
||||
expect(rec.meltPreview.outputData[0].secret).toBe('first')
|
||||
expect(rowCount(db)).toBe(1)
|
||||
db.close()
|
||||
})
|
||||
|
||||
test('remove deletes the entry (terminal success/failure)', () => {
|
||||
const db = freshDb()
|
||||
addMeltRecovery(db, 101, MINT, 'k1', previewFor('k1'))
|
||||
expect(rowCount(db)).toBe(1)
|
||||
|
||||
removeMeltRecovery(db, 101)
|
||||
expect(getMeltRecovery(db, 101)).toBeUndefined()
|
||||
expect(rowCount(db)).toBe(0)
|
||||
db.close()
|
||||
})
|
||||
|
||||
test('entries for different transactions are independent', () => {
|
||||
const db = freshDb()
|
||||
addMeltRecovery(db, 101, MINT, 'k1', previewFor('k1'))
|
||||
addMeltRecovery(db, 102, MINT, 'k2', previewFor('k2'))
|
||||
|
||||
expect(getMeltRecovery(db, 101)!.keysetId).toBe('k1')
|
||||
expect(getMeltRecovery(db, 102)!.keysetId).toBe('k2')
|
||||
|
||||
removeMeltRecovery(db, 101)
|
||||
expect(getMeltRecovery(db, 101)).toBeUndefined()
|
||||
expect(getMeltRecovery(db, 102)!.keysetId).toBe('k2') // unaffected
|
||||
db.close()
|
||||
})
|
||||
|
||||
test('seed is idempotent — does not overwrite an existing entry', () => {
|
||||
const db = freshDb()
|
||||
// Live entry already advanced/stored.
|
||||
addMeltRecovery(db, 101, MINT, 'k1', previewFor('k1', 'live'))
|
||||
// Upgrade seed re-runs with the snapshot copy.
|
||||
addMeltRecovery(db, 101, MINT, 'k1', previewFor('k1', 'snapshot'))
|
||||
|
||||
expect(getMeltRecovery(db, 101)!.meltPreview.outputData[0].secret).toBe('live')
|
||||
db.close()
|
||||
})
|
||||
})
|
||||
+6
-87
@@ -5,7 +5,6 @@ import {
|
||||
type MintKeys as CashuMintKeys,
|
||||
type MintKeyset as CashuMintKeyset,
|
||||
Mint as CashuMint,
|
||||
type MeltPreview,
|
||||
} from '@cashu/cashu-ts'
|
||||
import {colors, getRandomIconColor} from '../theme'
|
||||
import { log, Database } from '../services'
|
||||
@@ -15,14 +14,7 @@ import { MintUnit, MintUnits } from '../services/wallet/currency'
|
||||
import { getRootStore } from './helpers/getRootStore'
|
||||
import { generateId } from '../utils/utils'
|
||||
import { Proof } from './Proof'
|
||||
import { CashuProof, CashuUtils, StoredMeltPreview } from '../services/cashu/cashuUtils'
|
||||
|
||||
function serializeMeltPreview(meltPreview: MeltPreview): StoredMeltPreview {
|
||||
return {
|
||||
keysetId: meltPreview.keysetId,
|
||||
outputData: CashuUtils.serializeOutputData(meltPreview.outputData),
|
||||
}
|
||||
}
|
||||
import { CashuProof, CashuUtils } from '../services/cashu/cashuUtils'
|
||||
|
||||
export type MintBalance = {
|
||||
mintUrl: string
|
||||
@@ -56,14 +48,6 @@ const InFlightRequestModel = types.model('InFlightRequest', {
|
||||
request: types.frozen<any>(), // or replace `any` with your actual request type
|
||||
})
|
||||
|
||||
// Sub-model for melt previews (v3.x uses MeltPreview instead of just counter)
|
||||
const MeltCounterValueModel = types.model('MeltCounterValue', {
|
||||
transactionId: types.number,
|
||||
counterAtMelt: types.number, // the counter value when melt started (kept for backward compatibility)
|
||||
meltPreview: types.maybe(types.frozen<StoredMeltPreview>()),
|
||||
createdAt: types.optional(types.Date, () => new Date()), // optional: when it was added
|
||||
})
|
||||
|
||||
// === Migration function ===
|
||||
const migrateSnapshot = (snapshot: any): any => {
|
||||
if (!snapshot) return snapshot
|
||||
@@ -91,9 +75,11 @@ const migrateSnapshot = (snapshot: any): any => {
|
||||
snapshot = { ...snapshot, inFlightRequests: {} }
|
||||
}
|
||||
|
||||
// 2. Add missing meltCounterValues map (new in v2+)
|
||||
if (snapshot.meltCounterValues === undefined) {
|
||||
snapshot = { ...snapshot, meltCounterValues: {} }
|
||||
// 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
|
||||
@@ -156,9 +142,6 @@ export const MintProofsCounterModel = types
|
||||
|
||||
// In-flight mint requests
|
||||
inFlightRequests: types.map(InFlightRequestModel),
|
||||
|
||||
// Melt transactions that have started (counter value frozen at start)
|
||||
meltCounterValues: types.map(MeltCounterValueModel),
|
||||
})
|
||||
.preProcessSnapshot(migrateSnapshot)
|
||||
.actions(self => ({
|
||||
@@ -195,56 +178,6 @@ export const MintProofsCounterModel = types
|
||||
}
|
||||
},
|
||||
|
||||
// === Melt counter tracking ===
|
||||
addMeltCounterValue(transactionId: number, meltPreview?: MeltPreview): number {
|
||||
const key = transactionId.toString()
|
||||
if (self.meltCounterValues.has(key)) {
|
||||
log.warn('[addMeltCounterValue]', 'Melt already tracked', { transactionId })
|
||||
return self.meltCounterValues.get(key)!.counterAtMelt
|
||||
}
|
||||
|
||||
self.meltCounterValues.set(key, {
|
||||
transactionId,
|
||||
counterAtMelt: self.counter,
|
||||
meltPreview: meltPreview ? serializeMeltPreview(meltPreview) : undefined,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
|
||||
log.trace('[addMeltCounterValue]', {
|
||||
transactionId,
|
||||
counterAtMelt: self.counter,
|
||||
hasMeltPreview: !!meltPreview,
|
||||
})
|
||||
|
||||
return self.counter
|
||||
},
|
||||
|
||||
removeMeltCounterValue(transactionId: number) {
|
||||
if (!isAlive(self)) {
|
||||
log.error('[removeMeltCounterValue]', 'ProofsCounter is not alive')
|
||||
return
|
||||
}
|
||||
|
||||
const key = transactionId.toString()
|
||||
if (self.meltCounterValues.has(key)) {
|
||||
self.meltCounterValues.delete(key)
|
||||
log.trace('[removeMeltCounterValue]', { transactionId })
|
||||
}
|
||||
},
|
||||
|
||||
clearAllMeltCounterValues() {
|
||||
if (!isAlive(self)) {
|
||||
log.error('[clearAllMeltCounterValues]', 'ProofsCounter is not alive')
|
||||
return
|
||||
}
|
||||
|
||||
const count = self.meltCounterValues.size
|
||||
if (count > 0) {
|
||||
self.meltCounterValues.clear()
|
||||
log.info('[clearAllMeltCounterValues]', `Cleared ${count} melt tracking entries`)
|
||||
}
|
||||
},
|
||||
|
||||
// === Counter mutations (write through to the SQLite authority) ===
|
||||
increaseProofsCounter(numberOfProofs: number) {
|
||||
self.counter += numberOfProofs
|
||||
@@ -288,20 +221,6 @@ export const MintProofsCounterModel = types
|
||||
get allInFlightRequests(): Instance<typeof InFlightRequestModel>[] {
|
||||
return Array.from(self.inFlightRequests.values())
|
||||
},
|
||||
|
||||
// === Melt counter values ===
|
||||
meltCounterValueExists(transactionId: number): boolean {
|
||||
return self.meltCounterValues.has(transactionId.toString())
|
||||
},
|
||||
getMeltCounterValue(transactionId: number): Instance<typeof MeltCounterValueModel> | undefined {
|
||||
return self.meltCounterValues.get(transactionId.toString())
|
||||
},
|
||||
get meltCounterValueCount(): number {
|
||||
return self.meltCounterValues.size
|
||||
},
|
||||
get allMeltCounterValues(): Instance<typeof MeltCounterValueModel>[] {
|
||||
return Array.from(self.meltCounterValues.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
|
||||
|
||||
@@ -11,7 +11,7 @@ import {NwcStoreModel} from './NwcStore'
|
||||
import {AuthStoreModel} from './AuthStore'
|
||||
import { log } from '../services'
|
||||
|
||||
export const rootStoreModelVersion = 33 // Update this if model changes require migrations defined in setupRootStore.ts
|
||||
export const rootStoreModelVersion = 34 // Update this if model changes require migrations defined in setupRootStore.ts
|
||||
/**
|
||||
* A RootStore model.
|
||||
*/
|
||||
|
||||
+21
-14
@@ -20,7 +20,7 @@ import {
|
||||
MeltQuoteState,
|
||||
} from '@cashu/cashu-ts'
|
||||
import { JS_BUNDLE_VERSION } from '@env'
|
||||
import {KeyChain, MinibitsClient, WalletKeys} from '../services'
|
||||
import {Database, KeyChain, MinibitsClient, WalletKeys} from '../services'
|
||||
import {log} from '../services/logService'
|
||||
import AppError, { Err, MintError, NetworkError } from '../utils/AppError'
|
||||
import { Currencies, CurrencyCode, MintUnit } from '../services/wallet/currency'
|
||||
@@ -488,7 +488,7 @@ export const WalletStoreModel = types
|
||||
...receiveParams.options,
|
||||
onCountersReserved: (info: OperationCounters) => {
|
||||
reservedCounters = info
|
||||
log.debug('[receive] Counters reserved', info)
|
||||
log.debug('[WalletStore.receive] Counters reserved', info)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -500,7 +500,7 @@ export const WalletStoreModel = types
|
||||
// Update our counter to match what the wallet used (v3.x)
|
||||
if (reservedCounters) {
|
||||
currentCounter.setProofsCounter(reservedCounters.next)
|
||||
log.debug('[receive] Updated counter', {
|
||||
log.debug('[WalletStore.receive] Updated counter', {
|
||||
keysetId: reservedCounters.keysetId,
|
||||
start: reservedCounters.start,
|
||||
count: reservedCounters.count,
|
||||
@@ -605,7 +605,7 @@ export const WalletStoreModel = types
|
||||
...sendParams.options,
|
||||
onCountersReserved: (info: OperationCounters) => {
|
||||
reservedCounters = info
|
||||
log.debug('[send] Counters reserved', info)
|
||||
log.debug('[WalletStore.send] Counters reserved', info)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -615,7 +615,7 @@ export const WalletStoreModel = types
|
||||
// Update our counter to match what the wallet used (v3.x)
|
||||
if (reservedCounters) {
|
||||
currentCounter.setProofsCounter(reservedCounters.next)
|
||||
log.debug('[send] Updated counter', {
|
||||
log.debug('[WalletStore.send] Updated counter', {
|
||||
keysetId: reservedCounters.keysetId,
|
||||
start: reservedCounters.start,
|
||||
count: reservedCounters.count,
|
||||
@@ -729,7 +729,7 @@ export const WalletStoreModel = types
|
||||
description
|
||||
})
|
||||
|
||||
log.info('[createLightningMintQuote]', {mintQuoteResponse})
|
||||
log.info('[WalletStore.createLightningMintQuote]', {mintQuoteResponse})
|
||||
|
||||
return {
|
||||
encodedInvoice: mintQuoteResponse.request,
|
||||
@@ -759,7 +759,7 @@ export const WalletStoreModel = types
|
||||
quote
|
||||
)
|
||||
|
||||
log.info('[checkLightningMintQuote]', {quoteResponse})
|
||||
log.info('[WalletStore.checkLightningMintQuote]', {quoteResponse})
|
||||
|
||||
return {
|
||||
encodedInvoice: quoteResponse.request,
|
||||
@@ -843,7 +843,7 @@ export const WalletStoreModel = types
|
||||
keysetId: mintParams.options?.keysetId,
|
||||
onCountersReserved: (info: OperationCounters) => {
|
||||
reservedCounters = info
|
||||
log.debug('[mintProofsBolt11] Counters reserved', info)
|
||||
log.debug('[cashuWallet.mintProofsBolt11] Counters reserved', info)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -853,7 +853,7 @@ export const WalletStoreModel = types
|
||||
// Update our counter to match what the wallet used (v3.x)
|
||||
if (reservedCounters) {
|
||||
currentCounter.setProofsCounter(reservedCounters.next)
|
||||
log.debug('[mintProofs] Updated counter', {
|
||||
log.debug('[WalletStore.mintProofs] Updated counter', {
|
||||
keysetId: reservedCounters.keysetId,
|
||||
start: reservedCounters.start,
|
||||
count: reservedCounters.count,
|
||||
@@ -861,7 +861,7 @@ export const WalletStoreModel = types
|
||||
})
|
||||
}
|
||||
|
||||
log.debug('[mintProofs]', {amount: mintParams.amount, quote: mintParams.quote, proofs})
|
||||
log.debug('[WalletStore.mintProofs]', {amount: mintParams.amount, quote: mintParams.quote, proofs})
|
||||
|
||||
return proofs
|
||||
|
||||
@@ -972,8 +972,15 @@ export const WalletStoreModel = types
|
||||
}
|
||||
)
|
||||
|
||||
// Store the MeltPreview for potential recovery
|
||||
currentCounter.addMeltCounterValue(transactionId, meltPreview)
|
||||
// Store the MeltPreview for potential recovery. Synchronous SQLite
|
||||
// write BEFORE completeMelt, so the change can always be recovered
|
||||
// even if the app dies right after the payment is submitted.
|
||||
Database.addMeltRecovery(
|
||||
transactionId,
|
||||
mintUrl,
|
||||
cashuWallet.keysetId,
|
||||
CashuUtils.serializeMeltPreview(meltPreview),
|
||||
)
|
||||
|
||||
// Update our counter to match what the wallet used (v3.x)
|
||||
if (reservedCounters) {
|
||||
@@ -992,7 +999,7 @@ export const WalletStoreModel = types
|
||||
|
||||
// Keep the preview for PENDING async melts — handlePendingMeltTask needs it to unbind change later
|
||||
if (meltResponse.quote.state !== MeltQuoteState.PENDING) {
|
||||
currentCounter.removeMeltCounterValue(transactionId)
|
||||
Database.removeMeltRecovery(transactionId)
|
||||
}
|
||||
|
||||
log.trace('[payLightningMelt]', {meltResponse})
|
||||
@@ -1002,7 +1009,7 @@ export const WalletStoreModel = types
|
||||
if(!e.message.toLowerCase().includes('timeout') &&
|
||||
!e.message.toLowerCase().includes('network request failed')) {
|
||||
// remove only if it was not a timeout or network error
|
||||
currentCounter.removeMeltCounterValue(transactionId)
|
||||
Database.removeMeltRecovery(transactionId)
|
||||
}
|
||||
|
||||
let message = 'Lightning payment failed.'
|
||||
|
||||
@@ -16,7 +16,8 @@ import {
|
||||
} from 'mobx-state-tree'
|
||||
import * as Sentry from '@sentry/react-native'
|
||||
import type { RootStore } from '../RootStore'
|
||||
import { MMKVStorage } from '../../services'
|
||||
import { Database, MMKVStorage } from '../../services'
|
||||
import type { MeltRecoverySeed } from '../../services/db'
|
||||
import { log } from '../../services/logService'
|
||||
import { rootStoreModelVersion } from '../RootStore'
|
||||
import AppError, { Err } from '../../utils/AppError'
|
||||
@@ -124,7 +125,7 @@ export async function setupRootStore(rootStore: RootStore) {
|
||||
log.info(`RootStore loaded from MMKV, version is: ${rootStore.version}`, {caller: 'setupRootStore'})
|
||||
|
||||
if(rootStore.version < rootStoreModelVersion) {
|
||||
await _runMigrations(rootStore)
|
||||
await _runMigrations(rootStore, restoredState)
|
||||
}
|
||||
} catch (e: any) {
|
||||
log.error(Err.STORAGE_ERROR, e.message)
|
||||
@@ -143,7 +144,7 @@ export async function setupRootStore(rootStore: RootStore) {
|
||||
* Migrations code to execute based on code and on device model version.
|
||||
*/
|
||||
|
||||
async function _runMigrations(rootStore: RootStore) {
|
||||
async function _runMigrations(rootStore: RootStore, restoredState: any) {
|
||||
const {
|
||||
mintsStore,
|
||||
transactionsStore,
|
||||
@@ -170,6 +171,33 @@ async function _runMigrations(rootStore: RootStore) {
|
||||
mintsStore.seedCountersToDatabase()
|
||||
}
|
||||
|
||||
if(currentVersion < 34) {
|
||||
// meltCounterValues moved to SQLite (melt_recovery). The model no
|
||||
// longer holds them and applySnapshot strips them, so read straight
|
||||
// from the RAW pre-upgrade snapshot to carry over any melt that was
|
||||
// in-flight at upgrade time (usually none). Idempotent.
|
||||
const seeds: MeltRecoverySeed[] = []
|
||||
for (const mint of restoredState?.mintsStore?.mints ?? []) {
|
||||
for (const counter of mint?.proofsCounters ?? []) {
|
||||
const mcv = counter?.meltCounterValues ?? {}
|
||||
for (const key of Object.keys(mcv)) {
|
||||
const entry = mcv[key]
|
||||
if (entry?.meltPreview && typeof entry.transactionId === 'number') {
|
||||
seeds.push({
|
||||
transactionId: entry.transactionId,
|
||||
mintUrl: mint.mintUrl,
|
||||
keysetId: counter.keyset,
|
||||
meltPreview: entry.meltPreview,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (seeds.length > 0) {
|
||||
Database.seedMeltRecoveries(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)
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
PaymentRequestPayload,
|
||||
TokenMetadata,
|
||||
OutputDataLike,
|
||||
MeltPreview,
|
||||
} from '@cashu/cashu-ts'
|
||||
import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
|
||||
import AppError, {Err} from '../../utils/AppError'
|
||||
@@ -521,6 +522,12 @@ const deserializeOutputData = (serialized: SerializedOutputData[]): OutputData[]
|
||||
od.ephemeralE,
|
||||
))
|
||||
|
||||
/** Serialize a cashu-ts MeltPreview into the JSON-safe shape stored for recovery. */
|
||||
const serializeMeltPreview = (meltPreview: MeltPreview): StoredMeltPreview => ({
|
||||
keysetId: meltPreview.keysetId,
|
||||
outputData: serializeOutputData(meltPreview.outputData),
|
||||
})
|
||||
|
||||
export const CashuUtils = {
|
||||
findEncodedCashuToken,
|
||||
findEncodedCashuPaymentRequest,
|
||||
@@ -545,6 +552,7 @@ export const CashuUtils = {
|
||||
sumProofs,
|
||||
serializeOutputData,
|
||||
deserializeOutputData,
|
||||
serializeMeltPreview,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -51,6 +51,12 @@ import {
|
||||
bumpCounter,
|
||||
seedCounters,
|
||||
} from './countersRepo'
|
||||
import {
|
||||
addMeltRecovery,
|
||||
getMeltRecovery,
|
||||
removeMeltRecovery,
|
||||
seedMeltRecoveries,
|
||||
} from './meltRecoveryRepo'
|
||||
|
||||
export type {TransactionSearchFilters} from './transactionsRepo'
|
||||
export type {
|
||||
@@ -59,6 +65,7 @@ export type {
|
||||
ReservationTransactionUpdate,
|
||||
} from './reservationsRepo'
|
||||
export type {CounterRecord, CounterSeed} from './countersRepo'
|
||||
export type {MeltRecoveryRecord, MeltRecoverySeed} from './meltRecoveryRepo'
|
||||
|
||||
export const Database = {
|
||||
getInstance,
|
||||
@@ -100,4 +107,8 @@ export const Database = {
|
||||
setCounter,
|
||||
bumpCounter,
|
||||
seedCounters,
|
||||
addMeltRecovery,
|
||||
getMeltRecovery,
|
||||
removeMeltRecovery,
|
||||
seedMeltRecoveries,
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ export const cleanAll = function () {
|
||||
// may lack them; without the guard a missing table aborts the atomic batch.
|
||||
['DROP TABLE IF EXISTS reservations'],
|
||||
['DROP TABLE IF EXISTS mint_counters'],
|
||||
['DROP TABLE IF EXISTS melt_recovery'],
|
||||
] as SQLBatchTuple[]
|
||||
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import {getInstance} from './instance'
|
||||
import {dbError} from './errors'
|
||||
import {log} from '../logService'
|
||||
import {StoredMeltPreview} from '../cashu/cashuUtils'
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Melt recovery data.
|
||||
//
|
||||
// Per-transaction serialized `meltPreview` (the blinded change outputData) for
|
||||
// outgoing lightning payments. Written SYNCHRONOUSLY before the melt is
|
||||
// submitted so a paid-but-unconfirmed melt can always be recovered and its
|
||||
// change ecash unblinded — previously held on the MST MintProofsCounter and
|
||||
// persisted only via the debounced whole-tree MMKV snapshot, which risked
|
||||
// losing the preview (and the change) on a crash right after submission.
|
||||
//
|
||||
// A row exists only while a melt is in-flight; it is deleted on terminal
|
||||
// success/failure. Keyed by transactionId.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type MeltRecoveryRecord = {
|
||||
transactionId: number
|
||||
mintUrl: string | null
|
||||
keysetId: string | null
|
||||
meltPreview: StoredMeltPreview
|
||||
createdAt: string | null
|
||||
}
|
||||
|
||||
/** A single melt-recovery entry for the one-time seed from the MST/MMKV snapshot. */
|
||||
export type MeltRecoverySeed = {
|
||||
transactionId: number
|
||||
mintUrl?: string
|
||||
keysetId?: string
|
||||
meltPreview: StoredMeltPreview
|
||||
}
|
||||
|
||||
/**
|
||||
* Store the meltPreview for a transaction. Idempotent: the FIRST stored preview
|
||||
* for a transaction wins (ON CONFLICT DO NOTHING), matching the previous
|
||||
* addMeltCounterValue "already tracked" guard. Synchronous.
|
||||
*/
|
||||
export const addMeltRecovery = function (
|
||||
transactionId: number,
|
||||
mintUrl: string | undefined,
|
||||
keysetId: string | undefined,
|
||||
meltPreview: StoredMeltPreview,
|
||||
): void {
|
||||
try {
|
||||
getInstance().execute(
|
||||
`INSERT INTO melt_recovery (transactionId, mintUrl, keysetId, meltPreview, createdAt)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(transactionId) DO NOTHING`,
|
||||
[transactionId, mintUrl ?? null, keysetId ?? null, JSON.stringify(meltPreview), new Date().toISOString()],
|
||||
)
|
||||
} catch (e: any) {
|
||||
throw dbError('Melt recovery could not be saved to the database', e)
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the melt-recovery entry for a transaction, or undefined. */
|
||||
export const getMeltRecovery = function (transactionId: number): MeltRecoveryRecord | undefined {
|
||||
try {
|
||||
const {rows} = getInstance().execute(
|
||||
`SELECT transactionId, mintUrl, keysetId, meltPreview, createdAt FROM melt_recovery WHERE transactionId = ?`,
|
||||
[transactionId],
|
||||
)
|
||||
const row = rows?.item(0)
|
||||
if (!row) return undefined
|
||||
return {
|
||||
transactionId: row.transactionId,
|
||||
mintUrl: row.mintUrl,
|
||||
keysetId: row.keysetId,
|
||||
meltPreview: JSON.parse(row.meltPreview) as StoredMeltPreview,
|
||||
createdAt: row.createdAt,
|
||||
}
|
||||
} catch (e: any) {
|
||||
throw dbError('Melt recovery could not be retrieved from the database', e)
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete the melt-recovery entry for a transaction (terminal success/failure). */
|
||||
export const removeMeltRecovery = function (transactionId: number): void {
|
||||
try {
|
||||
getInstance().execute(`DELETE FROM melt_recovery WHERE transactionId = ?`, [transactionId])
|
||||
} catch (e: any) {
|
||||
throw dbError('Melt recovery could not be removed from the database', e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time, idempotent copy of MST/MMKV-resident melt previews into SQLite. Used
|
||||
* by the upgrade migration to carry over a melt that was in-flight at upgrade.
|
||||
*/
|
||||
export const seedMeltRecoveries = function (seeds: MeltRecoverySeed[]): {seeded: number} {
|
||||
if (!seeds || seeds.length === 0) return {seeded: 0}
|
||||
try {
|
||||
const now = new Date().toISOString()
|
||||
const db = getInstance()
|
||||
db.executeBatch(
|
||||
seeds.map(s => [
|
||||
`INSERT INTO melt_recovery (transactionId, mintUrl, keysetId, meltPreview, createdAt)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(transactionId) DO NOTHING`,
|
||||
[s.transactionId, s.mintUrl ?? null, s.keysetId ?? null, JSON.stringify(s.meltPreview), now],
|
||||
]),
|
||||
)
|
||||
log.info('[seedMeltRecoveries]', 'Seeded melt recovery entries into SQLite', {count: seeds.length})
|
||||
return {seeded: seeds.length}
|
||||
} catch (e: any) {
|
||||
throw dbError('Melt recovery entries could not be seeded into the database', e)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import {DbConnection, SQLBatchTuple} from './connection'
|
||||
import {createTable, PROOFS_COLUMNS, PROOFS_COLUMN_NAMES, RESERVATIONS_COLUMNS, MINT_COUNTERS_COLUMNS} from './schema'
|
||||
import {createTable, PROOFS_COLUMNS, PROOFS_COLUMN_NAMES, RESERVATIONS_COLUMNS, MINT_COUNTERS_COLUMNS, MELT_RECOVERY_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 = 27
|
||||
export const _dbVersion = 28
|
||||
|
||||
type Migration = {version: number; queries: SQLBatchTuple[]}
|
||||
|
||||
@@ -79,6 +79,13 @@ const MIGRATIONS: Migration[] = [
|
||||
version: 27,
|
||||
queries: [[createTable('mint_counters', MINT_COUNTERS_COLUMNS)]],
|
||||
},
|
||||
{
|
||||
// Add per-transaction melt recovery table. Empty on creation; any in-flight
|
||||
// meltCounterValues from the MST/MMKV snapshot are copied by a one-time JS
|
||||
// seed (see setupRootStore._runMigrations).
|
||||
version: 28,
|
||||
queries: [[createTable('melt_recovery', MELT_RECOVERY_COLUMNS)]],
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
|
||||
@@ -89,6 +89,26 @@ export const MINT_COUNTERS_COLUMNS = `
|
||||
PRIMARY KEY (mintUrl, keysetId)
|
||||
`
|
||||
|
||||
/**
|
||||
* Recovery data for outgoing lightning payments (melt).
|
||||
*
|
||||
* Holds the serialized `meltPreview` (the blinded change outputData) per
|
||||
* transaction, written synchronously BEFORE the melt is submitted so a paid-
|
||||
* but-unconfirmed melt can always be recovered and its change ecash unblinded —
|
||||
* previously kept on the MST MintProofsCounter (debounced MMKV), which risked
|
||||
* losing the preview (and the change) on a crash right after submission.
|
||||
*
|
||||
* Keyed by transactionId (globally unique). A row exists only while a melt is
|
||||
* in-flight; it is deleted on terminal success/failure.
|
||||
*/
|
||||
export const MELT_RECOVERY_COLUMNS = `
|
||||
transactionId INTEGER PRIMARY KEY NOT NULL,
|
||||
mintUrl TEXT,
|
||||
keysetId TEXT,
|
||||
meltPreview TEXT NOT NULL,
|
||||
createdAt TEXT
|
||||
`
|
||||
|
||||
/** Build a CREATE TABLE statement from a column block. */
|
||||
export const createTable = (
|
||||
name: string,
|
||||
@@ -112,4 +132,7 @@ export const createSchemaQueries: SQLBatchTuple[] = [
|
||||
// Per-keyset deterministic-derivation counters. Seeded from the MST/MMKV
|
||||
// counters on first run after this migration (see countersRepo).
|
||||
[createTable('mint_counters', MINT_COUNTERS_COLUMNS)],
|
||||
// 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)],
|
||||
]
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
getEncodedToken,
|
||||
} from '@cashu/cashu-ts'
|
||||
import {log} from '../../logService'
|
||||
import {Database} from '../../sqlite'
|
||||
import {translate} from '../../../i18n'
|
||||
import {MintError, ValidationError} from '../../../utils/AppError'
|
||||
import EventEmitter from '../../../utils/eventEmitter'
|
||||
@@ -93,10 +94,7 @@ const recoverMeltQuoteChange = async (
|
||||
|
||||
switch (state) {
|
||||
case MeltQuoteState.UNPAID:
|
||||
if (tx.keysetId) {
|
||||
const currentCounter = mintInstance.getProofsCounterByKeysetId!(tx.keysetId)
|
||||
currentCounter.removeMeltCounterValue(tx.id)
|
||||
}
|
||||
Database.removeMeltRecovery(tx.id)
|
||||
|
||||
throw new ValidationError(`Melt quote ${meltQuote} was not paid`)
|
||||
|
||||
@@ -115,20 +113,19 @@ const recoverMeltQuoteChange = async (
|
||||
throw new ValidationError('Missing keysetId on transaction', {meltQuote})
|
||||
}
|
||||
|
||||
const currentCounter = mintInstance.getProofsCounterByKeysetId!(tx.keysetId)
|
||||
const meltCounterValue = currentCounter?.getMeltCounterValue(tx.id)
|
||||
const meltRecovery = Database.getMeltRecovery(tx.id)
|
||||
|
||||
if (!meltCounterValue?.meltPreview) {
|
||||
if (!meltRecovery?.meltPreview) {
|
||||
throw new ValidationError('MeltPreview not found – this transaction may be from an older version', {meltQuote})
|
||||
}
|
||||
|
||||
const meltPreview = meltCounterValue.meltPreview
|
||||
const meltPreview = meltRecovery.meltPreview
|
||||
const cashuWallet = await walletStore.getWallet(mintUrl, unit, {withSeed: true, keysetId: meltPreview.keysetId})
|
||||
const keyset = cashuWallet.getKeyset(meltPreview.keysetId)
|
||||
|
||||
const reconstructedOutputData = CashuUtils.deserializeOutputData(meltPreview.outputData)
|
||||
const recoveredChange = change.map((sig, i) => reconstructedOutputData[i].toProof(sig, keyset))
|
||||
currentCounter.removeMeltCounterValue(tx.id)
|
||||
Database.removeMeltRecovery(tx.id)
|
||||
|
||||
const newChange = recoveredChange.filter(proof => !proofsStore.alreadyExists(proof))
|
||||
|
||||
@@ -221,10 +218,9 @@ const unblindPendingMeltChange = async function (params: {
|
||||
const mintInstance = mintsStore.findByUrl(mintUrl)
|
||||
if (!mintInstance || !transaction.keysetId) return {change: []}
|
||||
|
||||
const currentCounter = mintInstance.getProofsCounterByKeysetId!(transaction.keysetId)
|
||||
const meltCounterValue = currentCounter?.getMeltCounterValue(transaction.id)
|
||||
const meltRecovery = Database.getMeltRecovery(transaction.id)
|
||||
|
||||
if (!meltCounterValue?.meltPreview || !quoteChange?.length) {
|
||||
if (!meltRecovery?.meltPreview || !quoteChange?.length) {
|
||||
return {change: []}
|
||||
}
|
||||
|
||||
@@ -234,7 +230,7 @@ const unblindPendingMeltChange = async function (params: {
|
||||
changeCount: quoteChange.length,
|
||||
})
|
||||
|
||||
const {meltPreview} = meltCounterValue
|
||||
const {meltPreview} = meltRecovery
|
||||
const cashuWallet = await walletStore.getWallet(mintUrl, unit, {
|
||||
withSeed: true,
|
||||
keysetId: meltPreview.keysetId,
|
||||
@@ -246,7 +242,7 @@ const unblindPendingMeltChange = async function (params: {
|
||||
|
||||
log.trace('[handlePendingMelt] Change unblinded', {transactionId: transaction.id, quoteId, change})
|
||||
|
||||
currentCounter.removeMeltCounterValue(transaction.id)
|
||||
Database.removeMeltRecovery(transaction.id)
|
||||
|
||||
return {change}
|
||||
} catch (e: any) {
|
||||
|
||||
@@ -994,14 +994,13 @@ async function _unblindMeltChange(params: {
|
||||
const mintInstance = mintsStore.findByUrl(mintUrl)
|
||||
if (!mintInstance || !transaction.keysetId) return {change: []}
|
||||
|
||||
const currentCounter = mintInstance.getProofsCounterByKeysetId!(transaction.keysetId)
|
||||
const meltCounterValue = currentCounter?.getMeltCounterValue(transaction.id)
|
||||
const meltRecovery = Database.getMeltRecovery(transaction.id)
|
||||
|
||||
if (!meltCounterValue?.meltPreview || !quoteChange?.length) {
|
||||
if (!meltRecovery?.meltPreview || !quoteChange?.length) {
|
||||
return {change: []}
|
||||
}
|
||||
|
||||
const {meltPreview} = meltCounterValue
|
||||
const {meltPreview} = meltRecovery
|
||||
const cashuWallet = await walletStore.getWallet(mintUrl, unit, {
|
||||
withSeed: true,
|
||||
keysetId: meltPreview.keysetId,
|
||||
@@ -1019,7 +1018,7 @@ async function _unblindMeltChange(params: {
|
||||
change,
|
||||
})
|
||||
|
||||
currentCounter.removeMeltCounterValue(transaction.id)
|
||||
Database.removeMeltRecovery(transaction.id)
|
||||
return {change}
|
||||
} catch (e: any) {
|
||||
log.error(
|
||||
|
||||
Reference in New Issue
Block a user