New import strategy merging with existing wallet fi any

This commit is contained in:
minibits-cash
2026-09-07 17:31:16 +02:00
parent 4401ec3c84
commit 32b0b029fb
4 changed files with 265 additions and 90 deletions
+85 -34
View File
@@ -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<str
for (const keys of keysByMintUrl[mint.mintUrl] ?? []) mint.keys.push(keys)
}
// Mints first: a merged mint keeps the url this wallet reaches it at, and the
// proofs about to be imported have to be repointed at it.
const urlByBackupUrl = root.mintsStore.mergeFromBackup(backup.mintsStore as MintsStoreSnapshot)
for (const proof of backup.proofsStore.proofs) {
const resolvedUrl = urlByBackupUrl.get(proof.mintUrl)
if (resolvedUrl && resolvedUrl !== proof.mintUrl) proof.mintUrl = resolvedUrl
}
root.proofsStore.importProofs(backup.proofsStore.proofs)
root.proofsStore.importPendingByMintSecrets(backup.proofsStore.pendingByMintSecrets)
root.mintsStore.restoreFromBackup(backup.mintsStore as MintsStoreSnapshot)
applySnapshot(root.contactsStore, backup.contactsStore)
root.contactsStore.mergeFromBackup(backup.contactsStore)
// The counters ride in the backup's raw JSON (they are volatile in the model),
// seed the SQLite authority, and are read back from it.
@@ -231,8 +239,8 @@ describe('importing over an existing wallet', () => {
// 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)
})
})
+48 -1
View File
@@ -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)
+103 -47
View File
@@ -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<string, string> {
const urlByBackupUrl = new Map<string, string>()
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() {
+29 -8
View File
@@ -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