diff --git a/__tests__/meltRecovery.test.ts b/__tests__/meltRecovery.test.ts new file mode 100644 index 00000000..36095655 --- /dev/null +++ b/__tests__/meltRecovery.test.ts @@ -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() + }) +}) diff --git a/src/models/Mint.ts b/src/models/Mint.ts index 897c2bd8..877e7e5f 100644 --- a/src/models/Mint.ts +++ b/src/models/Mint.ts @@ -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(), // 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()), - 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[] { return Array.from(self.inFlightRequests.values()) }, - - // === Melt counter values === - meltCounterValueExists(transactionId: number): boolean { - return self.meltCounterValues.has(transactionId.toString()) - }, - getMeltCounterValue(transactionId: number): Instance | undefined { - return self.meltCounterValues.get(transactionId.toString()) - }, - get meltCounterValueCount(): number { - return self.meltCounterValues.size - }, - get allMeltCounterValues(): Instance[] { - 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 diff --git a/src/models/RootStore.ts b/src/models/RootStore.ts index 43ef2aff..d72d8b0f 100644 --- a/src/models/RootStore.ts +++ b/src/models/RootStore.ts @@ -11,7 +11,7 @@ import {NwcStoreModel} from './NwcStore' import {AuthStoreModel} from './AuthStore' import { log } from '../services' -export const rootStoreModelVersion = 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. */ diff --git a/src/models/WalletStore.ts b/src/models/WalletStore.ts index 958964e9..3e51d1f6 100644 --- a/src/models/WalletStore.ts +++ b/src/models/WalletStore.ts @@ -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.' diff --git a/src/models/helpers/setupRootStore.ts b/src/models/helpers/setupRootStore.ts index aa6d59ee..461e1fad 100644 --- a/src/models/helpers/setupRootStore.ts +++ b/src/models/helpers/setupRootStore.ts @@ -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) diff --git a/src/services/cashu/cashuUtils.ts b/src/services/cashu/cashuUtils.ts index e7b3ad5a..aa1c3044 100644 --- a/src/services/cashu/cashuUtils.ts +++ b/src/services/cashu/cashuUtils.ts @@ -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, } diff --git a/src/services/db/index.ts b/src/services/db/index.ts index 4623239c..8b0c729a 100644 --- a/src/services/db/index.ts +++ b/src/services/db/index.ts @@ -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, } diff --git a/src/services/db/instance.ts b/src/services/db/instance.ts index 49dd823b..5c99dcab 100644 --- a/src/services/db/instance.ts +++ b/src/services/db/instance.ts @@ -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 { diff --git a/src/services/db/meltRecoveryRepo.ts b/src/services/db/meltRecoveryRepo.ts new file mode 100644 index 00000000..9c2f9288 --- /dev/null +++ b/src/services/db/meltRecoveryRepo.ts @@ -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) + } +} diff --git a/src/services/db/migrations.ts b/src/services/db/migrations.ts index 23e75405..41463bee 100644 --- a/src/services/db/migrations.ts +++ b/src/services/db/migrations.ts @@ -1,10 +1,10 @@ import {DbConnection, SQLBatchTuple} from './connection' -import {createTable, PROOFS_COLUMNS, PROOFS_COLUMN_NAMES, RESERVATIONS_COLUMNS, MINT_COUNTERS_COLUMNS} 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)]], + }, ] /** diff --git a/src/services/db/schema.ts b/src/services/db/schema.ts index 0c17d5da..926ddb0c 100644 --- a/src/services/db/schema.ts +++ b/src/services/db/schema.ts @@ -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)], ] diff --git a/src/services/wallet/operations/meltOperations.ts b/src/services/wallet/operations/meltOperations.ts index 053d2cf9..063492ce 100644 --- a/src/services/wallet/operations/meltOperations.ts +++ b/src/services/wallet/operations/meltOperations.ts @@ -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) { diff --git a/src/services/wallet/operations/transferOperationApi.ts b/src/services/wallet/operations/transferOperationApi.ts index eeb4263b..8a30eb0b 100644 --- a/src/services/wallet/operations/transferOperationApi.ts +++ b/src/services/wallet/operations/transferOperationApi.ts @@ -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(