Files
minibits_wallet/__tests__/meltRecovery.test.ts
T
minibits-cashandClaude Opus 4.8 8d91fad254 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>
2026-06-05 09:36:53 +02:00

153 lines
5.3 KiB
TypeScript

/**
* 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()
})
})