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.