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