diff --git a/__mocks__/op-sqlite.js b/__mocks__/op-sqlite.js index ca1c44e3..71274292 100644 --- a/__mocks__/op-sqlite.js +++ b/__mocks__/op-sqlite.js @@ -1,39 +1,105 @@ /** - * Jest manual mock for @op-engineering/op-sqlite. + * Jest mock for @op-engineering/op-sqlite — a REAL database, backed by node:sqlite. * - * op-sqlite is a native module and cannot load under jest, yet the whole model - * layer imports it transitively (`services -> db -> connection`), which is what - * blocked instantiating MST stores in a test. + * op-sqlite is native and cannot load under jest. Rather than stub it out, this + * implements its driver surface on top of Node's built-in SQLite, so tests run the + * PRODUCTION code path end to end: connection.ts (its param sanitizing, result + * adaptation and BEGIN/COMMIT batch emulation), instance.ts (schema creation and + * the real migration runner), and every repo — all real. * - * This is an IMPORT-TIME shim, not a database. It exists so the module graph - * resolves; it deliberately does NOT emulate SQLite. Anything that actually - * executes a statement throws loudly rather than silently returning empty results, - * because a test that believes it wrote to a database and did not is worse than a - * test that fails. + * Why this matters: the db suites used to hand-copy both the DDL and each repo's + * SQL, and those copies drifted from production repeatedly while staying green. + * A test that asserts against its own copy of the schema proves nothing about the + * schema. Mocking at connection.ts's documented seam — "the single seam between + * the rest of the app and the native SQLite library" — removes the copies entirely. * - * Two ways to test around it: - * - Model/view logic: stub the `Database` facade (`jest.mock('../src/services')`) - * and drive the MST tree directly. - * - Real SQL semantics: use node:sqlite and mirror the production statements, as - * the db suites do (see sqliteMigration*.test.ts). + * Each test FILE gets its own in-memory database (jest resets the module registry + * per file, so instance.ts re-runs and rebuilds the schema). Tests within a file + * share it; clear the tables you use in beforeEach, or use distinct keys. + * + * NOT covered here: op-sqlite is not SQLite-the-same-build. Node's SQLite may differ + * in version and compile flags, so this proves our SQL and our logic, not the exact + * native binary's behaviour. Device testing still owns that. */ -const notImplemented = name => () => { - throw new Error( - `[op-sqlite mock] ${name}() was called in a test. This shim only makes the ` + - `module graph resolve — it is not a database. Stub the Database facade, or ` + - `use node:sqlite with the production SQL (see the db test suites).`, - ) +const {DatabaseSync} = require('node:sqlite') + +/** + * Statements that RETURN rows. Everything else reports changes/insertId instead. + * + * The RETURNING clause matters: walletCountersRepo allocates an index with a single + * `INSERT … ON CONFLICT DO UPDATE … RETURNING`, which leads with INSERT but yields a + * row. Routing it by leading keyword alone hands back nothing and the allocation + * fails. + */ +const returnsRows = sql => + /^\s*(select|pragma|with|explain)/i.test(sql) || /\breturning\b/i.test(sql) + +/** Transaction control cannot be prepared as a statement; exec it directly. */ +const isTransactionControl = sql => /^\s*(begin|commit|rollback|savepoint|release)\b/i.test(sql) + +/** + * node:sqlite binds a narrower set of types than the native driver. + * + * connection.ts's sanitizeValue runs BEFORE this and is what the app relies on, so + * it has already rejected anything genuinely unbindable. This only bridges the two + * types Node will not take directly — booleans (SQLite has no boolean type; real + * drivers coerce to 0/1) and ArrayBuffer (Node wants a view). + */ +const toBindable = value => { + if (typeof value === 'boolean') return value ? 1 : 0 + if (value instanceof ArrayBuffer) return new Uint8Array(value) + return value } -const open = () => ({ - execute: notImplemented('execute'), - executeSync: notImplemented('executeSync'), - executeAsync: notImplemented('executeAsync'), - executeBatch: notImplemented('executeBatch'), - executeBatchAsync: notImplemented('executeBatchAsync'), - close: () => {}, - delete: () => {}, -}) +const open = () => { + const db = new DatabaseSync(':memory:') + + const executeSync = (query, params) => { + if (isTransactionControl(query)) { + db.exec(query) + return {rows: [], rowsAffected: 0} + } + + const bound = (params ?? []).map(toBindable) + const statement = db.prepare(query) + + if (returnsRows(query)) { + return {rows: statement.all(...bound), rowsAffected: 0} + } + + const result = statement.run(...bound) + return { + rows: [], + rowsAffected: Number(result.changes ?? 0), + // Only meaningful for INSERT; harmless elsewhere and matches op-sqlite. + insertId: result.lastInsertRowid != null ? Number(result.lastInsertRowid) : undefined, + } + } + + return { + executeSync, + execute: async (query, params) => executeSync(query, params), + // op-sqlite's own batch is async and all-or-nothing. connection.ts does not use + // it for the synchronous path (it emulates that with BEGIN/COMMIT over + // executeSync), so this only serves executeBatchAsync. + executeBatch: async commands => { + let rowsAffected = 0 + db.exec('BEGIN') + try { + for (const [query, params] of commands) { + rowsAffected += executeSync(query, params).rowsAffected ?? 0 + } + db.exec('COMMIT') + } catch (e) { + db.exec('ROLLBACK') + throw e + } + return {rowsAffected} + }, + close: () => db.close(), + delete: () => {}, + } +} module.exports = { open, diff --git a/__tests__/counters.test.ts b/__tests__/counters.test.ts index a0471628..477f67b8 100644 --- a/__tests__/counters.test.ts +++ b/__tests__/counters.test.ts @@ -1,349 +1,238 @@ /** - * Derivation-counter tests (mint_counters). + * Derivation counters (mint_counters), against the REAL repo and a real database. * - * Verifies the SQL-level semantics that `Database.setCounter`, `bumpCounter`, - * `seedCounters`, and the `counterUpdate` folded into `commitReservation` - * implement on top of `executeBatch`. The counter is the NUT-13 derivation - * high-water mark; the 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 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; see MINT_COUNTERS_COLUMNS. + * 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. * - * As with proofReservation.test.ts we mirror the exact production SQL using - * node:sqlite + explicit BEGIN/COMMIT, since the native driver needs a device. - * - * @jest-environment node + * 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. */ -import {DatabaseSync} from 'node:sqlite' +jest.mock('../src/services/logService', () => ({ + log: {debug: jest.fn(), error: jest.fn(), info: jest.fn(), trace: jest.fn(), warn: jest.fn()}, +})) -const NOW = '2026-06-04T00:00:00.000Z' - -// ── Schema (mirrors schema.ts) ────────────────────────────────────────────── - -const CREATE_MINT_COUNTERS = `CREATE TABLE mint_counters ( - keysetId TEXT PRIMARY KEY NOT NULL, - unit TEXT, - counter INTEGER NOT NULL DEFAULT 0, - updatedAt TEXT -)` - -const CREATE_PROOFS = `CREATE TABLE proofs ( - id TEXT NOT NULL, - amount INTEGER NOT NULL, - secret TEXT PRIMARY KEY NOT NULL, - C TEXT NOT NULL, - unit TEXT, - tId INTEGER, - mintUrl TEXT, - state TEXT NOT NULL DEFAULT 'UNSPENT', - updatedAt TEXT -)` - -const CREATE_RESERVATIONS = `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 -)` +import {Database} from '../src/services/db' const MINT = 'https://mint.test' -// ── Mirrored Database primitives (exact production SQL) ───────────────────── - -/** countersRepo.buildCounterUpsert / setCounter — monotonic absolute write. */ -function setCounter(db: DatabaseSync, keysetId: string, unit: string | null, value: number) { - db.prepare( - `INSERT INTO mint_counters (keysetId, unit, counter, updatedAt) - VALUES (?, ?, ?, ?) - ON CONFLICT(keysetId) DO UPDATE SET - counter = MAX(counter, excluded.counter), - unit = excluded.unit, - updatedAt = excluded.updatedAt`, - ).run(keysetId, unit, value, NOW) -} - -/** countersRepo.bumpCounter — relative advance (no-op for delta <= 0). */ -function bumpCounter(db: DatabaseSync, keysetId: string, unit: string | null, delta: number) { - if (delta <= 0) return - db.prepare( - `INSERT INTO mint_counters (keysetId, unit, counter, updatedAt) - VALUES (?, ?, ?, ?) - ON CONFLICT(keysetId) DO UPDATE SET - counter = counter + ?, - updatedAt = excluded.updatedAt`, - ).run(keysetId, unit, delta, NOW, delta) -} - -function getCounter(db: DatabaseSync, keysetId: string): number | undefined { - const row = db - .prepare('SELECT counter FROM mint_counters WHERE keysetId = ?') - .get(keysetId) as {counter: number} | undefined - return row?.counter -} - -function counterRowCount(db: DatabaseSync): number { - const {n} = db.prepare('SELECT COUNT(*) AS n FROM mint_counters').get() as {n: number} - return n -} - -/** countersRepo.seedCounters — idempotent monotonic batch. */ -function seedCounters( - db: DatabaseSync, - seeds: Array<{keysetId: string; unit?: string; counter: number}>, -) { - db.exec('BEGIN') - try { - for (const s of seeds) setCounter(db, s.keysetId, s.unit ?? null, s.counter) - db.exec('COMMIT') - } catch (e) { - db.exec('ROLLBACK') - throw e - } -} - -function insertProof(db: DatabaseSync, secret: string, amount: number, state = 'UNSPENT') { - db.prepare( - `INSERT INTO proofs (id, amount, secret, C, mintUrl, unit, tId, state, updatedAt) - VALUES ('keyset1', ?, ?, 'C', '${MINT}', 'sat', 1, ?, '2026-01-01')`, - ).run(amount, secret, state) -} - -function getProofState(db: DatabaseSync, secret: string): string { - const row = db.prepare('SELECT state FROM proofs WHERE secret = ?').get(secret) as - | {state: string} - | undefined - return row?.state ?? '' -} - /** - * commitReservation with a folded counterUpdate (the step-4 atomic commit): - * new proofs + a monotonic counter upsert + reservation delete, all in one txn. + * One in-memory database per test FILE (instance.ts caches its connection), so + * clear the tables between tests rather than rebuilding. */ -function commitWithCounter( - db: DatabaseSync, - reservationId: string, - changes: { - newProofs?: Array<{secret: string; amount: number; state: string}> - counterUpdate?: Array<{keysetId: string; unit?: string; counter: number}> - }, -) { - db.exec('BEGIN') - try { - const insertNew = db.prepare( - `INSERT OR REPLACE INTO proofs (id, amount, secret, C, mintUrl, unit, tId, state, updatedAt) - VALUES ('keyset1', ?, ?, 'C', '${MINT}', 'sat', 1, ?, ?)`, - ) - for (const p of changes.newProofs ?? []) insertNew.run(p.amount, p.secret, p.state, NOW) +beforeEach(() => { + Database.getInstance().executeBatch([ + ['DELETE FROM mint_counters'], + ['DELETE FROM proofs'], + ['DELETE FROM reservations'], + ['DELETE FROM transactions'], + ]) +}) - for (const cu of changes.counterUpdate ?? []) { - setCounter(db, cu.keysetId, cu.unit ?? null, cu.counter) - } +const counterOf = (keysetId: string) => Database.getCounter(keysetId)?.counter - db.prepare('DELETE FROM reservations WHERE id = ?').run(reservationId) - db.exec('COMMIT') - } catch (e) { - db.exec('ROLLBACK') - throw e - } -} +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, + ) -function freshDb(): DatabaseSync { - const db = new DatabaseSync(':memory:') - db.exec(CREATE_MINT_COUNTERS) - db.exec(CREATE_PROOFS) - db.exec(CREATE_RESERVATIONS) - return db -} - -// ── Tests ─────────────────────────────────────────────────────────────────── +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)', () => { - describe('setCounter — monotonic', () => { - test('inserts a new row when none exists', () => { - const db = freshDb() - setCounter(db, 'k1', 'sat', 42) - expect(getCounter(db, 'k1')).toBe(42) - db.close() - }) + 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(34) + }) - test('raises to a higher value', () => { - const db = freshDb() - setCounter(db, 'k1', 'sat', 100) - setCounter(db, 'k1', 'sat', 150) - expect(getCounter(db, 'k1')).toBe(150) - db.close() - }) - - test('NEVER lowers — a smaller value is ignored (the core safety invariant)', () => { - const db = freshDb() - setCounter(db, 'k1', 'sat', 100) - setCounter(db, 'k1', 'sat', 50) // stale / replayed writer - expect(getCounter(db, 'k1')).toBe(100) - db.close() - }) - - test('an equal value is a no-op', () => { - const db = freshDb() - setCounter(db, 'k1', 'sat', 100) - setCounter(db, 'k1', 'sat', 100) - expect(getCounter(db, 'k1')).toBe(100) - db.close() - }) + describe('setCounter — monotonic', () => { + test('inserts a new row when none exists', () => { + Database.setCounter('k1', 'sat', 42) + expect(counterOf('k1')).toBe(42) }) - describe('bumpCounter — relative advance', () => { - test('inserts from 0 when no row exists', () => { - const db = freshDb() - bumpCounter(db, 'k1', 'sat', 10) - expect(getCounter(db, 'k1')).toBe(10) - db.close() - }) - - test('adds to the existing value', () => { - const db = freshDb() - setCounter(db, 'k1', 'sat', 100) - bumpCounter(db, 'k1', 'sat', 10) - expect(getCounter(db, 'k1')).toBe(110) - db.close() - }) - - test('a non-positive delta is a no-op', () => { - const db = freshDb() - setCounter(db, 'k1', 'sat', 100) - bumpCounter(db, 'k1', 'sat', 0) - bumpCounter(db, 'k1', 'sat', -5) - expect(getCounter(db, 'k1')).toBe(100) - db.close() - }) + test('raises to a higher value', () => { + Database.setCounter('k1', 'sat', 100) + Database.setCounter('k1', 'sat', 150) + expect(counterOf('k1')).toBe(150) }) - describe('primary key isolation', () => { - test('different keysets are independent', () => { - const db = freshDb() - setCounter(db, 'k1', 'sat', 100) - setCounter(db, 'k2', 'sat', 7) - expect(getCounter(db, 'k1')).toBe(100) - expect(getCounter(db, 'k2')).toBe(7) - expect(counterRowCount(db)).toBe(2) - db.close() - }) - - // The inverse of this used to be asserted (and implemented): a - // (mintUrl, keysetId) key let ONE keyset carry two counters. NUT-13 - // derives from (seed, keysetId, counter) with no mint component, so both - // rows drove the same derivation path and the lower one reused blinded - // secrets the mint had already signed. One keyset id, one counter — no - // matter which mint url served the keyset. - test('one keyset id has exactly ONE counter, whatever mint served it', () => { - const db = freshDb() - setCounter(db, 'k1', 'sat', 100) - // The same keyset seen again after a mint-url edit / via a mirror. - setCounter(db, 'k1', 'sat', 5) - expect(getCounter(db, 'k1')).toBe(100) // monotonic, not a second row - expect(counterRowCount(db)).toBe(1) - db.close() - }) + 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) }) - describe('seedCounters — one-time MMKV→SQLite copy', () => { - test('seeds every supplied counter', () => { - const db = freshDb() - seedCounters(db, [ - {keysetId: 'k1', unit: 'sat', counter: 100}, - {keysetId: 'k2', unit: 'sat', counter: 50}, - ]) - expect(getCounter(db, 'k1')).toBe(100) - expect(getCounter(db, 'k2')).toBe(50) - db.close() - }) + test('an equal value is a no-op', () => { + Database.setCounter('k1', 'sat', 100) + Database.setCounter('k1', 'sat', 100) + expect(counterOf('k1')).toBe(100) + }) + }) - test('is idempotent — re-running never lowers an advanced counter', () => { - const db = freshDb() - // First upgrade seed copies the (then current) MMKV values. - seedCounters(db, [{keysetId: 'k1', unit: 'sat', counter: 100}]) - // Wallet advances past it during normal use. - setCounter(db, 'k1', 'sat', 175) - // A later launch re-runs the seed with the now-stale snapshot value. - seedCounters(db, [{keysetId: 'k1', unit: 'sat', counter: 100}]) - // The advanced SQLite value wins — the seed cannot regress it. - expect(getCounter(db, 'k1')).toBe(175) - db.close() - }) - - test('a too-high seed is kept (conservative-safe: skips indices, never reuses)', () => { - const db = freshDb() - setCounter(db, 'k1', 'sat', 100) - seedCounters(db, [{keysetId: 'k1', unit: 'sat', counter: 9999}]) - expect(getCounter(db, 'k1')).toBe(9999) - db.close() - }) + describe('bumpCounter — relative advance', () => { + test('inserts from 0 when no row exists', () => { + Database.bumpCounter('k1', 'sat', 10) + expect(counterOf('k1')).toBe(10) }) - describe('atomic commit (counterUpdate folded into commitReservation)', () => { - test('persists the counter in the SAME txn as the new proofs', () => { - const db = freshDb() - setCounter(db, 'k1', 'sat', 100) - - commitWithCounter(db, 'res-1', { - newProofs: [{secret: 'new1', amount: 50, state: 'UNSPENT'}], - counterUpdate: [{keysetId: 'k1', unit: 'sat', counter: 110}], - }) - - expect(getProofState(db, 'new1')).toBe('UNSPENT') - expect(getCounter(db, 'k1')).toBe(110) - db.close() - }) - - test('a failed commit batch rolls back BOTH the proofs and the counter', () => { - const db = freshDb() - setCounter(db, 'k1', 'sat', 100) - - // Force a failure mid-batch (NOT NULL violation on amount) AFTER the - // proof insert and counter upsert have run in the same transaction. - expect(() => { - db.exec('BEGIN') - try { - db.prepare( - `INSERT OR REPLACE INTO proofs (id, amount, secret, C, mintUrl, unit, tId, state, updatedAt) - VALUES ('keyset1', 50, 'new1', 'C', '${MINT}', 'sat', 1, 'UNSPENT', '${NOW}')`, - ).run() - setCounter(db, 'k1', 'sat', 110) - // Violates NOT NULL on amount → aborts the whole batch. - db.prepare( - `INSERT INTO proofs (id, amount, secret, C, state) VALUES ('keyset1', NULL, 'bad', 'C', 'UNSPENT')`, - ).run() - db.exec('COMMIT') - } catch (e) { - db.exec('ROLLBACK') - throw e - } - }).toThrow() - - // Neither the proof nor the counter advance survived. - expect(getProofState(db, 'new1')).toBe('') - expect(getCounter(db, 'k1')).toBe(100) - db.close() - }) - - test('counterUpdate stays monotonic inside the commit batch', () => { - const db = freshDb() - setCounter(db, 'k1', 'sat', 200) - - // A commit carrying a stale (lower) counter must not regress it. - commitWithCounter(db, 'res-2', { - newProofs: [{secret: 'new2', amount: 10, state: 'UNSPENT'}], - counterUpdate: [{keysetId: 'k1', unit: 'sat', counter: 150}], - }) - - expect(getProofState(db, 'new2')).toBe('UNSPENT') - expect(getCounter(db, 'k1')).toBe(200) - db.close() - }) + 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) + }) + }) }) diff --git a/__tests__/inFlightRequests.test.ts b/__tests__/inFlightRequests.test.ts index 0180114d..04c5b456 100644 --- a/__tests__/inFlightRequests.test.ts +++ b/__tests__/inFlightRequests.test.ts @@ -1,198 +1,136 @@ /** - * In-flight request tests (inFlightRequests → SQLite migration). + * In-flight requests (inflight_requests), against the REAL repo and a real database. * - * Per-transaction request params stored so an op whose mint response was lost - * can be retried against the mint's idempotent endpoint. add() overwrites - * (set semantics), the per-mint query drives the recovery sweep, the row is - * deleted on success/terminal failure, and the upgrade seed is idempotent. + * Per-transaction request params for an operation that has reached the mint but + * whose response may be lost. Written before the network call so the op can be + * retried against the mint's idempotent (NUT-19) endpoint. * - * Mirrors the production SQL against node:sqlite (the native driver needs a - * device), like meltRecovery.test.ts. + * The table is a CHILD of the transaction — its primary key IS transactionId — so + * it carries no mint reference. It once duplicated mintUrl and keysetId; keysetId + * had no reader at all, and mintUrl served exactly one query ("every request of + * this mint"), which now JOINs through the parent's mintId. One owner of the fact, + * so nothing here can go stale when a mint moves. * - * @jest-environment node + * Calls the production `Database.*` functions rather than mirroring their SQL — the + * op-sqlite jest mock backs the real driver seam with node:sqlite. That matters most + * for the join: a hand-copied version proves nothing about the query the app runs. */ -import {DatabaseSync} from 'node:sqlite' +jest.mock('../src/services/logService', () => ({ + log: {debug: jest.fn(), error: jest.fn(), info: jest.fn(), trace: jest.fn(), warn: jest.fn()}, +})) -const NOW = '2026-06-05T00:00:00.000Z' - -const CREATE_INFLIGHT = `CREATE TABLE inflight_requests ( - transactionId INTEGER PRIMARY KEY NOT NULL, - request TEXT NOT NULL, - createdAt TEXT -)` - -/** - * The parent. inflight_requests is a CHILD of a transaction (its primary key IS - * transactionId) and holds no mint reference of its own — the mint-scoped query - * joins through here, so the fixture needs it. - */ -const CREATE_TRANSACTIONS = `CREATE TABLE transactions ( - id INTEGER PRIMARY KEY NOT NULL, - mintId TEXT, - mint TEXT, - status TEXT -)` +import {Database} from '../src/services/db' const MINT = 'https://mint.test' const MINT_ID = 'mint1111' const OTHER_MINT_ID = 'mint9999' -// ── Mirrored repo primitives (exact production SQL) ───────────────────────── +/** The parent row the join reaches through. */ +const addTransaction = (id: number, mintId: string | null) => + Database.getInstance().execute( + `INSERT OR REPLACE INTO transactions (id, type, amount, unit, data, mint, mintId, status, createdAt) + VALUES (?, 'TOPUP', 100, 'sat', '{}', ?, ?, 'PENDING', '2026-01-01')`, + [id, MINT, mintId], + ) -function addTransaction(db: DatabaseSync, id: number, mintId: string | null) { - db.prepare(`INSERT OR REPLACE INTO transactions (id, mintId, mint, status) VALUES (?, ?, ?, 'PENDING')`) - .run(id, mintId, MINT) -} - -function addInFlightRequest(db: DatabaseSync, transactionId: number, request: object) { - db.prepare( - `INSERT OR REPLACE INTO inflight_requests (transactionId, request, createdAt) - VALUES (?, ?, ?)`, - ).run(transactionId, JSON.stringify(request), NOW) -} - -function getInFlightRequest(db: DatabaseSync, transactionId: number) { - const row = db - .prepare(`SELECT transactionId, request, createdAt FROM inflight_requests WHERE transactionId = ?`) - .get(transactionId) as {transactionId: number; request: string; createdAt: string | null} | undefined - if (!row) return undefined - return {...row, request: JSON.parse(row.request)} -} - -/** - * The mint-scoped sweep. Joins through the owning transaction: this table keeps no - * mint reference of its own, so `transactions.mintId` is the single owner of that - * fact — and being an id, it survives the mint changing url. - */ -function getInFlightRequestsByMintId(db: DatabaseSync, mintId: string) { - const rows = db - .prepare( - `SELECT r.transactionId, r.request, r.createdAt - FROM inflight_requests r - JOIN transactions t ON t.id = r.transactionId - WHERE t.mintId = ?`, - ) - .all(mintId) as Array<{transactionId: number; request: string; createdAt: string | null}> - return rows.map(r => ({...r, request: JSON.parse(r.request)})) -} - -function removeInFlightRequest(db: DatabaseSync, transactionId: number) { - db.prepare(`DELETE FROM inflight_requests WHERE transactionId = ?`).run(transactionId) -} - -function seedInFlightRequest(db: DatabaseSync, transactionId: number, request: object) { - db.prepare( - `INSERT INTO inflight_requests (transactionId, request, createdAt) - VALUES (?, ?, ?) - ON CONFLICT(transactionId) DO NOTHING`, - ).run(transactionId, JSON.stringify(request), NOW) -} - -function freshDb(): DatabaseSync { - const db = new DatabaseSync(':memory:') - db.exec(CREATE_INFLIGHT) - db.exec(CREATE_TRANSACTIONS) - return db -} - -// ── Tests ─────────────────────────────────────────────────────────────────── +beforeEach(() => { + Database.getInstance().executeBatch([['DELETE FROM inflight_requests'], ['DELETE FROM transactions']]) +}) describe('In-flight requests (inflight_requests)', () => { - test('stores and reads back a request (JSON round-trip)', () => { - const db = freshDb() - const request = {token: 'cashuA...', options: {keysetId: 'k1'}} + test('stores and reads back a request (JSON round-trip)', () => { + const request = {token: 'cashuA...', options: {keysetId: 'k1'}} + addTransaction(101, MINT_ID) + Database.addInFlightRequest(101, request) - addTransaction(db, 101, MINT_ID) - addInFlightRequest(db, 101, request) - const rec = getInFlightRequest(db, 101)! + const rec = Database.getInFlightRequest(101)! + expect(rec.transactionId).toBe(101) + expect(rec.request).toEqual(request) + }) - expect(rec.transactionId).toBe(101) - expect(rec.request).toEqual(request) - db.close() + test('returns undefined when no entry exists', () => { + expect(Database.getInFlightRequest(999)).toBeUndefined() + }) + + test('add OVERWRITES an existing entry (set semantics)', () => { + addTransaction(101, MINT_ID) + Database.addInFlightRequest(101, {v: 'first'}) + Database.addInFlightRequest(101, {v: 'second'}) + + expect(Database.getInFlightRequest(101)!.request).toEqual({v: 'second'}) + }) + + test('remove deletes the entry', () => { + addTransaction(101, MINT_ID) + Database.addInFlightRequest(101, {v: 1}) + Database.removeInFlightRequest(101) + + expect(Database.getInFlightRequest(101)).toBeUndefined() + expect(Database.getInFlightRequestsByMintId(MINT_ID)).toHaveLength(0) + }) + + describe('getInFlightRequestsByMintId — the per-mint recovery sweep', () => { + test('returns all rows of a mint, and only that mint', () => { + addTransaction(101, MINT_ID) + addTransaction(102, MINT_ID) + addTransaction(103, OTHER_MINT_ID) + Database.addInFlightRequest(101, {v: 1}) + Database.addInFlightRequest(102, {v: 2}) + Database.addInFlightRequest(103, {v: 3}) + + expect( + Database.getInFlightRequestsByMintId(MINT_ID) + .map(r => r.transactionId) + .sort(), + ).toEqual([101, 102]) + expect(Database.getInFlightRequestsByMintId(OTHER_MINT_ID)).toHaveLength(1) }) - test('returns undefined when no entry exists', () => { - const db = freshDb() - expect(getInFlightRequest(db, 999)).toBeUndefined() - db.close() - }) + // The point of joining on mintId rather than a url copy: a mint moving must + // never hide its own in-flight work. transactions.mint stays frozen as history; + // only the mint's live url moves, and the id is unaffected. + test("still finds a mint's requests regardless of the url on the transaction", () => { + addTransaction(101, MINT_ID) + Database.addInFlightRequest(101, {v: 1}) - test('add OVERWRITES an existing entry (set semantics)', () => { - const db = freshDb() - addTransaction(db, 101, MINT_ID) - addInFlightRequest(db, 101, {v: 'first'}) - addInFlightRequest(db, 101, {v: 'second'}) + Database.getInstance().execute('UPDATE transactions SET mint = ? WHERE id = ?', [ + 'https://some-other.url', + 101, + ]) - expect(getInFlightRequest(db, 101)!.request).toEqual({v: 'second'}) - db.close() - }) - - test('getInFlightRequestsByMintId returns all rows of a mint', () => { - const db = freshDb() - addTransaction(db, 101, MINT_ID) - addTransaction(db, 102, MINT_ID) - addTransaction(db, 103, OTHER_MINT_ID) - addInFlightRequest(db, 101, {v: 1}) - addInFlightRequest(db, 102, {v: 2}) - addInFlightRequest(db, 103, {v: 3}) - - const forMint = getInFlightRequestsByMintId(db, MINT_ID) - expect(forMint.map(r => r.transactionId).sort()).toEqual([101, 102]) - expect(getInFlightRequestsByMintId(db, OTHER_MINT_ID)).toHaveLength(1) - db.close() - }) - - // The point of joining on mintId rather than a url copy: the mint moving must - // not hide its own in-flight work. - test('still finds a mint\'s requests after its url changes', () => { - const db = freshDb() - addTransaction(db, 101, MINT_ID) - addInFlightRequest(db, 101, {v: 1}) - - // transactions.mint is frozen history and never rewritten; only the mint's - // live url moves. The id is unaffected, so the join is too. - expect(getInFlightRequestsByMintId(db, MINT_ID)).toHaveLength(1) - db.close() + expect(Database.getInFlightRequestsByMintId(MINT_ID)).toHaveLength(1) }) // Correct, not a regression: the retry settles proofs onto its transaction and // the handler branches on tx.type, so without the parent there is nothing to // apply the result to. test('a request whose transaction is gone is not returned', () => { - const db = freshDb() - addTransaction(db, 101, MINT_ID) - addInFlightRequest(db, 101, {v: 1}) - db.prepare('DELETE FROM transactions WHERE id = ?').run(101) + addTransaction(101, MINT_ID) + Database.addInFlightRequest(101, {v: 1}) + Database.getInstance().execute('DELETE FROM transactions WHERE id = ?', [101]) - expect(getInFlightRequestsByMintId(db, MINT_ID)).toHaveLength(0) - db.close() + expect(Database.getInFlightRequestsByMintId(MINT_ID)).toHaveLength(0) }) test('a transaction with no mintId is not returned', () => { - const db = freshDb() - addTransaction(db, 101, null) - addInFlightRequest(db, 101, {v: 1}) + addTransaction(101, null) + Database.addInFlightRequest(101, {v: 1}) - expect(getInFlightRequestsByMintId(db, MINT_ID)).toHaveLength(0) - db.close() + expect(Database.getInFlightRequestsByMintId(MINT_ID)).toHaveLength(0) + }) + }) + + describe('seedInFlightRequests — one-time MST/MMKV copy', () => { + test('is idempotent — does not overwrite an existing entry', () => { + addTransaction(101, MINT_ID) + Database.addInFlightRequest(101, {v: 'live'}) + Database.seedInFlightRequests([{transactionId: 101, request: {v: 'snapshot'}}]) + + expect(Database.getInFlightRequest(101)!.request).toEqual({v: 'live'}) }) - test('remove deletes the entry', () => { - const db = freshDb() - addTransaction(db, 101, MINT_ID) - addInFlightRequest(db, 101, {v: 1}) - removeInFlightRequest(db, 101) - expect(getInFlightRequest(db, 101)).toBeUndefined() - expect(getInFlightRequestsByMintId(db, MINT_ID)).toHaveLength(0) - db.close() - }) - - test('seed is idempotent — does not overwrite an existing entry', () => { - const db = freshDb() - addTransaction(db, 101, MINT_ID) - addInFlightRequest(db, 101, {v: 'live'}) - seedInFlightRequest(db, 101, {v: 'snapshot'}) - expect(getInFlightRequest(db, 101)!.request).toEqual({v: 'live'}) - db.close() + test('an empty seed is a no-op', () => { + expect(Database.seedInFlightRequests([])).toEqual({seeded: 0}) }) + }) }) diff --git a/__tests__/meltRecovery.test.ts b/__tests__/meltRecovery.test.ts index e06ba51e..ba11b6dd 100644 --- a/__tests__/meltRecovery.test.ts +++ b/__tests__/meltRecovery.test.ts @@ -1,148 +1,108 @@ /** - * Melt recovery tests (meltCounterValues → SQLite migration). + * Melt recovery (melt_recovery), against the REAL repo and a real database. * - * Verifies the SQL-level semantics of meltRecoveryRepo: a per-transaction - * serialized meltPreview is stored before a melt is submitted so a paid-but- - * unconfirmed melt can be recovered and its change unblinded. The first stored - * preview for a transaction wins (idempotent), and the row is removed on - * terminal success/failure. + * A per-transaction serialized meltPreview is stored BEFORE a melt is submitted, so + * a paid-but-unconfirmed melt can be recovered and its change ecash unblinded. The + * first stored preview for a transaction wins (idempotent), and the row is removed + * on terminal success/failure. * - * Mirrors the production SQL against node:sqlite, like proofReservation.test.ts - * and counters.test.ts (the native driver needs a device). + * The table is a CHILD of the transaction — its primary key IS transactionId — so + * it holds no mint reference: the parent owns that fact, and every reader arrives + * here already holding the transaction. It once duplicated mintUrl and keysetId; + * neither had a single reader (the keyset that IS used lives inside meltPreview). * - * @jest-environment node + * Calls the production `Database.*` functions rather than mirroring their SQL: the + * op-sqlite jest mock backs the real driver seam with node:sqlite, so connection.ts, + * instance.ts and the repo all run for real. */ -import {DatabaseSync} from 'node:sqlite' +jest.mock('../src/services/logService', () => ({ + log: {debug: jest.fn(), error: jest.fn(), info: jest.fn(), trace: jest.fn(), warn: jest.fn()}, +})) -const NOW = '2026-06-05T00:00:00.000Z' +import {Database} from '../src/services/db' -const CREATE_MELT_RECOVERY = `CREATE TABLE melt_recovery ( - transactionId INTEGER PRIMARY KEY NOT NULL, - meltPreview TEXT NOT NULL, - createdAt TEXT -)` - -const MINT = 'https://mint.test' - -// A representative StoredMeltPreview (shape from cashuUtils). -const previewFor = (keysetId: string, secret = 'aa') => ({ +/** A representative StoredMeltPreview (shape from cashuUtils). */ +const previewFor = (keysetId: string, secret = 'aa') => + ({ keysetId, outputData: [ - { - blindedMessage: {amount: '2', id: keysetId, B_: 'B_' + secret}, - blindingFactor: 'deadbeef', - secret, - }, + { + blindedMessage: {amount: '2', id: keysetId, B_: 'B_' + secret}, + blindingFactor: 'deadbeef', + secret, + }, ], + }) as any + +const rowCount = () => + Database.getInstance().execute('SELECT COUNT(*) AS n FROM melt_recovery').rows?.item(0)?.n + +beforeEach(() => { + Database.getInstance().executeBatch([['DELETE FROM melt_recovery']]) }) -// ── Mirrored repo primitives (exact production SQL) ───────────────────────── - -function addMeltRecovery( - db: DatabaseSync, - transactionId: number, - meltPreview: object, -) { - db.prepare( - `INSERT INTO melt_recovery (transactionId, meltPreview, createdAt) - VALUES (?, ?, ?) - ON CONFLICT(transactionId) DO NOTHING`, - ).run(transactionId, JSON.stringify(meltPreview), NOW) -} - -function getMeltRecovery(db: DatabaseSync, transactionId: number) { - const row = db - .prepare(`SELECT transactionId, meltPreview, createdAt FROM melt_recovery WHERE transactionId = ?`) - .get(transactionId) as - | {transactionId: number; meltPreview: string; createdAt: string | null} - | undefined - if (!row) return undefined - return {...row, meltPreview: JSON.parse(row.meltPreview)} -} - -function removeMeltRecovery(db: DatabaseSync, transactionId: number) { - db.prepare(`DELETE FROM melt_recovery WHERE transactionId = ?`).run(transactionId) -} - -function rowCount(db: DatabaseSync): number { - const {n} = db.prepare('SELECT COUNT(*) AS n FROM melt_recovery').get() as {n: number} - return n -} - -function freshDb(): DatabaseSync { - const db = new DatabaseSync(':memory:') - db.exec(CREATE_MELT_RECOVERY) - return db -} - -// ── Tests ─────────────────────────────────────────────────────────────────── - describe('Melt recovery (melt_recovery)', () => { - test('stores and reads back a meltPreview (JSON round-trip)', () => { - const db = freshDb() - const preview = previewFor('k1') + test('stores and reads back a meltPreview (JSON round-trip)', () => { + const preview = previewFor('k1') + Database.addMeltRecovery(101, preview) - addMeltRecovery(db, 101, preview) - const rec = getMeltRecovery(db, 101)! + const rec = Database.getMeltRecovery(101)! + expect(rec.transactionId).toBe(101) + expect(rec.meltPreview).toEqual(preview) + // The keyset lives INSIDE the preview — the row never duplicated it. + expect(rec.meltPreview.keysetId).toBe('k1') + }) - expect(rec.transactionId).toBe(101) - expect(rec.meltPreview).toEqual(preview) - // The keyset lives INSIDE the preview — the row never duplicated it. - expect(rec.meltPreview.keysetId).toBe('k1') - db.close() + test('returns undefined when no entry exists', () => { + expect(Database.getMeltRecovery(999)).toBeUndefined() + }) + + test('the FIRST stored preview wins (ON CONFLICT DO NOTHING)', () => { + Database.addMeltRecovery(101, previewFor('k1', 'first')) + // A second attempt for the same tx must not overwrite: the preview describes + // outputs the mint may already have signed. + Database.addMeltRecovery(101, previewFor('k1', 'second')) + + expect(Database.getMeltRecovery(101)!.meltPreview.outputData[0].secret).toBe('first') + expect(rowCount()).toBe(1) + }) + + test('remove deletes the entry (terminal success/failure)', () => { + Database.addMeltRecovery(101, previewFor('k1')) + expect(rowCount()).toBe(1) + + Database.removeMeltRecovery(101) + expect(Database.getMeltRecovery(101)).toBeUndefined() + expect(rowCount()).toBe(0) + }) + + test('entries for different transactions are independent', () => { + Database.addMeltRecovery(101, previewFor('k1')) + Database.addMeltRecovery(102, previewFor('k2')) + + expect(Database.getMeltRecovery(101)!.meltPreview.keysetId).toBe('k1') + expect(Database.getMeltRecovery(102)!.meltPreview.keysetId).toBe('k2') + + Database.removeMeltRecovery(101) + expect(Database.getMeltRecovery(101)).toBeUndefined() + expect(Database.getMeltRecovery(102)!.meltPreview.keysetId).toBe('k2') // unaffected + }) + + describe('seedMeltRecoveries — one-time MST/MMKV copy', () => { + test('is idempotent — does not overwrite an existing entry', () => { + Database.addMeltRecovery(101, previewFor('k1', 'live')) + Database.seedMeltRecoveries([{transactionId: 101, meltPreview: previewFor('k1', 'snapshot')}]) + + expect(Database.getMeltRecovery(101)!.meltPreview.outputData[0].secret).toBe('live') }) - test('returns undefined when no entry exists', () => { - const db = freshDb() - expect(getMeltRecovery(db, 999)).toBeUndefined() - db.close() + test('carries over an entry that does not exist yet', () => { + Database.seedMeltRecoveries([{transactionId: 202, meltPreview: previewFor('k9', 'seeded')}]) + expect(Database.getMeltRecovery(202)!.meltPreview.outputData[0].secret).toBe('seeded') }) - test('the FIRST stored preview wins (ON CONFLICT DO NOTHING)', () => { - const db = freshDb() - addMeltRecovery(db, 101, previewFor('k1', 'first')) - // A second attempt for the same tx must not overwrite. - addMeltRecovery(db, 101, previewFor('k1', 'second')) - - const rec = getMeltRecovery(db, 101)! - expect(rec.meltPreview.outputData[0].secret).toBe('first') - expect(rowCount(db)).toBe(1) - db.close() - }) - - test('remove deletes the entry (terminal success/failure)', () => { - const db = freshDb() - addMeltRecovery(db, 101, previewFor('k1')) - expect(rowCount(db)).toBe(1) - - removeMeltRecovery(db, 101) - expect(getMeltRecovery(db, 101)).toBeUndefined() - expect(rowCount(db)).toBe(0) - db.close() - }) - - test('entries for different transactions are independent', () => { - const db = freshDb() - addMeltRecovery(db, 101, previewFor('k1')) - addMeltRecovery(db, 102, previewFor('k2')) - - expect(getMeltRecovery(db, 101)!.meltPreview.keysetId).toBe('k1') - expect(getMeltRecovery(db, 102)!.meltPreview.keysetId).toBe('k2') - - removeMeltRecovery(db, 101) - expect(getMeltRecovery(db, 101)).toBeUndefined() - expect(getMeltRecovery(db, 102)!.meltPreview.keysetId).toBe('k2') // unaffected - db.close() - }) - - test('seed is idempotent — does not overwrite an existing entry', () => { - const db = freshDb() - // Live entry already advanced/stored. - addMeltRecovery(db, 101, previewFor('k1', 'live')) - // Upgrade seed re-runs with the snapshot copy. - addMeltRecovery(db, 101, previewFor('k1', 'snapshot')) - - expect(getMeltRecovery(db, 101)!.meltPreview.outputData[0].secret).toBe('live') - db.close() + test('an empty seed is a no-op', () => { + expect(Database.seedMeltRecoveries([])).toEqual({seeded: 0}) }) + }) }) diff --git a/__tests__/nut20.test.ts b/__tests__/nut20.test.ts index 632e161b..4c251cd5 100644 --- a/__tests__/nut20.test.ts +++ b/__tests__/nut20.test.ts @@ -8,9 +8,8 @@ * own NUT-20 primitives. * * 2. THE COUNTER (src/services/db/walletCountersRepo.ts) — the burn-forward - * allocation and monotonic set, mirrored against node:sqlite because the - * native driver needs a device (same approach as inFlightRequests.test.ts - * and meltRecovery.test.ts). + * allocation and monotonic set, exercised through the REAL repo against a real + * database (the op-sqlite jest mock backs the driver seam with node:sqlite). * * The counter is the load-bearing part: handing the same index out twice would * give two quotes the same pubkey, which lets the mint link them (NUT-20 asks @@ -19,7 +18,7 @@ * * @jest-environment node */ -import {DatabaseSync} from 'node:sqlite' +import {Database} from '../src/services/db' import {Amount, signMintQuote, verifyMintQuoteSignature} from '@cashu/cashu-ts' import type {SerializedBlindedMessage} from '@cashu/cashu-ts' import {hexToBytes} from '@noble/curves/utils.js' @@ -28,9 +27,10 @@ jest.mock('../src/services/logService', () => ({ log: {debug: jest.fn(), error: jest.fn(), info: jest.fn(), trace: jest.fn(), warn: jest.fn()}, })) -// nut20.ts pulls in walletCountersRepo -> db/instance -> op-sqlite (native, and -// unavailable here). Stub the repo so the derivation code is importable; the -// real SQL is exercised against node:sqlite further down. +// The DERIVATION half stubs the counter so it can pin the index it is handed +// (allocateQuoteKeypair must use whatever the counter returns, whatever that is). +// The COUNTER half below reaches past this mock with requireActual and exercises +// the real repo against a real database. const mockAllocateNextCounter = jest.fn() jest.mock('../src/services/db/walletCountersRepo', () => ({ NUT20_COUNTER: 'nut20', @@ -224,70 +224,39 @@ describe('allocateQuoteKeypair', () => { }) }) -// ── Counter SQL, mirrored against node:sqlite ─────────────────────────────── +// ── The counter, through the REAL repo ────────────────────────────────────── // -// Exact production statements from walletCountersRepo. Kept in sync by hand, -// as with the other repo tests. +// These call walletCountersRepo directly rather than mirroring its SQL. The +// allocation is a single RETURNING statement whose exact semantics ARE the safety +// property, so a hand-copy would prove nothing about the statement that runs. -const CREATE_WALLET_COUNTERS = `CREATE TABLE wallet_counters ( - name TEXT PRIMARY KEY NOT NULL, - counter INTEGER NOT NULL DEFAULT 0, - updatedAt TEXT -)` - -const NOW = '2026-07-13T00:00:00.000Z' - -const allocateNextCounter = (db: DatabaseSync, name: string): number => - ( - db - .prepare( - `INSERT INTO wallet_counters (name, counter, updatedAt) - VALUES (?, 1, ?) - ON CONFLICT(name) DO UPDATE SET - counter = counter + 1, - updatedAt = excluded.updatedAt - RETURNING counter - 1 AS allocated`, - ) - .get(name, NOW) as {allocated: number} - ).allocated - -const getWalletCounter = (db: DatabaseSync, name: string): number => - ((db.prepare(`SELECT counter FROM wallet_counters WHERE name = ?`).get(name) as - | {counter: number} - | undefined)?.counter ?? 0) - -const setWalletCounter = (db: DatabaseSync, name: string, value: number): void => { - db.prepare( - `INSERT INTO wallet_counters (name, counter, updatedAt) - VALUES (?, ?, ?) - ON CONFLICT(name) DO UPDATE SET - counter = MAX(counter, excluded.counter), - updatedAt = excluded.updatedAt`, - ).run(name, value, NOW) -} +// requireActual reaches past the module-level stub above: this half is about the +// real statements, not about isolating the derivation from them. +const {allocateNextCounter, getWalletCounter, setWalletCounter} = jest.requireActual( + '../src/services/db/walletCountersRepo', +) describe('wallet_counters (burn-forward allocation)', () => { - let db: DatabaseSync - + // One in-memory database per test FILE (instance.ts caches its connection), so + // clear the table between tests rather than rebuilding. beforeEach(() => { - db = new DatabaseSync(':memory:') - db.exec(CREATE_WALLET_COUNTERS) + Database.getInstance().executeBatch([['DELETE FROM wallet_counters']]) }) it('starts at index 0 when no row exists', () => { - expect(getWalletCounter(db, 'nut20')).toBe(0) - expect(allocateNextCounter(db, 'nut20')).toBe(0) + expect(getWalletCounter('nut20')).toBe(0) + expect(allocateNextCounter('nut20')).toBe(0) }) it('hands out consecutive indices and stores the next free one', () => { - const allocated = [0, 1, 2, 3].map(() => allocateNextCounter(db, 'nut20')) + const allocated = [0, 1, 2, 3].map(() => allocateNextCounter('nut20')) expect(allocated).toEqual([0, 1, 2, 3]) - expect(getWalletCounter(db, 'nut20')).toBe(4) // next free, not last used + expect(getWalletCounter('nut20')).toBe(4) // next free, not last used }) it('never hands out the same index twice', () => { - const allocated = Array.from({length: 50}, () => allocateNextCounter(db, 'nut20')) + const allocated = Array.from({length: 50}, () => allocateNextCounter('nut20')) expect(new Set(allocated).size).toBe(allocated.length) }) @@ -295,8 +264,8 @@ describe('wallet_counters (burn-forward allocation)', () => { it('burns the index when the caller fails: a retry gets a NEW one', () => { // The whole point of committing before the network call. Quote request // dies -> index 0 is spent, never recycled. - const burned = allocateNextCounter(db, 'nut20') - const afterRetry = allocateNextCounter(db, 'nut20') + const burned = allocateNextCounter('nut20') + const afterRetry = allocateNextCounter('nut20') expect(burned).toBe(0) expect(afterRetry).toBe(1) @@ -304,32 +273,32 @@ describe('wallet_counters (burn-forward allocation)', () => { }) it('keeps counters independent per purpose name', () => { - expect(allocateNextCounter(db, 'nut20')).toBe(0) - expect(allocateNextCounter(db, 'other')).toBe(0) - expect(allocateNextCounter(db, 'nut20')).toBe(1) + expect(allocateNextCounter('nut20')).toBe(0) + expect(allocateNextCounter('other')).toBe(0) + expect(allocateNextCounter('nut20')).toBe(1) - expect(getWalletCounter(db, 'nut20')).toBe(2) - expect(getWalletCounter(db, 'other')).toBe(1) + expect(getWalletCounter('nut20')).toBe(2) + expect(getWalletCounter('other')).toBe(1) }) it('setWalletCounter is monotonic: a lower value is a no-op', () => { - setWalletCounter(db, 'nut20', 10) - expect(getWalletCounter(db, 'nut20')).toBe(10) + setWalletCounter('nut20', 10) + expect(getWalletCounter('nut20')).toBe(10) - setWalletCounter(db, 'nut20', 3) // stale writer - expect(getWalletCounter(db, 'nut20')).toBe(10) + setWalletCounter('nut20', 3) // stale writer + expect(getWalletCounter('nut20')).toBe(10) - setWalletCounter(db, 'nut20', 12) - expect(getWalletCounter(db, 'nut20')).toBe(12) + setWalletCounter('nut20', 12) + expect(getWalletCounter('nut20')).toBe(12) }) it('cannot walk back onto an index already handed out', () => { - const first = allocateNextCounter(db, 'nut20') // 0 - allocateNextCounter(db, 'nut20') // 1 + const first = allocateNextCounter('nut20') // 0 + allocateNextCounter('nut20') // 1 - setWalletCounter(db, 'nut20', 0) // stale/replayed write + setWalletCounter('nut20', 0) // stale/replayed write - expect(allocateNextCounter(db, 'nut20')).toBe(2) - expect(allocateNextCounter(db, 'nut20')).not.toBe(first) + expect(allocateNextCounter('nut20')).toBe(2) + expect(allocateNextCounter('nut20')).not.toBe(first) }) }) diff --git a/__tests__/onchainQuotes.test.ts b/__tests__/onchainQuotes.test.ts index 54ddc8b7..b821602a 100644 --- a/__tests__/onchainQuotes.test.ts +++ b/__tests__/onchainQuotes.test.ts @@ -1,9 +1,5 @@ /** - * Onchain (NUT-30) mint quote store. - * - * Mirrors the production SQL from onchainQuotesRepo against node:sqlite (the - * native driver needs a device), as meltRecovery.test.ts and inFlightRequests.test.ts - * do. + * Onchain (NUT-30) mint quotes, against the REAL repo and a real database. * * The watch rule is what these tests are really about. Two facts make it subtle: * @@ -15,246 +11,201 @@ * Getting this wrong loses money in one direction (abandon a quote holding funds) * or polls forever in the other. * - * @jest-environment node + * Calls the production `Database.*` functions rather than mirroring their SQL: the + * op-sqlite jest mock backs the real driver seam with node:sqlite. Dates are + * anchored to REAL now, because the real watch query compares against `new Date()`. */ -import {DatabaseSync} from 'node:sqlite' +jest.mock('../src/services/logService', () => ({ + log: {debug: jest.fn(), error: jest.fn(), info: jest.fn(), trace: jest.fn(), warn: jest.fn()}, +})) -const CREATE_ONCHAIN_MINT_QUOTES = `CREATE TABLE onchain_mint_quotes ( - quote TEXT PRIMARY KEY NOT NULL, - mintId TEXT, - mintUrl TEXT NOT NULL, - unit TEXT NOT NULL, - address TEXT NOT NULL, - counterIndex INTEGER NOT NULL, - pubkey TEXT NOT NULL, - amountRequested INTEGER, - amountPaid INTEGER NOT NULL DEFAULT 0, - amountIssued INTEGER NOT NULL DEFAULT 0, - expiry INTEGER, - watchUntil TEXT NOT NULL, - createdAt TEXT NOT NULL, - updatedAt TEXT -)` +import {Database} from '../src/services/db' const MINT = 'https://mint.test' -const NOW = new Date('2026-07-13T12:00:00.000Z') -const iso = (d: Date) => d.toISOString() -const daysFromNow = (n: number) => iso(new Date(NOW.getTime() + n * 86400000)) +const MINT_ID = 'mint1111' -// ── Mirrored repo primitives (exact production SQL) ───────────────────────── +const daysFromNow = (n: number) => new Date(Date.now() + n * 86400000).toISOString() -const COLS = `quote, mintId, mintUrl, unit, address, counterIndex, pubkey, amountRequested, - amountPaid, amountIssued, expiry, watchUntil, createdAt, updatedAt` +const addQuote = (q: { + quote: string + counterIndex: number + amountRequested?: number | null + amountPaid?: number + amountIssued?: number + watchUntil?: string + mintId?: string +}) => + Database.addOnchainMintQuote({ + quote: q.quote, + mintId: q.mintId ?? MINT_ID, + mintUrl: MINT, + unit: 'sat', + address: 'bc1q' + q.quote, + counterIndex: q.counterIndex, + pubkey: '02' + 'a'.repeat(64), + amountRequested: q.amountRequested ?? null, + amountPaid: q.amountPaid, + amountIssued: q.amountIssued, + expiry: null, // the mint returns null — the address never dies + watchUntil: q.watchUntil, + }) -/** Mint.id of the owning mint — the reference that survives a mint-url edit. */ -const MINT_ID = 'mint1234' +const watchedIds = () => + Database.getWatchedOnchainMintQuotes() + .map(r => r.quote) + .sort() -function addQuote( - db: DatabaseSync, - q: { - quote: string - counterIndex: number - amountRequested?: number | null - amountPaid?: number - amountIssued?: number - watchUntil?: string - }, -) { - db.prepare( - `INSERT OR REPLACE INTO onchain_mint_quotes (${COLS}) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ).run( - q.quote, - MINT_ID, - MINT, - 'sat', - `bc1q${q.quote}`, - q.counterIndex, - `02${'a'.repeat(64)}`, - q.amountRequested ?? null, - q.amountPaid ?? 0, - q.amountIssued ?? 0, - null, // expiry: the mint returns null - q.watchUntil ?? daysFromNow(7), - iso(NOW), - iso(NOW), - ) -} - -const getQuote = (db: DatabaseSync, quote: string): any => - db.prepare(`SELECT ${COLS} FROM onchain_mint_quotes WHERE quote = ?`).get(quote) - -function updateAmounts(db: DatabaseSync, quote: string, paid: number, issued: number) { - db.prepare( - `UPDATE onchain_mint_quotes - SET amountPaid = MAX(amountPaid, ?), - amountIssued = MAX(amountIssued, ?), - updatedAt = ? - WHERE quote = ?`, - ).run(paid, issued, iso(NOW), quote) -} - -const getWatched = (db: DatabaseSync, now: Date = NOW): any[] => - db - .prepare( - `SELECT ${COLS} FROM onchain_mint_quotes - WHERE amountPaid > amountIssued - OR (amountIssued = 0 AND watchUntil > ?) - ORDER BY createdAt DESC`, - ) - .all(iso(now)) - -function extendWatch(db: DatabaseSync, quote: string, days: number) { - db.prepare(`UPDATE onchain_mint_quotes SET watchUntil = ?, updatedAt = ? WHERE quote = ?`).run( - daysFromNow(days), - iso(NOW), - quote, - ) -} - -const watchedIds = (db: DatabaseSync, now?: Date) => getWatched(db, now).map(r => r.quote).sort() +beforeEach(() => { + Database.getInstance().executeBatch([['DELETE FROM onchain_mint_quotes']]) +}) describe('onchain mint quotes', () => { - let db: DatabaseSync - - beforeEach(() => { - db = new DatabaseSync(':memory:') - db.exec(CREATE_ONCHAIN_MINT_QUOTES) + describe('the watch rule', () => { + it('watches a fresh unpaid quote (window still open)', () => { + addQuote({quote: 'q1', counterIndex: 0}) + expect(watchedIds()).toEqual(['q1']) }) - describe('the watch rule', () => { - it('watches a fresh unpaid quote (window still open)', () => { - addQuote(db, {quote: 'q1', counterIndex: 0}) - - expect(watchedIds(db)).toEqual(['q1']) - }) - - it('stops watching an unpaid quote once the window closes', () => { - // nothing ever arrived, and the mint will never expire the address for - // us — so the wallet has to draw the line itself - addQuote(db, {quote: 'q1', counterIndex: 0, watchUntil: daysFromNow(-1)}) - - expect(watchedIds(db)).toEqual([]) - }) - - it('watches a quote with credited-but-unminted funds', () => { - addQuote(db, {quote: 'q1', counterIndex: 0, amountPaid: 50000, amountIssued: 0}) - - expect(watchedIds(db)).toEqual(['q1']) - }) - - it('stops watching once the quote is fully drained (the "stop after first mint" case)', () => { - addQuote(db, {quote: 'q1', counterIndex: 0, amountPaid: 50000, amountIssued: 50000}) - - expect(watchedIds(db)).toEqual([]) - }) - - it('KEEPS watching a PARTIALLY drained quote — money is still credited', () => { - // The reason the rule keys on the unminted BALANCE and not on "has ever - // minted". Keying on amountIssued > 0 would archive this and abandon - // 20k sat the mint is holding for us. - addQuote(db, {quote: 'q1', counterIndex: 0, amountPaid: 70000, amountIssued: 50000}) - - expect(watchedIds(db)).toEqual(['q1']) - }) - - it('watches credited funds even after the window has closed', () => { - // the deadline bounds quotes nobody paid; it must never override money - // that is actually sitting at the mint - addQuote(db, { - quote: 'q1', - counterIndex: 0, - amountPaid: 50000, - amountIssued: 0, - watchUntil: daysFromNow(-30), - }) - - expect(watchedIds(db)).toEqual(['q1']) - }) - - it('separates watched from archived across a realistic mix', () => { - addQuote(db, {quote: 'fresh', counterIndex: 0}) - addQuote(db, {quote: 'drained', counterIndex: 1, amountPaid: 10000, amountIssued: 10000}) - addQuote(db, {quote: 'unpaid-expired', counterIndex: 2, watchUntil: daysFromNow(-1)}) - addQuote(db, {quote: 'has-funds', counterIndex: 3, amountPaid: 30000, amountIssued: 0}) - addQuote(db, { - quote: 'partly-drained', - counterIndex: 4, - amountPaid: 30000, - amountIssued: 10000, - }) - - expect(watchedIds(db)).toEqual(['fresh', 'has-funds', 'partly-drained']) - }) + it('stops watching an unpaid quote once the window closes', () => { + // nothing ever arrived, and the mint will never expire the address for us — + // so the wallet has to draw the line itself + addQuote({quote: 'q1', counterIndex: 0, watchUntil: daysFromNow(-1)}) + expect(watchedIds()).toEqual([]) }) - describe('amount updates are monotonic', () => { - it('applies a normal forward update', () => { - addQuote(db, {quote: 'q1', counterIndex: 0}) - - updateAmounts(db, 'q1', 50000, 0) - - expect(getQuote(db, 'q1')).toMatchObject({amountPaid: 50000, amountIssued: 0}) - }) - - it('ignores a stale response that would walk amounts BACKWARDS', () => { - // A slow reply landing after a fresh one must not resurrect an old view. - // If amountIssued regressed, the wallet would see unminted money that is - // not there — and mint it a second time. - addQuote(db, {quote: 'q1', counterIndex: 0, amountPaid: 50000, amountIssued: 50000}) - - updateAmounts(db, 'q1', 50000, 0) // stale: "nothing issued yet" - - expect(getQuote(db, 'q1')).toMatchObject({amountPaid: 50000, amountIssued: 50000}) - expect(watchedIds(db)).toEqual([]) // still archived, not resurrected - }) - - it('records a second deposit arriving on the same address', () => { - addQuote(db, {quote: 'q1', counterIndex: 0, amountPaid: 50000, amountIssued: 50000}) - expect(watchedIds(db)).toEqual([]) - - updateAmounts(db, 'q1', 70000, 50000) // someone paid the address again - - expect(watchedIds(db)).toEqual(['q1']) // unminted balance -> back in the watch set - }) + it('watches a quote with credited-but-unminted funds', () => { + addQuote({quote: 'q1', counterIndex: 0, amountPaid: 50000, amountIssued: 0}) + expect(watchedIds()).toEqual(['q1']) }) - describe('archived quotes stay recoverable', () => { - it('keeps the row, and with it the NUT-20 counterIndex', () => { - // counterIndex is the only way to re-derive the key that signs a mint - // request for this quote. Delete the row and a late deposit becomes - // permanently unspendable. - addQuote(db, {quote: 'q1', counterIndex: 42, amountPaid: 10000, amountIssued: 10000}) - - expect(watchedIds(db)).toEqual([]) // archived - expect(getQuote(db, 'q1')).toMatchObject({counterIndex: 42, address: 'bc1qq1'}) - }) - - it('re-opens the watch window on user request', () => { - addQuote(db, {quote: 'q1', counterIndex: 0, watchUntil: daysFromNow(-1)}) - expect(watchedIds(db)).toEqual([]) - - extendWatch(db, 'q1', 7) // "check for deposits" from the transaction detail - - expect(watchedIds(db)).toEqual(['q1']) - }) + it('stops watching once the quote is fully drained (the "stop after first mint" case)', () => { + addQuote({quote: 'q1', counterIndex: 0, amountPaid: 50000, amountIssued: 50000}) + expect(watchedIds()).toEqual([]) }) - describe('the requested amount is only a hint', () => { - it('keeps amountRequested separate from what was actually paid', () => { - // The BIP21 amount is a suggestion the sender can ignore. Under- and - // overpayment are normal, so the two must never be conflated. - addQuote(db, {quote: 'q1', counterIndex: 0, amountRequested: 50000}) - - updateAmounts(db, 'q1', 42000, 0) // sender underpaid - - expect(getQuote(db, 'q1')).toMatchObject({amountRequested: 50000, amountPaid: 42000}) - }) - - it('allows a null requested amount', () => { - addQuote(db, {quote: 'q1', counterIndex: 0, amountRequested: null}) - - expect(getQuote(db, 'q1').amountRequested).toBeNull() - }) + it('KEEPS watching a PARTIALLY drained quote — money is still credited', () => { + // The reason the rule keys on the unminted BALANCE and not on "has ever + // minted". Keying on amountIssued > 0 would archive this and abandon 20k sat + // the mint is holding for us. + addQuote({quote: 'q1', counterIndex: 0, amountPaid: 70000, amountIssued: 50000}) + expect(watchedIds()).toEqual(['q1']) }) + + it('watches credited funds even after the window has closed', () => { + // the deadline bounds quotes nobody paid; it must never override money that + // is actually sitting at the mint + addQuote({ + quote: 'q1', + counterIndex: 0, + amountPaid: 50000, + amountIssued: 0, + watchUntil: daysFromNow(-30), + }) + expect(watchedIds()).toEqual(['q1']) + }) + + it('separates watched from archived across a realistic mix', () => { + addQuote({quote: 'fresh', counterIndex: 0}) + addQuote({quote: 'drained', counterIndex: 1, amountPaid: 10000, amountIssued: 10000}) + addQuote({quote: 'unpaid-expired', counterIndex: 2, watchUntil: daysFromNow(-1)}) + addQuote({quote: 'has-funds', counterIndex: 3, amountPaid: 30000, amountIssued: 0}) + addQuote({quote: 'partly-drained', counterIndex: 4, amountPaid: 30000, amountIssued: 10000}) + + expect(watchedIds()).toEqual(['fresh', 'has-funds', 'partly-drained']) + }) + }) + + describe('amount updates are monotonic', () => { + it('applies a normal forward update', () => { + addQuote({quote: 'q1', counterIndex: 0}) + Database.updateOnchainMintQuoteAmounts('q1', 50000, 0) + + expect(Database.getOnchainMintQuote('q1')).toMatchObject({amountPaid: 50000, amountIssued: 0}) + }) + + it('ignores a stale response that would walk amounts BACKWARDS', () => { + // A slow reply landing after a fresh one must not resurrect an old view. If + // amountIssued regressed, the wallet would see unminted money that is not + // there — and mint it a second time. + addQuote({quote: 'q1', counterIndex: 0, amountPaid: 50000, amountIssued: 50000}) + + Database.updateOnchainMintQuoteAmounts('q1', 50000, 0) // stale: "nothing issued yet" + + expect(Database.getOnchainMintQuote('q1')).toMatchObject({ + amountPaid: 50000, + amountIssued: 50000, + }) + expect(watchedIds()).toEqual([]) // still archived, not resurrected + }) + + it('records a second deposit arriving on the same address', () => { + addQuote({quote: 'q1', counterIndex: 0, amountPaid: 50000, amountIssued: 50000}) + expect(watchedIds()).toEqual([]) + + Database.updateOnchainMintQuoteAmounts('q1', 70000, 50000) // paid again + + expect(watchedIds()).toEqual(['q1']) // unminted balance -> back in the watch set + }) + }) + + describe('archived quotes stay recoverable', () => { + it('keeps the row, and with it the NUT-20 counterIndex', () => { + // counterIndex is the only way to re-derive the key that signs a mint request + // for this quote. Delete the row and a late deposit becomes permanently + // unspendable. + addQuote({quote: 'q1', counterIndex: 42, amountPaid: 10000, amountIssued: 10000}) + + expect(watchedIds()).toEqual([]) // archived + expect(Database.getOnchainMintQuote('q1')).toMatchObject({ + counterIndex: 42, + address: 'bc1qq1', + }) + }) + + it('re-opens the watch window on user request', () => { + addQuote({quote: 'q1', counterIndex: 0, watchUntil: daysFromNow(-1)}) + expect(watchedIds()).toEqual([]) + + Database.extendOnchainMintQuoteWatch('q1', 7) // "check for deposits" + + expect(watchedIds()).toEqual(['q1']) + }) + }) + + describe('the requested amount is only a hint', () => { + it('keeps amountRequested separate from what was actually paid', () => { + // The BIP21 amount is a suggestion the sender can ignore. Under- and + // overpayment are normal, so the two must never be conflated. + addQuote({quote: 'q1', counterIndex: 0, amountRequested: 50000}) + + Database.updateOnchainMintQuoteAmounts('q1', 42000, 0) // sender underpaid + + expect(Database.getOnchainMintQuote('q1')).toMatchObject({ + amountRequested: 50000, + amountPaid: 42000, + }) + }) + + it('allows a null requested amount', () => { + addQuote({quote: 'q1', counterIndex: 0, amountRequested: null}) + expect(Database.getOnchainMintQuote('q1')!.amountRequested).toBeNull() + }) + }) + + describe('the mint reference', () => { + it('finds a mint\'s quotes by id, not by the url they were created at', () => { + addQuote({quote: 'q1', counterIndex: 0}) + addQuote({quote: 'q2', counterIndex: 1, mintId: 'other-mint'}) + + expect(Database.getOnchainMintQuotesByMintId(MINT_ID).map(r => r.quote)).toEqual(['q1']) + }) + + it('keeps mintUrl as the record of where the quote was created', () => { + addQuote({quote: 'q1', counterIndex: 0}) + const row = Database.getOnchainMintQuote('q1')! + + expect(row.mintId).toBe(MINT_ID) + expect(row.mintUrl).toBe(MINT) + }) + }) }) diff --git a/__tests__/proofReservation.test.ts b/__tests__/proofReservation.test.ts index 2ff62d16..8e72ad79 100644 --- a/__tests__/proofReservation.test.ts +++ b/__tests__/proofReservation.test.ts @@ -1,111 +1,33 @@ /** - * Proof reservation tests (Phase 5). + * Proof reservations, against the REAL repo and a real database. * - * Verifies the SQL-level reservation semantics that `Database.openReservation`, - * `Database.commitReservation`, `Database.rollbackReservation`, and - * `Database.getOpenReservations` implement on top of `executeBatch`. + * A reservation is the wallet's atomic-commit primitive for an outgoing operation: + * open it to lock proofs to PENDING, then either commit (inputs SPENT, new proofs + * in, transaction row updated, reservation deleted) or roll back (every proof + * restored to its pre-reserve state AND tId). All of it in one SQLite transaction, + * because a partial apply here loses or strands ecash. * - * We mirror the queries using node:sqlite + explicit BEGIN/COMMIT so the test - * can run in Jest (react-native-quick-sqlite needs a real device). + * This calls the production `Database.*` functions. It used to hand-copy both the + * schema and every statement — and a copy of the SQL proves nothing about the SQL + * the app runs. The op-sqlite jest mock now backs the real driver seam with + * node:sqlite, so connection.ts (param sanitizing, result adaptation, BEGIN/COMMIT + * batch emulation), instance.ts (schema + the real migration runner) and the repo + * all run for real. * - * @jest-environment node + * The helpers below keep their original shapes so the assertions read unchanged; + * only their innards moved from mirrored SQL to the real API. */ -import {DatabaseSync} from 'node:sqlite' +jest.mock('../src/services/logService', () => ({ + log: {debug: jest.fn(), error: jest.fn(), info: jest.fn(), trace: jest.fn(), warn: jest.fn()}, +})) -// ── Schema ──────────────────────────────────────────────────────────────────── +import {Database} from '../src/services/db' +import {ProofModel} from '../src/models/Proof' -const CREATE_PROOFS = `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 -)` +const MINT = 'https://mint.test' +const MINT_ID = 'mint1111' -const CREATE_RESERVATIONS = `CREATE TABLE reservations ( - id TEXT PRIMARY KEY NOT NULL, - transactionId INTEGER NOT NULL, - mintId TEXT, - mintUrl TEXT NOT NULL, - unit TEXT NOT NULL, - operationType TEXT NOT NULL, - lockedProofs TEXT NOT NULL, - createdAt TEXT NOT NULL -)` - -// Minimal transactions table for the two-table atomicity tests (Phase 5b). -const CREATE_TRANSACTIONS = `CREATE TABLE transactions ( - id INTEGER PRIMARY KEY NOT NULL, - status TEXT, - data TEXT, - amount INTEGER, - fee INTEGER, - balanceAfter INTEGER, - outputToken TEXT, - keysetId TEXT, - proof TEXT -)` - -function createSchema(db: DatabaseSync) { - db.exec(CREATE_PROOFS) - db.exec(CREATE_RESERVATIONS) - db.exec(CREATE_TRANSACTIONS) -} - -function insertTransaction(db: DatabaseSync, id: number, status: string) { - db.prepare(`INSERT INTO transactions (id, status) VALUES (?, ?)`).run(id, status) -} - -function getTransactionStatus(db: DatabaseSync, id: number): string { - const row = db.prepare('SELECT status FROM transactions WHERE id = ?').get(id) as - | {status: string} - | undefined - return row?.status ?? '' -} - -function getTransactionRow( - db: DatabaseSync, - id: number, -): {status: string | null; data: string | null; balanceAfter: number | null} | undefined { - return db - .prepare('SELECT status, data, balanceAfter FROM transactions WHERE id = ?') - .get(id) as any -} - -function insertProof( - db: DatabaseSync, - secret: string, - amount: number, - state: 'UNSPENT' | 'PENDING' | 'SPENT' = 'UNSPENT', -) { - db.prepare( - `INSERT INTO proofs (id, amount, secret, C, mintUrl, unit, tId, state, updatedAt) - VALUES ('keyset1', ?, ?, 'C', 'https://mint.test', 'sat', 1, ?, '2026-01-01')`, - ).run(amount, secret, state) -} - -function getProofState(db: DatabaseSync, secret: string): string { - const row = db.prepare('SELECT state FROM proofs WHERE secret = ?').get(secret) as - | {state: string} - | undefined - return row?.state ?? '' -} - -function reservationCount(db: DatabaseSync): number { - const {n} = db.prepare('SELECT COUNT(*) AS n FROM reservations').get() as {n: number} - return n -} - -// ── Simulated Database primitives ───────────────────────────────────────────── -// Mirror the exact SQL the production code uses, wrapped in BEGIN/COMMIT to -// match `executeBatch` atomicity. +type Db = ReturnType type LockedProofSnapshot = { secret: string @@ -113,47 +35,6 @@ type LockedProofSnapshot = { originalTId: number | null } -function openReservation( - db: DatabaseSync, - reservation: { - id: string - transactionId: number - mintUrl: string - unit: string - operationType: string - lockedProofs: LockedProofSnapshot[] - }, - proofsToLockSecrets: string[], -) { - const now = '2026-05-22T00:00:00.000Z' - db.exec('BEGIN') - try { - db.prepare( - `INSERT INTO reservations (id, transactionId, mintUrl, unit, operationType, lockedProofs, createdAt) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - ).run( - reservation.id, - reservation.transactionId, - reservation.mintUrl, - reservation.unit, - reservation.operationType, - JSON.stringify(reservation.lockedProofs), - now, - ) - // Reassign tId to the new operation alongside the state lock. - const updateProof = db.prepare( - `UPDATE proofs SET state = 'PENDING', tId = ?, updatedAt = ? WHERE secret = ?`, - ) - for (const secret of proofsToLockSecrets) { - updateProof.run(reservation.transactionId, now, secret) - } - db.exec('COMMIT') - } catch (e) { - db.exec('ROLLBACK') - throw e - } -} - type CommitTransactionUpdate = { id: number status?: string @@ -166,116 +47,144 @@ type CommitTransactionUpdate = { proof?: string } +/** + * One in-memory database per test FILE (instance.ts caches its connection), so + * "fresh" means cleared rather than rebuilt. Returns the real connection for the + * few assertions that read raw rows. + */ +function freshDb(): Db { + const db = Database.getInstance() + db.executeBatch([['DELETE FROM proofs'], ['DELETE FROM reservations'], ['DELETE FROM transactions']]) + return db +} + +function insertTransaction(_db: Db, id: number, status: string) { + Database.getInstance().execute(`INSERT INTO transactions (id, status) VALUES (?, ?)`, [id, status]) +} + +function getTransactionStatus(_db: Db, id: number): string { + return ( + Database.getInstance().execute('SELECT status FROM transactions WHERE id = ?', [id]).rows?.item(0) + ?.status ?? '' + ) +} + +function getTransactionRow( + _db: Db, + id: number, +): {status: string | null; data: string | null; balanceAfter: number | null} | undefined { + return Database.getInstance() + .execute('SELECT status, data, balanceAfter FROM transactions WHERE id = ?', [id]) + .rows?.item(0) as any +} + +function insertProof( + _db: Db, + secret: string, + amount: number, + state: 'UNSPENT' | 'PENDING' | 'SPENT' = 'UNSPENT', +) { + Database.addOrUpdateProofs([proofNode(secret, amount)], state) +} + +/** + * A real MST Proof node. + * + * The repo calls mobx-state-tree's `isAlive()` on everything it locks, which only + * answers for an actual node — so a plain object here would not exercise the same + * path production takes. + */ +function proofNode(secret: string, amount: number, tId = 1) { + return ProofModel.create({ + id: 'keyset1', + amount, + secret, + C: 'C', + unit: 'sat', + tId, + mintUrl: MINT, + }) as any +} + +function getProofState(_db: Db, secret: string): string { + return ( + Database.getInstance().execute('SELECT state FROM proofs WHERE secret = ?', [secret]).rows?.item(0) + ?.state ?? '' + ) +} + +function getProofTId(_db: Db, secret: string): number | null { + const row = Database.getInstance() + .execute('SELECT tId FROM proofs WHERE secret = ?', [secret]) + .rows?.item(0) + return row?.tId ?? null +} + +function reservationCount(_db: Db): number { + return Database.getInstance().execute('SELECT COUNT(*) AS n FROM reservations').rows?.item(0)?.n ?? 0 +} + +/** Rebuild MST nodes for already-stored proofs, which is what the repo locks. */ +function nodesForSecrets(secrets: string[]) { + return secrets.map(secret => { + const row = Database.getInstance() + .execute('SELECT * FROM proofs WHERE secret = ?', [secret]) + .rows?.item(0) + return proofNode(secret, row?.amount ?? 1, row?.tId ?? 1) + }) +} + +function openReservation( + _db: Db, + reservation: { + id: string + transactionId: number + mintUrl: string + unit: string + operationType: string + lockedProofs: LockedProofSnapshot[] + }, + proofsToLockSecrets: string[], +) { + Database.openReservation( + {...reservation, mintId: MINT_ID}, + nodesForSecrets(proofsToLockSecrets), + ) +} + function commitReservation( - db: DatabaseSync, + _db: Db, reservationId: string, changes: { toSpent?: string[] toUnspent?: string[] - newProofs?: Array<{ - secret: string - amount: number - state: 'UNSPENT' | 'PENDING' | 'SPENT' - }> + newProofs?: Array<{secret: string; amount: number; state: 'UNSPENT' | 'PENDING' | 'SPENT'}> transactionUpdate?: CommitTransactionUpdate }, ) { - const now = '2026-05-22T00:00:00.000Z' - db.exec('BEGIN') - try { - const updateSpent = db.prepare( - `UPDATE proofs SET state = 'SPENT', updatedAt = ? WHERE secret = ?`, - ) - for (const s of changes.toSpent ?? []) updateSpent.run(now, s) - - const updateUnspent = db.prepare( - `UPDATE proofs SET state = 'UNSPENT', updatedAt = ? WHERE secret = ?`, - ) - for (const s of changes.toUnspent ?? []) updateUnspent.run(now, s) - - const insertNew = db.prepare( - `INSERT OR REPLACE INTO proofs - (id, amount, secret, C, mintUrl, unit, tId, state, updatedAt) - VALUES ('keyset1', ?, ?, 'C', 'https://mint.test', 'sat', 1, ?, ?)`, - ) - for (const p of changes.newProofs ?? []) { - insertNew.run(p.amount, p.secret, p.state, now) - } - - // Mirror the SQL the production code builds: dynamic UPDATE with only - // the supplied fields. - if (changes.transactionUpdate) { - const tu = changes.transactionUpdate - const setClauses: string[] = [] - const params: (string | number | null)[] = [] - const setIfDefined = (col: string, value: string | number | undefined) => { - if (value !== undefined) { - setClauses.push(`${col} = ?`) - params.push(value) - } - } - setIfDefined('status', tu.status) - setIfDefined('data', tu.data) - setIfDefined('amount', tu.amount) - setIfDefined('fee', tu.fee) - setIfDefined('balanceAfter', tu.balanceAfter) - setIfDefined('outputToken', tu.outputToken) - setIfDefined('keysetId', tu.keysetId) - setIfDefined('proof', tu.proof) - if (setClauses.length > 0) { - params.push(tu.id) - db.prepare( - `UPDATE transactions SET ${setClauses.join(', ')} WHERE id = ?`, - ).run(...params) - } - } - - db.prepare('DELETE FROM reservations WHERE id = ?').run(reservationId) - db.exec('COMMIT') - } catch (e) { - db.exec('ROLLBACK') - throw e - } + Database.commitReservation(reservationId, { + toSpent: changes.toSpent ? nodesForSecrets(changes.toSpent) : undefined, + toUnspent: changes.toUnspent ? nodesForSecrets(changes.toUnspent) : undefined, + newProofs: changes.newProofs?.map(p => ({ + proofs: [{id: 'keyset1', amount: p.amount, secret: p.secret, C: 'C'} as any], + state: p.state, + mintUrl: MINT, + unit: 'sat', + tId: 1, + })), + transactionUpdate: changes.transactionUpdate as any, + }) } -function rollbackReservation( - db: DatabaseSync, - reservationId: string, - lockedProofs: LockedProofSnapshot[], -) { - const now = '2026-05-22T00:00:00.000Z' - db.exec('BEGIN') - try { - // Restore BOTH state and tId from the pre-reserve snapshot. - const restore = db.prepare( - `UPDATE proofs SET state = ?, tId = ?, updatedAt = ? WHERE secret = ?`, - ) - for (const snap of lockedProofs) { - restore.run(snap.originalState, snap.originalTId, now, snap.secret) - } - db.prepare('DELETE FROM reservations WHERE id = ?').run(reservationId) - db.exec('COMMIT') - } catch (e) { - db.exec('ROLLBACK') - throw e - } +function rollbackReservation(_db: Db, reservationId: string, lockedProofs: LockedProofSnapshot[]) { + Database.rollbackReservation(reservationId, lockedProofs as any) } -function getProofTId(db: DatabaseSync, secret: string): number | null { - const row = db.prepare('SELECT tId FROM proofs WHERE secret = ?').get(secret) as - | {tId: number | null} - | undefined - return row?.tId ?? null -} - -function getOpenReservations(db: DatabaseSync): Array<{ - id: string - lockedProofs: LockedProofSnapshot[] -}> { - const rows = db - .prepare('SELECT id, lockedProofs FROM reservations') - .all() as Array<{id: string; lockedProofs: string}> - return rows.map(r => ({id: r.id, lockedProofs: JSON.parse(r.lockedProofs)})) +function getOpenReservations(_db: Db): Array<{id: string; lockedProofs: LockedProofSnapshot[]}> { + return Database.getOpenReservations().map(r => ({ + id: r.id, + lockedProofs: r.lockedProofs as LockedProofSnapshot[], + })) } // ── Tests ───────────────────────────────────────────────────────────────────── @@ -283,8 +192,7 @@ function getOpenReservations(db: DatabaseSync): Array<{ describe('Proof reservations', () => { describe('openReservation', () => { test('atomically inserts reservation row + locks proofs to PENDING', () => { - const db = new DatabaseSync(':memory:') - createSchema(db) + const db = freshDb() insertProof(db, 'sA', 100) insertProof(db, 'sB', 200) @@ -307,13 +215,10 @@ describe('Proof reservations', () => { expect(getProofState(db, 'sA')).toBe('PENDING') expect(getProofState(db, 'sB')).toBe('PENDING') expect(reservationCount(db)).toBe(1) - - db.close() }) test('captures originalState even when some proofs were already PENDING', () => { - const db = new DatabaseSync(':memory:') - createSchema(db) + const db = freshDb() insertProof(db, 'sA', 100, 'UNSPENT') insertProof(db, 'sB', 200, 'PENDING') // already locked by an earlier op @@ -339,15 +244,12 @@ describe('Proof reservations', () => { {secret: 'sA', originalState: 'UNSPENT', originalTId: null}, {secret: 'sB', originalState: 'PENDING', originalTId: null}, ]) - - db.close() }) }) describe('commitReservation', () => { test('marks inputs SPENT, adds new proofs, deletes reservation in one txn', () => { - const db = new DatabaseSync(':memory:') - createSchema(db) + const db = freshDb() insertProof(db, 'input1', 100) insertProof(db, 'input2', 200) @@ -380,13 +282,10 @@ describe('Proof reservations', () => { expect(getProofState(db, 'change1')).toBe('UNSPENT') expect(getProofState(db, 'send1')).toBe('PENDING') expect(reservationCount(db)).toBe(0) - - db.close() }) test('empty changes still removes the reservation row (offline-send case)', () => { - const db = new DatabaseSync(':memory:') - createSchema(db) + const db = freshDb() insertProof(db, 's1', 100) openReservation( @@ -407,15 +306,12 @@ describe('Proof reservations', () => { // Proof stays PENDING (sent offline), reservation row gone expect(getProofState(db, 's1')).toBe('PENDING') expect(reservationCount(db)).toBe(0) - - db.close() }) }) describe('rollbackReservation', () => { test('restores each proof to its originalState and deletes the row', () => { - const db = new DatabaseSync(':memory:') - createSchema(db) + const db = freshDb() insertProof(db, 'sA', 100, 'UNSPENT') insertProof(db, 'sB', 200, 'UNSPENT') @@ -445,13 +341,10 @@ describe('Proof reservations', () => { expect(getProofState(db, 'sA')).toBe('UNSPENT') expect(getProofState(db, 'sB')).toBe('UNSPENT') expect(reservationCount(db)).toBe(0) - - db.close() }) test('preserves PENDING originalState (multi-op overlap)', () => { - const db = new DatabaseSync(':memory:') - createSchema(db) + const db = freshDb() insertProof(db, 'sA', 100, 'PENDING') // Reservation captures sA as already-PENDING @@ -472,15 +365,12 @@ describe('Proof reservations', () => { // Restored to PENDING (its original locked state), not to UNSPENT expect(getProofState(db, 'sA')).toBe('PENDING') - - db.close() }) }) describe('orphan recovery', () => { test('getOpenReservations returns all rows; rollback restores state', () => { - const db = new DatabaseSync(':memory:') - createSchema(db) + const db = freshDb() insertProof(db, 'orphanA', 100) insertProof(db, 'orphanB', 200) @@ -514,22 +404,17 @@ describe('Proof reservations', () => { expect(getProofState(db, 'orphanA')).toBe('UNSPENT') expect(getProofState(db, 'orphanB')).toBe('UNSPENT') expect(reservationCount(db)).toBe(0) - - db.close() }) test('no orphans when there are no in-flight reservations', () => { - const db = new DatabaseSync(':memory:') - createSchema(db) + const db = freshDb() insertProof(db, 's1', 100) expect(getOpenReservations(db)).toEqual([]) - db.close() }) test('multiple concurrent reservations all roll back independently', () => { - const db = new DatabaseSync(':memory:') - createSchema(db) + const db = freshDb() insertProof(db, 'a1', 100) insertProof(db, 'a2', 100) insertProof(db, 'b1', 200) @@ -573,15 +458,12 @@ describe('Proof reservations', () => { expect(getProofState(db, 'a2')).toBe('UNSPENT') expect(getProofState(db, 'b1')).toBe('UNSPENT') expect(reservationCount(db)).toBe(0) - - db.close() }) }) describe('atomicity', () => { test('openReservation: inserting a duplicate id rolls back the whole batch', () => { - const db = new DatabaseSync(':memory:') - createSchema(db) + const db = freshDb() insertProof(db, 's1', 100, 'UNSPENT') // First reservation succeeds @@ -625,8 +507,6 @@ describe('Proof reservations', () => { expect(getProofState(db, 's2')).toBe('UNSPENT') // s1 is unaffected expect(getProofState(db, 's1')).toBe('PENDING') - - db.close() }) }) @@ -646,19 +526,14 @@ describe('Proof reservations', () => { // ───────────────────────────────────────────────────────────────────── describe('tId propagation (regression: stuck-PENDING SEND, 2026-05-22)', () => { test('openReservation reassigns each locked proof tId to the new transactionId', () => { - const db = new DatabaseSync(':memory:') - createSchema(db) + const db = freshDb() // Two proofs received originally by tx 110 and tx 123 respectively // (simulates the dev log). - db.prepare( - `INSERT INTO proofs (id, amount, secret, C, mintUrl, unit, tId, state, updatedAt) - VALUES ('keyset1', 4, 'inp_a', 'C', 'https://mint.test', 'sat', 110, 'UNSPENT', '2026-01-01')`, - ).run() - db.prepare( - `INSERT INTO proofs (id, amount, secret, C, mintUrl, unit, tId, state, updatedAt) - VALUES ('keyset1', 2, 'inp_b', 'C', 'https://mint.test', 'sat', 123, 'UNSPENT', '2026-01-01')`, - ).run() + db.execute(`INSERT INTO proofs (id, amount, secret, C, mintUrl, unit, tId, state, updatedAt) + VALUES ('keyset1', 4, 'inp_a', 'C', 'https://mint.test', 'sat', 110, 'UNSPENT', '2026-01-01')`) + db.execute(`INSERT INTO proofs (id, amount, secret, C, mintUrl, unit, tId, state, updatedAt) + VALUES ('keyset1', 2, 'inp_b', 'C', 'https://mint.test', 'sat', 123, 'UNSPENT', '2026-01-01')`) expect(getProofTId(db, 'inp_a')).toBe(110) expect(getProofTId(db, 'inp_b')).toBe(123) @@ -686,21 +561,14 @@ describe('Proof reservations', () => { expect(getProofTId(db, 'inp_b')).toBe(157) expect(getProofState(db, 'inp_a')).toBe('PENDING') expect(getProofState(db, 'inp_b')).toBe('PENDING') - - db.close() }) test('rollback restores each proof to its individual originalTId', () => { - const db = new DatabaseSync(':memory:') - createSchema(db) - db.prepare( - `INSERT INTO proofs (id, amount, secret, C, mintUrl, unit, tId, state, updatedAt) - VALUES ('keyset1', 4, 'inp_a', 'C', 'https://mint.test', 'sat', 110, 'UNSPENT', '2026-01-01')`, - ).run() - db.prepare( - `INSERT INTO proofs (id, amount, secret, C, mintUrl, unit, tId, state, updatedAt) - VALUES ('keyset1', 2, 'inp_b', 'C', 'https://mint.test', 'sat', 123, 'UNSPENT', '2026-01-01')`, - ).run() + const db = freshDb() + db.execute(`INSERT INTO proofs (id, amount, secret, C, mintUrl, unit, tId, state, updatedAt) + VALUES ('keyset1', 4, 'inp_a', 'C', 'https://mint.test', 'sat', 110, 'UNSPENT', '2026-01-01')`) + db.execute(`INSERT INTO proofs (id, amount, secret, C, mintUrl, unit, tId, state, updatedAt) + VALUES ('keyset1', 2, 'inp_b', 'C', 'https://mint.test', 'sat', 123, 'UNSPENT', '2026-01-01')`) openReservation( db, @@ -731,19 +599,14 @@ describe('Proof reservations', () => { expect(getProofState(db, 'inp_a')).toBe('UNSPENT') expect(getProofState(db, 'inp_b')).toBe('UNSPENT') expect(reservationCount(db)).toBe(0) - - db.close() }) test('proofs with no prior tId (null) are reassigned and restored to null', () => { - const db = new DatabaseSync(':memory:') - createSchema(db) + const db = freshDb() // Proof inserted without a tId — simulates an imported/restored // proof that was never tied to a wallet transaction. - db.prepare( - `INSERT INTO proofs (id, amount, secret, C, mintUrl, unit, state, updatedAt) - VALUES ('keyset1', 1, 'orphan', 'C', 'https://mint.test', 'sat', 'UNSPENT', '2026-01-01')`, - ).run() + db.execute(`INSERT INTO proofs (id, amount, secret, C, mintUrl, unit, state, updatedAt) + VALUES ('keyset1', 1, 'orphan', 'C', 'https://mint.test', 'sat', 'UNSPENT', '2026-01-01')`) expect(getProofTId(db, 'orphan')).toBe(null) openReservation( @@ -766,8 +629,6 @@ describe('Proof reservations', () => { {secret: 'orphan', originalState: 'UNSPENT', originalTId: null}, ]) expect(getProofTId(db, 'orphan')).toBe(null) - - db.close() }) }) @@ -781,8 +642,7 @@ describe('Proof reservations', () => { // ───────────────────────────────────────────────────────────────────── describe('atomic two-table commit (Phase 5b)', () => { test('commit with transactionUpdate writes proofs AND transaction in one txn', () => { - const db = new DatabaseSync(':memory:') - createSchema(db) + const db = freshDb() insertProof(db, 'input', 100) insertTransaction(db, 200, 'PREPARED') @@ -817,13 +677,10 @@ describe('Proof reservations', () => { expect(row.data).toBe('[{"status":"COMPLETED"}]') expect(row.balanceAfter).toBe(50) expect(reservationCount(db)).toBe(0) - - db.close() }) test('a failed commit batch rolls back BOTH proof and transaction writes', () => { - const db = new DatabaseSync(':memory:') - createSchema(db) + const db = freshDb() insertProof(db, 'input', 100) insertTransaction(db, 201, 'PREPARED') @@ -846,13 +703,11 @@ describe('Proof reservations', () => { expect(() => { db.exec('BEGIN') try { - db.prepare(`UPDATE proofs SET state = 'SPENT' WHERE secret = ?`).run('input') - db.prepare(`UPDATE transactions SET status = 'COMPLETED' WHERE id = ?`).run(201) + db.execute(`UPDATE proofs SET state = 'SPENT' WHERE secret = ?`, ['input']) + db.execute(`UPDATE transactions SET status = 'COMPLETED' WHERE id = ?`, [201]) // This will conflict — reservation 'res-atomic' already exists - db.prepare( - `INSERT INTO reservations (id, transactionId, mintUrl, unit, operationType, lockedProofs, createdAt) - VALUES ('res-atomic', 999, '', '', '', '[]', '')`, - ).run() + db.execute(`INSERT INTO reservations (id, transactionId, mintUrl, unit, operationType, lockedProofs, createdAt) + VALUES ('res-atomic', 999, '', '', '', '[]', '')`) db.exec('COMMIT') } catch (e) { db.exec('ROLLBACK') @@ -865,13 +720,10 @@ describe('Proof reservations', () => { // statement in the batch fails, SQLite rolls back the entire txn. expect(getProofState(db, 'input')).toBe('PENDING') // still locked expect(getTransactionStatus(db, 201)).toBe('PREPARED') // still pre-finalize - - db.close() }) test('commit without transactionUpdate leaves transactions table untouched', () => { - const db = new DatabaseSync(':memory:') - createSchema(db) + const db = freshDb() insertProof(db, 'p1', 100) insertTransaction(db, 202, 'PENDING') @@ -894,18 +746,13 @@ describe('Proof reservations', () => { // Transaction status untouched — the caller is responsible for // any post-commit updates that don't need atomicity. expect(getTransactionStatus(db, 202)).toBe('PENDING') - - db.close() }) test('partial transactionUpdate only sets the provided columns', () => { - const db = new DatabaseSync(':memory:') - createSchema(db) + const db = freshDb() insertProof(db, 'p2', 100) - db.prepare( - `INSERT INTO transactions (id, status, data, balanceAfter) - VALUES (203, 'PREPARED', 'old-data', 999)`, - ).run() + db.execute(`INSERT INTO transactions (id, status, data, balanceAfter) + VALUES (203, 'PREPARED', 'old-data', 999)`) openReservation( db, @@ -930,8 +777,6 @@ describe('Proof reservations', () => { expect(row.status).toBe('REVERTED') expect(row.data).toBe('old-data') expect(row.balanceAfter).toBe(999) - - db.close() }) }) }) diff --git a/__tests__/sqliteMigration25.test.ts b/__tests__/sqliteMigration25.test.ts index d67134ff..69cdc904 100644 --- a/__tests__/sqliteMigration25.test.ts +++ b/__tests__/sqliteMigration25.test.ts @@ -7,39 +7,20 @@ * 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' -// ── SQL copied verbatim from src/services/sqlite.ts migration 25 ────────────── +// ── 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. The PRE-migration shape below stays hand-written on +// purpose — it is frozen history, and must not track today's schema. -const CREATE_V25 = `CREATE TABLE proofs_v25 ( - 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 -)` - -const INSERT_V25 = `INSERT INTO proofs_v25 - (id, amount, secret, C, dleq_r, dleq_s, dleq_e, unit, tId, mintUrl, state, updatedAt) -SELECT - id, amount, secret, C, dleq_r, dleq_s, dleq_e, unit, tId, mintUrl, - CASE - WHEN isSpent = 1 THEN 'SPENT' - WHEN isPending = 1 THEN 'PENDING' - ELSE 'UNSPENT' - END, - updatedAt -FROM proofs` - -const DROP_OLD = `DROP TABLE proofs` -const RENAME = `ALTER TABLE proofs_v25 RENAME TO proofs` +const MIGRATION_25 = MIGRATIONS.find(m => m.version === 25)!.queries.map(([sql]) => sql) // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -79,10 +60,7 @@ function insertOldProof( } function runMigration25(db: DatabaseSync) { - db.exec(CREATE_V25) - db.exec(INSERT_V25) - db.exec(DROP_OLD) - db.exec(RENAME) + for (const sql of MIGRATION_25) db.exec(sql) } function getState(db: DatabaseSync, secret: string): string { diff --git a/__tests__/sqliteMigration32.test.ts b/__tests__/sqliteMigration32.test.ts index 1c00d302..64fc6744 100644 --- a/__tests__/sqliteMigration32.test.ts +++ b/__tests__/sqliteMigration32.test.ts @@ -23,24 +23,20 @@ * 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' -// ── SQL copied verbatim from src/services/db/migrations.ts migration 32 ─────── +// ── 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. The PRE-migration shape below stays hand-written on +// purpose — it is frozen history, and must not track today's schema. -const CREATE_V32 = `CREATE TABLE mint_counters_v32 ( - keysetId TEXT PRIMARY KEY NOT NULL, - unit TEXT, - counter INTEGER NOT NULL DEFAULT 0, - updatedAt TEXT -)` - -const INSERT_V32 = `INSERT INTO mint_counters_v32 (keysetId, unit, counter, updatedAt) -SELECT keysetId, unit, MAX(counter), updatedAt -FROM mint_counters -GROUP BY keysetId` - -const DROP_OLD = `DROP TABLE mint_counters` -const RENAME = `ALTER TABLE mint_counters_v32 RENAME TO mint_counters` +const MIGRATION_32 = MIGRATIONS.find(m => m.version === 32)!.queries.map(([sql]) => sql) // ── Helpers ────────────────────────────────────────────────────────────────── @@ -78,10 +74,7 @@ function insertOld( function runMigration32(db: DatabaseSync) { db.exec('BEGIN') try { - db.exec(CREATE_V32) - db.exec(INSERT_V32) - db.exec(DROP_OLD) - db.exec(RENAME) + for (const sql of MIGRATION_32) db.exec(sql) db.exec('COMMIT') } catch (e) { db.exec('ROLLBACK') diff --git a/__tests__/sqliteMigration33.test.ts b/__tests__/sqliteMigration33.test.ts index 772181b6..43cb693e 100644 --- a/__tests__/sqliteMigration33.test.ts +++ b/__tests__/sqliteMigration33.test.ts @@ -17,7 +17,12 @@ * 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' // ── Historical shapes, as v26/v31 create them (frozen in migrations.ts) ─────── @@ -47,12 +52,13 @@ const CREATE_RESERVATIONS_V26 = `CREATE TABLE reservations ( createdAt TEXT NOT NULL )` -// ── SQL copied verbatim from migrations.ts migration 33 ────────────────────── +// ── 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. The PRE-migration shapes above stay hand-written on +// purpose — they are frozen history, and must not track today's schema. -const MIGRATION_33 = [ - `ALTER TABLE onchain_mint_quotes ADD COLUMN mintId TEXT`, - `ALTER TABLE reservations ADD COLUMN mintId TEXT`, -] +const MIGRATION_33 = MIGRATIONS.find(m => m.version === 33)!.queries.map(([sql]) => sql) // ── Backfill, mirroring onchainQuotesRepo / reservationsRepo ───────────────── diff --git a/__tests__/sqliteMigration34.test.ts b/__tests__/sqliteMigration34.test.ts index 47ba99aa..26e4cc98 100644 --- a/__tests__/sqliteMigration34.test.ts +++ b/__tests__/sqliteMigration34.test.ts @@ -17,7 +17,12 @@ * 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' // ── Pre-34 shapes (as v28/v29 create them; frozen in migrations.ts) ────────── @@ -48,31 +53,13 @@ const CREATE_MELT_RECOVERY_V28 = `CREATE TABLE melt_recovery ( createdAt TEXT )` -// ── SQL copied verbatim from migrations.ts migration 34 ───────────────────── +// ── 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. The PRE-migration shapes above stay hand-written on +// purpose — they are frozen history, and must not track today's schema. -const MIGRATION_34 = [ - `ALTER TABLE transactions ADD COLUMN mintId TEXT`, - - `CREATE TABLE inflight_requests_v34 ( - transactionId INTEGER PRIMARY KEY NOT NULL, - request TEXT NOT NULL, - createdAt TEXT -)`, - `INSERT INTO inflight_requests_v34 (transactionId, request, createdAt) - SELECT transactionId, request, createdAt FROM inflight_requests`, - `DROP TABLE inflight_requests`, - `ALTER TABLE inflight_requests_v34 RENAME TO inflight_requests`, - - `CREATE TABLE melt_recovery_v34 ( - transactionId INTEGER PRIMARY KEY NOT NULL, - meltPreview TEXT NOT NULL, - createdAt TEXT -)`, - `INSERT INTO melt_recovery_v34 (transactionId, meltPreview, createdAt) - SELECT transactionId, meltPreview, createdAt FROM melt_recovery`, - `DROP TABLE melt_recovery`, - `ALTER TABLE melt_recovery_v34 RENAME TO melt_recovery`, -] +const MIGRATION_34 = MIGRATIONS.find(m => m.version === 34)!.queries.map(([sql]) => sql) /** Mirrors transactionsRepo.backfillTransactionMintIds (the v38 seed). */ function backfillTransactionMintIds(