From 8e73efe42a52f2d8eccf6a37c7112684183a8d81 Mon Sep 17 00:00:00 2001 From: minibits-cash Date: Fri, 17 Jul 2026 10:10:33 +0200 Subject: [PATCH] Carry mints into SQLite by looking, not by asking the version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A test wallet lost every mint on restart. Confirmed on device. The mints were in neither place. postProcessSnapshot strips them from every save, and the one-time seed that was supposed to copy them into SQLite never ran — so the snapshot stopped carrying them while the table stayed empty, and the next launch had nothing to load. The seed was gated on `currentVersion < 39`, and the version lied. rootStore.version is `types.optional(types.number, rootStoreModelVersion)`, so a factory reset stamps a wallet as fully migrated on the spot. Restore an older snapshot over that — install the v26 native bundle, whose rootStoreModelVersion is 32 — and the version stays 39: `39 < 39` skips the seed forever, on a wallet that has never seeded anything. The circumstances were unusual; the design was wrong. Gating a DATA migration on a version number assumes the version and the data agree, and SQLite and the MMKV snapshot are separate engines that fail independently — the database migration can roll back while the rootStore migration commits, or vice versa. When they disagree, the version is bookkeeping and the data is the truth. hydrateMintsFromDatabase now converges on the actual state, every launch: - table has mints -> SQLite wins; the snapshot no longer carries them - table empty, store has -> they exist only in the snapshot; write them through - both empty -> a fresh wallet; nothing to do No version consulted, so it cannot be skipped, and it is idempotent (an upsert by mint id). The <39 block is gone, with a note on why it must not come back. The mintId backfills in <38 stay version-gated, and correctly so: those migrate pre-existing rows once. This one is not that — it is a reconciliation between two stores of the same fact, and reconciliations must look. Tests: 510 pass. Two reproduce the device's exact state (mints only in the snapshot, no migration running) and fail against the version-gated design. Recovery for an already-emptied wallet needs no code: re-adding the mint at the same url reattaches its proofs (proofs.mintUrl never moved) and addMint restores the derivation index from mint_counters, which is keyed by keysetId and so was never touched. No blinded-secret reuse — which is what keying counters by keyset, and giving Mint.id referential authority only, were for. Co-Authored-By: Claude Opus 4.8 --- __tests__/mintsPersistence.test.ts | 64 +++++++++++++++++++++++----- src/models/MintsStore.ts | 45 +++++++++++++++---- src/models/helpers/setupRootStore.ts | 45 +++++++------------ 3 files changed, 106 insertions(+), 48 deletions(-) diff --git a/__tests__/mintsPersistence.test.ts b/__tests__/mintsPersistence.test.ts index 4921a63..1ebb46e 100644 --- a/__tests__/mintsPersistence.test.ts +++ b/__tests__/mintsPersistence.test.ts @@ -48,7 +48,7 @@ const mintSnapshot = (overrides: Record = {}) => ({ 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 + // hydrateMintsFromDatabase rebuilds it). The values themselves are volatile and come // from mint_counters. proofsCounters: [{keyset: KEYSET_1, unit: 'sat'}], color: '#abcdef', @@ -134,7 +134,7 @@ describe('mint persistence', () => { }) }) - describe('loadMintsFromDatabase', () => { + describe('hydrateMintsFromDatabase', () => { test('hydrates the mints back, keysets and keys included', () => { const seeded = makeRoot([mintSnapshot()]) seeded.mintsStore.persistAllMints() @@ -143,7 +143,7 @@ describe('mint persistence', () => { const restarted = makeRoot([]) expect(restarted.mintsStore.mints).toHaveLength(0) - restarted.mintsStore.loadMintsFromDatabase() + restarted.mintsStore.hydrateMintsFromDatabase() const mint = restarted.mintsStore.mints[0] expect(mint.mintUrl).toBe(MINT_URL) @@ -154,16 +154,60 @@ describe('mint persistence', () => { // 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', () => { + test('never wipes what the snapshot restored when the table is empty', () => { const root = makeRoot([mintSnapshot()]) - root.mintsStore.loadMintsFromDatabase() + root.mintsStore.hydrateMintsFromDatabase() 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 + // THE data-loss path, seen on a real device. rootStore.version defaults to the + // CURRENT rootStoreModelVersion, so a factory reset stamps a wallet as fully + // migrated on the spot; restore an older snapshot over that and a version-gated + // seed never runs. Meanwhile postProcessSnapshot strips mints from every save — + // so unless this converges on the DATA, the mints are in neither place and are + // gone on the next launch. + test('persists mints that exist ONLY in the snapshot, with no migration involved', () => { + const root = makeRoot([mintSnapshot()]) + expect(Database.getMints()).toEqual([]) // table empty, as on that device + + root.mintsStore.hydrateMintsFromDatabase() + + // Written through purely because the data said so. + expect(Database.getMints().map(m => m.mintUrl)).toEqual([MINT_URL]) + }) + + test('and those mints then survive a restart', () => { + const root = makeRoot([mintSnapshot()]) + root.mintsStore.hydrateMintsFromDatabase() + + // The next launch: the snapshot no longer carries mints at all. + const restarted = makeRoot([]) + restarted.mintsStore.hydrateMintsFromDatabase() + + expect(restarted.mintsStore.mints.map(m => m.mintUrl)).toEqual([MINT_URL]) + }) + + test('observes the snapshot-only mints too, so later edits persist', () => { + const root = makeRoot([mintSnapshot()]) + root.mintsStore.hydrateMintsFromDatabase() + + root.mintsStore.mints[0].setProp('shortname', 'Renamed') + + expect(Database.getMints()[0].shortname).toBe('Renamed') + }) + + test('does nothing when there are no mints anywhere (a fresh wallet)', () => { + const root = makeRoot([]) + root.mintsStore.hydrateMintsFromDatabase() + + expect(root.mintsStore.mints).toHaveLength(0) + expect(Database.getMints()).toEqual([]) + }) + + // THE fund-loss path, and the reason hydrateMintsFromDatabase 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 @@ -174,7 +218,7 @@ describe('mint persistence', () => { Database.setCounter(KEYSET_1, 'sat', 342) const restarted = makeRoot([]) - restarted.mintsStore.loadMintsFromDatabase() + restarted.mintsStore.hydrateMintsFromDatabase() // The shell must exist for the keyset... expect(restarted.mintsStore.mints[0].proofsCounters.map(c => c.keyset)).toEqual([KEYSET_1]) @@ -202,7 +246,7 @@ describe('mint persistence', () => { Database.setCounter(KEYSET_2, 'sat', 77) const restarted = makeRoot([]) - restarted.mintsStore.loadMintsFromDatabase() + restarted.mintsStore.hydrateMintsFromDatabase() restarted.mintsStore.hydrateCountersFromDatabase() const counters = restarted.mintsStore.mints[0].proofsCounters @@ -215,7 +259,7 @@ describe('mint persistence', () => { seeded.mintsStore.persistAllMints() const restarted = makeRoot([]) - restarted.mintsStore.loadMintsFromDatabase() + restarted.mintsStore.hydrateMintsFromDatabase() // onchain quotes, reservations and transactions all point at Mint.id. expect(restarted.mintsStore.findById('mint1111')).toBeDefined() @@ -267,7 +311,7 @@ describe('mint persistence', () => { root.mintsStore.mints[0].setMintUrl!('https://moved.test') const restarted = makeRoot([]) - restarted.mintsStore.loadMintsFromDatabase() + restarted.mintsStore.hydrateMintsFromDatabase() expect(restarted.mintsStore.mints[0].mintUrl).toBe('https://moved.test') }) diff --git a/src/models/MintsStore.ts b/src/models/MintsStore.ts index b523729..55a1007 100644 --- a/src/models/MintsStore.ts +++ b/src/models/MintsStore.ts @@ -262,20 +262,46 @@ export const MintsStoreModel = types }, /** - * Hydrate the mints from SQLite, the authority (startup). + * Reconcile the mints between SQLite (the authority) and whatever + * applySnapshot restored, and leave the two agreeing. Startup, every launch. * - * 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. + * CONVERGES on the actual state; it is deliberately NOT gated on a migration + * version. SQLite and the MMKV snapshot are different engines that fail + * independently, so "the seed already ran" is a claim about a version number, + * not about the data — and when the two disagree, the version loses. That is + * not hypothetical: rootStore.version defaults to the CURRENT + * rootStoreModelVersion, so a factory reset stamps a wallet as fully migrated + * on the spot. Restore an older snapshot over that and the version-gated seed + * never runs, while postProcessSnapshot strips mints from every save — the + * mints are then in neither place, and vanish on the next launch. * - * Does nothing when the table is empty, so a wallet whose mints have not yet - * been seeded (the v39 migration) keeps whatever applySnapshot restored. + * Three cases, all handled by looking rather than asking: + * - table has mints → SQLite wins; the snapshot no longer carries them. + * - table empty, store has mints → they exist only in the snapshot (a wallet + * upgrading from before mints moved here). Write them through. + * - both empty → a fresh wallet, or one with no mints. Nothing to do. + * + * Observers are attached AFTER the array settles: attaching first would make + * loading write straight back. */ - loadMintsFromDatabase() { + hydrateMintsFromDatabase() { const records = Database.getMints() if (records.length === 0) { - log.trace('[loadMintsFromDatabase]', 'No mints in the database') + // Nothing stored. Anything the snapshot restored is now the only copy + // that exists, so it has to be persisted before the next save strips + // it. persistAllMints is an upsert by mint id, so this is safe to hit + // on any launch. + if (self.mints.length > 0) { + log.info('[hydrateMintsFromDatabase]', 'Mints found only in the snapshot — persisting', { + count: self.mints.length, + }) + self.observeMints() + self.persistAllMints() + } else { + log.trace('[hydrateMintsFromDatabase]', 'No mints in the database or the snapshot') + self.observeMints() + } return } @@ -299,7 +325,8 @@ export const MintsStoreModel = types } as any), ), ) - log.trace('[loadMintsFromDatabase]', {loaded: self.mints.length}) + self.observeMints() + log.trace('[hydrateMintsFromDatabase]', {loaded: self.mints.length}) }, })) .actions(self => ({ diff --git a/src/models/helpers/setupRootStore.ts b/src/models/helpers/setupRootStore.ts index 54bcd73..e6244f3 100644 --- a/src/models/helpers/setupRootStore.ts +++ b/src/models/helpers/setupRootStore.ts @@ -83,19 +83,15 @@ 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 + // Reconcile the mints between SQLite (the authority) and the snapshot just + // applied, and attach their persistence observers. 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() + // This also carries a wallet whose mints are still only in the snapshot into + // SQLite — on any launch, not just a migrating one. See + // hydrateMintsFromDatabase for why that must not be gated on a version. + mintsStore.hydrateMintsFromDatabase() if(walletProfileStore.walletId) { Sentry.setUser({ id: walletProfileStore.walletId }) @@ -400,25 +396,16 @@ 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() - } - } + // NOTE: copying the mints into SQLite (db v35) is deliberately NOT a step + // here. It is done by mintsStore.hydrateMintsFromDatabase on EVERY launch, + // keyed on whether the table is actually empty rather than on this version. + // + // A version-gated seed cannot be trusted for it: rootStore.version defaults + // to the current rootStoreModelVersion, so a factory reset stamps a wallet as + // fully migrated on the spot, and restoring an older snapshot over that + // skips the seed forever — while postProcessSnapshot strips mints from every + // save. The mints then exist in neither place and disappear on the next + // launch. Observed on a test device. // Set once, after all steps succeed: if any step throws, the version is // NOT bumped and the whole migration retries on the next launch.