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>
240 lines
8.8 KiB
TypeScript
240 lines
8.8 KiB
TypeScript
/**
|
|
* Derivation counters (mint_counters), against the REAL repo and a real database.
|
|
*
|
|
* The counter is the NUT-13 derivation high-water mark for a keyset. Its single
|
|
* most important invariant is that it is MONOTONIC — a stored counter can never
|
|
* move backward — because a regression would let the next derivation reuse a
|
|
* blinded secret the mint has already signed.
|
|
*
|
|
* Rows are keyed by keysetId ALONE: NUT-13 derives from (seed, keysetId, counter)
|
|
* with no mint component, so one keyset id means one derivation sequence no matter
|
|
* which url served it. See MINT_COUNTERS_COLUMNS.
|
|
*
|
|
* This suite calls the production `Database.*` functions. It used to hand-copy both
|
|
* the schema and each statement, and those copies drifted from production while
|
|
* staying green — including asserting the OLD (mintUrl, keysetId) key long after it
|
|
* was gone. The op-sqlite jest mock now backs the real driver seam with node:sqlite,
|
|
* so there is nothing left to copy: connection.ts, instance.ts (schema + the real
|
|
* migration runner) and the repos all run for real.
|
|
*/
|
|
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 {_dbVersion} from '../src/services/db/migrations'
|
|
|
|
const MINT = 'https://mint.test'
|
|
|
|
/**
|
|
* One in-memory database per test FILE (instance.ts caches its connection), so
|
|
* clear the tables between tests rather than rebuilding.
|
|
*/
|
|
beforeEach(() => {
|
|
Database.getInstance().executeBatch([
|
|
['DELETE FROM mint_counters'],
|
|
['DELETE FROM proofs'],
|
|
['DELETE FROM reservations'],
|
|
['DELETE FROM transactions'],
|
|
])
|
|
})
|
|
|
|
const counterOf = (keysetId: string) => Database.getCounter(keysetId)?.counter
|
|
|
|
const insertProof = (secret: string, amount: number, tId = 1, state: 'UNSPENT' | 'PENDING' = 'UNSPENT') =>
|
|
Database.addOrUpdateProofs(
|
|
[{id: 'keyset1', amount, secret, C: 'C' + secret, mintUrl: MINT, unit: 'sat', tId} as any],
|
|
state,
|
|
)
|
|
|
|
const proofStateOf = (secret: string): string | undefined =>
|
|
Database.getInstance().execute('SELECT state FROM proofs WHERE secret = ?', [secret]).rows?.item(0)
|
|
?.state
|
|
|
|
describe('Derivation counters (mint_counters)', () => {
|
|
test('the database is at the current schema version', () => {
|
|
// 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(_dbVersion)
|
|
})
|
|
|
|
describe('setCounter — monotonic', () => {
|
|
test('inserts a new row when none exists', () => {
|
|
Database.setCounter('k1', 'sat', 42)
|
|
expect(counterOf('k1')).toBe(42)
|
|
})
|
|
|
|
test('raises to a higher value', () => {
|
|
Database.setCounter('k1', 'sat', 100)
|
|
Database.setCounter('k1', 'sat', 150)
|
|
expect(counterOf('k1')).toBe(150)
|
|
})
|
|
|
|
test('NEVER lowers — a smaller value is ignored (the core safety invariant)', () => {
|
|
Database.setCounter('k1', 'sat', 100)
|
|
Database.setCounter('k1', 'sat', 50) // stale / replayed writer
|
|
expect(counterOf('k1')).toBe(100)
|
|
})
|
|
|
|
test('an equal value is a no-op', () => {
|
|
Database.setCounter('k1', 'sat', 100)
|
|
Database.setCounter('k1', 'sat', 100)
|
|
expect(counterOf('k1')).toBe(100)
|
|
})
|
|
})
|
|
|
|
describe('bumpCounter — relative advance', () => {
|
|
test('inserts from 0 when no row exists', () => {
|
|
Database.bumpCounter('k1', 'sat', 10)
|
|
expect(counterOf('k1')).toBe(10)
|
|
})
|
|
|
|
test('adds to the existing value', () => {
|
|
Database.setCounter('k1', 'sat', 100)
|
|
Database.bumpCounter('k1', 'sat', 10)
|
|
expect(counterOf('k1')).toBe(110)
|
|
})
|
|
|
|
test('a non-positive delta is a no-op', () => {
|
|
Database.setCounter('k1', 'sat', 100)
|
|
Database.bumpCounter('k1', 'sat', 0)
|
|
Database.bumpCounter('k1', 'sat', -5)
|
|
expect(counterOf('k1')).toBe(100)
|
|
})
|
|
})
|
|
|
|
describe('primary key — one keyset, one counter', () => {
|
|
test('different keysets are independent', () => {
|
|
Database.setCounter('k1', 'sat', 100)
|
|
Database.setCounter('k2', 'sat', 7)
|
|
expect(counterOf('k1')).toBe(100)
|
|
expect(counterOf('k2')).toBe(7)
|
|
expect(Database.getCounters()).toHaveLength(2)
|
|
})
|
|
|
|
// The inverse of this used to be asserted (and implemented): a
|
|
// (mintUrl, keysetId) key let ONE keyset carry two counters. Both drove the
|
|
// same derivation path, so the lower one handed out indices the mint had
|
|
// already signed against the higher.
|
|
test('one keyset id has exactly ONE counter, whatever mint served it', () => {
|
|
Database.setCounter('k1', 'sat', 100)
|
|
Database.setCounter('k1', 'sat', 5) // same keyset seen via another url
|
|
expect(counterOf('k1')).toBe(100) // monotonic, not a second row
|
|
expect(Database.getCounters()).toHaveLength(1)
|
|
})
|
|
|
|
test('getCounter returns undefined for an unknown keyset', () => {
|
|
expect(Database.getCounter('nope')).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('seedCounters — one-time MST/MMKV copy', () => {
|
|
test('seeds every supplied counter', () => {
|
|
Database.seedCounters([
|
|
{keysetId: 'k1', unit: 'sat', counter: 100},
|
|
{keysetId: 'k2', unit: 'sat', counter: 50},
|
|
])
|
|
expect(counterOf('k1')).toBe(100)
|
|
expect(counterOf('k2')).toBe(50)
|
|
})
|
|
|
|
test('is idempotent — re-running never lowers an advanced counter', () => {
|
|
Database.seedCounters([{keysetId: 'k1', unit: 'sat', counter: 100}])
|
|
Database.setCounter('k1', 'sat', 175) // wallet advances during normal use
|
|
Database.seedCounters([{keysetId: 'k1', unit: 'sat', counter: 100}]) // stale re-run
|
|
expect(counterOf('k1')).toBe(175)
|
|
})
|
|
|
|
test('a too-high seed is kept (conservative-safe: skips indices, never reuses)', () => {
|
|
Database.setCounter('k1', 'sat', 100)
|
|
Database.seedCounters([{keysetId: 'k1', unit: 'sat', counter: 9999}])
|
|
expect(counterOf('k1')).toBe(9999)
|
|
})
|
|
|
|
test('an empty seed is a no-op', () => {
|
|
expect(Database.seedCounters([])).toEqual({seeded: 0})
|
|
})
|
|
})
|
|
|
|
describe('atomic commit (counterUpdate folded into commitReservation)', () => {
|
|
const openReservation = (id: string, transactionId: number) =>
|
|
Database.openReservation(
|
|
{id, transactionId, mintId: 'mint1', mintUrl: MINT, unit: 'sat', operationType: 'send', lockedProofs: []},
|
|
[],
|
|
)
|
|
|
|
test('persists the counter in the SAME txn as the new proofs', () => {
|
|
Database.setCounter('k1', 'sat', 100)
|
|
openReservation('res-1', 1)
|
|
|
|
Database.commitReservation('res-1', {
|
|
newProofs: [
|
|
{
|
|
proofs: [{id: 'keyset1', amount: 50, secret: 'new1', C: 'C'} as any],
|
|
state: 'UNSPENT',
|
|
mintUrl: MINT,
|
|
unit: 'sat',
|
|
tId: 1,
|
|
},
|
|
],
|
|
counterUpdate: [{keysetId: 'k1', unit: 'sat', counter: 110}],
|
|
})
|
|
|
|
expect(proofStateOf('new1')).toBe('UNSPENT')
|
|
expect(counterOf('k1')).toBe(110)
|
|
// Committing also clears the reservation row.
|
|
expect(Database.getOpenReservations()).toHaveLength(0)
|
|
})
|
|
|
|
test('counterUpdate stays monotonic inside the commit batch', () => {
|
|
Database.setCounter('k1', 'sat', 200)
|
|
openReservation('res-2', 2)
|
|
|
|
// A commit carrying a stale (lower) counter must not regress it.
|
|
Database.commitReservation('res-2', {
|
|
newProofs: [
|
|
{
|
|
proofs: [{id: 'keyset1', amount: 10, secret: 'new2', C: 'C'} as any],
|
|
state: 'UNSPENT',
|
|
mintUrl: MINT,
|
|
unit: 'sat',
|
|
tId: 2,
|
|
},
|
|
],
|
|
counterUpdate: [{keysetId: 'k1', unit: 'sat', counter: 150}],
|
|
})
|
|
|
|
expect(proofStateOf('new2')).toBe('UNSPENT')
|
|
expect(counterOf('k1')).toBe(200)
|
|
})
|
|
|
|
test('a failing commit rolls back BOTH the proofs and the counter', () => {
|
|
Database.setCounter('k1', 'sat', 100)
|
|
openReservation('res-3', 3)
|
|
|
|
// A non-finite amount is rejected by connection.ts's param sanitizing, mid
|
|
// batch — after the counter upsert has already run inside the transaction.
|
|
expect(() =>
|
|
Database.commitReservation('res-3', {
|
|
newProofs: [
|
|
{
|
|
proofs: [{id: 'keyset1', amount: Number.NaN, secret: 'bad', C: 'C'} as any],
|
|
state: 'UNSPENT',
|
|
mintUrl: MINT,
|
|
unit: 'sat',
|
|
tId: 3,
|
|
},
|
|
],
|
|
counterUpdate: [{keysetId: 'k1', unit: 'sat', counter: 110}],
|
|
}),
|
|
).toThrow()
|
|
|
|
// All-or-nothing: no proof, no counter advance, and the reservation stands.
|
|
expect(proofStateOf('bad')).toBeUndefined()
|
|
expect(counterOf('k1')).toBe(100)
|
|
expect(Database.getOpenReservations()).toHaveLength(1)
|
|
})
|
|
})
|
|
})
|