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:
minibits-cash
2026-06-05 09:36:53 +02:00
co-authored by Claude Opus 4.8
parent 2d294c2200
commit 8d91fad254
13 changed files with 388 additions and 126 deletions
+152
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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.'
+31 -3
View File
@@ -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)
+8
View File
@@ -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,
}
+11
View File
@@ -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,
}
+1
View File
@@ -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 {
+111
View File
@@ -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)
}
}
+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} 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)]],
},
]
/**
+23
View File
@@ -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(