From 32b0b029fb0a2aa1074e2d2a3048dc8618a9b17a Mon Sep 17 00:00:00 2001 From: minibits-cash Date: Mon, 7 Sep 2026 17:03:47 +0200 Subject: [PATCH] New import strategy merging with existing wallet fi any --- __tests__/backupRoundtrip.test.ts | 119 ++++++++++++++++------- src/models/ContactsStore.ts | 49 +++++++++- src/models/MintsStore.ts | 150 ++++++++++++++++++++--------- src/screens/ImportBackupScreen.tsx | 37 +++++-- 4 files changed, 265 insertions(+), 90 deletions(-) diff --git a/__tests__/backupRoundtrip.test.ts b/__tests__/backupRoundtrip.test.ts index 54f16d7a..7432e6de 100644 --- a/__tests__/backupRoundtrip.test.ts +++ b/__tests__/backupRoundtrip.test.ts @@ -21,7 +21,7 @@ jest.mock('../src/services/logService', () => ({ })) import {mnemonicToSeedSync} from '@scure/bip39' -import {types, getSnapshot, applySnapshot} from 'mobx-state-tree' +import {types, getSnapshot} from 'mobx-state-tree' import {encodeBackup, decodeBackup} from '../src/services/backup/backupCodec' import {MintsStoreModel, MintsStoreSnapshot} from '../src/models/MintsStore' import {ProofsStoreModel} from '../src/models/ProofsStore' @@ -107,10 +107,18 @@ const importBackup = (root: Instance, encoded: string, keysByMintUrl: Record { // Onboarding adds the Minibits mint before the user can ever reach the import // screen, so the wallet ALWAYS has a mint here — and the backup's copy of that // same mint carries a different local id. - const onboardedWallet = () => { - const root = TestRoot.create({mintsStore: {mints: [mintSnapshot({id: 'onboard1'})]}}) + const onboardedWallet = (mints: any[] = [mintSnapshot({id: 'onboard1'})]) => { + const root = TestRoot.create({mintsStore: {mints}}) root.mintsStore.persistAllMints() root.mintsStore.observeMints() return root @@ -247,12 +255,14 @@ describe('importing over an existing wallet', () => { importBackup(target, backup) - expect(Database.getMints().map(m => m.id)).toEqual(['backup01']) + // Merged into the local entry, which keeps its own id: transactions reference + // mintId, and this device's history must keep resolving. + expect(Database.getMints().map(m => m.id)).toEqual(['onboard1']) }) // The duplicate was not even the worst of it: mint_keysets is keyed by keysetId - // and its upsert moves mintId, so the replaced mint's row was left with no - // keysets and no keys — and rehydrated on the next launch as an unusable husk. + // and its upsert moves mintId, so a second entry for one mint left the first with + // no keysets and no keys — an unusable husk after the next launch. test('and the next launch sees exactly one, intact', () => { const source = TestRoot.create({mintsStore: {mints: [mintSnapshot({id: 'backup01'})]}}) @@ -267,45 +277,86 @@ describe('importing over an existing wallet', () => { expect(restarted.mintsStore.mints[0].keys.map((k: any) => k.id)).toEqual([KEYSET_1]) }) - // The backup is the wallet being restored: a mint it does not carry is gone, - // exactly as it was when the whole state lived in one MMKV snapshot. - test('a mint the backup does not carry is removed from the database too', () => { - const source = TestRoot.create({mintsStore: {mints: [mintSnapshot({id: 'backup01'})]}}) - - const target = TestRoot.create({ - mintsStore: { - mints: [ - mintSnapshot({id: 'onboard1'}), - mintSnapshot({ - id: 'straymint', - mintUrl: OTHER_MINT_URL, - hostname: 'other.test', - keysets: [{id: KEYSET_2, unit: 'sat', active: true, input_fee_ppk: 0}], - keys: [{id: KEYSET_2, unit: 'sat', keys: {'1': '02cc'}}], - proofsCounters: [{keyset: KEYSET_2, unit: 'sat'}], - }), - ], - }, + // A mint can be reached at more than one url, so the url cannot be what says + // "same mint" — the keysets do. Matching on the url would file this mint twice. + test('the same mint at a different url is merged, not duplicated', () => { + const source = TestRoot.create({ + mintsStore: {mints: [mintSnapshot({id: 'backup01', mintUrl: OTHER_MINT_URL, hostname: 'other.test'})]}, + proofsStore: {proofs: {b1: {...proofSnapshot('b1'), mintUrl: OTHER_MINT_URL}}} as any, }) - target.mintsStore.persistAllMints() - target.mintsStore.observeMints() - expect(Database.getMints()).toHaveLength(2) + + const target = onboardedWallet() + importBackup(target, exportBackup(source)) + + expect(target.mintsStore.mints).toHaveLength(1) + // The local url wins — it is the one this device is reaching the mint on. + expect(target.mintsStore.mints[0].mintUrl).toBe(MINT_URL) + // ...so the imported proof has to arrive under it, or it belongs to no mint. + expect(target.proofsStore.getBySecret('b1')?.mintUrl).toBe(MINT_URL) + expect(target.proofsStore.findOrphanedProofs()).toEqual([]) + }) + + // The reason for merging rather than replacing: this mint is the only place the + // wallet's own ecash can be spent, and the backup has never heard of it. + test('a mint the backup does not carry is left alone, with its ecash spendable', () => { + const source = TestRoot.create({ + mintsStore: {mints: [mintSnapshot({id: 'backup01'})]}, + proofsStore: {proofs: {b1: proofSnapshot('b1')}} as any, // 2 sat at MINT_URL + }) + + const target = onboardedWallet([ + mintSnapshot({id: 'onboard1'}), + mintSnapshot({ + id: 'straymint', + mintUrl: OTHER_MINT_URL, + hostname: 'other.test', + keysets: [{id: KEYSET_2, unit: 'sat', active: true, input_fee_ppk: 0}], + keys: [{id: KEYSET_2, unit: 'sat', keys: {'1': '02cc'}}], + proofsCounters: [{keyset: KEYSET_2, unit: 'sat'}], + }), + ]) + target.proofsStore.importProofs([ + {...proofSnapshot('own1'), id: KEYSET_2, mintUrl: OTHER_MINT_URL, amount: 42}, + ] as any) importBackup(target, exportBackup(source)) - expect(Database.getMints().map(m => m.mintUrl)).toEqual([MINT_URL]) + expect(Database.getMints().map(m => m.mintUrl).sort()).toEqual([MINT_URL, OTHER_MINT_URL].sort()) + expect(target.proofsStore.findOrphanedProofs()).toEqual([]) + expect(target.proofsStore.getUnitBalance('sat').unitBalance).toBe(44) + }) + + test('contacts are merged too, keeping the ones added on this device', () => { + const source = TestRoot.create({ + contactsStore: {contacts: [{pubkey: 'aa', npub: 'npub-aa', name: 'from-backup'}]} as any, + }) + + const target = onboardedWallet() + target.contactsStore.mergeFromBackup({ + contacts: [{pubkey: 'bb', npub: 'npub-bb', name: 'added-here'}], + } as any) + + importBackup(target, exportBackup(source)) + + expect(target.contactsStore.contacts.map((c: any) => c.name).sort()).toEqual([ + 'added-here', + 'from-backup', + ]) }) // Counters are keyed by keysetId and deliberately outlive their mint, so // re-adding one recovers its real derivation index instead of restarting at 0. - test('but that mint keeps its derivation counters', () => { + test('a keyset the wallet already advanced keeps the higher counter', () => { const source = TestRoot.create({mintsStore: {mints: [mintSnapshot({id: 'backup01'})]}}) + source.mintsStore.mints[0].proofsCounters[0].increaseProofsCounter(5) const target = onboardedWallet() - Database.seedCounters([{keysetId: KEYSET_2, unit: 'sat', counter: 99}]) + Database.seedCounters([{keysetId: KEYSET_1, unit: 'sat', counter: 99}]) importBackup(target, exportBackup(source)) - expect(Database.getCounters().find(c => c.keysetId === KEYSET_2)?.counter).toBe(99) + // Monotonic: a backup taken before this device advanced can never rewind it, + // which is what stops a blinded secret being derived at an index twice. + expect(Database.getCounters().find(c => c.keysetId === KEYSET_1)?.counter).toBe(99) }) }) diff --git a/src/models/ContactsStore.ts b/src/models/ContactsStore.ts index e151c6cd..f3231b58 100644 --- a/src/models/ContactsStore.ts +++ b/src/models/ContactsStore.ts @@ -71,7 +71,54 @@ import { MINIBITS_NIP05_DOMAIN } from '@env' return contactInstance }, - saveNote (pubkey: string, note: string) { + /** + * Fold a backup's contacts into the wallet's own. + * + * Same rule as the mints: the LOCAL entry wins, the backup fills gaps. + * An import used to applySnapshot over this store, which silently threw + * away every contact the user had added on this device — and unlike + * ecash, a contact list has no other copy to recover it from. + * + * Not addContact: that stamps `createdAt` with now and resets `type`, + * which would rewrite history the backup is carrying faithfully. It does + * contribute the two checks worth keeping — a pubkey already present, and + * a nip05 already claimed by a different pubkey, since the wallet treats + * a nip05 as an address and two contacts sharing one would be ambiguous. + */ + mergeFromBackup(snapshot: ContactsStoreSnapshot) { + let added = 0 + + for (const contact of snapshot?.contacts ?? []) { + if (!contact?.pubkey || self.alreadyExists(contact.pubkey)) continue + + if (contact.nip05 && self.nip05AlreadyExists(contact.nip05)) { + log.warn('[mergeFromBackup]', 'Skipped a backup contact whose nip05 is already taken', { + nip05: contact.nip05, + }) + continue + } + + self.contacts.push(ContactModel.create(contact)) + added++ + } + + // Wallet-level fields the backup also carries: adopted only where this + // wallet has nothing, so restoring onto a fresh install gets them and + // merging into a live wallet leaves its own alone. + if (!self.publicPubkey && snapshot?.publicPubkey) { + self.publicPubkey = snapshot.publicPubkey + } + + if (!self.lastPendingReceivedCheck && snapshot?.lastPendingReceivedCheck) { + self.lastPendingReceivedCheck = snapshot.lastPendingReceivedCheck + } + + log.info('[mergeFromBackup]', 'Contacts merged from a backup', { + added, + total: self.contacts.length, + }) + }, + saveNote (pubkey: string, note: string) { const contactInstance = self.findByPubkey(pubkey) if (contactInstance) { contactInstance.setNoteToSelf(note) diff --git a/src/models/MintsStore.ts b/src/models/MintsStore.ts index 0bb59bcd..a7788a32 100644 --- a/src/models/MintsStore.ts +++ b/src/models/MintsStore.ts @@ -3,7 +3,6 @@ import { SnapshotOut, types, destroy, - applySnapshot, isStateTreeNode, detach, flow, @@ -48,8 +47,8 @@ export type MintsByUnit = { // 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. Note the one thing it does NOT cover: nodes that arrive already-formed -// rather than by mutation never fire an observer, which is why restoring a backup -// goes through restoreFromBackup instead of a bare applySnapshot. +// rather than by mutation never fire an observer, which is why a backup is folded in +// by mergeFromBackup, which writes them through explicitly. // // Derivation counters are unaffected: `counter` is volatile, so it never appears in // a snapshot and a bump never fires these. @@ -238,9 +237,9 @@ export const MintsStoreModel = types /** * 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. + * For when mints arrive already-formed rather than by mutation — i.e. the + * ones mergeFromBackup builds. Observers only fire on CHANGE, so a mint that + * was complete before it entered the tree would otherwise never reach SQLite. */ persistAllMints() { for (const mint of self.mints) self.persistMint(getSnapshot(mint as any)) @@ -249,61 +248,118 @@ export const MintsStoreModel = types .actions(self => ({ /** - * Replace the wallet's mints with the ones from a backup — in BOTH engines. + * Fold a backup's mints into the wallet's own. Nothing is replaced and + * nothing is removed — the counterpart to `backupSnapshot`. * - * The counterpart to `backupSnapshot`, and it exists for the same reason: - * ImportBackup used to do this inline, as applySnapshot + persistAllMints, - * and that covers only half of it. applySnapshot REPLACES the mints in the - * model, but a mint the wallet already had keeps its SQLite row — under its - * own id, which the backup's copy of the same mint does not share. The - * import screen is reached from an onboarded wallet, which always has the - * Minibits mint (WelcomeScreen adds it), so this was not an edge case: the - * next launch hydrated BOTH rows and the user saw the same mint twice. + * IDENTITY IS THE KEYSETS, not the url. One mint can be reached at several + * urls, and matching on the url would file the same mint twice; that is not + * merely untidy, it breaks an invariant the storage layer rests on. + * mint_keysets is keyed by keysetId with a single mintId column, and + * hydrateCountersFromDatabase resolves a counter's owner as "whichever mint + * holds that keyset" — so two entries claiming one keyset means the second + * upsert steals the row (mintId = excluded.mintId) and the first rehydrates + * as a husk with no keysets and no keys. Exactly one mint entry per keyset + * id, wallet-wide, is the rule this enforces. * - * Worse than a duplicate: mint_keysets is keyed by keysetId and its upsert - * reassigns mintId, so the imported mint takes the keysets with it and the - * stale row rehydrates as a husk with no keysets and no keys. + * The url is the fallback signal, for a mint that has rotated every keyset + * since the backup was taken. Two different mints can never share a url, so + * a url match is safe once the keysets have failed to match. * - * So the removals are the point. Under MMKV this came for free — the - * snapshot WAS the state, and applying one dropped whatever it omitted. - * With mints mastered in SQLite, dropping them has to be said out loud. + * On a match the LOCAL entry wins: its id (transactions reference it), its + * url (the one this device is reachable on — changing it is a deliberate act + * in mint settings), its colour and name. The backup contributes keysets, + * keys, and mint info the wallet does not have. * - * Observers are disposed first (they point at nodes applySnapshot is about - * to destroy) and re-attached at the end, once the array has settled. + * @returns backup url → the url that mint now lives at, so the caller can + * repoint the proofs it is about to import. `proofs.mintUrl` is a + * denormalized copy of the locator, and a proof whose url matches no mint + * is spendable by nothing. */ - restoreFromBackup(snapshot: MintsStoreSnapshot) { - const previousMintIds = self.mints.map(m => m.id as string) + mergeFromBackup(snapshot: MintsStoreSnapshot): Map { + const urlByBackupUrl = new Map() + let added = 0 - for (const mintId of [...self.mintObservers.keys()]) self.unobserveMint(mintId) + for (const backupMint of snapshot?.mints ?? []) { + if (!backupMint?.mintUrl) continue - applySnapshot(self, snapshot as any) + const backupKeysetIds = (backupMint.keysets ?? []).map((k: any) => k.id) - const restoredMintIds = new Set(self.mints.map(m => m.id as string)) + let mint = + self.mints.find(m => m.keysetIds.some((id: string) => backupKeysetIds.includes(id))) ?? + self.findByUrl(backupMint.mintUrl) - // Rows for mints the backup does not carry. Their mint_counters rows - // stay behind, as they do on any mint removal, so re-adding a mint - // recovers its real derivation counter rather than restarting at 0. - for (const mintId of previousMintIds) { - if (restoredMintIds.has(mintId)) continue - try { - Database.removeMintById(mintId) - } catch (e: any) { - log.error('[restoreFromBackup]', 'Could not remove a replaced mint', { - error: e?.message, - mintId, - }) + const isNew = !mint + + if (!mint) { + // Built outside the tree and pushed once complete, as addMint does: + // keysets, keys and units all arrive through initKeyset/initKeys + // below, so a mint restored from a backup is assembled exactly like + // one added by hand — collision check and counter shells included. + mint = MintModel.create({ + ...(backupMint as any), + keysets: [], + keys: [], + units: [], + proofsCounters: [], + createdAt: backupMint.createdAt ? new Date(backupMint.createdAt) : undefined, + } as any) + added++ } + + // Per keyset, not per mint: a keyset the wallet cannot take (an unknown + // unit, or an id colliding with another mint's) must not cost the user + // the rest of the mint. + for (const keyset of backupMint.keysets ?? []) { + try { + mint.initKeyset(keyset as any, self.allKeysetIds) + } catch (e: any) { + log.warn('[mergeFromBackup]', 'Skipped a keyset from the backup', { + mintUrl: backupMint.mintUrl, + keysetId: (keyset as any)?.id, + error: e?.message, + }) + } + } + + for (const keys of backupMint.keys ?? []) { + try { + mint.initKeys(keys as any) + } catch (e: any) { + log.warn('[mergeFromBackup]', 'Skipped a keyset\'s keys from the backup', { + mintUrl: backupMint.mintUrl, + keysetId: (keys as any)?.id, + error: e?.message, + }) + } + } + + // Only where the wallet has none: capabilities are read off mintInfo, + // and a backup's copy is better than nothing until the next refresh. + if (!mint.mintInfo && backupMint.mintInfo) { + mint.setMintInfo!(backupMint.mintInfo as any) + } + + if (isNew) self.mints.push(mint) + + urlByBackupUrl.set(backupMint.mintUrl, mint.mintUrl) } - // Nodes that arrive already-formed never fire an observer, so the write - // through has to be explicit. - self.persistAllMints() - self.observeMints() + for (const blockedUrl of snapshot?.blockedMintUrls ?? []) { + if (!self.blockedMintUrls.includes(blockedUrl)) self.blockedMintUrls.push(blockedUrl) + } - log.info('[restoreFromBackup]', 'Mints restored from a backup', { - restored: self.mints.length, - removed: previousMintIds.filter(id => !restoredMintIds.has(id)).length, + // Mints that arrive already-formed never fire an observer, so the write + // through is explicit; observers are (re)attached once the array settles. + self.observeMints() + self.persistAllMints() + + log.info('[mergeFromBackup]', 'Mints merged from a backup', { + added, + merged: urlByBackupUrl.size - added, + total: self.mints.length, }) + + return urlByBackupUrl }, hydrateCountersFromDatabase() { diff --git a/src/screens/ImportBackupScreen.tsx b/src/screens/ImportBackupScreen.tsx index ddd11211..d551dabb 100644 --- a/src/screens/ImportBackupScreen.tsx +++ b/src/screens/ImportBackupScreen.tsx @@ -25,7 +25,6 @@ import { useStores } from '../models' import {MnemonicInput} from './Recovery/MnemonicInput' import { MINIBITS_MINT_URL } from '@env' import { delay } from '../utils/utils' -import { applySnapshot} from 'mobx-state-tree' import { verticalScale } from '@gocodingnow/rn-size-matters' import { translate } from '../i18n' import { MintsStoreSnapshot } from '../models/MintsStore' @@ -212,17 +211,39 @@ export const ImportBackupScreen = observer(function ImportBackupScreen({ route } } } - // applySnapshot(proofsStore, walletSnapshot.proofsStore) + // Mints FIRST, because the proofs have to follow them. The backup is folded + // into whatever this wallet already holds rather than replacing it — a mint + // is recognised by its keysets, so the same mint reached at a different url + // is merged rather than duplicated, and a mint only this device has keeps + // its ecash spendable. See MintsStore.mergeFromBackup. + const urlByBackupUrl = mintsStore.mergeFromBackup(walletSnapshot.mintsStore) + + // A merged mint keeps the url THIS wallet reaches it at, so a proof arriving + // under the backup's url has to be repointed: `proofs.mintUrl` is a + // denormalized copy of the locator, and a proof whose url matches no mint + // belongs to no mint — it counts toward the balance and cannot be spent. + // Cheap to do here, while these are still plain objects from the JSON; once + // they are rows, moving them means a transaction (see mintsRepo.updateMintUrl). + for (const proof of walletSnapshot.proofsStore.proofs) { + const resolvedUrl = urlByBackupUrl.get(proof.mintUrl) + + if (resolvedUrl && resolvedUrl !== proof.mintUrl) { + log.trace('[importWallet] Repointing an imported proof', { + from: proof.mintUrl, + to: resolvedUrl, + }) + proof.mintUrl = resolvedUrl + } + } + proofsStore.importProofs(walletSnapshot.proofsStore.proofs) proofsStore.importPendingByMintSecrets(walletSnapshot.proofsStore.pendingByMintSecrets) - // Mints are mastered in SQLite, so restoring them is more than an - // applySnapshot — the mints being replaced have rows of their own that have - // to go. See MintsStore.restoreFromBackup. - mintsStore.restoreFromBackup(walletSnapshot.mintsStore) - applySnapshot(contactsStore, walletSnapshot.contactsStore) + // Merged, not applied: an applySnapshot here used to discard every contact + // added on this device, and a contact list has no second copy anywhere. + contactsStore.mergeFromBackup(walletSnapshot.contactsStore) // The backup carries real derivation counters in its raw MST snapshot. - // `counter` is VOLATILE in the model (mastered in SQLite), so the restore + // `counter` is VOLATILE in the model (mastered in SQLite), so the merge // above does NOT load it — read the values straight from // the backup snapshot, seed SQLite (monotonic, never lowers), then // hydrate the in-memory cache from the authority. Seeding 0 from the