mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-07-22 07:48:28 +00:00
Fix the upgrade path; master mints in SQLite
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>
This commit is contained in:
@@ -51,9 +51,27 @@ const toBindable = value => {
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Test hook: pre-populate the NEXT database opened.
|
||||
*
|
||||
* Lets a test hand instance.ts a database that already exists at some older
|
||||
* version, which is the only way to exercise the real upgrade path — the one that
|
||||
* runs on every user's device and that a fresh in-memory database never touches.
|
||||
* Consumed once, so it cannot leak into the next open.
|
||||
*/
|
||||
let _seedNextDatabase = null
|
||||
const __seedNextDatabase = seed => {
|
||||
_seedNextDatabase = seed
|
||||
}
|
||||
|
||||
const open = () => {
|
||||
const db = new DatabaseSync(':memory:')
|
||||
|
||||
if (_seedNextDatabase) {
|
||||
_seedNextDatabase(db)
|
||||
_seedNextDatabase = null
|
||||
}
|
||||
|
||||
const executeSync = (query, params) => {
|
||||
if (isTransactionControl(query)) {
|
||||
db.exec(query)
|
||||
@@ -103,6 +121,7 @@ const open = () => {
|
||||
|
||||
module.exports = {
|
||||
open,
|
||||
__seedNextDatabase,
|
||||
IOS_DOCUMENT_PATH: '/mock/ios/documents',
|
||||
ANDROID_FILES_PATH: '/mock/android/files',
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ jest.mock('../src/services/logService', () => ({
|
||||
}))
|
||||
|
||||
import {Database} from '../src/services/db'
|
||||
import {_dbVersion} from '../src/services/db/migrations'
|
||||
|
||||
const MINT = 'https://mint.test'
|
||||
|
||||
@@ -55,7 +56,7 @@ describe('Derivation counters (mint_counters)', () => {
|
||||
// Guards the whole suite: these run against instance.ts's real schema + the
|
||||
// real migration registry, so a version mismatch means the rest is testing
|
||||
// something other than production.
|
||||
expect(Database.getDatabaseVersion(Database.getInstance()).version).toBe(34)
|
||||
expect(Database.getDatabaseVersion(Database.getInstance()).version).toBe(_dbVersion)
|
||||
})
|
||||
|
||||
describe('setCounter — monotonic', () => {
|
||||
|
||||
235
__tests__/dbUpgradePath.test.ts
Normal file
235
__tests__/dbUpgradePath.test.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* The UPGRADE path: an existing database at an old version, driven through the real
|
||||
* instance.ts (createSchemaQueries + the real migration registry).
|
||||
*
|
||||
* This is the path every user's device takes and the one nothing covered. Every
|
||||
* other 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, and the
|
||||
* interaction between createSchemaQueries and the registry is never exercised.
|
||||
*
|
||||
* That gap shipped a real bug. createSchemaQueries runs `CREATE TABLE IF NOT EXISTS`
|
||||
* for the CURRENT shape of every table, on every launch. On a device old enough not
|
||||
* to have a table yet, it therefore created it at TODAY's shape — and the migration
|
||||
* that later adds a column to that table then failed with "duplicate column name",
|
||||
* rolling back the whole batch and leaving the database unmigrated. A wallet at v29
|
||||
* showed a zero balance.
|
||||
*
|
||||
* @jest-environment node
|
||||
*/
|
||||
jest.mock('../src/services/logService', () => ({
|
||||
log: {debug: jest.fn(), error: jest.fn(), info: jest.fn(), trace: jest.fn(), warn: jest.fn()},
|
||||
}))
|
||||
|
||||
// ── A device at db v26 ───────────────────────────────────────────────────────
|
||||
//
|
||||
// v26 is the baseline that matters most: it is what the last released NATIVE bundle
|
||||
// (tag v0.4.3-beta.3, `_dbVersion = 26`, `rootStoreModelVersion = 32`) creates, so
|
||||
// even a BRAND NEW install today starts here and then OTA-updates the JS to the
|
||||
// current migrations. It is the common path, not an old edge case.
|
||||
//
|
||||
// VERIFIED against that tag rather than reconstructed: its createSchemaQueries
|
||||
// builds exactly these four tables, and its TRANSACTIONS_COLUMNS (25),
|
||||
// PROOFS_COLUMNS (12) and RESERVATIONS_COLUMNS (7) match the shapes below column for
|
||||
// column. mint_counters (v27), melt_recovery (v28), inflight_requests (v29),
|
||||
// wallet_counters (v30), onchain_mint_quotes (v31) and mints/mint_keysets (v35) do
|
||||
// not exist there yet.
|
||||
//
|
||||
// Frozen deliberately: this is history, and must NOT be re-pointed at schema.ts. A
|
||||
// fixture that tracks today's shape describes a device that never existed and stops
|
||||
// testing the upgrade at all.
|
||||
|
||||
const SCHEMA_V26 = [
|
||||
`CREATE TABLE transactions (
|
||||
id INTEGER PRIMARY KEY NOT NULL, paymentId TEXT, type TEXT, amount INTEGER, unit TEXT,
|
||||
fee INTEGER, data TEXT, keysetId TEXT, sentFrom TEXT, sentTo TEXT, profile TEXT,
|
||||
memo TEXT, mint TEXT, quote TEXT, paymentRequest TEXT, zapRequest TEXT,
|
||||
inputToken TEXT, outputToken TEXT, proof TEXT, balanceAfter INTEGER, noteToSelf TEXT,
|
||||
tags TEXT, status TEXT, expiresAt TEXT, createdAt TEXT
|
||||
)`,
|
||||
`CREATE TABLE proofs (
|
||||
id TEXT NOT NULL, amount INTEGER NOT NULL, secret TEXT PRIMARY KEY NOT NULL, C TEXT NOT NULL,
|
||||
dleq_r TEXT, dleq_s TEXT, dleq_e TEXT, unit TEXT, tId INTEGER, mintUrl TEXT,
|
||||
state TEXT NOT NULL DEFAULT 'UNSPENT', updatedAt TEXT
|
||||
)`,
|
||||
`CREATE TABLE dbversion (id INTEGER PRIMARY KEY NOT NULL, version INTEGER, createdAt TEXT)`,
|
||||
`CREATE TABLE reservations (
|
||||
id TEXT PRIMARY KEY NOT NULL, transactionId INTEGER NOT NULL, mintUrl TEXT NOT NULL,
|
||||
unit TEXT NOT NULL, operationType TEXT NOT NULL, lockedProofs TEXT NOT NULL, createdAt TEXT NOT NULL
|
||||
)`,
|
||||
]
|
||||
|
||||
const KEYSET = '009a1f293253e41e'
|
||||
const MINT_URL = 'https://mint.test'
|
||||
|
||||
const seedV26 = (db: any) => {
|
||||
for (const sql of SCHEMA_V26) db.exec(sql)
|
||||
db.prepare(`INSERT INTO dbversion (id, version, createdAt) VALUES (1, 26, '2026-01-01')`).run()
|
||||
// Real user data, so the assertions below mean something.
|
||||
db.prepare(
|
||||
`INSERT INTO proofs (id, amount, secret, C, unit, tId, mintUrl, state, updatedAt)
|
||||
VALUES (?, 100, 's1', 'C', 'sat', 1, ?, 'UNSPENT', '2026-01-01')`,
|
||||
).run(KEYSET, MINT_URL)
|
||||
db.prepare(
|
||||
`INSERT INTO transactions (id, type, amount, unit, data, mint, status, createdAt)
|
||||
VALUES (1, 'TOPUP', 100, 'sat', '{}', ?, 'COMPLETED', '2026-01-01')`,
|
||||
).run(MINT_URL)
|
||||
}
|
||||
|
||||
/**
|
||||
* A device at version N, built honestly: the v26 schema, then the REAL migrations
|
||||
* replayed up to N. Stamping a different version onto the v26 fixture would describe
|
||||
* a device that never existed — one claiming v31 without the table v31 creates — and
|
||||
* would fail for that reason rather than a real one.
|
||||
*/
|
||||
const seedAtVersion = (startVersion: number) => (db: any) => {
|
||||
seedV26(db)
|
||||
const {MIGRATIONS} = require('../src/services/db/migrations')
|
||||
for (const migration of MIGRATIONS) {
|
||||
if (migration.version > 26 && migration.version <= startVersion) {
|
||||
for (const [sql] of migration.queries) db.exec(sql)
|
||||
}
|
||||
}
|
||||
db.prepare('UPDATE dbversion SET version = ?').run(startVersion)
|
||||
|
||||
// A derivation counter, once the table it lives in exists. Losing this is the one
|
||||
// failure that re-issues blinded secrets the mint has already signed.
|
||||
if (startVersion >= 27) {
|
||||
const hasMintUrl = (db.prepare(`PRAGMA table_info(mint_counters)`).all() as any[]).some(
|
||||
c => c.name === 'mintUrl',
|
||||
)
|
||||
if (hasMintUrl) {
|
||||
db.prepare(
|
||||
`INSERT INTO mint_counters (mintUrl, keysetId, unit, counter, updatedAt)
|
||||
VALUES (?, ?, 'sat', 342, '2026-01-01')`,
|
||||
).run(MINT_URL, KEYSET)
|
||||
} else {
|
||||
db.prepare(
|
||||
`INSERT INTO mint_counters (keysetId, unit, counter, updatedAt)
|
||||
VALUES (?, 'sat', 342, '2026-01-01')`,
|
||||
).run(KEYSET)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Open a database at `startVersion` and let the REAL instance.ts upgrade it. */
|
||||
const upgradeFrom = (startVersion: number) => {
|
||||
jest.resetModules()
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
require('@op-engineering/op-sqlite').__seedNextDatabase(seedAtVersion(startVersion))
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const {Database} = require('../src/services/db')
|
||||
const {_dbVersion} = require('../src/services/db/migrations')
|
||||
return {Database, _dbVersion, db: Database.getInstance()}
|
||||
}
|
||||
|
||||
/** The baseline the last released native bundle ships. */
|
||||
const upgradeFromV26 = () => upgradeFrom(26)
|
||||
|
||||
const columns = (db: any, table: string): string[] =>
|
||||
(db.execute(`PRAGMA table_info(${table})`).rows?._array ?? []).map((r: any) => r.name)
|
||||
|
||||
describe('upgrading an existing database (the device path)', () => {
|
||||
test('a v26 wallet — the last released native bundle — migrates to the current version', () => {
|
||||
// The reported failure: this threw "duplicate column name: mintId", the batch
|
||||
// rolled back, and the wallet came up with nothing.
|
||||
const {Database, _dbVersion, db} = upgradeFromV26()
|
||||
|
||||
expect(Database.getDatabaseVersion(db).version).toBe(_dbVersion)
|
||||
})
|
||||
|
||||
test('the money survives', () => {
|
||||
const {db} = upgradeFromV26()
|
||||
|
||||
const proof = db.execute(`SELECT * FROM proofs WHERE secret = 's1'`).rows?.item(0)
|
||||
expect(proof.amount).toBe(100)
|
||||
expect(proof.mintUrl).toBe(MINT_URL)
|
||||
})
|
||||
|
||||
test('a derivation counter survives the v32 re-key', () => {
|
||||
// From v27, the first version that HAS mint_counters. A v26 wallet has no such
|
||||
// table at all — its counters are still in the MMKV snapshot, and the rootStore
|
||||
// v33 seed is what copies them in.
|
||||
//
|
||||
// This is the one value whose loss re-issues blinded secrets the mint has
|
||||
// already signed, and v32 re-keys the very table it lives in, from
|
||||
// (mintUrl, keysetId) onto keysetId.
|
||||
const {Database} = upgradeFrom(27)
|
||||
|
||||
expect(Database.getCounter(KEYSET)?.counter).toBe(342)
|
||||
})
|
||||
|
||||
test('every column the later migrations add is present', () => {
|
||||
const {db} = upgradeFromV26()
|
||||
|
||||
// v33 / v34: the mint-identity columns.
|
||||
expect(columns(db, 'onchain_mint_quotes')).toContain('mintId')
|
||||
expect(columns(db, 'reservations')).toContain('mintId')
|
||||
expect(columns(db, 'transactions')).toContain('mintId')
|
||||
|
||||
// v34 also rebuilt the child tables WITHOUT their duplicated mint reference.
|
||||
expect(columns(db, 'inflight_requests')).not.toContain('mintUrl')
|
||||
expect(columns(db, 'melt_recovery')).not.toContain('mintUrl')
|
||||
})
|
||||
|
||||
test('tables introduced after v29 exist, at the current shape', () => {
|
||||
const {db} = upgradeFromV26()
|
||||
|
||||
expect(columns(db, 'wallet_counters')).toContain('counter') // v30
|
||||
expect(columns(db, 'onchain_mint_quotes')).toContain('counterIndex') // v31
|
||||
expect(columns(db, 'mints')).toContain('mintUrl') // v35
|
||||
expect(columns(db, 'mint_keysets')).toContain('keysetId') // v35
|
||||
})
|
||||
|
||||
test('mint_counters is re-keyed onto keysetId alone', () => {
|
||||
const {db} = upgradeFromV26()
|
||||
|
||||
expect(columns(db, 'mint_counters')).toEqual(
|
||||
expect.arrayContaining(['keysetId', 'unit', 'counter', 'updatedAt']),
|
||||
)
|
||||
expect(columns(db, 'mint_counters')).not.toContain('mintUrl')
|
||||
})
|
||||
|
||||
test('the repos work against the upgraded database', () => {
|
||||
// The real point: not just that the DDL landed, but that the app can use it.
|
||||
const {Database} = upgradeFromV26()
|
||||
|
||||
Database.setCounter('00ad268c4d1f5826', 'sat', 7)
|
||||
expect(Database.getCounter('00ad268c4d1f5826')?.counter).toBe(7)
|
||||
|
||||
expect(Database.getMints()).toEqual([])
|
||||
expect(Database.getWatchedOnchainMintQuotes()).toEqual([])
|
||||
})
|
||||
|
||||
test('re-running the upgrade on an already-current database is a no-op', () => {
|
||||
// Every subsequent launch takes this path.
|
||||
jest.resetModules()
|
||||
const {Database} = require('../src/services/db')
|
||||
const {_dbVersion} = require('../src/services/db/migrations')
|
||||
|
||||
const db = Database.getInstance()
|
||||
expect(Database.getDatabaseVersion(db).version).toBe(_dbVersion)
|
||||
|
||||
// A second call must not re-run anything.
|
||||
expect(Database.getDatabaseVersion(Database.getInstance()).version).toBe(_dbVersion)
|
||||
})
|
||||
|
||||
// The bug's real shape: it bites at any version predating a table that a LATER
|
||||
// migration alters. v29 is simply where it was reported. Rather than trust that
|
||||
// one case, walk every version the registry knows about.
|
||||
describe('every supported starting version reaches the current schema', () => {
|
||||
test.each([26, 27, 28, 29, 30, 31, 32, 33, 34])('from v%i', startVersion => {
|
||||
jest.resetModules()
|
||||
require('@op-engineering/op-sqlite').__seedNextDatabase(seedAtVersion(startVersion))
|
||||
const {Database} = require('../src/services/db')
|
||||
const {_dbVersion} = require('../src/services/db/migrations')
|
||||
|
||||
const db = Database.getInstance()
|
||||
|
||||
expect(Database.getDatabaseVersion(db).version).toBe(_dbVersion)
|
||||
expect(columns(db, 'onchain_mint_quotes')).toContain('mintId')
|
||||
expect(columns(db, 'transactions')).toContain('mintId')
|
||||
// The money, at every starting point.
|
||||
expect(db.execute(`SELECT amount FROM proofs WHERE secret = 's1'`).rows?.item(0)?.amount).toBe(100)
|
||||
})
|
||||
})
|
||||
})
|
||||
326
__tests__/mintsPersistence.test.ts
Normal file
326
__tests__/mintsPersistence.test.ts
Normal file
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
265
__tests__/mintsRepo.test.ts
Normal file
265
__tests__/mintsRepo.test.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* Mints and their keysets in SQLite (mintsRepo), against the REAL repo and a real
|
||||
* database.
|
||||
*
|
||||
* Mints were the last core entity persisted only by serializing the whole MST tree
|
||||
* to MMKV. Moving them here takes that cost off the hot path (every MST action
|
||||
* anywhere re-serialized every mint's keys) and puts them in the same engine as the
|
||||
* proofs, so a mint-url edit can finally commit atomically with the proofs it
|
||||
* renames.
|
||||
*
|
||||
* What these pin, in order of how much they would hurt to get wrong:
|
||||
* - a mint round-trips exactly, keys included — losing keysets or keys means the
|
||||
* wallet cannot verify or spend that mint's ecash;
|
||||
* - the rename moves the mint AND its proofs or neither;
|
||||
* - removal keeps the derivation counters.
|
||||
*/
|
||||
jest.mock('../src/services/logService', () => ({
|
||||
log: {debug: jest.fn(), error: jest.fn(), info: jest.fn(), trace: jest.fn(), warn: jest.fn()},
|
||||
}))
|
||||
|
||||
import {Database} from '../src/services/db'
|
||||
import type {MintRecord} from '../src/services/db'
|
||||
|
||||
const MINT_URL = 'https://mint.test'
|
||||
|
||||
const keyset = (id: string, overrides: Record<string, any> = {}) => ({
|
||||
id,
|
||||
unit: 'sat',
|
||||
active: true,
|
||||
input_fee_ppk: 0,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const keysFor = (id: string) => ({id, unit: 'sat', keys: {'1': '02aa', '2': '02bb'}})
|
||||
|
||||
const mintRecord = (overrides: Partial<MintRecord> = {}): MintRecord => ({
|
||||
id: 'mint1111',
|
||||
mintUrl: MINT_URL,
|
||||
hostname: 'mint.test',
|
||||
shortname: 'Test Mint',
|
||||
units: ['sat'],
|
||||
keysets: [keyset('k1')],
|
||||
keys: [keysFor('k1')],
|
||||
mintInfo: {name: 'Test Mint', time: 1234},
|
||||
color: '#abcdef',
|
||||
status: 'ONLINE',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const proofRow = (secret: string, mintUrl: string) =>
|
||||
Database.getInstance().execute(
|
||||
`INSERT OR REPLACE INTO proofs (id, amount, secret, C, unit, tId, mintUrl, state, updatedAt)
|
||||
VALUES ('k1', 10, ?, 'C', 'sat', 1, ?, 'UNSPENT', '2026-01-01')`,
|
||||
[secret, mintUrl],
|
||||
)
|
||||
|
||||
const proofMintUrl = (secret: string): string | undefined =>
|
||||
Database.getInstance().execute('SELECT mintUrl FROM proofs WHERE secret = ?', [secret]).rows?.item(0)
|
||||
?.mintUrl
|
||||
|
||||
beforeEach(() => {
|
||||
Database.getInstance().executeBatch([
|
||||
['DELETE FROM mints'],
|
||||
['DELETE FROM mint_keysets'],
|
||||
['DELETE FROM proofs'],
|
||||
['DELETE FROM mint_counters'],
|
||||
])
|
||||
})
|
||||
|
||||
describe('mints in SQLite', () => {
|
||||
describe('round-trip', () => {
|
||||
test('a mint survives a write/read cycle intact', () => {
|
||||
const mint = mintRecord()
|
||||
Database.upsertMint(mint)
|
||||
|
||||
const [loaded] = Database.getMints()
|
||||
expect(loaded).toEqual(mint)
|
||||
})
|
||||
|
||||
test('preserves keyset fields the wallet does not name', () => {
|
||||
// MintKeyset carries final_expiry, which feeds NUT-02 v2 id derivation. The
|
||||
// keyset is stored as a whole JSON object precisely so fields like this are
|
||||
// not silently dropped by an enumerated column list.
|
||||
Database.upsertMint(mintRecord({keysets: [keyset('k1', {final_expiry: 1799999999})]}))
|
||||
|
||||
expect(Database.getMints()[0].keysets[0]).toMatchObject({final_expiry: 1799999999})
|
||||
})
|
||||
|
||||
test('carries multiple keysets and their keys', () => {
|
||||
Database.upsertMint(
|
||||
mintRecord({
|
||||
keysets: [keyset('k1'), keyset('k2', {active: false})],
|
||||
keys: [keysFor('k1'), keysFor('k2')],
|
||||
}),
|
||||
)
|
||||
|
||||
const [loaded] = Database.getMints()
|
||||
expect(loaded.keysets.map(k => k.id).sort()).toEqual(['k1', 'k2'])
|
||||
expect(loaded.keys.map(k => k.id).sort()).toEqual(['k1', 'k2'])
|
||||
})
|
||||
|
||||
test('a keyset whose keys were never fetched loads with no keys entry', () => {
|
||||
// keysets and keys are independent arrays in the model: the mint advertises a
|
||||
// keyset before (or without) the wallet fetching its keys.
|
||||
Database.upsertMint(mintRecord({keysets: [keyset('k1'), keyset('k2')], keys: [keysFor('k1')]}))
|
||||
|
||||
const [loaded] = Database.getMints()
|
||||
expect(loaded.keysets).toHaveLength(2)
|
||||
expect(loaded.keys.map(k => k.id)).toEqual(['k1'])
|
||||
})
|
||||
|
||||
test('an absent mintInfo stays absent', () => {
|
||||
Database.upsertMint(mintRecord({mintInfo: undefined}))
|
||||
expect(Database.getMints()[0].mintInfo).toBeUndefined()
|
||||
})
|
||||
|
||||
test('two mints are independent', () => {
|
||||
Database.upsertMint(mintRecord())
|
||||
Database.upsertMint(
|
||||
mintRecord({id: 'mint2222', mintUrl: 'https://other.test', keysets: [keyset('k9')], keys: []}),
|
||||
)
|
||||
|
||||
const loaded = Database.getMints()
|
||||
expect(loaded).toHaveLength(2)
|
||||
expect(loaded.find(m => m.id === 'mint2222')!.keysets.map(k => k.id)).toEqual(['k9'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('upsert', () => {
|
||||
test('is idempotent — re-writing the same mint changes nothing', () => {
|
||||
const mint = mintRecord()
|
||||
Database.upsertMint(mint)
|
||||
Database.upsertMint(mint)
|
||||
|
||||
expect(Database.getMints()).toHaveLength(1)
|
||||
expect(Database.getMints()[0]).toEqual(mint)
|
||||
})
|
||||
|
||||
test('updates changed fields in place', () => {
|
||||
Database.upsertMint(mintRecord())
|
||||
Database.upsertMint(mintRecord({shortname: 'Renamed', status: 'OFFLINE'}))
|
||||
|
||||
const [loaded] = Database.getMints()
|
||||
expect(loaded.shortname).toBe('Renamed')
|
||||
expect(loaded.status).toBe('OFFLINE')
|
||||
})
|
||||
|
||||
test('adds a newly advertised keyset without disturbing the existing ones', () => {
|
||||
Database.upsertMint(mintRecord())
|
||||
Database.upsertMint(
|
||||
mintRecord({keysets: [keyset('k1'), keyset('k2')], keys: [keysFor('k1'), keysFor('k2')]}),
|
||||
)
|
||||
|
||||
expect(Database.getMints()[0].keysets.map(k => k.id).sort()).toEqual(['k1', 'k2'])
|
||||
})
|
||||
|
||||
// Keys and keyset metadata are fetched from the mint separately, so a
|
||||
// metadata-only refresh must never erase keys already stored — without them the
|
||||
// wallet cannot verify or spend that keyset.
|
||||
test('a keyset refresh with no keys does NOT erase stored keys', () => {
|
||||
Database.upsertMint(mintRecord())
|
||||
expect(Database.getMints()[0].keys).toHaveLength(1)
|
||||
|
||||
Database.upsertMint(mintRecord({keys: []})) // metadata-only refresh
|
||||
|
||||
expect(Database.getMints()[0].keys.map(k => k.id)).toEqual(['k1'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('the atomic rename', () => {
|
||||
test('moves the mint AND its proofs together', () => {
|
||||
Database.upsertMint(mintRecord())
|
||||
proofRow('p1', MINT_URL)
|
||||
proofRow('p2', MINT_URL)
|
||||
|
||||
Database.updateMintUrlWithProofs('mint1111', MINT_URL, 'https://moved.test', 'moved.test')
|
||||
|
||||
expect(Database.getMints()[0].mintUrl).toBe('https://moved.test')
|
||||
expect(Database.getMints()[0].hostname).toBe('moved.test')
|
||||
expect(proofMintUrl('p1')).toBe('https://moved.test')
|
||||
expect(proofMintUrl('p2')).toBe('https://moved.test')
|
||||
})
|
||||
|
||||
test('leaves another mint\'s proofs alone', () => {
|
||||
Database.upsertMint(mintRecord())
|
||||
proofRow('mine', MINT_URL)
|
||||
proofRow('theirs', 'https://other.test')
|
||||
|
||||
Database.updateMintUrlWithProofs('mint1111', MINT_URL, 'https://moved.test', 'moved.test')
|
||||
|
||||
expect(proofMintUrl('mine')).toBe('https://moved.test')
|
||||
expect(proofMintUrl('theirs')).toBe('https://other.test')
|
||||
})
|
||||
|
||||
// The whole point of putting mints in the same engine as proofs. When the url
|
||||
// lived in MMKV and the proofs in SQLite, a crash between the two writes left
|
||||
// the proofs owned by no mint: the money vanished from every per-mint balance
|
||||
// while still counting in the total, and could not be spent.
|
||||
test('is all-or-nothing: a failure leaves BOTH untouched', () => {
|
||||
Database.upsertMint(mintRecord())
|
||||
proofRow('p1', MINT_URL)
|
||||
|
||||
// A non-finite param is rejected by connection.ts's sanitizing, mid batch.
|
||||
expect(() =>
|
||||
Database.updateMintUrlWithProofs('mint1111', MINT_URL, Number.NaN as any, 'moved.test'),
|
||||
).toThrow()
|
||||
|
||||
expect(Database.getMints()[0].mintUrl).toBe(MINT_URL)
|
||||
expect(proofMintUrl('p1')).toBe(MINT_URL)
|
||||
})
|
||||
})
|
||||
|
||||
describe('removal', () => {
|
||||
test('deletes the mint and its keysets', () => {
|
||||
Database.upsertMint(mintRecord())
|
||||
Database.removeMintById('mint1111')
|
||||
|
||||
expect(Database.getMints()).toEqual([])
|
||||
const keysetCount = Database.getInstance()
|
||||
.execute('SELECT COUNT(*) AS n FROM mint_keysets')
|
||||
.rows?.item(0)?.n
|
||||
expect(keysetCount).toBe(0)
|
||||
})
|
||||
|
||||
test('KEEPS the derivation counters', () => {
|
||||
// mint_counters rows are keyed by keysetId and retained across removal, so a
|
||||
// re-added mint recovers its real index instead of restarting at 0 — which
|
||||
// would reuse blinded secrets the mint has already signed.
|
||||
Database.upsertMint(mintRecord())
|
||||
Database.setCounter('k1', 'sat', 342)
|
||||
|
||||
Database.removeMintById('mint1111')
|
||||
|
||||
expect(Database.getCounter('k1')?.counter).toBe(342)
|
||||
})
|
||||
|
||||
test('leaves other mints alone', () => {
|
||||
Database.upsertMint(mintRecord())
|
||||
Database.upsertMint(mintRecord({id: 'mint2222', mintUrl: 'https://other.test'}))
|
||||
|
||||
Database.removeMintById('mint1111')
|
||||
|
||||
expect(Database.getMints().map(m => m.id)).toEqual(['mint2222'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('seedMints — the one-time MMKV copy', () => {
|
||||
test('copies mints across', () => {
|
||||
Database.seedMints([mintRecord(), mintRecord({id: 'mint2222', mintUrl: 'https://other.test'})])
|
||||
expect(Database.getMints()).toHaveLength(2)
|
||||
})
|
||||
|
||||
test('is idempotent — upserts by id, so a re-run cannot duplicate a mint', () => {
|
||||
Database.seedMints([mintRecord()])
|
||||
Database.seedMints([mintRecord()])
|
||||
|
||||
expect(Database.getMints()).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('an empty seed is a no-op', () => {
|
||||
expect(Database.seedMints([])).toEqual({seeded: 0})
|
||||
})
|
||||
})
|
||||
})
|
||||
147
__tests__/sqliteMigration35.test.ts
Normal file
147
__tests__/sqliteMigration35.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Repeatable migration tests for SQLite migration 35.
|
||||
*
|
||||
* Migration 35 creates the `mints` and `mint_keysets` tables, moving the mints out
|
||||
* of the MST/MMKV snapshot and into SQLite.
|
||||
*
|
||||
* WHY: postProcessSnapshot already strips proofs and transactions, so mints — with
|
||||
* every keyset's `keys` map — had become 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. It also puts mints in the same engine as the
|
||||
* proofs, so a mint-url edit can commit atomically with the proofs it renames.
|
||||
*
|
||||
* The tables are created EMPTY: mints live in the snapshot, so nothing in SQL can
|
||||
* read them. The v39 seed copies them once the store is hydrated.
|
||||
*
|
||||
* Uses Node.js built-in node:sqlite (requires Node 22.5+).
|
||||
* @jest-environment node
|
||||
*/
|
||||
jest.mock('../src/services/logService', () => ({
|
||||
log: {debug: jest.fn(), error: jest.fn(), info: jest.fn(), trace: jest.fn(), warn: jest.fn()},
|
||||
}))
|
||||
|
||||
import {DatabaseSync} from 'node:sqlite'
|
||||
import {MIGRATIONS} from '../src/services/db/migrations'
|
||||
|
||||
// ── The REAL migration, taken from the registry ─────────────────────────────
|
||||
//
|
||||
// Imported rather than copied: a copy of the SQL proves nothing about the SQL that
|
||||
// actually runs on a device.
|
||||
|
||||
const MIGRATION_35 = MIGRATIONS.find(m => m.version === 35)!.queries.map(([sql]) => sql)
|
||||
|
||||
function runMigration35(db: DatabaseSync) {
|
||||
db.exec('BEGIN')
|
||||
try {
|
||||
for (const sql of MIGRATION_35) db.exec(sql)
|
||||
db.exec('COMMIT')
|
||||
} catch (e) {
|
||||
db.exec('ROLLBACK')
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
function columns(db: DatabaseSync, table: string): string[] {
|
||||
const rows = db.prepare(`PRAGMA table_info(${table})`).all() as unknown as Array<{name: string}>
|
||||
return rows.map(r => r.name)
|
||||
}
|
||||
|
||||
function tables(db: DatabaseSync): string[] {
|
||||
return (
|
||||
db.prepare(`SELECT name FROM sqlite_master WHERE type='table' ORDER BY name`).all() as unknown as Array<{
|
||||
name: string
|
||||
}>
|
||||
).map(r => r.name)
|
||||
}
|
||||
|
||||
/** A pre-35 database: no mints tables at all. */
|
||||
const freshDb = () => new DatabaseSync(':memory:')
|
||||
|
||||
describe('SQLite migration 35 — mints move into SQLite', () => {
|
||||
test('creates both tables', () => {
|
||||
const db = freshDb()
|
||||
expect(tables(db)).not.toContain('mints')
|
||||
|
||||
runMigration35(db)
|
||||
|
||||
expect(tables(db)).toContain('mints')
|
||||
expect(tables(db)).toContain('mint_keysets')
|
||||
db.close()
|
||||
})
|
||||
|
||||
test('mints carries the identity, the url and the JSON payloads', () => {
|
||||
const db = freshDb()
|
||||
runMigration35(db)
|
||||
|
||||
expect(columns(db, 'mints').sort()).toEqual(
|
||||
['color', 'createdAt', 'hostname', 'id', 'mintInfo', 'mintUrl', 'shortname', 'status', 'units'].sort(),
|
||||
)
|
||||
db.close()
|
||||
})
|
||||
|
||||
test('mint_keysets is keyed by keysetId and owned by mintId', () => {
|
||||
const db = freshDb()
|
||||
runMigration35(db)
|
||||
|
||||
expect(columns(db, 'mint_keysets').sort()).toEqual(['keys', 'keyset', 'keysetId', 'mintId', 'unit'].sort())
|
||||
db.close()
|
||||
})
|
||||
|
||||
test('starts empty — the v39 seed fills it, because SQL cannot read the MMKV snapshot', () => {
|
||||
const db = freshDb()
|
||||
runMigration35(db)
|
||||
|
||||
expect(db.prepare('SELECT * FROM mints').all()).toEqual([])
|
||||
expect(db.prepare('SELECT * FROM mint_keysets').all()).toEqual([])
|
||||
db.close()
|
||||
})
|
||||
|
||||
describe('keys', () => {
|
||||
test('a mint id is unique', () => {
|
||||
const db = freshDb()
|
||||
runMigration35(db)
|
||||
const insert = () =>
|
||||
db
|
||||
.prepare(`INSERT INTO mints (id, mintUrl) VALUES (?, ?)`)
|
||||
.run('mint1', 'https://a.test')
|
||||
|
||||
insert()
|
||||
expect(insert).toThrow()
|
||||
db.close()
|
||||
})
|
||||
|
||||
// Matches mint_counters, which is also keyed by keysetId alone: the two agree on
|
||||
// what a keyset is, and a keyset id is unique wallet-wide (isCollidingKeysetId).
|
||||
test('a keyset id is unique across mints, not just within one', () => {
|
||||
const db = freshDb()
|
||||
runMigration35(db)
|
||||
|
||||
db.prepare(`INSERT INTO mint_keysets (keysetId, mintId, keyset) VALUES (?, ?, ?)`).run(
|
||||
'k1',
|
||||
'mint1',
|
||||
'{}',
|
||||
)
|
||||
|
||||
expect(() =>
|
||||
db
|
||||
.prepare(`INSERT INTO mint_keysets (keysetId, mintId, keyset) VALUES (?, ?, ?)`)
|
||||
.run('k1', 'mint2', '{}'),
|
||||
).toThrow()
|
||||
db.close()
|
||||
})
|
||||
|
||||
test('mints.mintUrl is NOT unique — the schema does not decide mint identity', () => {
|
||||
// Identity is Mint.id. Two rows could legitimately share a url mid-rename, and
|
||||
// the constraint that matters (one real mint, one Mint node) is enforced by
|
||||
// keyset collision detection, not by a url index.
|
||||
const db = freshDb()
|
||||
runMigration35(db)
|
||||
|
||||
db.prepare(`INSERT INTO mints (id, mintUrl) VALUES (?, ?)`).run('mint1', 'https://a.test')
|
||||
expect(() =>
|
||||
db.prepare(`INSERT INTO mints (id, mintUrl) VALUES (?, ?)`).run('mint2', 'https://a.test'),
|
||||
).not.toThrow()
|
||||
db.close()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -623,7 +623,31 @@ export const MintModel = types
|
||||
throw new AppError(Err.VALIDATION_ERROR, 'Mint URL already exists.', {url: normalized})
|
||||
}
|
||||
|
||||
proofsStore.updateMintUrl(currentMintUrl, normalized)
|
||||
// ONE SQLite transaction for the mint row and its proofs.
|
||||
//
|
||||
// This is what mints-in-SQLite bought. The url used to live in the MMKV
|
||||
// snapshot while the proofs lived in SQLite, so the rename spanned two
|
||||
// engines with nothing joining them: a crash in between left the proofs
|
||||
// pointing at a url no mint owned — the money vanished from every
|
||||
// per-mint balance while still counting in the total, and was unspendable
|
||||
// (send and melt select proofs by mint).
|
||||
//
|
||||
// Written BEFORE the model changes, so a failure throws with the tree
|
||||
// untouched rather than leaving memory ahead of the database. The mint's
|
||||
// own observer will re-persist the same row afterwards; the equality
|
||||
// guard makes that a no-op.
|
||||
let hostname: string | null = null
|
||||
try {
|
||||
hostname = new URL(normalized).hostname
|
||||
} catch {
|
||||
// normalizeMintUrl already parsed it, so this cannot realistically
|
||||
// fail; hostname simply stays null rather than aborting the rename.
|
||||
}
|
||||
|
||||
Database.updateMintUrlWithProofs(self.id, currentMintUrl, normalized, hostname)
|
||||
|
||||
// Mirror into the in-memory caches now that SQLite is durable.
|
||||
proofsStore.updateMintUrlInMemory(currentMintUrl, normalized)
|
||||
|
||||
self.mintUrl = normalized
|
||||
self.setHostname() // derived from mintUrl — stale until recomputed
|
||||
|
||||
@@ -6,6 +6,9 @@ import {
|
||||
isStateTreeNode,
|
||||
detach,
|
||||
flow,
|
||||
onSnapshot,
|
||||
getSnapshot,
|
||||
IDisposer,
|
||||
} from 'mobx-state-tree'
|
||||
import {withSetPropAction} from './helpers/withSetPropAction'
|
||||
import {MintModel, Mint, MintProofsCounter} from './Mint'
|
||||
@@ -13,6 +16,7 @@ import {
|
||||
import {log} from '../services/logService'
|
||||
// Direct import, not the '../services' barrel — see the note in Mint.ts.
|
||||
import {Database} from '../services/db'
|
||||
import type {MintRecord} from '../services/db'
|
||||
import AppError, { Err } from '../utils/AppError'
|
||||
import {
|
||||
Mint as CashuMint,
|
||||
@@ -34,6 +38,36 @@ export type MintsByUnit = {
|
||||
mints: Mint[]
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Per-mint persistence.
|
||||
//
|
||||
// SQLite is the authority for mints; this model is the in-memory cache (the same
|
||||
// split as proofs and transactions). Rather than call a write-through from each of
|
||||
// the ~20 Mint mutators — where forgetting one is SILENT staleness, the exact bug
|
||||
// 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, and it gives ImportBackup persistence for free (applySnapshot fires it).
|
||||
//
|
||||
// Derivation counters are unaffected: `counter` is volatile, so it never appears in
|
||||
// a snapshot and a bump never fires these.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Project a Mint snapshot onto the row shape SQLite stores. */
|
||||
const toMintRecord = (snapshot: any): MintRecord => ({
|
||||
id: snapshot.id,
|
||||
mintUrl: snapshot.mintUrl,
|
||||
hostname: snapshot.hostname,
|
||||
shortname: snapshot.shortname,
|
||||
units: [...(snapshot.units ?? [])],
|
||||
keysets: [...(snapshot.keysets ?? [])],
|
||||
keys: [...(snapshot.keys ?? [])],
|
||||
mintInfo: snapshot.mintInfo,
|
||||
color: snapshot.color,
|
||||
status: snapshot.status,
|
||||
// types.Date snapshots as a timestamp; the column stores ISO.
|
||||
createdAt: snapshot.createdAt ? new Date(snapshot.createdAt).toISOString() : undefined,
|
||||
})
|
||||
|
||||
export const MintsStoreModel = types
|
||||
.model('MintsStore', {
|
||||
mints: types.array(MintModel),
|
||||
@@ -49,6 +83,37 @@ export const MintsStoreModel = types
|
||||
const {counterBackups, ...rest} = s
|
||||
return rest
|
||||
})
|
||||
/**
|
||||
* Mints are loaded from SQLite on startup; only blockedMintUrls is persisted
|
||||
* here. Same treatment as proofs and transactions, and the reason for the whole
|
||||
* move: mints (with every keyset's `keys` map) were the largest thing left in
|
||||
* the tree, and `JSON.stringify(snapshot)` runs on EVERY MST action anywhere —
|
||||
* including every proof mutation during a send. Stripping them takes that cost
|
||||
* off the hot path.
|
||||
*
|
||||
* NOTE for anything that reads the snapshot: `getSnapshot(mintsStore).mints` is
|
||||
* now ALWAYS empty. Code that wants the real mints must read the live instances
|
||||
* (see ExportBackupScreen, which does exactly that for proofs already).
|
||||
*/
|
||||
.postProcessSnapshot(snapshot => ({
|
||||
// `as typeof snapshot.mints` keeps the element type: a bare [] would widen
|
||||
// the snapshot type to never[], and every reader of a mints snapshot (the
|
||||
// backup format above all) would stop type-checking against a real Mint.
|
||||
mints: [] as typeof snapshot.mints,
|
||||
blockedMintUrls: snapshot.blockedMintUrls,
|
||||
}))
|
||||
/**
|
||||
* Observer bookkeeping. VOLATILE, and on the store rather than at module scope:
|
||||
* this is per-store state, and a module-level map would be shared by every store
|
||||
* in the process — which production never notices (there is one root store) and
|
||||
* a test immediately does.
|
||||
*/
|
||||
.volatile(() => ({
|
||||
/** Live per-mint observers, by mint id, so they can be disposed. */
|
||||
mintObservers: new Map<string, IDisposer>(),
|
||||
/** Last payload written per mint id — the equality guard's memory. */
|
||||
lastPersistedMints: new Map<string, string>(),
|
||||
}))
|
||||
.views(self => ({
|
||||
findByUrl: (mintUrl: string | URL) => {
|
||||
const mint = self.mints.find(m => m.mintUrl === mintUrl)
|
||||
@@ -110,6 +175,76 @@ export const MintsStoreModel = types
|
||||
* the keyset — and a mint-url edit no longer strands the row, which used to
|
||||
* leave the counter at its volatile 0 and risk blinded-secret reuse.
|
||||
*/
|
||||
/**
|
||||
* Write one mint through, skipping writes that would change nothing.
|
||||
*
|
||||
* The guard compares the PERSISTED payload, not the raw snapshot: a mint's
|
||||
* subtree changes for reasons its row does not care about, and without this
|
||||
* every such action would re-write the mint and all its keysets.
|
||||
*
|
||||
* Errors are logged, never thrown: persistence must not break a wallet flow,
|
||||
* and the next mutation retries. A dropped write costs a stale row, which the
|
||||
* following write corrects.
|
||||
*/
|
||||
persistMint(snapshot: any) {
|
||||
const record = toMintRecord(snapshot)
|
||||
const serialized = JSON.stringify(record)
|
||||
|
||||
if (self.lastPersistedMints.get(record.id) === serialized) return
|
||||
|
||||
try {
|
||||
Database.upsertMint(record)
|
||||
self.lastPersistedMints.set(record.id, serialized)
|
||||
} catch (e: any) {
|
||||
log.error('[persistMint]', 'Mint write-through failed', {
|
||||
error: e?.message,
|
||||
mintId: record.id,
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
/** Dispose one mint's observer and forget its guard entry. */
|
||||
unobserveMint(mintId: string) {
|
||||
self.mintObservers.get(mintId)?.()
|
||||
self.mintObservers.delete(mintId)
|
||||
self.lastPersistedMints.delete(mintId)
|
||||
},
|
||||
}))
|
||||
.actions(self => ({
|
||||
/** Attach (or replace) one mint's persistence observer. */
|
||||
observeMint(mint: Mint) {
|
||||
const mintId = mint.id as string
|
||||
self.unobserveMint(mintId)
|
||||
self.mintObservers.set(mintId, onSnapshot(mint as any, snapshot => self.persistMint(snapshot)))
|
||||
},
|
||||
}))
|
||||
.actions(self => ({
|
||||
/**
|
||||
* (Re)attach the per-mint persistence observers.
|
||||
*
|
||||
* Must run AFTER the mints are in place, never before: attaching first means
|
||||
* loading them from SQLite immediately writes them straight back. Disposes
|
||||
* any existing observers, so it is safe to call again after applySnapshot
|
||||
* has replaced the whole array with new nodes.
|
||||
*/
|
||||
observeMints() {
|
||||
for (const mintId of [...self.mintObservers.keys()]) self.unobserveMint(mintId)
|
||||
for (const mint of self.mints) self.observeMint(mint as Mint)
|
||||
},
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
persistAllMints() {
|
||||
for (const mint of self.mints) self.persistMint(getSnapshot(mint as any))
|
||||
},
|
||||
}))
|
||||
.actions(self => ({
|
||||
|
||||
hydrateCountersFromDatabase() {
|
||||
const rows = Database.getCounters()
|
||||
if (rows.length === 0) return
|
||||
@@ -125,6 +260,47 @@ export const MintsStoreModel = types
|
||||
countersByKeyset.get(row.keysetId)?.hydrateCounterFromDb(row.counter)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Hydrate the mints from SQLite, the authority (startup).
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Does nothing when the table is empty, so a wallet whose mints have not yet
|
||||
* been seeded (the v39 migration) keeps whatever applySnapshot restored.
|
||||
*/
|
||||
loadMintsFromDatabase() {
|
||||
const records = Database.getMints()
|
||||
|
||||
if (records.length === 0) {
|
||||
log.trace('[loadMintsFromDatabase]', 'No mints in the database')
|
||||
return
|
||||
}
|
||||
|
||||
self.mints.replace(
|
||||
records.map(r =>
|
||||
MintModel.create({
|
||||
...r,
|
||||
// The column stores ISO; types.Date takes a Date or a unix ms
|
||||
// timestamp, never a string.
|
||||
createdAt: r.createdAt ? new Date(r.createdAt) : undefined,
|
||||
// Recreate the counter shells addMint would have built via
|
||||
// initKeyset. They are NOT optional: hydrateCountersFromDatabase
|
||||
// fills the real index into these, and with no shell to fill,
|
||||
// the counter would be created on demand at 0 and re-derive
|
||||
// blinded secrets the mint has already signed. The values
|
||||
// themselves are volatile and come from mint_counters.
|
||||
proofsCounters: (r.keysets ?? []).map((k: any) => ({
|
||||
keyset: k.id,
|
||||
unit: k.unit,
|
||||
})),
|
||||
} as any),
|
||||
),
|
||||
)
|
||||
log.trace('[loadMintsFromDatabase]', {loaded: self.mints.length})
|
||||
},
|
||||
}))
|
||||
.actions(self => ({
|
||||
addMint: flow(function* addMint(mintUrl: string) {
|
||||
@@ -184,6 +360,12 @@ export const MintsStoreModel = types
|
||||
|
||||
self.mints.push(mintInstance)
|
||||
|
||||
// Write it once, then let the observer carry every later change. The
|
||||
// explicit write is needed because observers fire on CHANGE, and the
|
||||
// mint was fully built before it entered the tree.
|
||||
self.persistMint(getSnapshot(mintInstance as any))
|
||||
self.observeMint(mintInstance as Mint)
|
||||
|
||||
// SQLite retains derivation counters by keysetId across mint removal
|
||||
// (rows are never deleted), so a re-added mint recovers its real
|
||||
// counter from the authority here — now also when it is re-added under
|
||||
@@ -254,8 +436,17 @@ export const MintsStoreModel = types
|
||||
}
|
||||
|
||||
if (mintInstance) {
|
||||
const mintId = mintInstance.id as string
|
||||
|
||||
// Dispose BEFORE destroying: the observer would otherwise fire on the
|
||||
// teardown and try to persist a dying node.
|
||||
self.unobserveMint(mintId)
|
||||
|
||||
// No counter backup needed: the mint's mint_counters rows are
|
||||
// retained in SQLite and restored on re-add via hydrate.
|
||||
// retained in SQLite and restored on re-add via hydrate — which is
|
||||
// also why removeMintById deliberately leaves them behind.
|
||||
Database.removeMintById(mintId)
|
||||
|
||||
detach(mintInstance)
|
||||
destroy(mintInstance)
|
||||
log.info('[removeMint]', 'Mint removed from MintsStore')
|
||||
@@ -281,6 +472,43 @@ export const MintsStoreModel = types
|
||||
get allMints() {
|
||||
return self.mints
|
||||
},
|
||||
|
||||
/**
|
||||
* The mints as a backup payload.
|
||||
*
|
||||
* Built from the LIVE instances, and it must stay that way: mints are
|
||||
* mastered in SQLite and stripped in postProcessSnapshot, so
|
||||
* `getSnapshot(mintsStore).mints` is ALWAYS empty. A backup taken from the
|
||||
* store snapshot would contain zero mints, raise no error, and only reveal
|
||||
* the loss on restore. That is the single most destructive mistake available
|
||||
* in this file, which is why the export lives here — behind a test — rather
|
||||
* than inline in the screen.
|
||||
*
|
||||
* Two deliberate deviations from a plain snapshot:
|
||||
* - `keys` are dropped: they are re-fetched from the mint on import, and
|
||||
* they are the bulk of the payload.
|
||||
* - `counter` is re-injected per keyset. It is volatile (mastered in
|
||||
* mint_counters), so it is absent from the snapshot — and a backup
|
||||
* restored with counter 0 would re-derive blinded secrets the mint has
|
||||
* already signed.
|
||||
*/
|
||||
get backupSnapshot() {
|
||||
return {
|
||||
mints: self.mints.map((liveMint: Mint) => {
|
||||
const mint: any = JSON.parse(JSON.stringify(getSnapshot(liveMint as any)))
|
||||
|
||||
mint.keys = []
|
||||
|
||||
mint.proofsCounters?.forEach((pc: any) => {
|
||||
const live = liveMint.proofsCounters?.find(c => c.keyset === pc.keyset)
|
||||
if (live) pc.counter = live.counter
|
||||
})
|
||||
|
||||
return mint
|
||||
}),
|
||||
blockedMintUrls: [...self.blockedMintUrls],
|
||||
}
|
||||
},
|
||||
get groupedByHostname() {
|
||||
const grouped: Record<string, MintsByHostname> = {}
|
||||
|
||||
|
||||
@@ -78,13 +78,17 @@ import {
|
||||
* Spendable proofs whose `mintUrl` matches no mint in the wallet, grouped by
|
||||
* that url.
|
||||
*
|
||||
* This should always be empty. `proofs.mintUrl` is a denormalized copy of a
|
||||
* mint's LOCATOR, and it is joined to `mint.mintUrl` by string equality
|
||||
* across two persistence engines — proofs in SQLite, mint.mintUrl in the
|
||||
* MMKV snapshot. A crash between those two writes during a mint-url edit
|
||||
* desyncs them, and the balance view then counts the proofs in the unit total
|
||||
* while attributing them to no mint (see `balances`), which also makes them
|
||||
* unspendable, since send and melt select proofs by mint.
|
||||
* This should always be empty, and is now a residual safety net rather than a
|
||||
* live hazard. `proofs.mintUrl` is a denormalized copy of a mint's LOCATOR,
|
||||
* joined to `mint.mintUrl` by string equality. That copy used to be able to
|
||||
* drift: the mint's url lived in the MMKV snapshot while its proofs lived in
|
||||
* SQLite, so a mint-url edit spanned two engines with no transaction between
|
||||
* them, and a crash in the gap left proofs owned by no mint. Both now live in
|
||||
* SQLite and the rename is a single transaction (mintsRepo.updateMintUrl), so
|
||||
* that path is closed — this stays because the consequence is severe enough to
|
||||
* keep watching for: the balance view counts such proofs in the unit total
|
||||
* while attributing them to no mint (see `balances`), and they cannot be
|
||||
* spent, since send and melt select proofs by mint.
|
||||
*
|
||||
* SPENT proofs are excluded: they are outside the balance and are exactly
|
||||
* what a removed mint leaves behind.
|
||||
@@ -268,16 +272,18 @@ import {
|
||||
},
|
||||
|
||||
/**
|
||||
* Repoint this mint's proofs at its new url (see Mint.setMintUrl).
|
||||
* Mirror a mint-url edit onto the in-memory proofs — MEMORY ONLY.
|
||||
*
|
||||
* SQLite first: a failed write then propagates with the MST tree still
|
||||
* matching the database. The reverse order would leave memory holding a url
|
||||
* SQLite never got — the UI would show the balance under the new mint until
|
||||
* the next restart silently moved it back.
|
||||
* The SQLite write is deliberately not here. It belongs in the same
|
||||
* transaction as the mint's own row (Database.updateMintUrlWithProofs, called
|
||||
* from Mint.setMintUrl), because those two writes must not be separable: a
|
||||
* crash between them leaves proofs pointing at a url no mint owns, and the
|
||||
* money then counts toward the total while belonging to no mint — and cannot
|
||||
* be spent, since send and melt select proofs by mint.
|
||||
*
|
||||
* Call this only AFTER that transaction commits.
|
||||
*/
|
||||
updateMintUrl(currentMintUrl: string, updatedMintUrl: string) {
|
||||
Database.updateProofsMintUrl(currentMintUrl, updatedMintUrl)
|
||||
|
||||
updateMintUrlInMemory(currentMintUrl: string, updatedMintUrl: string) {
|
||||
const updateInMap = (map: typeof self.proofs) => {
|
||||
for (const proof of map.values()) {
|
||||
if (proof.mintUrl === currentMintUrl) {
|
||||
@@ -293,7 +299,7 @@ import {
|
||||
|
||||
updateInMap(self.proofs)
|
||||
|
||||
log.trace('[updateMintUrl] Updated mint URL in proofs')
|
||||
log.trace('[updateMintUrlInMemory] Mirrored mint url onto proofs')
|
||||
},
|
||||
|
||||
// Import proofs from backup without validation or side effects
|
||||
|
||||
@@ -12,7 +12,7 @@ import {AuthStoreModel} from './AuthStore'
|
||||
// Direct import, not the '../services' barrel — see the note in Mint.ts.
|
||||
import { log } from '../services/logService'
|
||||
|
||||
export const rootStoreModelVersion = 38 // Update this if model changes require migrations defined in setupRootStore.ts
|
||||
export const rootStoreModelVersion = 39 // Update this if model changes require migrations defined in setupRootStore.ts
|
||||
/**
|
||||
* A RootStore model.
|
||||
*/
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
*/
|
||||
import {
|
||||
applySnapshot,
|
||||
getSnapshot,
|
||||
IDisposer,
|
||||
onSnapshot,
|
||||
} from 'mobx-state-tree'
|
||||
@@ -82,6 +83,20 @@ 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
|
||||
// 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()
|
||||
|
||||
if(walletProfileStore.walletId) {
|
||||
Sentry.setUser({ id: walletProfileStore.walletId })
|
||||
}
|
||||
@@ -385,6 +400,26 @@ 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()
|
||||
}
|
||||
}
|
||||
|
||||
// Set once, after all steps succeed: if any step throws, the version is
|
||||
// NOT bumped and the whole migration retries on the next launch.
|
||||
rootStore.setVersion(rootStoreModelVersion)
|
||||
|
||||
@@ -142,22 +142,14 @@ export const ExportBackupScreen = function ExportBackup({ route }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
if(isMintsInBackup) {
|
||||
exportedMintsStore = JSON.parse(JSON.stringify(getSnapshot(mintsStore)))
|
||||
|
||||
exportedMintsStore.mints.forEach((mint: any) => {
|
||||
mint.keys = [];
|
||||
|
||||
// `counter` is stripped from snapshots (mastered in SQLite), so
|
||||
// re-inject the live derivation index per keyset. Exporting a
|
||||
// backup with counter 0 would risk blinded-secret reuse on the
|
||||
// recovered wallet.
|
||||
const liveMint = mintsStore.findByUrl(mint.mintUrl)
|
||||
mint.proofsCounters?.forEach((pc: any) => {
|
||||
const live = liveMint?.proofsCounters.find(c => c.keyset === pc.keyset)
|
||||
if (live) pc.counter = live.counter
|
||||
})
|
||||
})
|
||||
if(isMintsInBackup) {
|
||||
// NOT getSnapshot(mintsStore): mints are mastered in SQLite and stripped
|
||||
// in snapshot postprocess (exactly as proofsStore is above), so the store
|
||||
// snapshot reports zero mints. backupSnapshot builds from the live
|
||||
// instances and re-injects the volatile derivation counters — it lives on
|
||||
// the store, behind a test, because getting this wrong exports an empty
|
||||
// backup with no error and only shows up on restore.
|
||||
exportedMintsStore = mintsStore.backupSnapshot as MintsStoreSnapshot
|
||||
|
||||
//log.trace({exportedMintsStore})
|
||||
}
|
||||
|
||||
@@ -204,6 +204,15 @@ export const ImportBackupScreen = observer(function ImportBackupScreen({ route }
|
||||
applySnapshot(mintsStore, walletSnapshot.mintsStore)
|
||||
applySnapshot(contactsStore, walletSnapshot.contactsStore)
|
||||
|
||||
// Mints are mastered in SQLite, and applySnapshot only puts them in the
|
||||
// model — so write them through explicitly. The per-mint observers fire on
|
||||
// CHANGE, and these nodes arrived already-formed, so nothing else would
|
||||
// persist them; the mints would then vanish on the next launch, which
|
||||
// hydrates from the database. Re-attach the observers too: applySnapshot
|
||||
// replaced the array, so the previous ones point at destroyed nodes.
|
||||
mintsStore.persistAllMints()
|
||||
mintsStore.observeMints()
|
||||
|
||||
// The backup carries real derivation counters in its raw MST snapshot.
|
||||
// `counter` is VOLATILE in the model (mastered in SQLite), so the
|
||||
// applySnapshot above does NOT load it — read the values straight from
|
||||
|
||||
@@ -34,7 +34,6 @@ import {
|
||||
import {
|
||||
addOrUpdateProof,
|
||||
addOrUpdateProofs,
|
||||
updateProofsMintUrl,
|
||||
removeAllProofs,
|
||||
getProofById,
|
||||
getProofs,
|
||||
@@ -82,6 +81,13 @@ import {
|
||||
updateOnchainMintQuoteAmounts,
|
||||
extendOnchainMintQuoteWatch,
|
||||
} from './onchainQuotesRepo'
|
||||
import {
|
||||
upsertMint,
|
||||
getMints,
|
||||
removeMintById,
|
||||
updateMintUrl as updateMintUrlWithProofs,
|
||||
seedMints,
|
||||
} from './mintsRepo'
|
||||
|
||||
export type {TransactionSearchFilters} from './transactionsRepo'
|
||||
export type {
|
||||
@@ -95,9 +101,15 @@ export type {OnchainMintQuoteRecord} from './onchainQuotesRepo'
|
||||
export {ONCHAIN_QUOTE_WATCH_DAYS} from './onchainQuotesRepo'
|
||||
export type {MeltRecoveryRecord, MeltRecoverySeed} from './meltRecoveryRepo'
|
||||
export type {InFlightRequestRecord, InFlightRequestSeed} from './inFlightRepo'
|
||||
export type {MintRecord} from './mintsRepo'
|
||||
|
||||
export const Database = {
|
||||
getInstance,
|
||||
upsertMint,
|
||||
getMints,
|
||||
removeMintById,
|
||||
updateMintUrlWithProofs,
|
||||
seedMints,
|
||||
getDatabaseVersion,
|
||||
cleanAll,
|
||||
getTransactionsCount,
|
||||
@@ -124,7 +136,6 @@ export const Database = {
|
||||
getPendingAmount,
|
||||
addOrUpdateProof,
|
||||
addOrUpdateProofs,
|
||||
updateProofsMintUrl,
|
||||
removeAllProofs,
|
||||
getProofById,
|
||||
getProofs,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {DbConnection, open, SQLBatchTuple} from './connection'
|
||||
import {createSchemaQueries} from './schema'
|
||||
import {createSchemaQueries, createTable, DBVERSION_COLUMNS} from './schema'
|
||||
import {_dbVersion, readDatabaseVersion, seedDatabaseVersion, runMigrations} from './migrations'
|
||||
import {dbError} from './errors'
|
||||
import {log} from '../logService'
|
||||
@@ -27,25 +27,44 @@ const _createDatabaseInstance = function () {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring the database to the current schema — by BUILDING it (fresh install) or by
|
||||
* MIGRATING it (everything else), never both.
|
||||
*
|
||||
* That either/or is load-bearing. `createSchemaQueries` describes today's shape, so
|
||||
* running it on an existing database was actively harmful: `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 old enough to predate a table therefore got
|
||||
* it fully-formed, and the migration that adds a column to that table then died on
|
||||
* `duplicate column name`, rolling back the entire batch and leaving the database
|
||||
* unmigrated. Shipped exactly that: a v29 wallet, which predates onchain_mint_quotes
|
||||
* (v31), got it built WITH the mintId that v33 exists to add, and came up with a
|
||||
* zero balance.
|
||||
*
|
||||
* So: the version row decides. Only `dbversion` itself is created unconditionally,
|
||||
* because reading the version requires it.
|
||||
*/
|
||||
const _createOrUpdateSchema = function (db: DbConnection) {
|
||||
try {
|
||||
const {rowsAffected} = db.executeBatch(createSchemaQueries)
|
||||
// The one table that must exist before anything can be decided.
|
||||
db.execute(createTable('dbversion', DBVERSION_COLUMNS))
|
||||
|
||||
if (rowsAffected && rowsAffected > 0) {
|
||||
log.info('[_createOrUpdateSchema] New database schema created')
|
||||
}
|
||||
const version = readDatabaseVersion(db)
|
||||
|
||||
let version = readDatabaseVersion(db)
|
||||
if (version === null) {
|
||||
// Fresh database: the schema was just created at the latest shape, so
|
||||
// seed the version row and skip migrations.
|
||||
// Fresh install: build at the latest shape and record it, so the migrations
|
||||
// that produced that shape are correctly skipped.
|
||||
db.executeBatch(createSchemaQueries)
|
||||
seedDatabaseVersion(db)
|
||||
version = _dbVersion
|
||||
log.info('[_createOrUpdateSchema]', `New database created at version ${_dbVersion}`)
|
||||
return
|
||||
}
|
||||
|
||||
log.info('[_createOrUpdateSchema]', `Device database version: ${version}`)
|
||||
|
||||
// Trigger migrations if there is versions mismatch
|
||||
// Existing database: migrations own every shape change from here. Each table a
|
||||
// later version introduced is created by ITS migration, at the shape that
|
||||
// version had — which is what keeps the subsequent ALTERs valid.
|
||||
if (version < _dbVersion) {
|
||||
runMigrations(db)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import {DbConnection, SQLBatchTuple} from './connection'
|
||||
// RESERVATIONS_COLUMNS / ONCHAIN_MINT_QUOTES_COLUMNS are deliberately NOT imported:
|
||||
// v26 and v31 build those tables from the frozen historical shapes below, not from
|
||||
// the live schema. See the note there.
|
||||
import {createTable, PROOFS_COLUMNS, PROOFS_COLUMN_NAMES, MINT_COUNTERS_COLUMNS, MINT_COUNTERS_COLUMN_NAMES, MELT_RECOVERY_COLUMNS, INFLIGHT_REQUESTS_COLUMNS, WALLET_COUNTERS_COLUMNS} from './schema'
|
||||
import {createTable, PROOFS_COLUMNS, PROOFS_COLUMN_NAMES, MINT_COUNTERS_COLUMNS, MINT_COUNTERS_COLUMN_NAMES, MELT_RECOVERY_COLUMNS, INFLIGHT_REQUESTS_COLUMNS, WALLET_COUNTERS_COLUMNS, MINTS_COLUMNS, MINT_KEYSETS_COLUMNS} from './schema'
|
||||
import {dbError} from './errors'
|
||||
import {log} from '../logService'
|
||||
|
||||
@@ -256,6 +256,25 @@ export const MIGRATIONS: Migration[] = [
|
||||
[`ALTER TABLE melt_recovery_v34 RENAME TO melt_recovery`],
|
||||
],
|
||||
},
|
||||
{
|
||||
// Master the mints here instead of in the MST/MMKV snapshot.
|
||||
//
|
||||
// Mints were the last core entity persisted by serializing the whole tree.
|
||||
// Since postProcessSnapshot already strips proofs and transactions, mints —
|
||||
// with every keyset's `keys` map — had become the largest thing left in it, and
|
||||
// `JSON.stringify(snapshot)` runs on EVERY MST action, including every proof
|
||||
// mutation during a send. It also puts mints in the same engine as proofs, so a
|
||||
// mint-url edit can finally commit atomically with the proofs it renames.
|
||||
//
|
||||
// Created EMPTY: mints live in the MMKV snapshot, so nothing in SQL can read
|
||||
// them. The v39 seed in setupRootStore copies them once the store is hydrated —
|
||||
// the same shape as the counters/melt/inflight seeds before it.
|
||||
version: 35,
|
||||
queries: [
|
||||
[createTable('mints', MINTS_COLUMNS)],
|
||||
[createTable('mint_keysets', MINT_KEYSETS_COLUMNS)],
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
|
||||
228
src/services/db/mintsRepo.ts
Normal file
228
src/services/db/mintsRepo.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import {SQLBatchTuple} from './connection'
|
||||
import {getInstance} from './instance'
|
||||
import {dbError} from './errors'
|
||||
import {log} from '../logService'
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Mints and their keysets.
|
||||
//
|
||||
// The authority for the wallet's mints. They were the last core entity persisted
|
||||
// only by serializing the whole MST tree to MMKV — and since proofs and
|
||||
// transactions are stripped from that snapshot, mints (with every keyset's `keys`
|
||||
// map) had become the largest thing left in it, re-serialized on EVERY MST action
|
||||
// anywhere in the tree.
|
||||
//
|
||||
// As with proofs and transactions: SQLite is the authority, the MST model is an
|
||||
// in-memory cache. Reads go through the model (MobX cannot observe a table);
|
||||
// writes land here.
|
||||
//
|
||||
// See MINTS_COLUMNS / MINT_KEYSETS_COLUMNS for why the keyset and keys payloads are
|
||||
// stored as whole JSON objects rather than exploded into columns.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** A mint row plus its keysets, shaped to drop straight into MintModel.create. */
|
||||
export type MintRecord = {
|
||||
id: string
|
||||
mintUrl: string
|
||||
hostname?: string
|
||||
shortname?: string
|
||||
units: string[]
|
||||
keysets: any[]
|
||||
keys: any[]
|
||||
mintInfo?: any
|
||||
color?: string
|
||||
status?: string
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
const parseJson = <T>(value: string | null | undefined, fallback: T): T => {
|
||||
if (!value) return fallback
|
||||
try {
|
||||
return JSON.parse(value) as T
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full write for one mint: the mint row plus a row per keyset.
|
||||
*
|
||||
* Returned as batch tuples so the same write can run standalone (upsertMint) or be
|
||||
* folded into a larger transaction — which is what lets a mint-url edit commit
|
||||
* atomically with the proofs it renames.
|
||||
*
|
||||
* Keysets are upserted, never cleared-and-reinserted: a keyset is only ever added
|
||||
* (refreshKeysets does not prune, and removeKeyset is unused), and a delete/insert
|
||||
* pair would briefly leave a mint with no keys inside the transaction.
|
||||
*/
|
||||
export const buildMintUpsert = function (mint: MintRecord): SQLBatchTuple[] {
|
||||
const batch: SQLBatchTuple[] = [
|
||||
[
|
||||
`INSERT INTO mints (id, mintUrl, hostname, shortname, color, status, units, mintInfo, createdAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
mintUrl = excluded.mintUrl,
|
||||
hostname = excluded.hostname,
|
||||
shortname = excluded.shortname,
|
||||
color = excluded.color,
|
||||
status = excluded.status,
|
||||
units = excluded.units,
|
||||
mintInfo = excluded.mintInfo`,
|
||||
[
|
||||
mint.id,
|
||||
mint.mintUrl,
|
||||
mint.hostname ?? null,
|
||||
mint.shortname ?? null,
|
||||
mint.color ?? null,
|
||||
mint.status ?? null,
|
||||
JSON.stringify(mint.units ?? []),
|
||||
mint.mintInfo ? JSON.stringify(mint.mintInfo) : null,
|
||||
mint.createdAt ?? new Date().toISOString(),
|
||||
],
|
||||
],
|
||||
]
|
||||
|
||||
const keysById = new Map<string, any>((mint.keys ?? []).map(k => [k.id, k]))
|
||||
|
||||
for (const keyset of mint.keysets ?? []) {
|
||||
const keys = keysById.get(keyset.id)
|
||||
batch.push([
|
||||
`INSERT INTO mint_keysets (keysetId, mintId, unit, keyset, keys)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(keysetId) DO UPDATE SET
|
||||
mintId = excluded.mintId,
|
||||
unit = excluded.unit,
|
||||
keyset = excluded.keyset,
|
||||
-- Never overwrite stored keys with null: a keyset's keys are fetched
|
||||
-- separately from its metadata, so a metadata-only refresh must not erase
|
||||
-- them (without keys the wallet cannot verify or spend that keyset).
|
||||
keys = COALESCE(excluded.keys, mint_keysets.keys)`,
|
||||
[keyset.id, mint.id, keyset.unit ?? null, JSON.stringify(keyset), keys ? JSON.stringify(keys) : null],
|
||||
])
|
||||
}
|
||||
|
||||
return batch
|
||||
}
|
||||
|
||||
/** Write one mint (and its keysets) atomically. */
|
||||
export const upsertMint = function (mint: MintRecord): void {
|
||||
try {
|
||||
getInstance().executeBatch(buildMintUpsert(mint))
|
||||
} catch (e: any) {
|
||||
throw dbError('Mint could not be saved to the database', e)
|
||||
}
|
||||
}
|
||||
|
||||
/** Every mint with its keysets, for the startup hydrate. */
|
||||
export const getMints = function (): MintRecord[] {
|
||||
try {
|
||||
const db = getInstance()
|
||||
const {rows: mintRows} = db.execute(
|
||||
`SELECT id, mintUrl, hostname, shortname, color, status, units, mintInfo, createdAt
|
||||
FROM mints ORDER BY createdAt`,
|
||||
)
|
||||
const {rows: keysetRows} = db.execute(`SELECT keysetId, mintId, unit, keyset, keys FROM mint_keysets`)
|
||||
|
||||
const keysetsByMint = new Map<string, {keysets: any[]; keys: any[]}>()
|
||||
for (const row of keysetRows?._array ?? []) {
|
||||
const entry = keysetsByMint.get(row.mintId) ?? {keysets: [], keys: []}
|
||||
entry.keysets.push(parseJson(row.keyset, null))
|
||||
// A keyset whose keys were never fetched contributes nothing to `keys`,
|
||||
// matching the model, where the two arrays are independent.
|
||||
const keys = parseJson<any>(row.keys, null)
|
||||
if (keys) entry.keys.push(keys)
|
||||
keysetsByMint.set(row.mintId, entry)
|
||||
}
|
||||
|
||||
return (mintRows?._array ?? []).map((row: any) => {
|
||||
const owned = keysetsByMint.get(row.id) ?? {keysets: [], keys: []}
|
||||
return {
|
||||
id: row.id,
|
||||
mintUrl: row.mintUrl,
|
||||
hostname: row.hostname ?? undefined,
|
||||
shortname: row.shortname ?? undefined,
|
||||
units: parseJson<string[]>(row.units, []),
|
||||
keysets: owned.keysets.filter(Boolean),
|
||||
keys: owned.keys,
|
||||
mintInfo: parseJson<any>(row.mintInfo, undefined),
|
||||
color: row.color ?? undefined,
|
||||
status: row.status ?? undefined,
|
||||
createdAt: row.createdAt ?? undefined,
|
||||
}
|
||||
})
|
||||
} catch (e: any) {
|
||||
throw dbError('Mints could not be retrieved from the database', e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a mint and its keysets.
|
||||
*
|
||||
* Its mint_counters rows are deliberately left behind: they are keyed by keysetId
|
||||
* and retained across removal, so re-adding the mint recovers its real derivation
|
||||
* counter instead of restarting at 0 (see MINT_COUNTERS_COLUMNS).
|
||||
*/
|
||||
export const removeMintById = function (mintId: string): void {
|
||||
try {
|
||||
getInstance().executeBatch([
|
||||
[`DELETE FROM mint_keysets WHERE mintId = ?`, [mintId]],
|
||||
[`DELETE FROM mints WHERE id = ?`, [mintId]],
|
||||
])
|
||||
log.debug('[removeMintById]', 'Mint removed from the database', {mintId})
|
||||
} catch (e: any) {
|
||||
throw dbError('Mint could not be removed from the database', e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Point a mint at a new url, together with the proofs that reference it — in ONE
|
||||
* transaction.
|
||||
*
|
||||
* This is the whole reason mints belong in SQLite. Before, the mint's url lived in
|
||||
* the MMKV snapshot and its proofs in SQLite, so a rename spanned two engines with
|
||||
* no transaction between them. A crash in that window left the proofs pointing at a
|
||||
* url no mint owned: the money vanished from every per-mint balance while still
|
||||
* counting toward the total, and was unspendable, because send and melt select
|
||||
* proofs by mint.
|
||||
*
|
||||
* `proofs.mintUrl` stays denormalized, but it can no longer drift — it is now
|
||||
* maintained inside the same transaction as its source of truth.
|
||||
*/
|
||||
export const updateMintUrl = function (
|
||||
mintId: string,
|
||||
currentMintUrl: string,
|
||||
updatedMintUrl: string,
|
||||
hostname: string | null,
|
||||
): void {
|
||||
try {
|
||||
getInstance().executeBatch([
|
||||
[`UPDATE mints SET mintUrl = ?, hostname = ? WHERE id = ?`, [updatedMintUrl, hostname, mintId]],
|
||||
[`UPDATE proofs SET mintUrl = ? WHERE mintUrl = ?`, [updatedMintUrl, currentMintUrl]],
|
||||
])
|
||||
log.debug('[updateMintUrl]', 'Mint url updated with its proofs', {
|
||||
mintId,
|
||||
currentMintUrl,
|
||||
updatedMintUrl,
|
||||
})
|
||||
} catch (e: any) {
|
||||
throw dbError('Mint url could not be updated in the database', e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time copy of the MST/MMKV-resident mints into SQLite (the v39 seed).
|
||||
*
|
||||
* Idempotent by construction: it upserts by the mint's own id, so re-running it
|
||||
* cannot duplicate a mint. Runs in a single batch.
|
||||
*/
|
||||
export const seedMints = function (mints: MintRecord[]): {seeded: number} {
|
||||
if (!mints || mints.length === 0) return {seeded: 0}
|
||||
try {
|
||||
const batch = mints.flatMap(buildMintUpsert)
|
||||
getInstance().executeBatch(batch)
|
||||
log.info('[seedMints]', 'Seeded mints into SQLite', {count: mints.length})
|
||||
return {seeded: mints.length}
|
||||
} catch (e: any) {
|
||||
throw dbError('Mints could not be seeded into the database', e)
|
||||
}
|
||||
}
|
||||
@@ -98,25 +98,11 @@ export const addOrUpdateProofs = function (
|
||||
}
|
||||
|
||||
|
||||
export const updateProofsMintUrl = function (currentMintUrl: string, updatedMintUrl: string) {
|
||||
try {
|
||||
const query = `
|
||||
UPDATE proofs
|
||||
SET mintUrl = ?
|
||||
WHERE mintUrl = ?
|
||||
`
|
||||
const params = [updatedMintUrl, currentMintUrl]
|
||||
|
||||
const db = getInstance()
|
||||
db.execute(query, params)
|
||||
|
||||
log.debug('[updateMintUrl]', 'Proof mintUrl updated', {currentMintUrl, updatedMintUrl})
|
||||
|
||||
|
||||
} catch (e: any) {
|
||||
throw dbError('Could not update proof mintUrl in database', e)
|
||||
}
|
||||
}
|
||||
// A standalone updateProofsMintUrl used to live here. It is gone on purpose: a
|
||||
// mint-url edit must move the mint row and its proofs in ONE transaction, so that
|
||||
// write belongs with the mint's own — see mintsRepo.updateMintUrl. Repointing
|
||||
// proofs by themselves is what left them owned by no mint when the two writes were
|
||||
// separable.
|
||||
|
||||
export const removeAllProofs = async function () {
|
||||
try {
|
||||
|
||||
@@ -270,6 +270,63 @@ export const ONCHAIN_MINT_QUOTES_COLUMNS = `
|
||||
updatedAt TEXT
|
||||
`
|
||||
|
||||
/**
|
||||
* Mints — the wallet's mints, mastered here rather than in the MST/MMKV snapshot.
|
||||
*
|
||||
* Mints were the last core entity still persisted by serializing the whole MST tree
|
||||
* to MMKV. That cost more than it looks: postProcessSnapshot already strips proofs
|
||||
* and transactions, so mints (with every keyset's `keys` map) had become BY FAR the
|
||||
* largest thing left in the tree — and while an equality guard skips redundant
|
||||
* writes, `JSON.stringify(snapshot)` still ran on EVERY MST action anywhere,
|
||||
* including every proof mutation during a send. Moving them here takes that cost off
|
||||
* the hot path, and lets a mint-url edit commit atomically with the proofs it
|
||||
* renames (previously two engines, no transaction).
|
||||
*
|
||||
* MST keeps a full in-memory copy — reads and MobX reactivity are unchanged. SQLite
|
||||
* is the authority; the model is the cache. Same split as proofs and transactions.
|
||||
*
|
||||
* `mintInfo` and `units` are JSON: nothing queries inside them. `units` is
|
||||
* derivable from the keysets, but is stored so a load round-trips the model exactly
|
||||
* rather than reconstructing it.
|
||||
*/
|
||||
export const MINTS_COLUMNS = `
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
mintUrl TEXT NOT NULL,
|
||||
hostname TEXT,
|
||||
shortname TEXT,
|
||||
color TEXT,
|
||||
status TEXT,
|
||||
units TEXT,
|
||||
mintInfo TEXT,
|
||||
createdAt TEXT
|
||||
`
|
||||
|
||||
/**
|
||||
* A mint's keysets, one row each, and the keys that belong to them.
|
||||
*
|
||||
* Keyed by keysetId ALONE, matching mint_counters — the two now agree on what a
|
||||
* keyset is, and a keyset id is unique wallet-wide (enforced at the door by
|
||||
* CashuUtils.isCollidingKeysetId, per NUT-02). `mintId` is what makes "which mint
|
||||
* owns this keyset" answerable in SQL for the first time, which is also what would
|
||||
* let proofs derive their mint from `proofs.id` (a proof's id IS its keyset id)
|
||||
* instead of carrying a denormalized url. That is deliberately NOT done yet.
|
||||
*
|
||||
* `keyset` and `keys` are stored as whole JSON objects rather than exploded into
|
||||
* columns: MintKeyset carries fields like `final_expiry` that feed NUT-02 v2 id
|
||||
* derivation, and enumerating columns would silently drop whatever cashu-ts adds
|
||||
* next. keysetId/mintId/unit are the queryable projection; the blobs are the payload.
|
||||
*
|
||||
* `keys` is nullable — a keyset the mint advertised but whose keys were not fetched
|
||||
* (or were skipped as an unsupported unit) has none.
|
||||
*/
|
||||
export const MINT_KEYSETS_COLUMNS = `
|
||||
keysetId TEXT PRIMARY KEY NOT NULL,
|
||||
mintId TEXT NOT NULL,
|
||||
unit TEXT,
|
||||
keyset TEXT NOT NULL,
|
||||
keys TEXT
|
||||
`
|
||||
|
||||
/** Build a CREATE TABLE statement from a column block. */
|
||||
export const createTable = (
|
||||
name: string,
|
||||
@@ -308,4 +365,9 @@ export const createSchemaQueries: SQLBatchTuple[] = [
|
||||
// Onchain (NUT-30) mint quotes — the long-lived deposit addresses transactions
|
||||
// point at via transactions.quote (see onchainQuotesRepo).
|
||||
[createTable('onchain_mint_quotes', ONCHAIN_MINT_QUOTES_COLUMNS)],
|
||||
// The wallet's mints and their keysets, mastered here rather than in the MMKV
|
||||
// snapshot (see mintsRepo). Seeded from the pre-upgrade snapshot on first run
|
||||
// after the migration.
|
||||
[createTable('mints', MINTS_COLUMNS)],
|
||||
[createTable('mint_keysets', MINT_KEYSETS_COLUMNS)],
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user