diff --git a/__mocks__/op-sqlite.js b/__mocks__/op-sqlite.js index 7127429..7c1e659 100644 --- a/__mocks__/op-sqlite.js +++ b/__mocks__/op-sqlite.js @@ -51,9 +51,27 @@ const toBindable = value => { return value } +/** + * Test hook: pre-populate the NEXT database opened. + * + * Lets a test hand instance.ts a database that already exists at some older + * version, which is the only way to exercise the real upgrade path — the one that + * runs on every user's device and that a fresh in-memory database never touches. + * Consumed once, so it cannot leak into the next open. + */ +let _seedNextDatabase = null +const __seedNextDatabase = seed => { + _seedNextDatabase = seed +} + const open = () => { const db = new DatabaseSync(':memory:') + if (_seedNextDatabase) { + _seedNextDatabase(db) + _seedNextDatabase = null + } + const executeSync = (query, params) => { if (isTransactionControl(query)) { db.exec(query) @@ -103,6 +121,7 @@ const open = () => { module.exports = { open, + __seedNextDatabase, IOS_DOCUMENT_PATH: '/mock/ios/documents', ANDROID_FILES_PATH: '/mock/android/files', } diff --git a/__tests__/counters.test.ts b/__tests__/counters.test.ts index 477f67b..79786b1 100644 --- a/__tests__/counters.test.ts +++ b/__tests__/counters.test.ts @@ -22,6 +22,7 @@ jest.mock('../src/services/logService', () => ({ })) import {Database} from '../src/services/db' +import {_dbVersion} from '../src/services/db/migrations' const MINT = 'https://mint.test' @@ -55,7 +56,7 @@ describe('Derivation counters (mint_counters)', () => { // Guards the whole suite: these run against instance.ts's real schema + the // real migration registry, so a version mismatch means the rest is testing // something other than production. - expect(Database.getDatabaseVersion(Database.getInstance()).version).toBe(34) + expect(Database.getDatabaseVersion(Database.getInstance()).version).toBe(_dbVersion) }) describe('setCounter — monotonic', () => { diff --git a/__tests__/dbUpgradePath.test.ts b/__tests__/dbUpgradePath.test.ts new file mode 100644 index 0000000..70d4396 --- /dev/null +++ b/__tests__/dbUpgradePath.test.ts @@ -0,0 +1,235 @@ +/** + * The UPGRADE path: an existing database at an old version, driven through the real + * instance.ts (createSchemaQueries + the real migration registry). + * + * This is the path every user's device takes and the one nothing covered. Every + * other db suite starts from a FRESH in-memory database, where instance.ts builds + * the latest schema and seeds the version — so migrations never run at all, and the + * interaction between createSchemaQueries and the registry is never exercised. + * + * That gap shipped a real bug. createSchemaQueries runs `CREATE TABLE IF NOT EXISTS` + * for the CURRENT shape of every table, on every launch. On a device old enough not + * to have a table yet, it therefore created it at TODAY's shape — and the migration + * that later adds a column to that table then failed with "duplicate column name", + * rolling back the whole batch and leaving the database unmigrated. A wallet at v29 + * showed a zero balance. + * + * @jest-environment node + */ +jest.mock('../src/services/logService', () => ({ + log: {debug: jest.fn(), error: jest.fn(), info: jest.fn(), trace: jest.fn(), warn: jest.fn()}, +})) + +// ── A device at db v26 ─────────────────────────────────────────────────────── +// +// v26 is the baseline that matters most: it is what the last released NATIVE bundle +// (tag v0.4.3-beta.3, `_dbVersion = 26`, `rootStoreModelVersion = 32`) creates, so +// even a BRAND NEW install today starts here and then OTA-updates the JS to the +// current migrations. It is the common path, not an old edge case. +// +// VERIFIED against that tag rather than reconstructed: its createSchemaQueries +// builds exactly these four tables, and its TRANSACTIONS_COLUMNS (25), +// PROOFS_COLUMNS (12) and RESERVATIONS_COLUMNS (7) match the shapes below column for +// column. mint_counters (v27), melt_recovery (v28), inflight_requests (v29), +// wallet_counters (v30), onchain_mint_quotes (v31) and mints/mint_keysets (v35) do +// not exist there yet. +// +// Frozen deliberately: this is history, and must NOT be re-pointed at schema.ts. A +// fixture that tracks today's shape describes a device that never existed and stops +// testing the upgrade at all. + +const SCHEMA_V26 = [ + `CREATE TABLE transactions ( + id INTEGER PRIMARY KEY NOT NULL, paymentId TEXT, type TEXT, amount INTEGER, unit TEXT, + fee INTEGER, data TEXT, keysetId TEXT, sentFrom TEXT, sentTo TEXT, profile TEXT, + memo TEXT, mint TEXT, quote TEXT, paymentRequest TEXT, zapRequest TEXT, + inputToken TEXT, outputToken TEXT, proof TEXT, balanceAfter INTEGER, noteToSelf TEXT, + tags TEXT, status TEXT, expiresAt TEXT, createdAt TEXT + )`, + `CREATE TABLE proofs ( + id TEXT NOT NULL, amount INTEGER NOT NULL, secret TEXT PRIMARY KEY NOT NULL, C TEXT NOT NULL, + dleq_r TEXT, dleq_s TEXT, dleq_e TEXT, unit TEXT, tId INTEGER, mintUrl TEXT, + state TEXT NOT NULL DEFAULT 'UNSPENT', updatedAt TEXT + )`, + `CREATE TABLE dbversion (id INTEGER PRIMARY KEY NOT NULL, version INTEGER, createdAt TEXT)`, + `CREATE TABLE reservations ( + id TEXT PRIMARY KEY NOT NULL, transactionId INTEGER NOT NULL, mintUrl TEXT NOT NULL, + unit TEXT NOT NULL, operationType TEXT NOT NULL, lockedProofs TEXT NOT NULL, createdAt TEXT NOT NULL + )`, +] + +const KEYSET = '009a1f293253e41e' +const MINT_URL = 'https://mint.test' + +const seedV26 = (db: any) => { + for (const sql of SCHEMA_V26) db.exec(sql) + db.prepare(`INSERT INTO dbversion (id, version, createdAt) VALUES (1, 26, '2026-01-01')`).run() + // Real user data, so the assertions below mean something. + db.prepare( + `INSERT INTO proofs (id, amount, secret, C, unit, tId, mintUrl, state, updatedAt) + VALUES (?, 100, 's1', 'C', 'sat', 1, ?, 'UNSPENT', '2026-01-01')`, + ).run(KEYSET, MINT_URL) + db.prepare( + `INSERT INTO transactions (id, type, amount, unit, data, mint, status, createdAt) + VALUES (1, 'TOPUP', 100, 'sat', '{}', ?, 'COMPLETED', '2026-01-01')`, + ).run(MINT_URL) +} + +/** + * A device at version N, built honestly: the v26 schema, then the REAL migrations + * replayed up to N. Stamping a different version onto the v26 fixture would describe + * a device that never existed — one claiming v31 without the table v31 creates — and + * would fail for that reason rather than a real one. + */ +const seedAtVersion = (startVersion: number) => (db: any) => { + seedV26(db) + const {MIGRATIONS} = require('../src/services/db/migrations') + for (const migration of MIGRATIONS) { + if (migration.version > 26 && migration.version <= startVersion) { + for (const [sql] of migration.queries) db.exec(sql) + } + } + db.prepare('UPDATE dbversion SET version = ?').run(startVersion) + + // A derivation counter, once the table it lives in exists. Losing this is the one + // failure that re-issues blinded secrets the mint has already signed. + if (startVersion >= 27) { + const hasMintUrl = (db.prepare(`PRAGMA table_info(mint_counters)`).all() as any[]).some( + c => c.name === 'mintUrl', + ) + if (hasMintUrl) { + db.prepare( + `INSERT INTO mint_counters (mintUrl, keysetId, unit, counter, updatedAt) + VALUES (?, ?, 'sat', 342, '2026-01-01')`, + ).run(MINT_URL, KEYSET) + } else { + db.prepare( + `INSERT INTO mint_counters (keysetId, unit, counter, updatedAt) + VALUES (?, 'sat', 342, '2026-01-01')`, + ).run(KEYSET) + } + } +} + +/** Open a database at `startVersion` and let the REAL instance.ts upgrade it. */ +const upgradeFrom = (startVersion: number) => { + jest.resetModules() + // eslint-disable-next-line @typescript-eslint/no-var-requires + require('@op-engineering/op-sqlite').__seedNextDatabase(seedAtVersion(startVersion)) + // eslint-disable-next-line @typescript-eslint/no-var-requires + const {Database} = require('../src/services/db') + const {_dbVersion} = require('../src/services/db/migrations') + return {Database, _dbVersion, db: Database.getInstance()} +} + +/** The baseline the last released native bundle ships. */ +const upgradeFromV26 = () => upgradeFrom(26) + +const columns = (db: any, table: string): string[] => + (db.execute(`PRAGMA table_info(${table})`).rows?._array ?? []).map((r: any) => r.name) + +describe('upgrading an existing database (the device path)', () => { + test('a v26 wallet — the last released native bundle — migrates to the current version', () => { + // The reported failure: this threw "duplicate column name: mintId", the batch + // rolled back, and the wallet came up with nothing. + const {Database, _dbVersion, db} = upgradeFromV26() + + expect(Database.getDatabaseVersion(db).version).toBe(_dbVersion) + }) + + test('the money survives', () => { + const {db} = upgradeFromV26() + + const proof = db.execute(`SELECT * FROM proofs WHERE secret = 's1'`).rows?.item(0) + expect(proof.amount).toBe(100) + expect(proof.mintUrl).toBe(MINT_URL) + }) + + test('a derivation counter survives the v32 re-key', () => { + // From v27, the first version that HAS mint_counters. A v26 wallet has no such + // table at all — its counters are still in the MMKV snapshot, and the rootStore + // v33 seed is what copies them in. + // + // This is the one value whose loss re-issues blinded secrets the mint has + // already signed, and v32 re-keys the very table it lives in, from + // (mintUrl, keysetId) onto keysetId. + const {Database} = upgradeFrom(27) + + expect(Database.getCounter(KEYSET)?.counter).toBe(342) + }) + + test('every column the later migrations add is present', () => { + const {db} = upgradeFromV26() + + // v33 / v34: the mint-identity columns. + expect(columns(db, 'onchain_mint_quotes')).toContain('mintId') + expect(columns(db, 'reservations')).toContain('mintId') + expect(columns(db, 'transactions')).toContain('mintId') + + // v34 also rebuilt the child tables WITHOUT their duplicated mint reference. + expect(columns(db, 'inflight_requests')).not.toContain('mintUrl') + expect(columns(db, 'melt_recovery')).not.toContain('mintUrl') + }) + + test('tables introduced after v29 exist, at the current shape', () => { + const {db} = upgradeFromV26() + + expect(columns(db, 'wallet_counters')).toContain('counter') // v30 + expect(columns(db, 'onchain_mint_quotes')).toContain('counterIndex') // v31 + expect(columns(db, 'mints')).toContain('mintUrl') // v35 + expect(columns(db, 'mint_keysets')).toContain('keysetId') // v35 + }) + + test('mint_counters is re-keyed onto keysetId alone', () => { + const {db} = upgradeFromV26() + + expect(columns(db, 'mint_counters')).toEqual( + expect.arrayContaining(['keysetId', 'unit', 'counter', 'updatedAt']), + ) + expect(columns(db, 'mint_counters')).not.toContain('mintUrl') + }) + + test('the repos work against the upgraded database', () => { + // The real point: not just that the DDL landed, but that the app can use it. + const {Database} = upgradeFromV26() + + Database.setCounter('00ad268c4d1f5826', 'sat', 7) + expect(Database.getCounter('00ad268c4d1f5826')?.counter).toBe(7) + + expect(Database.getMints()).toEqual([]) + expect(Database.getWatchedOnchainMintQuotes()).toEqual([]) + }) + + test('re-running the upgrade on an already-current database is a no-op', () => { + // Every subsequent launch takes this path. + jest.resetModules() + const {Database} = require('../src/services/db') + const {_dbVersion} = require('../src/services/db/migrations') + + const db = Database.getInstance() + expect(Database.getDatabaseVersion(db).version).toBe(_dbVersion) + + // A second call must not re-run anything. + expect(Database.getDatabaseVersion(Database.getInstance()).version).toBe(_dbVersion) + }) + + // The bug's real shape: it bites at any version predating a table that a LATER + // migration alters. v29 is simply where it was reported. Rather than trust that + // one case, walk every version the registry knows about. + describe('every supported starting version reaches the current schema', () => { + test.each([26, 27, 28, 29, 30, 31, 32, 33, 34])('from v%i', startVersion => { + jest.resetModules() + require('@op-engineering/op-sqlite').__seedNextDatabase(seedAtVersion(startVersion)) + const {Database} = require('../src/services/db') + const {_dbVersion} = require('../src/services/db/migrations') + + const db = Database.getInstance() + + expect(Database.getDatabaseVersion(db).version).toBe(_dbVersion) + expect(columns(db, 'onchain_mint_quotes')).toContain('mintId') + expect(columns(db, 'transactions')).toContain('mintId') + // The money, at every starting point. + expect(db.execute(`SELECT amount FROM proofs WHERE secret = 's1'`).rows?.item(0)?.amount).toBe(100) + }) + }) +}) diff --git a/__tests__/mintsPersistence.test.ts b/__tests__/mintsPersistence.test.ts new file mode 100644 index 0000000..4921a63 --- /dev/null +++ b/__tests__/mintsPersistence.test.ts @@ -0,0 +1,326 @@ +/** + * Mint persistence through the MST layer: the per-mint observer, the startup + * hydrate, and the snapshot strip. + * + * SQLite is the authority for mints; the model is the in-memory cache the UI + * observes. Rather than a write-through in each of ~20 Mint mutators — where + * forgetting one is silent staleness — each mint carries one onSnapshot observer. + * These tests pin the parts of that arrangement which are easy to get subtly wrong: + * WHEN observers attach, that loading does not write back, and that a mint stripped + * from the snapshot still survives a restart. + */ +jest.mock('../src/services/nostrService', () => ({ + // cashuUtils -> nostrService -> minibitsService -> models is an import CYCLE. + NostrClient: {getFirstTagValue: jest.fn()}, +})) +jest.mock('../src/services/logService', () => ({ + log: {debug: jest.fn(), error: jest.fn(), info: jest.fn(), trace: jest.fn(), warn: jest.fn()}, +})) + +import {types, getSnapshot} from 'mobx-state-tree' +import {MintsStoreModel} from '../src/models/MintsStore' +import {ProofsStoreModel} from '../src/models/ProofsStore' +import {Database} from '../src/services/db' +import {ProofModel} from '../src/models/Proof' + +// A minimal root: the real RootStore additionally pulls AuthStore/NwcStore/ +// WalletProfileStore and, through them, the whole service layer. getRootStore only +// needs the root to expose the stores actually used. +const TestRoot = types.model('RootStore', { + mintsStore: types.optional(MintsStoreModel, {}), + proofsStore: types.optional(ProofsStoreModel, {}), +}) + +const MINT_URL = 'https://mint.test' + +// Real-shaped keyset ids ('00' + 14 hex). Placeholders like 'k1' are not hex, so +// isCollidingKeysetId reads them as legacy base64 ids and they alias onto the same +// derivation index — the wallet would reject the second as a collision. +const KEYSET_1 = '009a1f293253e41e' +const KEYSET_2 = '00ad268c4d1f5826' + +const mintSnapshot = (overrides: Record = {}) => ({ + id: 'mint1111', + mintUrl: MINT_URL, + hostname: 'mint.test', + shortname: 'Test Mint', + units: ['sat'], + keysets: [{id: KEYSET_1, unit: 'sat', active: true, input_fee_ppk: 0}], + keys: [{id: KEYSET_1, unit: 'sat', keys: {'1': '02aa'}}], + // Every keyset has a counter shell in production (initKeyset creates it, and + // loadMintsFromDatabase rebuilds it). The values themselves are volatile and come + // from mint_counters. + proofsCounters: [{keyset: KEYSET_1, unit: 'sat'}], + color: '#abcdef', + status: 'ONLINE', + ...overrides, +}) + +const makeRoot = (mints: any[] = []) => TestRoot.create({mintsStore: {mints}, proofsStore: {}}) + +const storedMintUrls = () => Database.getMints().map(m => m.mintUrl) + +beforeEach(() => { + Database.getInstance().executeBatch([ + ['DELETE FROM mints'], + ['DELETE FROM mint_keysets'], + ['DELETE FROM proofs'], + ['DELETE FROM mint_counters'], + ]) +}) + +describe('mint persistence', () => { + describe('the snapshot no longer carries mints', () => { + // The reason for the whole move: mints (with every keyset's keys map) were the + // largest thing left in the persisted tree, and JSON.stringify(snapshot) runs + // on EVERY MST action anywhere — including every proof mutation during a send. + test('getSnapshot reports no mints, whatever the store holds', () => { + const root = makeRoot([mintSnapshot()]) + + expect(root.mintsStore.mints).toHaveLength(1) // live + expect(getSnapshot(root.mintsStore).mints).toEqual([]) // persisted + }) + + test('but blockedMintUrls IS still persisted there', () => { + const root = makeRoot([mintSnapshot()]) + root.mintsStore.blockMint(root.mintsStore.mints[0] as any) + + expect(getSnapshot(root.mintsStore).blockedMintUrls).toEqual([MINT_URL]) + }) + }) + + describe('the observer', () => { + test('persists a mint mutation without any explicit write', () => { + const root = makeRoot([mintSnapshot()]) + root.mintsStore.persistAllMints() + root.mintsStore.observeMints() + + root.mintsStore.mints[0].setProp('shortname', 'Renamed') + + expect(Database.getMints()[0].shortname).toBe('Renamed') + }) + + test('persists a keyset added later', () => { + const root = makeRoot([mintSnapshot()]) + root.mintsStore.persistAllMints() + root.mintsStore.observeMints() + + root.mintsStore.mints[0].initKeyset({id: KEYSET_2, unit: 'sat', active: true} as any, [KEYSET_1]) + + expect(Database.getMints()[0].keysets.map(k => k.id).sort()).toEqual([KEYSET_1, KEYSET_2].sort()) + }) + + test('stops persisting a mint once it is removed', () => { + const root = makeRoot([mintSnapshot()]) + root.mintsStore.persistAllMints() + root.mintsStore.observeMints() + + root.mintsStore.removeMint(root.mintsStore.mints[0] as any) + + expect(Database.getMints()).toEqual([]) + }) + + // A counter bump must not churn the mint row: `counter` is volatile, so it never + // reaches a snapshot and cannot fire the observer at all. + test('a derivation-counter bump does not touch the mint row', () => { + const root = makeRoot([mintSnapshot()]) + root.mintsStore.persistAllMints() + root.mintsStore.observeMints() + + const before = Database.getMints()[0] + root.mintsStore.mints[0].proofsCounters[0].increaseProofsCounter(5) + + expect(Database.getMints()[0]).toEqual(before) + }) + }) + + describe('loadMintsFromDatabase', () => { + test('hydrates the mints back, keysets and keys included', () => { + const seeded = makeRoot([mintSnapshot()]) + seeded.mintsStore.persistAllMints() + + // A fresh tree, as on the next launch: the snapshot carries no mints. + const restarted = makeRoot([]) + expect(restarted.mintsStore.mints).toHaveLength(0) + + restarted.mintsStore.loadMintsFromDatabase() + + const mint = restarted.mintsStore.mints[0] + expect(mint.mintUrl).toBe(MINT_URL) + expect(mint.shortname).toBe('Test Mint') + expect(mint.keysets.map(k => k.id)).toEqual([KEYSET_1]) + expect(mint.keys.map(k => k.id)).toEqual([KEYSET_1]) + }) + + // The launch that migrates: the table is empty and the mints still come from the + // MMKV snapshot. Wiping them here would delete the user's mints. + test('is a NO-OP when the table is empty — it never wipes what the snapshot restored', () => { + const root = makeRoot([mintSnapshot()]) + + root.mintsStore.loadMintsFromDatabase() + + expect(root.mintsStore.mints).toHaveLength(1) + expect(root.mintsStore.mints[0].mintUrl).toBe(MINT_URL) + }) + + // THE fund-loss path, and the reason loadMintsFromDatabase rebuilds the counter + // shells by hand. Loading bypasses initKeyset, which is what normally creates + // them. With no shell, hydrateCountersFromDatabase has nothing to fill, the + // counter is later created on demand at 0, and derivation re-issues blinded + // secrets the mint has already signed. + test('rebuilds a counter shell per keyset, so the real index can hydrate', () => { + const seeded = makeRoot([mintSnapshot()]) + seeded.mintsStore.persistAllMints() + Database.setCounter(KEYSET_1, 'sat', 342) + + const restarted = makeRoot([]) + restarted.mintsStore.loadMintsFromDatabase() + + // The shell must exist for the keyset... + expect(restarted.mintsStore.mints[0].proofsCounters.map(c => c.keyset)).toEqual([KEYSET_1]) + + // ...so that the authority's value lands on it, rather than the counter being + // recreated at 0 on first use. + restarted.mintsStore.hydrateCountersFromDatabase() + expect(restarted.mintsStore.mints[0].proofsCounters[0].counter).toBe(342) + }) + + test('rebuilds a shell for EVERY keyset, not just the first', () => { + const seeded = makeRoot([ + mintSnapshot({ + keysets: [ + {id: KEYSET_1, unit: 'sat', active: true}, + {id: KEYSET_2, unit: 'sat', active: false}, + ], + proofsCounters: [ + {keyset: KEYSET_1, unit: 'sat'}, + {keyset: KEYSET_2, unit: 'sat'}, + ], + }), + ]) + seeded.mintsStore.persistAllMints() + Database.setCounter(KEYSET_2, 'sat', 77) + + const restarted = makeRoot([]) + restarted.mintsStore.loadMintsFromDatabase() + restarted.mintsStore.hydrateCountersFromDatabase() + + const counters = restarted.mintsStore.mints[0].proofsCounters + expect(counters.map(c => c.keyset).sort()).toEqual([KEYSET_1, KEYSET_2].sort()) + expect(counters.find(c => c.keyset === KEYSET_2)!.counter).toBe(77) + }) + + test('preserves the mint id, so rows referencing it still resolve', () => { + const seeded = makeRoot([mintSnapshot()]) + seeded.mintsStore.persistAllMints() + + const restarted = makeRoot([]) + restarted.mintsStore.loadMintsFromDatabase() + + // onchain quotes, reservations and transactions all point at Mint.id. + expect(restarted.mintsStore.findById('mint1111')).toBeDefined() + }) + }) + + describe('the rename, end to end', () => { + test('moves the mint and its proofs, in memory and on disk', async () => { + const root = makeRoot([mintSnapshot()]) + root.mintsStore.persistAllMints() + root.mintsStore.observeMints() + + Database.addOrUpdateProofs( + [ + ProofModel.create({ + id: KEYSET_1, + amount: 10, + secret: 's1', + C: 'C', + unit: 'sat', + tId: 1, + mintUrl: MINT_URL, + }) as any, + ], + 'UNSPENT', + ) + await root.proofsStore.loadProofsFromDatabase() + + root.mintsStore.mints[0].setMintUrl!('https://moved.test') + + // Model + expect(root.mintsStore.mints[0].mintUrl).toBe('https://moved.test') + expect(root.mintsStore.mints[0].hostname).toBe('moved.test') + expect(root.proofsStore.getBySecret('s1')!.mintUrl).toBe('https://moved.test') + // Database + expect(storedMintUrls()).toEqual(['https://moved.test']) + + // And the balance still finds the money — the failure this whole change is + // about is proofs left owned by no mint. + expect(root.proofsStore.findOrphanedProofs()).toEqual([]) + expect(root.proofsStore.balances.mintBalances[0].balances.sat).toBe(10) + }) + + test('a rename survives a restart', () => { + const root = makeRoot([mintSnapshot()]) + root.mintsStore.persistAllMints() + root.mintsStore.observeMints() + + root.mintsStore.mints[0].setMintUrl!('https://moved.test') + + const restarted = makeRoot([]) + restarted.mintsStore.loadMintsFromDatabase() + + expect(restarted.mintsStore.mints[0].mintUrl).toBe('https://moved.test') + }) + }) + + describe('the backup payload — the trap that loses data silently', () => { + // getSnapshot(mintsStore).mints is ALWAYS empty now. A backup built from the + // store snapshot would contain zero mints, raise no error, and only reveal the + // loss on restore. This is why the export lives on the store behind a test + // rather than inline in the screen. + test('contains the mints, unlike the store snapshot', () => { + const root = makeRoot([mintSnapshot()]) + + expect(getSnapshot(root.mintsStore).mints).toEqual([]) // the trap + expect(root.mintsStore.backupSnapshot.mints).toHaveLength(1) // the fix + expect(root.mintsStore.backupSnapshot.mints[0].mintUrl).toBe(MINT_URL) + }) + + test('carries the keysets, which the recovered wallet needs', () => { + const root = makeRoot([mintSnapshot()]) + expect(root.mintsStore.backupSnapshot.mints[0].keysets.map((k: any) => k.id)).toEqual([KEYSET_1]) + }) + + test('drops keys — they are re-fetched from the mint on import', () => { + const root = makeRoot([mintSnapshot()]) + expect(root.mintsStore.backupSnapshot.mints[0].keys).toEqual([]) + }) + + // A backup restored with counter 0 would re-derive blinded secrets the mint has + // already signed. `counter` is volatile, so it is absent from the snapshot and + // has to be put back deliberately. + test('re-injects the live derivation counter per keyset', () => { + const root = makeRoot([mintSnapshot()]) + root.mintsStore.mints[0].proofsCounters[0].setProofsCounter(342) + + const [mint] = root.mintsStore.backupSnapshot.mints + expect(mint.proofsCounters[0].counter).toBe(342) + }) + + test('carries blockedMintUrls', () => { + const root = makeRoot([mintSnapshot()]) + root.mintsStore.blockMint(root.mintsStore.mints[0] as any) + + expect(root.mintsStore.backupSnapshot.blockedMintUrls).toEqual([MINT_URL]) + }) + + test('is a plain detached copy — mutating it cannot touch the store', () => { + const root = makeRoot([mintSnapshot()]) + const backup = root.mintsStore.backupSnapshot + + backup.mints[0].mintUrl = 'https://tampered.test' + + expect(root.mintsStore.mints[0].mintUrl).toBe(MINT_URL) + }) + }) +}) diff --git a/__tests__/mintsRepo.test.ts b/__tests__/mintsRepo.test.ts new file mode 100644 index 0000000..79829d7 --- /dev/null +++ b/__tests__/mintsRepo.test.ts @@ -0,0 +1,265 @@ +/** + * Mints and their keysets in SQLite (mintsRepo), against the REAL repo and a real + * database. + * + * Mints were the last core entity persisted only by serializing the whole MST tree + * to MMKV. Moving them here takes that cost off the hot path (every MST action + * anywhere re-serialized every mint's keys) and puts them in the same engine as the + * proofs, so a mint-url edit can finally commit atomically with the proofs it + * renames. + * + * What these pin, in order of how much they would hurt to get wrong: + * - a mint round-trips exactly, keys included — losing keysets or keys means the + * wallet cannot verify or spend that mint's ecash; + * - the rename moves the mint AND its proofs or neither; + * - removal keeps the derivation counters. + */ +jest.mock('../src/services/logService', () => ({ + log: {debug: jest.fn(), error: jest.fn(), info: jest.fn(), trace: jest.fn(), warn: jest.fn()}, +})) + +import {Database} from '../src/services/db' +import type {MintRecord} from '../src/services/db' + +const MINT_URL = 'https://mint.test' + +const keyset = (id: string, overrides: Record = {}) => ({ + id, + unit: 'sat', + active: true, + input_fee_ppk: 0, + ...overrides, +}) + +const keysFor = (id: string) => ({id, unit: 'sat', keys: {'1': '02aa', '2': '02bb'}}) + +const mintRecord = (overrides: Partial = {}): MintRecord => ({ + id: 'mint1111', + mintUrl: MINT_URL, + hostname: 'mint.test', + shortname: 'Test Mint', + units: ['sat'], + keysets: [keyset('k1')], + keys: [keysFor('k1')], + mintInfo: {name: 'Test Mint', time: 1234}, + color: '#abcdef', + status: 'ONLINE', + createdAt: '2026-01-01T00:00:00.000Z', + ...overrides, +}) + +const proofRow = (secret: string, mintUrl: string) => + Database.getInstance().execute( + `INSERT OR REPLACE INTO proofs (id, amount, secret, C, unit, tId, mintUrl, state, updatedAt) + VALUES ('k1', 10, ?, 'C', 'sat', 1, ?, 'UNSPENT', '2026-01-01')`, + [secret, mintUrl], + ) + +const proofMintUrl = (secret: string): string | undefined => + Database.getInstance().execute('SELECT mintUrl FROM proofs WHERE secret = ?', [secret]).rows?.item(0) + ?.mintUrl + +beforeEach(() => { + Database.getInstance().executeBatch([ + ['DELETE FROM mints'], + ['DELETE FROM mint_keysets'], + ['DELETE FROM proofs'], + ['DELETE FROM mint_counters'], + ]) +}) + +describe('mints in SQLite', () => { + describe('round-trip', () => { + test('a mint survives a write/read cycle intact', () => { + const mint = mintRecord() + Database.upsertMint(mint) + + const [loaded] = Database.getMints() + expect(loaded).toEqual(mint) + }) + + test('preserves keyset fields the wallet does not name', () => { + // MintKeyset carries final_expiry, which feeds NUT-02 v2 id derivation. The + // keyset is stored as a whole JSON object precisely so fields like this are + // not silently dropped by an enumerated column list. + Database.upsertMint(mintRecord({keysets: [keyset('k1', {final_expiry: 1799999999})]})) + + expect(Database.getMints()[0].keysets[0]).toMatchObject({final_expiry: 1799999999}) + }) + + test('carries multiple keysets and their keys', () => { + Database.upsertMint( + mintRecord({ + keysets: [keyset('k1'), keyset('k2', {active: false})], + keys: [keysFor('k1'), keysFor('k2')], + }), + ) + + const [loaded] = Database.getMints() + expect(loaded.keysets.map(k => k.id).sort()).toEqual(['k1', 'k2']) + expect(loaded.keys.map(k => k.id).sort()).toEqual(['k1', 'k2']) + }) + + test('a keyset whose keys were never fetched loads with no keys entry', () => { + // keysets and keys are independent arrays in the model: the mint advertises a + // keyset before (or without) the wallet fetching its keys. + Database.upsertMint(mintRecord({keysets: [keyset('k1'), keyset('k2')], keys: [keysFor('k1')]})) + + const [loaded] = Database.getMints() + expect(loaded.keysets).toHaveLength(2) + expect(loaded.keys.map(k => k.id)).toEqual(['k1']) + }) + + test('an absent mintInfo stays absent', () => { + Database.upsertMint(mintRecord({mintInfo: undefined})) + expect(Database.getMints()[0].mintInfo).toBeUndefined() + }) + + test('two mints are independent', () => { + Database.upsertMint(mintRecord()) + Database.upsertMint( + mintRecord({id: 'mint2222', mintUrl: 'https://other.test', keysets: [keyset('k9')], keys: []}), + ) + + const loaded = Database.getMints() + expect(loaded).toHaveLength(2) + expect(loaded.find(m => m.id === 'mint2222')!.keysets.map(k => k.id)).toEqual(['k9']) + }) + }) + + describe('upsert', () => { + test('is idempotent — re-writing the same mint changes nothing', () => { + const mint = mintRecord() + Database.upsertMint(mint) + Database.upsertMint(mint) + + expect(Database.getMints()).toHaveLength(1) + expect(Database.getMints()[0]).toEqual(mint) + }) + + test('updates changed fields in place', () => { + Database.upsertMint(mintRecord()) + Database.upsertMint(mintRecord({shortname: 'Renamed', status: 'OFFLINE'})) + + const [loaded] = Database.getMints() + expect(loaded.shortname).toBe('Renamed') + expect(loaded.status).toBe('OFFLINE') + }) + + test('adds a newly advertised keyset without disturbing the existing ones', () => { + Database.upsertMint(mintRecord()) + Database.upsertMint( + mintRecord({keysets: [keyset('k1'), keyset('k2')], keys: [keysFor('k1'), keysFor('k2')]}), + ) + + expect(Database.getMints()[0].keysets.map(k => k.id).sort()).toEqual(['k1', 'k2']) + }) + + // Keys and keyset metadata are fetched from the mint separately, so a + // metadata-only refresh must never erase keys already stored — without them the + // wallet cannot verify or spend that keyset. + test('a keyset refresh with no keys does NOT erase stored keys', () => { + Database.upsertMint(mintRecord()) + expect(Database.getMints()[0].keys).toHaveLength(1) + + Database.upsertMint(mintRecord({keys: []})) // metadata-only refresh + + expect(Database.getMints()[0].keys.map(k => k.id)).toEqual(['k1']) + }) + }) + + describe('the atomic rename', () => { + test('moves the mint AND its proofs together', () => { + Database.upsertMint(mintRecord()) + proofRow('p1', MINT_URL) + proofRow('p2', MINT_URL) + + Database.updateMintUrlWithProofs('mint1111', MINT_URL, 'https://moved.test', 'moved.test') + + expect(Database.getMints()[0].mintUrl).toBe('https://moved.test') + expect(Database.getMints()[0].hostname).toBe('moved.test') + expect(proofMintUrl('p1')).toBe('https://moved.test') + expect(proofMintUrl('p2')).toBe('https://moved.test') + }) + + test('leaves another mint\'s proofs alone', () => { + Database.upsertMint(mintRecord()) + proofRow('mine', MINT_URL) + proofRow('theirs', 'https://other.test') + + Database.updateMintUrlWithProofs('mint1111', MINT_URL, 'https://moved.test', 'moved.test') + + expect(proofMintUrl('mine')).toBe('https://moved.test') + expect(proofMintUrl('theirs')).toBe('https://other.test') + }) + + // The whole point of putting mints in the same engine as proofs. When the url + // lived in MMKV and the proofs in SQLite, a crash between the two writes left + // the proofs owned by no mint: the money vanished from every per-mint balance + // while still counting in the total, and could not be spent. + test('is all-or-nothing: a failure leaves BOTH untouched', () => { + Database.upsertMint(mintRecord()) + proofRow('p1', MINT_URL) + + // A non-finite param is rejected by connection.ts's sanitizing, mid batch. + expect(() => + Database.updateMintUrlWithProofs('mint1111', MINT_URL, Number.NaN as any, 'moved.test'), + ).toThrow() + + expect(Database.getMints()[0].mintUrl).toBe(MINT_URL) + expect(proofMintUrl('p1')).toBe(MINT_URL) + }) + }) + + describe('removal', () => { + test('deletes the mint and its keysets', () => { + Database.upsertMint(mintRecord()) + Database.removeMintById('mint1111') + + expect(Database.getMints()).toEqual([]) + const keysetCount = Database.getInstance() + .execute('SELECT COUNT(*) AS n FROM mint_keysets') + .rows?.item(0)?.n + expect(keysetCount).toBe(0) + }) + + test('KEEPS the derivation counters', () => { + // mint_counters rows are keyed by keysetId and retained across removal, so a + // re-added mint recovers its real index instead of restarting at 0 — which + // would reuse blinded secrets the mint has already signed. + Database.upsertMint(mintRecord()) + Database.setCounter('k1', 'sat', 342) + + Database.removeMintById('mint1111') + + expect(Database.getCounter('k1')?.counter).toBe(342) + }) + + test('leaves other mints alone', () => { + Database.upsertMint(mintRecord()) + Database.upsertMint(mintRecord({id: 'mint2222', mintUrl: 'https://other.test'})) + + Database.removeMintById('mint1111') + + expect(Database.getMints().map(m => m.id)).toEqual(['mint2222']) + }) + }) + + describe('seedMints — the one-time MMKV copy', () => { + test('copies mints across', () => { + Database.seedMints([mintRecord(), mintRecord({id: 'mint2222', mintUrl: 'https://other.test'})]) + expect(Database.getMints()).toHaveLength(2) + }) + + test('is idempotent — upserts by id, so a re-run cannot duplicate a mint', () => { + Database.seedMints([mintRecord()]) + Database.seedMints([mintRecord()]) + + expect(Database.getMints()).toHaveLength(1) + }) + + test('an empty seed is a no-op', () => { + expect(Database.seedMints([])).toEqual({seeded: 0}) + }) + }) +}) diff --git a/__tests__/sqliteMigration35.test.ts b/__tests__/sqliteMigration35.test.ts new file mode 100644 index 0000000..adefc61 --- /dev/null +++ b/__tests__/sqliteMigration35.test.ts @@ -0,0 +1,147 @@ +/** + * Repeatable migration tests for SQLite migration 35. + * + * Migration 35 creates the `mints` and `mint_keysets` tables, moving the mints out + * of the MST/MMKV snapshot and into SQLite. + * + * WHY: postProcessSnapshot already strips proofs and transactions, so mints — with + * every keyset's `keys` map — had become the largest thing left in the persisted + * tree, and `JSON.stringify(snapshot)` runs on EVERY MST action anywhere, including + * every proof mutation during a send. It also puts mints in the same engine as the + * proofs, so a mint-url edit can commit atomically with the proofs it renames. + * + * The tables are created EMPTY: mints live in the snapshot, so nothing in SQL can + * read them. The v39 seed copies them once the store is hydrated. + * + * Uses Node.js built-in node:sqlite (requires Node 22.5+). + * @jest-environment node + */ +jest.mock('../src/services/logService', () => ({ + log: {debug: jest.fn(), error: jest.fn(), info: jest.fn(), trace: jest.fn(), warn: jest.fn()}, +})) + +import {DatabaseSync} from 'node:sqlite' +import {MIGRATIONS} from '../src/services/db/migrations' + +// ── The REAL migration, taken from the registry ───────────────────────────── +// +// Imported rather than copied: a copy of the SQL proves nothing about the SQL that +// actually runs on a device. + +const MIGRATION_35 = MIGRATIONS.find(m => m.version === 35)!.queries.map(([sql]) => sql) + +function runMigration35(db: DatabaseSync) { + db.exec('BEGIN') + try { + for (const sql of MIGRATION_35) db.exec(sql) + db.exec('COMMIT') + } catch (e) { + db.exec('ROLLBACK') + throw e + } +} + +function columns(db: DatabaseSync, table: string): string[] { + const rows = db.prepare(`PRAGMA table_info(${table})`).all() as unknown as Array<{name: string}> + return rows.map(r => r.name) +} + +function tables(db: DatabaseSync): string[] { + return ( + db.prepare(`SELECT name FROM sqlite_master WHERE type='table' ORDER BY name`).all() as unknown as Array<{ + name: string + }> + ).map(r => r.name) +} + +/** A pre-35 database: no mints tables at all. */ +const freshDb = () => new DatabaseSync(':memory:') + +describe('SQLite migration 35 — mints move into SQLite', () => { + test('creates both tables', () => { + const db = freshDb() + expect(tables(db)).not.toContain('mints') + + runMigration35(db) + + expect(tables(db)).toContain('mints') + expect(tables(db)).toContain('mint_keysets') + db.close() + }) + + test('mints carries the identity, the url and the JSON payloads', () => { + const db = freshDb() + runMigration35(db) + + expect(columns(db, 'mints').sort()).toEqual( + ['color', 'createdAt', 'hostname', 'id', 'mintInfo', 'mintUrl', 'shortname', 'status', 'units'].sort(), + ) + db.close() + }) + + test('mint_keysets is keyed by keysetId and owned by mintId', () => { + const db = freshDb() + runMigration35(db) + + expect(columns(db, 'mint_keysets').sort()).toEqual(['keys', 'keyset', 'keysetId', 'mintId', 'unit'].sort()) + db.close() + }) + + test('starts empty — the v39 seed fills it, because SQL cannot read the MMKV snapshot', () => { + const db = freshDb() + runMigration35(db) + + expect(db.prepare('SELECT * FROM mints').all()).toEqual([]) + expect(db.prepare('SELECT * FROM mint_keysets').all()).toEqual([]) + db.close() + }) + + describe('keys', () => { + test('a mint id is unique', () => { + const db = freshDb() + runMigration35(db) + const insert = () => + db + .prepare(`INSERT INTO mints (id, mintUrl) VALUES (?, ?)`) + .run('mint1', 'https://a.test') + + insert() + expect(insert).toThrow() + db.close() + }) + + // Matches mint_counters, which is also keyed by keysetId alone: the two agree on + // what a keyset is, and a keyset id is unique wallet-wide (isCollidingKeysetId). + test('a keyset id is unique across mints, not just within one', () => { + const db = freshDb() + runMigration35(db) + + db.prepare(`INSERT INTO mint_keysets (keysetId, mintId, keyset) VALUES (?, ?, ?)`).run( + 'k1', + 'mint1', + '{}', + ) + + expect(() => + db + .prepare(`INSERT INTO mint_keysets (keysetId, mintId, keyset) VALUES (?, ?, ?)`) + .run('k1', 'mint2', '{}'), + ).toThrow() + db.close() + }) + + test('mints.mintUrl is NOT unique — the schema does not decide mint identity', () => { + // Identity is Mint.id. Two rows could legitimately share a url mid-rename, and + // the constraint that matters (one real mint, one Mint node) is enforced by + // keyset collision detection, not by a url index. + const db = freshDb() + runMigration35(db) + + db.prepare(`INSERT INTO mints (id, mintUrl) VALUES (?, ?)`).run('mint1', 'https://a.test') + expect(() => + db.prepare(`INSERT INTO mints (id, mintUrl) VALUES (?, ?)`).run('mint2', 'https://a.test'), + ).not.toThrow() + db.close() + }) + }) +}) diff --git a/src/models/Mint.ts b/src/models/Mint.ts index 3ee9e07..788692c 100644 --- a/src/models/Mint.ts +++ b/src/models/Mint.ts @@ -623,7 +623,31 @@ export const MintModel = types throw new AppError(Err.VALIDATION_ERROR, 'Mint URL already exists.', {url: normalized}) } - proofsStore.updateMintUrl(currentMintUrl, normalized) + // ONE SQLite transaction for the mint row and its proofs. + // + // This is what mints-in-SQLite bought. The url used to live in the MMKV + // snapshot while the proofs lived in SQLite, so the rename spanned two + // engines with nothing joining them: a crash in between left the proofs + // pointing at a url no mint owned — the money vanished from every + // per-mint balance while still counting in the total, and was unspendable + // (send and melt select proofs by mint). + // + // Written BEFORE the model changes, so a failure throws with the tree + // untouched rather than leaving memory ahead of the database. The mint's + // own observer will re-persist the same row afterwards; the equality + // guard makes that a no-op. + let hostname: string | null = null + try { + hostname = new URL(normalized).hostname + } catch { + // normalizeMintUrl already parsed it, so this cannot realistically + // fail; hostname simply stays null rather than aborting the rename. + } + + Database.updateMintUrlWithProofs(self.id, currentMintUrl, normalized, hostname) + + // Mirror into the in-memory caches now that SQLite is durable. + proofsStore.updateMintUrlInMemory(currentMintUrl, normalized) self.mintUrl = normalized self.setHostname() // derived from mintUrl — stale until recomputed diff --git a/src/models/MintsStore.ts b/src/models/MintsStore.ts index b7c510c..b523729 100644 --- a/src/models/MintsStore.ts +++ b/src/models/MintsStore.ts @@ -6,6 +6,9 @@ import { isStateTreeNode, detach, flow, + onSnapshot, + getSnapshot, + IDisposer, } from 'mobx-state-tree' import {withSetPropAction} from './helpers/withSetPropAction' import {MintModel, Mint, MintProofsCounter} from './Mint' @@ -13,6 +16,7 @@ import { import {log} from '../services/logService' // Direct import, not the '../services' barrel — see the note in Mint.ts. import {Database} from '../services/db' + import type {MintRecord} from '../services/db' import AppError, { Err } from '../utils/AppError' import { Mint as CashuMint, @@ -34,6 +38,36 @@ export type MintsByUnit = { mints: Mint[] } +// ───────────────────────────────────────────────────────────────────────────── +// Per-mint persistence. +// +// SQLite is the authority for mints; this model is the in-memory cache (the same +// split as proofs and transactions). Rather than call a write-through from each of +// the ~20 Mint mutators — where forgetting one is SILENT staleness, the exact bug +// class this whole effort has been about — each mint gets one onSnapshot observer +// that persists its row whenever anything in its subtree changes. It cannot be +// forgotten, and it gives ImportBackup persistence for free (applySnapshot fires it). +// +// Derivation counters are unaffected: `counter` is volatile, so it never appears in +// a snapshot and a bump never fires these. +// ───────────────────────────────────────────────────────────────────────────── + +/** Project a Mint snapshot onto the row shape SQLite stores. */ +const toMintRecord = (snapshot: any): MintRecord => ({ + id: snapshot.id, + mintUrl: snapshot.mintUrl, + hostname: snapshot.hostname, + shortname: snapshot.shortname, + units: [...(snapshot.units ?? [])], + keysets: [...(snapshot.keysets ?? [])], + keys: [...(snapshot.keys ?? [])], + mintInfo: snapshot.mintInfo, + color: snapshot.color, + status: snapshot.status, + // types.Date snapshots as a timestamp; the column stores ISO. + createdAt: snapshot.createdAt ? new Date(snapshot.createdAt).toISOString() : undefined, +}) + export const MintsStoreModel = types .model('MintsStore', { mints: types.array(MintModel), @@ -49,6 +83,37 @@ export const MintsStoreModel = types const {counterBackups, ...rest} = s return rest }) + /** + * Mints are loaded from SQLite on startup; only blockedMintUrls is persisted + * here. Same treatment as proofs and transactions, and the reason for the whole + * move: mints (with every keyset's `keys` map) were the largest thing left in + * the tree, and `JSON.stringify(snapshot)` runs on EVERY MST action anywhere — + * including every proof mutation during a send. Stripping them takes that cost + * off the hot path. + * + * NOTE for anything that reads the snapshot: `getSnapshot(mintsStore).mints` is + * now ALWAYS empty. Code that wants the real mints must read the live instances + * (see ExportBackupScreen, which does exactly that for proofs already). + */ + .postProcessSnapshot(snapshot => ({ + // `as typeof snapshot.mints` keeps the element type: a bare [] would widen + // the snapshot type to never[], and every reader of a mints snapshot (the + // backup format above all) would stop type-checking against a real Mint. + mints: [] as typeof snapshot.mints, + blockedMintUrls: snapshot.blockedMintUrls, + })) + /** + * Observer bookkeeping. VOLATILE, and on the store rather than at module scope: + * this is per-store state, and a module-level map would be shared by every store + * in the process — which production never notices (there is one root store) and + * a test immediately does. + */ + .volatile(() => ({ + /** Live per-mint observers, by mint id, so they can be disposed. */ + mintObservers: new Map(), + /** Last payload written per mint id — the equality guard's memory. */ + lastPersistedMints: new Map(), + })) .views(self => ({ findByUrl: (mintUrl: string | URL) => { const mint = self.mints.find(m => m.mintUrl === mintUrl) @@ -110,6 +175,76 @@ export const MintsStoreModel = types * the keyset — and a mint-url edit no longer strands the row, which used to * leave the counter at its volatile 0 and risk blinded-secret reuse. */ + /** + * Write one mint through, skipping writes that would change nothing. + * + * The guard compares the PERSISTED payload, not the raw snapshot: a mint's + * subtree changes for reasons its row does not care about, and without this + * every such action would re-write the mint and all its keysets. + * + * Errors are logged, never thrown: persistence must not break a wallet flow, + * and the next mutation retries. A dropped write costs a stale row, which the + * following write corrects. + */ + persistMint(snapshot: any) { + const record = toMintRecord(snapshot) + const serialized = JSON.stringify(record) + + if (self.lastPersistedMints.get(record.id) === serialized) return + + try { + Database.upsertMint(record) + self.lastPersistedMints.set(record.id, serialized) + } catch (e: any) { + log.error('[persistMint]', 'Mint write-through failed', { + error: e?.message, + mintId: record.id, + }) + } + }, + + /** Dispose one mint's observer and forget its guard entry. */ + unobserveMint(mintId: string) { + self.mintObservers.get(mintId)?.() + self.mintObservers.delete(mintId) + self.lastPersistedMints.delete(mintId) + }, + })) + .actions(self => ({ + /** Attach (or replace) one mint's persistence observer. */ + observeMint(mint: Mint) { + const mintId = mint.id as string + self.unobserveMint(mintId) + self.mintObservers.set(mintId, onSnapshot(mint as any, snapshot => self.persistMint(snapshot))) + }, + })) + .actions(self => ({ + /** + * (Re)attach the per-mint persistence observers. + * + * Must run AFTER the mints are in place, never before: attaching first means + * loading them from SQLite immediately writes them straight back. Disposes + * any existing observers, so it is safe to call again after applySnapshot + * has replaced the whole array with new nodes. + */ + observeMints() { + for (const mintId of [...self.mintObservers.keys()]) self.unobserveMint(mintId) + for (const mint of self.mints) self.observeMint(mint as Mint) + }, + + /** + * Write every mint through, unconditionally. + * + * For when mints arrive already-formed rather than by mutation — i.e. + * ImportBackup's applySnapshot. Observers only fire on CHANGE, so freshly + * applied nodes would otherwise never reach SQLite. + */ + persistAllMints() { + for (const mint of self.mints) self.persistMint(getSnapshot(mint as any)) + }, + })) + .actions(self => ({ + hydrateCountersFromDatabase() { const rows = Database.getCounters() if (rows.length === 0) return @@ -125,6 +260,47 @@ export const MintsStoreModel = types countersByKeyset.get(row.keysetId)?.hydrateCounterFromDb(row.counter) } }, + + /** + * Hydrate the mints from SQLite, the authority (startup). + * + * Mirrors proofsStore.loadProofsFromDatabase: SQLite holds the data, the + * model is the cache the UI observes. Observers are attached AFTER the array + * is populated — attaching first would make loading write straight back. + * + * Does nothing when the table is empty, so a wallet whose mints have not yet + * been seeded (the v39 migration) keeps whatever applySnapshot restored. + */ + loadMintsFromDatabase() { + const records = Database.getMints() + + if (records.length === 0) { + log.trace('[loadMintsFromDatabase]', 'No mints in the database') + return + } + + self.mints.replace( + records.map(r => + MintModel.create({ + ...r, + // The column stores ISO; types.Date takes a Date or a unix ms + // timestamp, never a string. + createdAt: r.createdAt ? new Date(r.createdAt) : undefined, + // Recreate the counter shells addMint would have built via + // initKeyset. They are NOT optional: hydrateCountersFromDatabase + // fills the real index into these, and with no shell to fill, + // the counter would be created on demand at 0 and re-derive + // blinded secrets the mint has already signed. The values + // themselves are volatile and come from mint_counters. + proofsCounters: (r.keysets ?? []).map((k: any) => ({ + keyset: k.id, + unit: k.unit, + })), + } as any), + ), + ) + log.trace('[loadMintsFromDatabase]', {loaded: self.mints.length}) + }, })) .actions(self => ({ addMint: flow(function* addMint(mintUrl: string) { @@ -184,6 +360,12 @@ export const MintsStoreModel = types self.mints.push(mintInstance) + // Write it once, then let the observer carry every later change. The + // explicit write is needed because observers fire on CHANGE, and the + // mint was fully built before it entered the tree. + self.persistMint(getSnapshot(mintInstance as any)) + self.observeMint(mintInstance as Mint) + // SQLite retains derivation counters by keysetId across mint removal // (rows are never deleted), so a re-added mint recovers its real // counter from the authority here — now also when it is re-added under @@ -254,8 +436,17 @@ export const MintsStoreModel = types } if (mintInstance) { + const mintId = mintInstance.id as string + + // Dispose BEFORE destroying: the observer would otherwise fire on the + // teardown and try to persist a dying node. + self.unobserveMint(mintId) + // No counter backup needed: the mint's mint_counters rows are - // retained in SQLite and restored on re-add via hydrate. + // retained in SQLite and restored on re-add via hydrate — which is + // also why removeMintById deliberately leaves them behind. + Database.removeMintById(mintId) + detach(mintInstance) destroy(mintInstance) log.info('[removeMint]', 'Mint removed from MintsStore') @@ -281,6 +472,43 @@ export const MintsStoreModel = types get allMints() { return self.mints }, + + /** + * The mints as a backup payload. + * + * Built from the LIVE instances, and it must stay that way: mints are + * mastered in SQLite and stripped in postProcessSnapshot, so + * `getSnapshot(mintsStore).mints` is ALWAYS empty. A backup taken from the + * store snapshot would contain zero mints, raise no error, and only reveal + * the loss on restore. That is the single most destructive mistake available + * in this file, which is why the export lives here — behind a test — rather + * than inline in the screen. + * + * Two deliberate deviations from a plain snapshot: + * - `keys` are dropped: they are re-fetched from the mint on import, and + * they are the bulk of the payload. + * - `counter` is re-injected per keyset. It is volatile (mastered in + * mint_counters), so it is absent from the snapshot — and a backup + * restored with counter 0 would re-derive blinded secrets the mint has + * already signed. + */ + get backupSnapshot() { + return { + mints: self.mints.map((liveMint: Mint) => { + const mint: any = JSON.parse(JSON.stringify(getSnapshot(liveMint as any))) + + mint.keys = [] + + mint.proofsCounters?.forEach((pc: any) => { + const live = liveMint.proofsCounters?.find(c => c.keyset === pc.keyset) + if (live) pc.counter = live.counter + }) + + return mint + }), + blockedMintUrls: [...self.blockedMintUrls], + } + }, get groupedByHostname() { const grouped: Record = {} diff --git a/src/models/ProofsStore.ts b/src/models/ProofsStore.ts index ea8d025..4fb288f 100644 --- a/src/models/ProofsStore.ts +++ b/src/models/ProofsStore.ts @@ -78,13 +78,17 @@ import { * Spendable proofs whose `mintUrl` matches no mint in the wallet, grouped by * that url. * - * This should always be empty. `proofs.mintUrl` is a denormalized copy of a - * mint's LOCATOR, and it is joined to `mint.mintUrl` by string equality - * across two persistence engines — proofs in SQLite, mint.mintUrl in the - * MMKV snapshot. A crash between those two writes during a mint-url edit - * desyncs them, and the balance view then counts the proofs in the unit total - * while attributing them to no mint (see `balances`), which also makes them - * unspendable, since send and melt select proofs by mint. + * This should always be empty, and is now a residual safety net rather than a + * live hazard. `proofs.mintUrl` is a denormalized copy of a mint's LOCATOR, + * joined to `mint.mintUrl` by string equality. That copy used to be able to + * drift: the mint's url lived in the MMKV snapshot while its proofs lived in + * SQLite, so a mint-url edit spanned two engines with no transaction between + * them, and a crash in the gap left proofs owned by no mint. Both now live in + * SQLite and the rename is a single transaction (mintsRepo.updateMintUrl), so + * that path is closed — this stays because the consequence is severe enough to + * keep watching for: the balance view counts such proofs in the unit total + * while attributing them to no mint (see `balances`), and they cannot be + * spent, since send and melt select proofs by mint. * * SPENT proofs are excluded: they are outside the balance and are exactly * what a removed mint leaves behind. @@ -268,16 +272,18 @@ import { }, /** - * Repoint this mint's proofs at its new url (see Mint.setMintUrl). + * Mirror a mint-url edit onto the in-memory proofs — MEMORY ONLY. * - * SQLite first: a failed write then propagates with the MST tree still - * matching the database. The reverse order would leave memory holding a url - * SQLite never got — the UI would show the balance under the new mint until - * the next restart silently moved it back. + * The SQLite write is deliberately not here. It belongs in the same + * transaction as the mint's own row (Database.updateMintUrlWithProofs, called + * from Mint.setMintUrl), because those two writes must not be separable: a + * crash between them leaves proofs pointing at a url no mint owns, and the + * money then counts toward the total while belonging to no mint — and cannot + * be spent, since send and melt select proofs by mint. + * + * Call this only AFTER that transaction commits. */ - updateMintUrl(currentMintUrl: string, updatedMintUrl: string) { - Database.updateProofsMintUrl(currentMintUrl, updatedMintUrl) - + updateMintUrlInMemory(currentMintUrl: string, updatedMintUrl: string) { const updateInMap = (map: typeof self.proofs) => { for (const proof of map.values()) { if (proof.mintUrl === currentMintUrl) { @@ -293,7 +299,7 @@ import { updateInMap(self.proofs) - log.trace('[updateMintUrl] Updated mint URL in proofs') + log.trace('[updateMintUrlInMemory] Mirrored mint url onto proofs') }, // Import proofs from backup without validation or side effects diff --git a/src/models/RootStore.ts b/src/models/RootStore.ts index 708ae97..1572c79 100644 --- a/src/models/RootStore.ts +++ b/src/models/RootStore.ts @@ -12,7 +12,7 @@ import {AuthStoreModel} from './AuthStore' // Direct import, not the '../services' barrel — see the note in Mint.ts. import { log } from '../services/logService' -export const rootStoreModelVersion = 38 // Update this if model changes require migrations defined in setupRootStore.ts +export const rootStoreModelVersion = 39 // Update this if model changes require migrations defined in setupRootStore.ts /** * A RootStore model. */ diff --git a/src/models/helpers/setupRootStore.ts b/src/models/helpers/setupRootStore.ts index dd013e0..54bcd73 100644 --- a/src/models/helpers/setupRootStore.ts +++ b/src/models/helpers/setupRootStore.ts @@ -11,6 +11,7 @@ */ import { applySnapshot, + getSnapshot, IDisposer, onSnapshot, } from 'mobx-state-tree' @@ -82,6 +83,20 @@ export async function setupRootStore(rootStore: RootStore, opts: SetupRootStoreO const {proofsStore, walletProfileStore, authStore, userSettingsStore, transactionsStore, mintsStore} = rootStore + // Hydrate the mints from SQLite, the authority. Deliberately BEFORE the + // counter hydrate below: this replaces the mints array with fresh nodes, and + // the counters must attach to the nodes that survive. + // + // A no-op when the table is empty, which is exactly the case on the launch + // that migrates: mints still come from the MMKV snapshot applied above, the + // v39 seed copies them into SQLite, and every launch after this one loads + // them from here instead. + mintsStore.loadMintsFromDatabase() + + // Attach the per-mint persistence observers AFTER the mints are in place — + // attaching first would make loading them write straight back. + mintsStore.observeMints() + if(walletProfileStore.walletId) { Sentry.setUser({ id: walletProfileStore.walletId }) } @@ -385,6 +400,26 @@ async function _runMigrations(rootStore: RootStore, restoredState: any) { } } + if(currentVersion < 39) { + // db v35 created the mints/mint_keysets tables empty: mints lived in this + // MST snapshot, so nothing in SQL could read them. Copy them across once, + // here, where the store is hydrated. + // + // Reads the LIVE store, not restoredState: applySnapshot has already run, + // so the tree holds exactly what the snapshot did — and unlike the earlier + // seeds, nothing about a mint is stripped on load, so there is no reason + // to reach for the raw snapshot. + // + // persistAllMints rather than a mapping written out here: that mapping + // already exists (toMintRecord), and a second copy of it is precisely the + // kind of duplicate that drifts. Idempotent — it upserts by each mint's + // own id, so a re-run cannot duplicate a mint. After this, mints are + // mastered in SQLite and every later launch hydrates from there. + if (rootStore.mintsStore.mintCount > 0) { + rootStore.mintsStore.persistAllMints() + } + } + // 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/screens/ExportBackupScreen.tsx b/src/screens/ExportBackupScreen.tsx index 2107d4e..5cf5e32 100644 --- a/src/screens/ExportBackupScreen.tsx +++ b/src/screens/ExportBackupScreen.tsx @@ -142,22 +142,14 @@ export const ExportBackupScreen = function ExportBackup({ route }: Props) { } } - if(isMintsInBackup) { - exportedMintsStore = JSON.parse(JSON.stringify(getSnapshot(mintsStore))) - - exportedMintsStore.mints.forEach((mint: any) => { - mint.keys = []; - - // `counter` is stripped from snapshots (mastered in SQLite), so - // re-inject the live derivation index per keyset. Exporting a - // backup with counter 0 would risk blinded-secret reuse on the - // recovered wallet. - const liveMint = mintsStore.findByUrl(mint.mintUrl) - mint.proofsCounters?.forEach((pc: any) => { - const live = liveMint?.proofsCounters.find(c => c.keyset === pc.keyset) - if (live) pc.counter = live.counter - }) - }) + if(isMintsInBackup) { + // NOT getSnapshot(mintsStore): mints are mastered in SQLite and stripped + // in snapshot postprocess (exactly as proofsStore is above), so the store + // snapshot reports zero mints. backupSnapshot builds from the live + // instances and re-injects the volatile derivation counters — it lives on + // the store, behind a test, because getting this wrong exports an empty + // backup with no error and only shows up on restore. + exportedMintsStore = mintsStore.backupSnapshot as MintsStoreSnapshot //log.trace({exportedMintsStore}) } diff --git a/src/screens/ImportBackupScreen.tsx b/src/screens/ImportBackupScreen.tsx index 08f888f..020319b 100644 --- a/src/screens/ImportBackupScreen.tsx +++ b/src/screens/ImportBackupScreen.tsx @@ -204,6 +204,15 @@ export const ImportBackupScreen = observer(function ImportBackupScreen({ route } applySnapshot(mintsStore, walletSnapshot.mintsStore) applySnapshot(contactsStore, walletSnapshot.contactsStore) + // Mints are mastered in SQLite, and applySnapshot only puts them in the + // model — so write them through explicitly. The per-mint observers fire on + // CHANGE, and these nodes arrived already-formed, so nothing else would + // persist them; the mints would then vanish on the next launch, which + // hydrates from the database. Re-attach the observers too: applySnapshot + // replaced the array, so the previous ones point at destroyed nodes. + mintsStore.persistAllMints() + mintsStore.observeMints() + // The backup carries real derivation counters in its raw MST snapshot. // `counter` is VOLATILE in the model (mastered in SQLite), so the // applySnapshot above does NOT load it — read the values straight from diff --git a/src/services/db/index.ts b/src/services/db/index.ts index 2724617..ef77a35 100644 --- a/src/services/db/index.ts +++ b/src/services/db/index.ts @@ -34,7 +34,6 @@ import { import { addOrUpdateProof, addOrUpdateProofs, - updateProofsMintUrl, removeAllProofs, getProofById, getProofs, @@ -82,6 +81,13 @@ import { updateOnchainMintQuoteAmounts, extendOnchainMintQuoteWatch, } from './onchainQuotesRepo' +import { + upsertMint, + getMints, + removeMintById, + updateMintUrl as updateMintUrlWithProofs, + seedMints, +} from './mintsRepo' export type {TransactionSearchFilters} from './transactionsRepo' export type { @@ -95,9 +101,15 @@ export type {OnchainMintQuoteRecord} from './onchainQuotesRepo' export {ONCHAIN_QUOTE_WATCH_DAYS} from './onchainQuotesRepo' export type {MeltRecoveryRecord, MeltRecoverySeed} from './meltRecoveryRepo' export type {InFlightRequestRecord, InFlightRequestSeed} from './inFlightRepo' +export type {MintRecord} from './mintsRepo' export const Database = { getInstance, + upsertMint, + getMints, + removeMintById, + updateMintUrlWithProofs, + seedMints, getDatabaseVersion, cleanAll, getTransactionsCount, @@ -124,7 +136,6 @@ export const Database = { getPendingAmount, addOrUpdateProof, addOrUpdateProofs, - updateProofsMintUrl, removeAllProofs, getProofById, getProofs, diff --git a/src/services/db/instance.ts b/src/services/db/instance.ts index b134f07..b780112 100644 --- a/src/services/db/instance.ts +++ b/src/services/db/instance.ts @@ -1,5 +1,5 @@ import {DbConnection, open, SQLBatchTuple} from './connection' -import {createSchemaQueries} from './schema' +import {createSchemaQueries, createTable, DBVERSION_COLUMNS} from './schema' import {_dbVersion, readDatabaseVersion, seedDatabaseVersion, runMigrations} from './migrations' import {dbError} from './errors' import {log} from '../logService' @@ -27,25 +27,44 @@ const _createDatabaseInstance = function () { } } +/** + * Bring the database to the current schema — by BUILDING it (fresh install) or by + * MIGRATING it (everything else), never both. + * + * That either/or is load-bearing. `createSchemaQueries` describes today's shape, so + * running it on an existing database was actively harmful: `CREATE TABLE IF NOT + * EXISTS` skips the tables a device already has, but silently creates the ones it + * does not — at TODAY's shape. A device old enough to predate a table therefore got + * it fully-formed, and the migration that adds a column to that table then died on + * `duplicate column name`, rolling back the entire batch and leaving the database + * unmigrated. Shipped exactly that: a v29 wallet, which predates onchain_mint_quotes + * (v31), got it built WITH the mintId that v33 exists to add, and came up with a + * zero balance. + * + * So: the version row decides. Only `dbversion` itself is created unconditionally, + * because reading the version requires it. + */ const _createOrUpdateSchema = function (db: DbConnection) { try { - const {rowsAffected} = db.executeBatch(createSchemaQueries) + // The one table that must exist before anything can be decided. + db.execute(createTable('dbversion', DBVERSION_COLUMNS)) - if (rowsAffected && rowsAffected > 0) { - log.info('[_createOrUpdateSchema] New database schema created') - } + const version = readDatabaseVersion(db) - let version = readDatabaseVersion(db) if (version === null) { - // Fresh database: the schema was just created at the latest shape, so - // seed the version row and skip migrations. + // Fresh install: build at the latest shape and record it, so the migrations + // that produced that shape are correctly skipped. + db.executeBatch(createSchemaQueries) seedDatabaseVersion(db) - version = _dbVersion + log.info('[_createOrUpdateSchema]', `New database created at version ${_dbVersion}`) + return } log.info('[_createOrUpdateSchema]', `Device database version: ${version}`) - // Trigger migrations if there is versions mismatch + // Existing database: migrations own every shape change from here. Each table a + // later version introduced is created by ITS migration, at the shape that + // version had — which is what keeps the subsequent ALTERs valid. if (version < _dbVersion) { runMigrations(db) } diff --git a/src/services/db/migrations.ts b/src/services/db/migrations.ts index ab0cd95..09987dd 100644 --- a/src/services/db/migrations.ts +++ b/src/services/db/migrations.ts @@ -2,7 +2,7 @@ import {DbConnection, SQLBatchTuple} from './connection' // RESERVATIONS_COLUMNS / ONCHAIN_MINT_QUOTES_COLUMNS are deliberately NOT imported: // v26 and v31 build those tables from the frozen historical shapes below, not from // the live schema. See the note there. -import {createTable, PROOFS_COLUMNS, PROOFS_COLUMN_NAMES, MINT_COUNTERS_COLUMNS, MINT_COUNTERS_COLUMN_NAMES, MELT_RECOVERY_COLUMNS, INFLIGHT_REQUESTS_COLUMNS, WALLET_COUNTERS_COLUMNS} from './schema' +import {createTable, PROOFS_COLUMNS, PROOFS_COLUMN_NAMES, MINT_COUNTERS_COLUMNS, MINT_COUNTERS_COLUMN_NAMES, MELT_RECOVERY_COLUMNS, INFLIGHT_REQUESTS_COLUMNS, WALLET_COUNTERS_COLUMNS, MINTS_COLUMNS, MINT_KEYSETS_COLUMNS} from './schema' import {dbError} from './errors' import {log} from '../logService' @@ -256,6 +256,25 @@ export const MIGRATIONS: Migration[] = [ [`ALTER TABLE melt_recovery_v34 RENAME TO melt_recovery`], ], }, + { + // Master the mints here instead of in the MST/MMKV snapshot. + // + // Mints were the last core entity persisted by serializing the whole tree. + // Since postProcessSnapshot already strips proofs and transactions, mints — + // with every keyset's `keys` map — had become the largest thing left in it, and + // `JSON.stringify(snapshot)` runs on EVERY MST action, including every proof + // mutation during a send. It also puts mints in the same engine as proofs, so a + // mint-url edit can finally commit atomically with the proofs it renames. + // + // Created EMPTY: mints live in the MMKV snapshot, so nothing in SQL can read + // them. The v39 seed in setupRootStore copies them once the store is hydrated — + // the same shape as the counters/melt/inflight seeds before it. + version: 35, + queries: [ + [createTable('mints', MINTS_COLUMNS)], + [createTable('mint_keysets', MINT_KEYSETS_COLUMNS)], + ], + }, ] /** diff --git a/src/services/db/mintsRepo.ts b/src/services/db/mintsRepo.ts new file mode 100644 index 0000000..c1e793b --- /dev/null +++ b/src/services/db/mintsRepo.ts @@ -0,0 +1,228 @@ +import {SQLBatchTuple} from './connection' +import {getInstance} from './instance' +import {dbError} from './errors' +import {log} from '../logService' + +// ───────────────────────────────────────────────────────────────────────────── +// Mints and their keysets. +// +// The authority for the wallet's mints. They were the last core entity persisted +// only by serializing the whole MST tree to MMKV — and since proofs and +// transactions are stripped from that snapshot, mints (with every keyset's `keys` +// map) had become the largest thing left in it, re-serialized on EVERY MST action +// anywhere in the tree. +// +// As with proofs and transactions: SQLite is the authority, the MST model is an +// in-memory cache. Reads go through the model (MobX cannot observe a table); +// writes land here. +// +// See MINTS_COLUMNS / MINT_KEYSETS_COLUMNS for why the keyset and keys payloads are +// stored as whole JSON objects rather than exploded into columns. +// ───────────────────────────────────────────────────────────────────────────── + +/** A mint row plus its keysets, shaped to drop straight into MintModel.create. */ +export type MintRecord = { + id: string + mintUrl: string + hostname?: string + shortname?: string + units: string[] + keysets: any[] + keys: any[] + mintInfo?: any + color?: string + status?: string + createdAt?: string +} + +const parseJson = (value: string | null | undefined, fallback: T): T => { + if (!value) return fallback + try { + return JSON.parse(value) as T + } catch { + return fallback + } +} + +/** + * Build the full write for one mint: the mint row plus a row per keyset. + * + * Returned as batch tuples so the same write can run standalone (upsertMint) or be + * folded into a larger transaction — which is what lets a mint-url edit commit + * atomically with the proofs it renames. + * + * Keysets are upserted, never cleared-and-reinserted: a keyset is only ever added + * (refreshKeysets does not prune, and removeKeyset is unused), and a delete/insert + * pair would briefly leave a mint with no keys inside the transaction. + */ +export const buildMintUpsert = function (mint: MintRecord): SQLBatchTuple[] { + const batch: SQLBatchTuple[] = [ + [ + `INSERT INTO mints (id, mintUrl, hostname, shortname, color, status, units, mintInfo, createdAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + mintUrl = excluded.mintUrl, + hostname = excluded.hostname, + shortname = excluded.shortname, + color = excluded.color, + status = excluded.status, + units = excluded.units, + mintInfo = excluded.mintInfo`, + [ + mint.id, + mint.mintUrl, + mint.hostname ?? null, + mint.shortname ?? null, + mint.color ?? null, + mint.status ?? null, + JSON.stringify(mint.units ?? []), + mint.mintInfo ? JSON.stringify(mint.mintInfo) : null, + mint.createdAt ?? new Date().toISOString(), + ], + ], + ] + + const keysById = new Map((mint.keys ?? []).map(k => [k.id, k])) + + for (const keyset of mint.keysets ?? []) { + const keys = keysById.get(keyset.id) + batch.push([ + `INSERT INTO mint_keysets (keysetId, mintId, unit, keyset, keys) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(keysetId) DO UPDATE SET + mintId = excluded.mintId, + unit = excluded.unit, + keyset = excluded.keyset, + -- Never overwrite stored keys with null: a keyset's keys are fetched + -- separately from its metadata, so a metadata-only refresh must not erase + -- them (without keys the wallet cannot verify or spend that keyset). + keys = COALESCE(excluded.keys, mint_keysets.keys)`, + [keyset.id, mint.id, keyset.unit ?? null, JSON.stringify(keyset), keys ? JSON.stringify(keys) : null], + ]) + } + + return batch +} + +/** Write one mint (and its keysets) atomically. */ +export const upsertMint = function (mint: MintRecord): void { + try { + getInstance().executeBatch(buildMintUpsert(mint)) + } catch (e: any) { + throw dbError('Mint could not be saved to the database', e) + } +} + +/** Every mint with its keysets, for the startup hydrate. */ +export const getMints = function (): MintRecord[] { + try { + const db = getInstance() + const {rows: mintRows} = db.execute( + `SELECT id, mintUrl, hostname, shortname, color, status, units, mintInfo, createdAt + FROM mints ORDER BY createdAt`, + ) + const {rows: keysetRows} = db.execute(`SELECT keysetId, mintId, unit, keyset, keys FROM mint_keysets`) + + const keysetsByMint = new Map() + for (const row of keysetRows?._array ?? []) { + const entry = keysetsByMint.get(row.mintId) ?? {keysets: [], keys: []} + entry.keysets.push(parseJson(row.keyset, null)) + // A keyset whose keys were never fetched contributes nothing to `keys`, + // matching the model, where the two arrays are independent. + const keys = parseJson(row.keys, null) + if (keys) entry.keys.push(keys) + keysetsByMint.set(row.mintId, entry) + } + + return (mintRows?._array ?? []).map((row: any) => { + const owned = keysetsByMint.get(row.id) ?? {keysets: [], keys: []} + return { + id: row.id, + mintUrl: row.mintUrl, + hostname: row.hostname ?? undefined, + shortname: row.shortname ?? undefined, + units: parseJson(row.units, []), + keysets: owned.keysets.filter(Boolean), + keys: owned.keys, + mintInfo: parseJson(row.mintInfo, undefined), + color: row.color ?? undefined, + status: row.status ?? undefined, + createdAt: row.createdAt ?? undefined, + } + }) + } catch (e: any) { + throw dbError('Mints could not be retrieved from the database', e) + } +} + +/** + * Remove a mint and its keysets. + * + * Its mint_counters rows are deliberately left behind: they are keyed by keysetId + * and retained across removal, so re-adding the mint recovers its real derivation + * counter instead of restarting at 0 (see MINT_COUNTERS_COLUMNS). + */ +export const removeMintById = function (mintId: string): void { + try { + getInstance().executeBatch([ + [`DELETE FROM mint_keysets WHERE mintId = ?`, [mintId]], + [`DELETE FROM mints WHERE id = ?`, [mintId]], + ]) + log.debug('[removeMintById]', 'Mint removed from the database', {mintId}) + } catch (e: any) { + throw dbError('Mint could not be removed from the database', e) + } +} + +/** + * Point a mint at a new url, together with the proofs that reference it — in ONE + * transaction. + * + * This is the whole reason mints belong in SQLite. Before, the mint's url lived in + * the MMKV snapshot and its proofs in SQLite, so a rename spanned two engines with + * no transaction between them. A crash in that window left the proofs pointing at a + * url no mint owned: the money vanished from every per-mint balance while still + * counting toward the total, and was unspendable, because send and melt select + * proofs by mint. + * + * `proofs.mintUrl` stays denormalized, but it can no longer drift — it is now + * maintained inside the same transaction as its source of truth. + */ +export const updateMintUrl = function ( + mintId: string, + currentMintUrl: string, + updatedMintUrl: string, + hostname: string | null, +): void { + try { + getInstance().executeBatch([ + [`UPDATE mints SET mintUrl = ?, hostname = ? WHERE id = ?`, [updatedMintUrl, hostname, mintId]], + [`UPDATE proofs SET mintUrl = ? WHERE mintUrl = ?`, [updatedMintUrl, currentMintUrl]], + ]) + log.debug('[updateMintUrl]', 'Mint url updated with its proofs', { + mintId, + currentMintUrl, + updatedMintUrl, + }) + } catch (e: any) { + throw dbError('Mint url could not be updated in the database', e) + } +} + +/** + * One-time copy of the MST/MMKV-resident mints into SQLite (the v39 seed). + * + * Idempotent by construction: it upserts by the mint's own id, so re-running it + * cannot duplicate a mint. Runs in a single batch. + */ +export const seedMints = function (mints: MintRecord[]): {seeded: number} { + if (!mints || mints.length === 0) return {seeded: 0} + try { + const batch = mints.flatMap(buildMintUpsert) + getInstance().executeBatch(batch) + log.info('[seedMints]', 'Seeded mints into SQLite', {count: mints.length}) + return {seeded: mints.length} + } catch (e: any) { + throw dbError('Mints could not be seeded into the database', e) + } +} diff --git a/src/services/db/proofsRepo.ts b/src/services/db/proofsRepo.ts index 0b6cfd7..9109bcb 100644 --- a/src/services/db/proofsRepo.ts +++ b/src/services/db/proofsRepo.ts @@ -98,25 +98,11 @@ export const addOrUpdateProofs = function ( } -export const updateProofsMintUrl = function (currentMintUrl: string, updatedMintUrl: string) { - try { - const query = ` - UPDATE proofs - SET mintUrl = ? - WHERE mintUrl = ? - ` - const params = [updatedMintUrl, currentMintUrl] - - const db = getInstance() - db.execute(query, params) - - log.debug('[updateMintUrl]', 'Proof mintUrl updated', {currentMintUrl, updatedMintUrl}) - - - } catch (e: any) { - throw dbError('Could not update proof mintUrl in database', e) - } -} +// A standalone updateProofsMintUrl used to live here. It is gone on purpose: a +// mint-url edit must move the mint row and its proofs in ONE transaction, so that +// write belongs with the mint's own — see mintsRepo.updateMintUrl. Repointing +// proofs by themselves is what left them owned by no mint when the two writes were +// separable. export const removeAllProofs = async function () { try { diff --git a/src/services/db/schema.ts b/src/services/db/schema.ts index 87bb163..6bd0d32 100644 --- a/src/services/db/schema.ts +++ b/src/services/db/schema.ts @@ -270,6 +270,63 @@ export const ONCHAIN_MINT_QUOTES_COLUMNS = ` updatedAt TEXT ` +/** + * Mints — the wallet's mints, mastered here rather than in the MST/MMKV snapshot. + * + * Mints were the last core entity still persisted by serializing the whole MST tree + * to MMKV. That cost more than it looks: postProcessSnapshot already strips proofs + * and transactions, so mints (with every keyset's `keys` map) had become BY FAR the + * largest thing left in the tree — and while an equality guard skips redundant + * writes, `JSON.stringify(snapshot)` still ran on EVERY MST action anywhere, + * including every proof mutation during a send. Moving them here takes that cost off + * the hot path, and lets a mint-url edit commit atomically with the proofs it + * renames (previously two engines, no transaction). + * + * MST keeps a full in-memory copy — reads and MobX reactivity are unchanged. SQLite + * is the authority; the model is the cache. Same split as proofs and transactions. + * + * `mintInfo` and `units` are JSON: nothing queries inside them. `units` is + * derivable from the keysets, but is stored so a load round-trips the model exactly + * rather than reconstructing it. + */ +export const MINTS_COLUMNS = ` + id TEXT PRIMARY KEY NOT NULL, + mintUrl TEXT NOT NULL, + hostname TEXT, + shortname TEXT, + color TEXT, + status TEXT, + units TEXT, + mintInfo TEXT, + createdAt TEXT +` + +/** + * A mint's keysets, one row each, and the keys that belong to them. + * + * Keyed by keysetId ALONE, matching mint_counters — the two now agree on what a + * keyset is, and a keyset id is unique wallet-wide (enforced at the door by + * CashuUtils.isCollidingKeysetId, per NUT-02). `mintId` is what makes "which mint + * owns this keyset" answerable in SQL for the first time, which is also what would + * let proofs derive their mint from `proofs.id` (a proof's id IS its keyset id) + * instead of carrying a denormalized url. That is deliberately NOT done yet. + * + * `keyset` and `keys` are stored as whole JSON objects rather than exploded into + * columns: MintKeyset carries fields like `final_expiry` that feed NUT-02 v2 id + * derivation, and enumerating columns would silently drop whatever cashu-ts adds + * next. keysetId/mintId/unit are the queryable projection; the blobs are the payload. + * + * `keys` is nullable — a keyset the mint advertised but whose keys were not fetched + * (or were skipped as an unsupported unit) has none. + */ +export const MINT_KEYSETS_COLUMNS = ` + keysetId TEXT PRIMARY KEY NOT NULL, + mintId TEXT NOT NULL, + unit TEXT, + keyset TEXT NOT NULL, + keys TEXT +` + /** Build a CREATE TABLE statement from a column block. */ export const createTable = ( name: string, @@ -308,4 +365,9 @@ export const createSchemaQueries: SQLBatchTuple[] = [ // Onchain (NUT-30) mint quotes — the long-lived deposit addresses transactions // point at via transactions.quote (see onchainQuotesRepo). [createTable('onchain_mint_quotes', ONCHAIN_MINT_QUOTES_COLUMNS)], + // The wallet's mints and their keysets, mastered here rather than in the MMKV + // snapshot (see mintsRepo). Seeded from the pre-upgrade snapshot on first run + // after the migration. + [createTable('mints', MINTS_COLUMNS)], + [createTable('mint_keysets', MINT_KEYSETS_COLUMNS)], ]