mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-10-05 19:28:26 +00:00
Two things, in one commit because the test that proves the first needs the second.
Fix the upgrade path (a bug shipped in 2f7a276)
A wallet at db v29 came up with a zero balance. instance.ts ran
createSchemaQueries on EVERY launch, before migrations. `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 predating a table therefore received it
fully formed, and the migration that adds a column to that table then died on
"duplicate column name". The batch is atomic, so EVERY migration rolled back:
hence the cascade of "no such column: mintId" and a wallet with nothing in it.
Concretely: v29 predates onchain_mint_quotes (v31), so it was built already
carrying the mintId that v33 exists to add.
This is the same replay trap that made v26/v28/v29/v31 freeze their column
lists. I fixed it for migrations sharing live constants and missed that
createSchemaQueries does the same thing, on every existing database.
The fix is strictly either/or. Only dbversion is created unconditionally (reading
the version needs it); then the version decides. Fresh install → build at the
latest shape and record it. Existing database → migrations own every shape
change, so each table is created by ITS migration at THAT version's shape, which
is what keeps the later ALTERs valid.
The gap was structural: every 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. The path every user takes had no coverage. dbUpgradePath.test.ts
closes it via a __seedNextDatabase hook on the op-sqlite mock, covering every
version 26→34 through the real instance.ts and asserting the money, the
derivation counter, the added columns, and that the repos work afterwards.
Reverted to the shipped ordering, 16 of its 17 tests fail.
The v26 fixture is VERIFIED against tag v0.4.3-beta.3 — the last released native
bundle, `_dbVersion = 26`, `rootStoreModelVersion = 32`, which is where even a
brand new install starts today before OTA. Its createSchemaQueries builds exactly
those four tables and its column lists match the fixture one for one. It is frozen
on purpose: a fixture that tracks schema.ts describes a device that never existed.
Master mints in SQLite (Stage 1)
Mints were the last core entity persisted by serializing the whole MST tree.
Since postProcessSnapshot already strips proofs and transactions, mints — with
every keyset's `keys` map — were the largest thing left in it, and
JSON.stringify(snapshot) runs on EVERY MST action anywhere, including every proof
mutation during a send. New tables: mints, and mint_keysets keyed by keysetId
(matching mint_counters; keyset and keys stored as whole JSON so fields like
final_expiry, which feeds NUT-02 v2 id derivation, cannot be dropped by an
enumerated column list).
SQLite is the authority, MST the cache — as for proofs and transactions. Reads
and MobX reactivity are unchanged. Persistence is one onSnapshot observer per
mint rather than a write-through in each of ~20 Mint mutators, where forgetting
one is silent staleness; it is equality-guarded on the PERSISTED payload, so a
proofsCounters change cannot churn the row, and attached only after load.
The rename is now ONE transaction across the mint row and its proofs
(mintsRepo.updateMintUrl). The standalone updateProofsMintUrl is deleted so the
non-atomic path cannot come back. Previously the url lived in MMKV and the proofs
in SQLite, so a crash between the two writes left proofs owned by no mint: the
money vanished from every per-mint balance while still counting in the total, and
could not be spent.
postProcessSnapshot strips mints, so ExportBackup had to change in the same
commit: getSnapshot(mintsStore).mints is now ALWAYS empty, and a backup taken
from it would contain zero mints, raise no error, and reveal the loss only on
restore. The build moved onto the store as a tested `backupSnapshot` view.
ImportBackup persists explicitly, since applySnapshot nodes arrive already-formed
and observers fire only on change.
Two bugs the tests caught before the device could
- types.Date rejects an ISO string, so loading threw on typecheck: every launch
after the migration would have failed to load any mint.
- proofsCounters came back EMPTY, because loading bypasses initKeyset. The
counter hydrate would have had nothing to fill, the counter would be recreated
at 0 on first use, and derivation would reuse blinded secrets the mint had
already signed — the exact fund loss this branch began with, reintroduced by
its own fix. Neither is SQL; no mirror-style test could have found them.
Sabotage-verified: dropping the counter shells, building the backup from
getSnapshot, and skipping the proofs in the rename each fail the suite. The first
of those did NOT fail until the assertion was added, which is why it was checked.
Tests: 503 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
327 lines
13 KiB
TypeScript
327 lines
13 KiB
TypeScript
/**
|
|
* 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<string, any> = {}) => ({
|
|
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)
|
|
})
|
|
})
|
|
})
|