Run the db tests against the real code, not copies of it

The db suites hand-copied both the schema and each repo's SQL, then asserted
against the copy. That proves nothing about the code the app runs, and it drifted
six times across this branch while staying green — counters.test.ts was still
asserting the OLD (mintUrl, keysetId) key long after it was gone, and
transactionsMintUrl.test.ts kept passing while testing a function that had been
deleted.

Rather than make the copies track production, this removes them.

The op-sqlite jest mock now backs connection.ts's driver seam with node:sqlite.
connection.ts documents itself as "the single seam between the rest of the app
and the native SQLite library", so mocking exactly there means tests run the
production path end to end: real connection.ts (param sanitizing, result
adaptation, BEGIN/COMMIT batch emulation), real instance.ts (schema creation AND
the real migration runner), real repos. Net -418 lines, and no production code
changed.

- counters, proofReservation, onchainQuotes, meltRecovery, inFlightRequests and
  nut20's counter half now call Database.* directly. No copied DDL, no copied SQL.
- The migration suites import their SQL from the MIGRATIONS registry. Their
  PRE-migration shapes stay hand-written ON PURPOSE — those are frozen history and
  must never track today's schema, which is the whole reason v26/v28/v29/v31 froze
  their column lists. That distinction is now stated in each file so the next
  person does not "helpfully" point them at schema.ts and reintroduce the replay
  bug.

It found a bug on contact: walletCountersRepo allocates an index with a single
`INSERT … ON CONFLICT DO UPDATE … RETURNING`. The mock routed statements by
leading keyword, sent it down .run(), and the allocation failed — exactly the
point, since the mirror had RE-IMPLEMENTED that statement rather than running it
and so could never exercise RETURNING.

Two smaller gains from using the real path: counters' rollback test now trips the
real sanitizeParams guard instead of a hand-rolled NOT NULL violation, and
proofReservation locks real MST Proof nodes, because the repo calls isAlive() —
which only answers for an actual node, so plain objects never took production's
path.

Limits, recorded in the mock: Node's SQLite is not op-sqlite's build (version and
compile flags may differ), so this proves our SQL and our logic, not the exact
native binary — device testing still owns that. And each test FILE shares one
in-memory database (instance.ts caches its connection), so suites clear tables in
beforeEach rather than rebuilding.

Tests: 441 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
minibits-cash
2026-07-16 23:13:36 +02:00
co-authored by Claude Opus 4.8
parent c56729aa38
commit 73610e4db8
11 changed files with 941 additions and 1359 deletions
+94 -28
View File
@@ -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 * op-sqlite is native and cannot load under jest. Rather than stub it out, this
* layer imports it transitively (`services -> db -> connection`), which is what * implements its driver surface on top of Node's built-in SQLite, so tests run the
* blocked instantiating MST stores in a test. * 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 * Why this matters: the db suites used to hand-copy both the DDL and each repo's
* resolves; it deliberately does NOT emulate SQLite. Anything that actually * SQL, and those copies drifted from production repeatedly while staying green.
* executes a statement throws loudly rather than silently returning empty results, * A test that asserts against its own copy of the schema proves nothing about the
* because a test that believes it wrote to a database and did not is worse than a * schema. Mocking at connection.ts's documented seam — "the single seam between
* test that fails. * the rest of the app and the native SQLite library" — removes the copies entirely.
* *
* Two ways to test around it: * Each test FILE gets its own in-memory database (jest resets the module registry
* - Model/view logic: stub the `Database` facade (`jest.mock('../src/services')`) * per file, so instance.ts re-runs and rebuilds the schema). Tests within a file
* and drive the MST tree directly. * share it; clear the tables you use in beforeEach, or use distinct keys.
* - Real SQL semantics: use node:sqlite and mirror the production statements, as *
* the db suites do (see sqliteMigration*.test.ts). * 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 => () => { const {DatabaseSync} = require('node:sqlite')
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 ` + * Statements that RETURN rows. Everything else reports changes/insertId instead.
`use node:sqlite with the production SQL (see the db test suites).`, *
) * 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 = () => ({ const open = () => {
execute: notImplemented('execute'), const db = new DatabaseSync(':memory:')
executeSync: notImplemented('executeSync'),
executeAsync: notImplemented('executeAsync'), const executeSync = (query, params) => {
executeBatch: notImplemented('executeBatch'), if (isTransactionControl(query)) {
executeBatchAsync: notImplemented('executeBatchAsync'), db.exec(query)
close: () => {}, 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: () => {}, delete: () => {},
}) }
}
module.exports = { module.exports = {
open, open,
+161 -272
View File
@@ -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`, * The counter is the NUT-13 derivation high-water mark for a keyset. Its single
* `seedCounters`, and the `counterUpdate` folded into `commitReservation` * most important invariant is that it is MONOTONIC — a stored counter can never
* implement on top of `executeBatch`. The counter is the NUT-13 derivation * move backward — because a regression would let the next derivation reuse a
* high-water mark; the single most important invariant is that it is MONOTONIC * blinded secret the mint has already signed.
* — a stored counter can never move backward — because a regression would let
* the next derivation reuse a blinded secret.
* *
* Rows are keyed by keysetId ALONE. NUT-13 derives from (seed, keysetId, * Rows are keyed by keysetId ALONE: NUT-13 derives from (seed, keysetId, counter)
* counter) with no mint component, so one keyset id means one derivation * with no mint component, so one keyset id means one derivation sequence no matter
* sequence; see MINT_COUNTERS_COLUMNS. * which url served it. See MINT_COUNTERS_COLUMNS.
* *
* As with proofReservation.test.ts we mirror the exact production SQL using * This suite calls the production `Database.*` functions. It used to hand-copy both
* node:sqlite + explicit BEGIN/COMMIT, since the native driver needs a device. * the schema and each statement, and those copies drifted from production while
* * staying green — including asserting the OLD (mintUrl, keysetId) key long after it
* @jest-environment node * 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' import {Database} from '../src/services/db'
// ── 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
)`
const MINT = 'https://mint.test' 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): * One in-memory database per test FILE (instance.ts caches its connection), so
* new proofs + a monotonic counter upsert + reservation delete, all in one txn. * clear the tables between tests rather than rebuilding.
*/ */
function commitWithCounter( beforeEach(() => {
db: DatabaseSync, Database.getInstance().executeBatch([
reservationId: string, ['DELETE FROM mint_counters'],
changes: { ['DELETE FROM proofs'],
newProofs?: Array<{secret: string; amount: number; state: string}> ['DELETE FROM reservations'],
counterUpdate?: Array<{keysetId: string; unit?: string; counter: number}> ['DELETE FROM transactions'],
}, ])
) { })
db.exec('BEGIN')
try { const counterOf = (keysetId: string) => Database.getCounter(keysetId)?.counter
const insertNew = db.prepare(
`INSERT OR REPLACE INTO proofs (id, amount, secret, C, mintUrl, unit, tId, state, updatedAt) const insertProof = (secret: string, amount: number, tId = 1, state: 'UNSPENT' | 'PENDING' = 'UNSPENT') =>
VALUES ('keyset1', ?, ?, 'C', '${MINT}', 'sat', 1, ?, ?)`, Database.addOrUpdateProofs(
[{id: 'keyset1', amount, secret, C: 'C' + secret, mintUrl: MINT, unit: 'sat', tId} as any],
state,
) )
for (const p of changes.newProofs ?? []) insertNew.run(p.amount, p.secret, p.state, NOW)
for (const cu of changes.counterUpdate ?? []) { const proofStateOf = (secret: string): string | undefined =>
setCounter(db, cu.keysetId, cu.unit ?? null, cu.counter) Database.getInstance().execute('SELECT state FROM proofs WHERE secret = ?', [secret]).rows?.item(0)
} ?.state
db.prepare('DELETE FROM reservations WHERE id = ?').run(reservationId)
db.exec('COMMIT')
} catch (e) {
db.exec('ROLLBACK')
throw e
}
}
function freshDb(): DatabaseSync {
const db = new DatabaseSync(':memory:')
db.exec(CREATE_MINT_COUNTERS)
db.exec(CREATE_PROOFS)
db.exec(CREATE_RESERVATIONS)
return db
}
// ── Tests ───────────────────────────────────────────────────────────────────
describe('Derivation counters (mint_counters)', () => { describe('Derivation counters (mint_counters)', () => {
test('the database is at the current schema version', () => {
// Guards the whole suite: these run against instance.ts's real schema + the
// real migration registry, so a version mismatch means the rest is testing
// something other than production.
expect(Database.getDatabaseVersion(Database.getInstance()).version).toBe(34)
})
describe('setCounter — monotonic', () => { describe('setCounter — monotonic', () => {
test('inserts a new row when none exists', () => { test('inserts a new row when none exists', () => {
const db = freshDb() Database.setCounter('k1', 'sat', 42)
setCounter(db, 'k1', 'sat', 42) expect(counterOf('k1')).toBe(42)
expect(getCounter(db, 'k1')).toBe(42)
db.close()
}) })
test('raises to a higher value', () => { test('raises to a higher value', () => {
const db = freshDb() Database.setCounter('k1', 'sat', 100)
setCounter(db, 'k1', 'sat', 100) Database.setCounter('k1', 'sat', 150)
setCounter(db, 'k1', 'sat', 150) expect(counterOf('k1')).toBe(150)
expect(getCounter(db, 'k1')).toBe(150)
db.close()
}) })
test('NEVER lowers — a smaller value is ignored (the core safety invariant)', () => { test('NEVER lowers — a smaller value is ignored (the core safety invariant)', () => {
const db = freshDb() Database.setCounter('k1', 'sat', 100)
setCounter(db, 'k1', 'sat', 100) Database.setCounter('k1', 'sat', 50) // stale / replayed writer
setCounter(db, 'k1', 'sat', 50) // stale / replayed writer expect(counterOf('k1')).toBe(100)
expect(getCounter(db, 'k1')).toBe(100)
db.close()
}) })
test('an equal value is a no-op', () => { test('an equal value is a no-op', () => {
const db = freshDb() Database.setCounter('k1', 'sat', 100)
setCounter(db, 'k1', 'sat', 100) Database.setCounter('k1', 'sat', 100)
setCounter(db, 'k1', 'sat', 100) expect(counterOf('k1')).toBe(100)
expect(getCounter(db, 'k1')).toBe(100)
db.close()
}) })
}) })
describe('bumpCounter — relative advance', () => { describe('bumpCounter — relative advance', () => {
test('inserts from 0 when no row exists', () => { test('inserts from 0 when no row exists', () => {
const db = freshDb() Database.bumpCounter('k1', 'sat', 10)
bumpCounter(db, 'k1', 'sat', 10) expect(counterOf('k1')).toBe(10)
expect(getCounter(db, 'k1')).toBe(10)
db.close()
}) })
test('adds to the existing value', () => { test('adds to the existing value', () => {
const db = freshDb() Database.setCounter('k1', 'sat', 100)
setCounter(db, 'k1', 'sat', 100) Database.bumpCounter('k1', 'sat', 10)
bumpCounter(db, 'k1', 'sat', 10) expect(counterOf('k1')).toBe(110)
expect(getCounter(db, 'k1')).toBe(110)
db.close()
}) })
test('a non-positive delta is a no-op', () => { test('a non-positive delta is a no-op', () => {
const db = freshDb() Database.setCounter('k1', 'sat', 100)
setCounter(db, 'k1', 'sat', 100) Database.bumpCounter('k1', 'sat', 0)
bumpCounter(db, 'k1', 'sat', 0) Database.bumpCounter('k1', 'sat', -5)
bumpCounter(db, 'k1', 'sat', -5) expect(counterOf('k1')).toBe(100)
expect(getCounter(db, 'k1')).toBe(100)
db.close()
}) })
}) })
describe('primary key isolation', () => { describe('primary key — one keyset, one counter', () => {
test('different keysets are independent', () => { test('different keysets are independent', () => {
const db = freshDb() Database.setCounter('k1', 'sat', 100)
setCounter(db, 'k1', 'sat', 100) Database.setCounter('k2', 'sat', 7)
setCounter(db, 'k2', 'sat', 7) expect(counterOf('k1')).toBe(100)
expect(getCounter(db, 'k1')).toBe(100) expect(counterOf('k2')).toBe(7)
expect(getCounter(db, 'k2')).toBe(7) expect(Database.getCounters()).toHaveLength(2)
expect(counterRowCount(db)).toBe(2)
db.close()
}) })
// The inverse of this used to be asserted (and implemented): a // The inverse of this used to be asserted (and implemented): a
// (mintUrl, keysetId) key let ONE keyset carry two counters. NUT-13 // (mintUrl, keysetId) key let ONE keyset carry two counters. Both drove the
// derives from (seed, keysetId, counter) with no mint component, so both // same derivation path, so the lower one handed out indices the mint had
// rows drove the same derivation path and the lower one reused blinded // already signed against the higher.
// 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', () => { test('one keyset id has exactly ONE counter, whatever mint served it', () => {
const db = freshDb() Database.setCounter('k1', 'sat', 100)
setCounter(db, 'k1', 'sat', 100) Database.setCounter('k1', 'sat', 5) // same keyset seen via another url
// The same keyset seen again after a mint-url edit / via a mirror. expect(counterOf('k1')).toBe(100) // monotonic, not a second row
setCounter(db, 'k1', 'sat', 5) expect(Database.getCounters()).toHaveLength(1)
expect(getCounter(db, 'k1')).toBe(100) // monotonic, not a second row })
expect(counterRowCount(db)).toBe(1)
db.close() test('getCounter returns undefined for an unknown keyset', () => {
expect(Database.getCounter('nope')).toBeUndefined()
}) })
}) })
describe('seedCounters — one-time MMKV→SQLite copy', () => { describe('seedCounters — one-time MST/MMKV copy', () => {
test('seeds every supplied counter', () => { test('seeds every supplied counter', () => {
const db = freshDb() Database.seedCounters([
seedCounters(db, [
{keysetId: 'k1', unit: 'sat', counter: 100}, {keysetId: 'k1', unit: 'sat', counter: 100},
{keysetId: 'k2', unit: 'sat', counter: 50}, {keysetId: 'k2', unit: 'sat', counter: 50},
]) ])
expect(getCounter(db, 'k1')).toBe(100) expect(counterOf('k1')).toBe(100)
expect(getCounter(db, 'k2')).toBe(50) expect(counterOf('k2')).toBe(50)
db.close()
}) })
test('is idempotent — re-running never lowers an advanced counter', () => { test('is idempotent — re-running never lowers an advanced counter', () => {
const db = freshDb() Database.seedCounters([{keysetId: 'k1', unit: 'sat', counter: 100}])
// First upgrade seed copies the (then current) MMKV values. Database.setCounter('k1', 'sat', 175) // wallet advances during normal use
seedCounters(db, [{keysetId: 'k1', unit: 'sat', counter: 100}]) Database.seedCounters([{keysetId: 'k1', unit: 'sat', counter: 100}]) // stale re-run
// Wallet advances past it during normal use. expect(counterOf('k1')).toBe(175)
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)', () => { test('a too-high seed is kept (conservative-safe: skips indices, never reuses)', () => {
const db = freshDb() Database.setCounter('k1', 'sat', 100)
setCounter(db, 'k1', 'sat', 100) Database.seedCounters([{keysetId: 'k1', unit: 'sat', counter: 9999}])
seedCounters(db, [{keysetId: 'k1', unit: 'sat', counter: 9999}]) expect(counterOf('k1')).toBe(9999)
expect(getCounter(db, 'k1')).toBe(9999) })
db.close()
test('an empty seed is a no-op', () => {
expect(Database.seedCounters([])).toEqual({seeded: 0})
}) })
}) })
describe('atomic commit (counterUpdate folded into commitReservation)', () => { describe('atomic commit (counterUpdate folded into commitReservation)', () => {
test('persists the counter in the SAME txn as the new proofs', () => { const openReservation = (id: string, transactionId: number) =>
const db = freshDb() Database.openReservation(
setCounter(db, 'k1', 'sat', 100) {id, transactionId, mintId: 'mint1', mintUrl: MINT, unit: 'sat', operationType: 'send', lockedProofs: []},
[],
)
commitWithCounter(db, 'res-1', { test('persists the counter in the SAME txn as the new proofs', () => {
newProofs: [{secret: 'new1', amount: 50, state: 'UNSPENT'}], 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}], counterUpdate: [{keysetId: 'k1', unit: 'sat', counter: 110}],
}) })
expect(getProofState(db, 'new1')).toBe('UNSPENT') expect(proofStateOf('new1')).toBe('UNSPENT')
expect(getCounter(db, 'k1')).toBe(110) expect(counterOf('k1')).toBe(110)
db.close() // Committing also clears the reservation row.
}) expect(Database.getOpenReservations()).toHaveLength(0)
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', () => { test('counterUpdate stays monotonic inside the commit batch', () => {
const db = freshDb() Database.setCounter('k1', 'sat', 200)
setCounter(db, 'k1', 'sat', 200) openReservation('res-2', 2)
// A commit carrying a stale (lower) counter must not regress it. // A commit carrying a stale (lower) counter must not regress it.
commitWithCounter(db, 'res-2', { Database.commitReservation('res-2', {
newProofs: [{secret: 'new2', amount: 10, state: 'UNSPENT'}], 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}], counterUpdate: [{keysetId: 'k1', unit: 'sat', counter: 150}],
}) })
expect(getProofState(db, 'new2')).toBe('UNSPENT') expect(proofStateOf('new2')).toBe('UNSPENT')
expect(getCounter(db, 'k1')).toBe(200) expect(counterOf('k1')).toBe(200)
db.close() })
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)
}) })
}) })
}) })
+86 -148
View File
@@ -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 * Per-transaction request params for an operation that has reached the mint but
* can be retried against the mint's idempotent endpoint. add() overwrites * whose response may be lost. Written before the network call so the op can be
* (set semantics), the per-mint query drives the recovery sweep, the row is * retried against the mint's idempotent (NUT-19) endpoint.
* deleted on success/terminal failure, and the upgrade seed is idempotent.
* *
* Mirrors the production SQL against node:sqlite (the native driver needs a * The table is a CHILD of the transaction — its primary key IS transactionId — so
* device), like meltRecovery.test.ts. * 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' import {Database} from '../src/services/db'
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
)`
const MINT = 'https://mint.test' const MINT = 'https://mint.test'
const MINT_ID = 'mint1111' const MINT_ID = 'mint1111'
const OTHER_MINT_ID = 'mint9999' 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) =>
function addTransaction(db: DatabaseSync, id: number, mintId: string | null) { Database.getInstance().execute(
db.prepare(`INSERT OR REPLACE INTO transactions (id, mintId, mint, status) VALUES (?, ?, ?, 'PENDING')`) `INSERT OR REPLACE INTO transactions (id, type, amount, unit, data, mint, mintId, status, createdAt)
.run(id, mintId, MINT) VALUES (?, 'TOPUP', 100, 'sat', '{}', ?, ?, 'PENDING', '2026-01-01')`,
} [id, MINT, mintId],
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) { beforeEach(() => {
db.prepare(`DELETE FROM inflight_requests WHERE transactionId = ?`).run(transactionId) Database.getInstance().executeBatch([['DELETE FROM inflight_requests'], ['DELETE FROM transactions']])
} })
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 ───────────────────────────────────────────────────────────────────
describe('In-flight requests (inflight_requests)', () => { describe('In-flight requests (inflight_requests)', () => {
test('stores and reads back a request (JSON round-trip)', () => { test('stores and reads back a request (JSON round-trip)', () => {
const db = freshDb()
const request = {token: 'cashuA...', options: {keysetId: 'k1'}} const request = {token: 'cashuA...', options: {keysetId: 'k1'}}
addTransaction(101, MINT_ID)
Database.addInFlightRequest(101, request)
addTransaction(db, 101, MINT_ID) const rec = Database.getInFlightRequest(101)!
addInFlightRequest(db, 101, request)
const rec = getInFlightRequest(db, 101)!
expect(rec.transactionId).toBe(101) expect(rec.transactionId).toBe(101)
expect(rec.request).toEqual(request) expect(rec.request).toEqual(request)
db.close()
}) })
test('returns undefined when no entry exists', () => { test('returns undefined when no entry exists', () => {
const db = freshDb() expect(Database.getInFlightRequest(999)).toBeUndefined()
expect(getInFlightRequest(db, 999)).toBeUndefined()
db.close()
}) })
test('add OVERWRITES an existing entry (set semantics)', () => { test('add OVERWRITES an existing entry (set semantics)', () => {
const db = freshDb() addTransaction(101, MINT_ID)
addTransaction(db, 101, MINT_ID) Database.addInFlightRequest(101, {v: 'first'})
addInFlightRequest(db, 101, {v: 'first'}) Database.addInFlightRequest(101, {v: 'second'})
addInFlightRequest(db, 101, {v: 'second'})
expect(getInFlightRequest(db, 101)!.request).toEqual({v: 'second'}) expect(Database.getInFlightRequest(101)!.request).toEqual({v: 'second'})
db.close()
}) })
test('getInFlightRequestsByMintId returns all rows of a mint', () => { test('remove deletes the entry', () => {
const db = freshDb() addTransaction(101, MINT_ID)
addTransaction(db, 101, MINT_ID) Database.addInFlightRequest(101, {v: 1})
addTransaction(db, 102, MINT_ID) Database.removeInFlightRequest(101)
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(Database.getInFlightRequest(101)).toBeUndefined()
expect(forMint.map(r => r.transactionId).sort()).toEqual([101, 102]) expect(Database.getInFlightRequestsByMintId(MINT_ID)).toHaveLength(0)
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 describe('getInFlightRequestsByMintId — the per-mint recovery sweep', () => {
// not hide its own in-flight work. test('returns all rows of a mint, and only that mint', () => {
test('still finds a mint\'s requests after its url changes', () => { addTransaction(101, MINT_ID)
const db = freshDb() addTransaction(102, MINT_ID)
addTransaction(db, 101, MINT_ID) addTransaction(103, OTHER_MINT_ID)
addInFlightRequest(db, 101, {v: 1}) Database.addInFlightRequest(101, {v: 1})
Database.addInFlightRequest(102, {v: 2})
Database.addInFlightRequest(103, {v: 3})
// transactions.mint is frozen history and never rewritten; only the mint's expect(
// live url moves. The id is unaffected, so the join is too. Database.getInFlightRequestsByMintId(MINT_ID)
expect(getInFlightRequestsByMintId(db, MINT_ID)).toHaveLength(1) .map(r => r.transactionId)
db.close() .sort(),
).toEqual([101, 102])
expect(Database.getInFlightRequestsByMintId(OTHER_MINT_ID)).toHaveLength(1)
})
// 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})
Database.getInstance().execute('UPDATE transactions SET mint = ? WHERE id = ?', [
'https://some-other.url',
101,
])
expect(Database.getInFlightRequestsByMintId(MINT_ID)).toHaveLength(1)
}) })
// Correct, not a regression: the retry settles proofs onto its transaction and // 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 // the handler branches on tx.type, so without the parent there is nothing to
// apply the result to. // apply the result to.
test('a request whose transaction is gone is not returned', () => { test('a request whose transaction is gone is not returned', () => {
const db = freshDb() addTransaction(101, MINT_ID)
addTransaction(db, 101, MINT_ID) Database.addInFlightRequest(101, {v: 1})
addInFlightRequest(db, 101, {v: 1}) Database.getInstance().execute('DELETE FROM transactions WHERE id = ?', [101])
db.prepare('DELETE FROM transactions WHERE id = ?').run(101)
expect(getInFlightRequestsByMintId(db, MINT_ID)).toHaveLength(0) expect(Database.getInFlightRequestsByMintId(MINT_ID)).toHaveLength(0)
db.close()
}) })
test('a transaction with no mintId is not returned', () => { test('a transaction with no mintId is not returned', () => {
const db = freshDb() addTransaction(101, null)
addTransaction(db, 101, null) Database.addInFlightRequest(101, {v: 1})
addInFlightRequest(db, 101, {v: 1})
expect(getInFlightRequestsByMintId(db, MINT_ID)).toHaveLength(0) expect(Database.getInFlightRequestsByMintId(MINT_ID)).toHaveLength(0)
db.close() })
}) })
test('remove deletes the entry', () => { describe('seedInFlightRequests — one-time MST/MMKV copy', () => {
const db = freshDb() test('is idempotent — does not overwrite an existing entry', () => {
addTransaction(db, 101, MINT_ID) addTransaction(101, MINT_ID)
addInFlightRequest(db, 101, {v: 1}) Database.addInFlightRequest(101, {v: 'live'})
removeInFlightRequest(db, 101) Database.seedInFlightRequests([{transactionId: 101, request: {v: 'snapshot'}}])
expect(getInFlightRequest(db, 101)).toBeUndefined()
expect(getInFlightRequestsByMintId(db, MINT_ID)).toHaveLength(0) expect(Database.getInFlightRequest(101)!.request).toEqual({v: 'live'})
db.close()
}) })
test('seed is idempotent — does not overwrite an existing entry', () => { test('an empty seed is a no-op', () => {
const db = freshDb() expect(Database.seedInFlightRequests([])).toEqual({seeded: 0})
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()
}) })
}) })
+62 -102
View File
@@ -1,31 +1,29 @@
/** /**
* 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 * A per-transaction serialized meltPreview is stored BEFORE a melt is submitted, so
* serialized meltPreview is stored before a melt is submitted so a paid-but- * a paid-but-unconfirmed melt can be recovered and its change ecash unblinded. The
* unconfirmed melt can be recovered and its change unblinded. The first stored * first stored preview for a transaction wins (idempotent), and the row is removed
* preview for a transaction wins (idempotent), and the row is removed on * on terminal success/failure.
* terminal success/failure.
* *
* Mirrors the production SQL against node:sqlite, like proofReservation.test.ts * The table is a CHILD of the transaction — its primary key IS transactionId — so
* and counters.test.ts (the native driver needs a device). * 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 ( /** A representative StoredMeltPreview (shape from cashuUtils). */
transactionId INTEGER PRIMARY KEY NOT NULL, const previewFor = (keysetId: string, secret = 'aa') =>
meltPreview TEXT NOT NULL, ({
createdAt TEXT
)`
const MINT = 'https://mint.test'
// A representative StoredMeltPreview (shape from cashuUtils).
const previewFor = (keysetId: string, secret = 'aa') => ({
keysetId, keysetId,
outputData: [ outputData: [
{ {
@@ -34,115 +32,77 @@ const previewFor = (keysetId: string, secret = 'aa') => ({
secret, 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)', () => { describe('Melt recovery (melt_recovery)', () => {
test('stores and reads back a meltPreview (JSON round-trip)', () => { test('stores and reads back a meltPreview (JSON round-trip)', () => {
const db = freshDb()
const preview = previewFor('k1') const preview = previewFor('k1')
Database.addMeltRecovery(101, preview)
addMeltRecovery(db, 101, preview) const rec = Database.getMeltRecovery(101)!
const rec = getMeltRecovery(db, 101)!
expect(rec.transactionId).toBe(101) expect(rec.transactionId).toBe(101)
expect(rec.meltPreview).toEqual(preview) expect(rec.meltPreview).toEqual(preview)
// The keyset lives INSIDE the preview — the row never duplicated it. // The keyset lives INSIDE the preview — the row never duplicated it.
expect(rec.meltPreview.keysetId).toBe('k1') expect(rec.meltPreview.keysetId).toBe('k1')
db.close()
}) })
test('returns undefined when no entry exists', () => { test('returns undefined when no entry exists', () => {
const db = freshDb() expect(Database.getMeltRecovery(999)).toBeUndefined()
expect(getMeltRecovery(db, 999)).toBeUndefined()
db.close()
}) })
test('the FIRST stored preview wins (ON CONFLICT DO NOTHING)', () => { test('the FIRST stored preview wins (ON CONFLICT DO NOTHING)', () => {
const db = freshDb() Database.addMeltRecovery(101, previewFor('k1', 'first'))
addMeltRecovery(db, 101, previewFor('k1', 'first')) // A second attempt for the same tx must not overwrite: the preview describes
// A second attempt for the same tx must not overwrite. // outputs the mint may already have signed.
addMeltRecovery(db, 101, previewFor('k1', 'second')) Database.addMeltRecovery(101, previewFor('k1', 'second'))
const rec = getMeltRecovery(db, 101)! expect(Database.getMeltRecovery(101)!.meltPreview.outputData[0].secret).toBe('first')
expect(rec.meltPreview.outputData[0].secret).toBe('first') expect(rowCount()).toBe(1)
expect(rowCount(db)).toBe(1)
db.close()
}) })
test('remove deletes the entry (terminal success/failure)', () => { test('remove deletes the entry (terminal success/failure)', () => {
const db = freshDb() Database.addMeltRecovery(101, previewFor('k1'))
addMeltRecovery(db, 101, previewFor('k1')) expect(rowCount()).toBe(1)
expect(rowCount(db)).toBe(1)
removeMeltRecovery(db, 101) Database.removeMeltRecovery(101)
expect(getMeltRecovery(db, 101)).toBeUndefined() expect(Database.getMeltRecovery(101)).toBeUndefined()
expect(rowCount(db)).toBe(0) expect(rowCount()).toBe(0)
db.close()
}) })
test('entries for different transactions are independent', () => { test('entries for different transactions are independent', () => {
const db = freshDb() Database.addMeltRecovery(101, previewFor('k1'))
addMeltRecovery(db, 101, previewFor('k1')) Database.addMeltRecovery(102, previewFor('k2'))
addMeltRecovery(db, 102, previewFor('k2'))
expect(getMeltRecovery(db, 101)!.meltPreview.keysetId).toBe('k1') expect(Database.getMeltRecovery(101)!.meltPreview.keysetId).toBe('k1')
expect(getMeltRecovery(db, 102)!.meltPreview.keysetId).toBe('k2') expect(Database.getMeltRecovery(102)!.meltPreview.keysetId).toBe('k2')
removeMeltRecovery(db, 101) Database.removeMeltRecovery(101)
expect(getMeltRecovery(db, 101)).toBeUndefined() expect(Database.getMeltRecovery(101)).toBeUndefined()
expect(getMeltRecovery(db, 102)!.meltPreview.keysetId).toBe('k2') // unaffected expect(Database.getMeltRecovery(102)!.meltPreview.keysetId).toBe('k2') // unaffected
db.close()
}) })
test('seed is idempotent — does not overwrite an existing entry', () => { describe('seedMeltRecoveries — one-time MST/MMKV copy', () => {
const db = freshDb() test('is idempotent — does not overwrite an existing entry', () => {
// Live entry already advanced/stored. Database.addMeltRecovery(101, previewFor('k1', 'live'))
addMeltRecovery(db, 101, previewFor('k1', 'live')) Database.seedMeltRecoveries([{transactionId: 101, meltPreview: previewFor('k1', 'snapshot')}])
// Upgrade seed re-runs with the snapshot copy.
addMeltRecovery(db, 101, previewFor('k1', 'snapshot'))
expect(getMeltRecovery(db, 101)!.meltPreview.outputData[0].secret).toBe('live') expect(Database.getMeltRecovery(101)!.meltPreview.outputData[0].secret).toBe('live')
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('an empty seed is a no-op', () => {
expect(Database.seedMeltRecoveries([])).toEqual({seeded: 0})
})
}) })
}) })
+41 -72
View File
@@ -8,9 +8,8 @@
* own NUT-20 primitives. * own NUT-20 primitives.
* *
* 2. THE COUNTER (src/services/db/walletCountersRepo.ts) — the burn-forward * 2. THE COUNTER (src/services/db/walletCountersRepo.ts) — the burn-forward
* allocation and monotonic set, mirrored against node:sqlite because the * allocation and monotonic set, exercised through the REAL repo against a real
* native driver needs a device (same approach as inFlightRequests.test.ts * database (the op-sqlite jest mock backs the driver seam with node:sqlite).
* and meltRecovery.test.ts).
* *
* The counter is the load-bearing part: handing the same index out twice would * 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 * give two quotes the same pubkey, which lets the mint link them (NUT-20 asks
@@ -19,7 +18,7 @@
* *
* @jest-environment node * @jest-environment node
*/ */
import {DatabaseSync} from 'node:sqlite' import {Database} from '../src/services/db'
import {Amount, signMintQuote, verifyMintQuoteSignature} from '@cashu/cashu-ts' import {Amount, signMintQuote, verifyMintQuoteSignature} from '@cashu/cashu-ts'
import type {SerializedBlindedMessage} from '@cashu/cashu-ts' import type {SerializedBlindedMessage} from '@cashu/cashu-ts'
import {hexToBytes} from '@noble/curves/utils.js' 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()}, 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 // The DERIVATION half stubs the counter so it can pin the index it is handed
// unavailable here). Stub the repo so the derivation code is importable; the // (allocateQuoteKeypair must use whatever the counter returns, whatever that is).
// real SQL is exercised against node:sqlite further down. // The COUNTER half below reaches past this mock with requireActual and exercises
// the real repo against a real database.
const mockAllocateNextCounter = jest.fn() const mockAllocateNextCounter = jest.fn()
jest.mock('../src/services/db/walletCountersRepo', () => ({ jest.mock('../src/services/db/walletCountersRepo', () => ({
NUT20_COUNTER: 'nut20', 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, // These call walletCountersRepo directly rather than mirroring its SQL. The
// as with the other repo tests. // 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 ( // requireActual reaches past the module-level stub above: this half is about the
name TEXT PRIMARY KEY NOT NULL, // real statements, not about isolating the derivation from them.
counter INTEGER NOT NULL DEFAULT 0, const {allocateNextCounter, getWalletCounter, setWalletCounter} = jest.requireActual(
updatedAt TEXT '../src/services/db/walletCountersRepo',
)`
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)
}
describe('wallet_counters (burn-forward allocation)', () => { 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(() => { beforeEach(() => {
db = new DatabaseSync(':memory:') Database.getInstance().executeBatch([['DELETE FROM wallet_counters']])
db.exec(CREATE_WALLET_COUNTERS)
}) })
it('starts at index 0 when no row exists', () => { it('starts at index 0 when no row exists', () => {
expect(getWalletCounter(db, 'nut20')).toBe(0) expect(getWalletCounter('nut20')).toBe(0)
expect(allocateNextCounter(db, 'nut20')).toBe(0) expect(allocateNextCounter('nut20')).toBe(0)
}) })
it('hands out consecutive indices and stores the next free one', () => { 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(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', () => { 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) 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', () => { 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 // The whole point of committing before the network call. Quote request
// dies -> index 0 is spent, never recycled. // dies -> index 0 is spent, never recycled.
const burned = allocateNextCounter(db, 'nut20') const burned = allocateNextCounter('nut20')
const afterRetry = allocateNextCounter(db, 'nut20') const afterRetry = allocateNextCounter('nut20')
expect(burned).toBe(0) expect(burned).toBe(0)
expect(afterRetry).toBe(1) expect(afterRetry).toBe(1)
@@ -304,32 +273,32 @@ describe('wallet_counters (burn-forward allocation)', () => {
}) })
it('keeps counters independent per purpose name', () => { it('keeps counters independent per purpose name', () => {
expect(allocateNextCounter(db, 'nut20')).toBe(0) expect(allocateNextCounter('nut20')).toBe(0)
expect(allocateNextCounter(db, 'other')).toBe(0) expect(allocateNextCounter('other')).toBe(0)
expect(allocateNextCounter(db, 'nut20')).toBe(1) expect(allocateNextCounter('nut20')).toBe(1)
expect(getWalletCounter(db, 'nut20')).toBe(2) expect(getWalletCounter('nut20')).toBe(2)
expect(getWalletCounter(db, 'other')).toBe(1) expect(getWalletCounter('other')).toBe(1)
}) })
it('setWalletCounter is monotonic: a lower value is a no-op', () => { it('setWalletCounter is monotonic: a lower value is a no-op', () => {
setWalletCounter(db, 'nut20', 10) setWalletCounter('nut20', 10)
expect(getWalletCounter(db, 'nut20')).toBe(10) expect(getWalletCounter('nut20')).toBe(10)
setWalletCounter(db, 'nut20', 3) // stale writer setWalletCounter('nut20', 3) // stale writer
expect(getWalletCounter(db, 'nut20')).toBe(10) expect(getWalletCounter('nut20')).toBe(10)
setWalletCounter(db, 'nut20', 12) setWalletCounter('nut20', 12)
expect(getWalletCounter(db, 'nut20')).toBe(12) expect(getWalletCounter('nut20')).toBe(12)
}) })
it('cannot walk back onto an index already handed out', () => { it('cannot walk back onto an index already handed out', () => {
const first = allocateNextCounter(db, 'nut20') // 0 const first = allocateNextCounter('nut20') // 0
allocateNextCounter(db, 'nut20') // 1 allocateNextCounter('nut20') // 1
setWalletCounter(db, 'nut20', 0) // stale/replayed write setWalletCounter('nut20', 0) // stale/replayed write
expect(allocateNextCounter(db, 'nut20')).toBe(2) expect(allocateNextCounter('nut20')).toBe(2)
expect(allocateNextCounter(db, 'nut20')).not.toBe(first) expect(allocateNextCounter('nut20')).not.toBe(first)
}) })
}) })
+114 -163
View File
@@ -1,9 +1,5 @@
/** /**
* Onchain (NUT-30) mint quote store. * Onchain (NUT-30) mint quotes, against the REAL repo and a real database.
*
* Mirrors the production SQL from onchainQuotesRepo against node:sqlite (the
* native driver needs a device), as meltRecovery.test.ts and inFlightRequests.test.ts
* do.
* *
* The watch rule is what these tests are really about. Two facts make it subtle: * The watch rule is what these tests are really about. Two facts make it subtle:
* *
@@ -15,228 +11,164 @@
* Getting this wrong loses money in one direction (abandon a quote holding funds) * Getting this wrong loses money in one direction (abandon a quote holding funds)
* or polls forever in the other. * 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 ( import {Database} from '../src/services/db'
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
)`
const MINT = 'https://mint.test' const MINT = 'https://mint.test'
const NOW = new Date('2026-07-13T12:00:00.000Z') const MINT_ID = 'mint1111'
const iso = (d: Date) => d.toISOString()
const daysFromNow = (n: number) => iso(new Date(NOW.getTime() + n * 86400000))
// ── 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, const addQuote = (q: {
amountPaid, amountIssued, expiry, watchUntil, createdAt, updatedAt`
/** Mint.id of the owning mint — the reference that survives a mint-url edit. */
const MINT_ID = 'mint1234'
function addQuote(
db: DatabaseSync,
q: {
quote: string quote: string
counterIndex: number counterIndex: number
amountRequested?: number | null amountRequested?: number | null
amountPaid?: number amountPaid?: number
amountIssued?: number amountIssued?: number
watchUntil?: string watchUntil?: string
}, mintId?: string
) { }) =>
db.prepare( Database.addOnchainMintQuote({
`INSERT OR REPLACE INTO onchain_mint_quotes (${COLS}) quote: q.quote,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, mintId: q.mintId ?? MINT_ID,
).run( mintUrl: MINT,
q.quote, unit: 'sat',
MINT_ID, address: 'bc1q' + q.quote,
MINT, counterIndex: q.counterIndex,
'sat', pubkey: '02' + 'a'.repeat(64),
`bc1q${q.quote}`, amountRequested: q.amountRequested ?? null,
q.counterIndex, amountPaid: q.amountPaid,
`02${'a'.repeat(64)}`, amountIssued: q.amountIssued,
q.amountRequested ?? null, expiry: null, // the mint returns null — the address never dies
q.amountPaid ?? 0, watchUntil: q.watchUntil,
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()
describe('onchain mint quotes', () => {
let db: DatabaseSync
beforeEach(() => {
db = new DatabaseSync(':memory:')
db.exec(CREATE_ONCHAIN_MINT_QUOTES)
}) })
const watchedIds = () =>
Database.getWatchedOnchainMintQuotes()
.map(r => r.quote)
.sort()
beforeEach(() => {
Database.getInstance().executeBatch([['DELETE FROM onchain_mint_quotes']])
})
describe('onchain mint quotes', () => {
describe('the watch rule', () => { describe('the watch rule', () => {
it('watches a fresh unpaid quote (window still open)', () => { it('watches a fresh unpaid quote (window still open)', () => {
addQuote(db, {quote: 'q1', counterIndex: 0}) addQuote({quote: 'q1', counterIndex: 0})
expect(watchedIds()).toEqual(['q1'])
expect(watchedIds(db)).toEqual(['q1'])
}) })
it('stops watching an unpaid quote once the window closes', () => { it('stops watching an unpaid quote once the window closes', () => {
// nothing ever arrived, and the mint will never expire the address for // nothing ever arrived, and the mint will never expire the address for us —
// us — so the wallet has to draw the line itself // so the wallet has to draw the line itself
addQuote(db, {quote: 'q1', counterIndex: 0, watchUntil: daysFromNow(-1)}) addQuote({quote: 'q1', counterIndex: 0, watchUntil: daysFromNow(-1)})
expect(watchedIds()).toEqual([])
expect(watchedIds(db)).toEqual([])
}) })
it('watches a quote with credited-but-unminted funds', () => { it('watches a quote with credited-but-unminted funds', () => {
addQuote(db, {quote: 'q1', counterIndex: 0, amountPaid: 50000, amountIssued: 0}) addQuote({quote: 'q1', counterIndex: 0, amountPaid: 50000, amountIssued: 0})
expect(watchedIds()).toEqual(['q1'])
expect(watchedIds(db)).toEqual(['q1'])
}) })
it('stops watching once the quote is fully drained (the "stop after first mint" case)', () => { 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}) addQuote({quote: 'q1', counterIndex: 0, amountPaid: 50000, amountIssued: 50000})
expect(watchedIds()).toEqual([])
expect(watchedIds(db)).toEqual([])
}) })
it('KEEPS watching a PARTIALLY drained quote — money is still credited', () => { 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 // The reason the rule keys on the unminted BALANCE and not on "has ever
// minted". Keying on amountIssued > 0 would archive this and abandon // minted". Keying on amountIssued > 0 would archive this and abandon 20k sat
// 20k sat the mint is holding for us. // the mint is holding for us.
addQuote(db, {quote: 'q1', counterIndex: 0, amountPaid: 70000, amountIssued: 50000}) addQuote({quote: 'q1', counterIndex: 0, amountPaid: 70000, amountIssued: 50000})
expect(watchedIds()).toEqual(['q1'])
expect(watchedIds(db)).toEqual(['q1'])
}) })
it('watches credited funds even after the window has closed', () => { it('watches credited funds even after the window has closed', () => {
// the deadline bounds quotes nobody paid; it must never override money // the deadline bounds quotes nobody paid; it must never override money that
// that is actually sitting at the mint // is actually sitting at the mint
addQuote(db, { addQuote({
quote: 'q1', quote: 'q1',
counterIndex: 0, counterIndex: 0,
amountPaid: 50000, amountPaid: 50000,
amountIssued: 0, amountIssued: 0,
watchUntil: daysFromNow(-30), watchUntil: daysFromNow(-30),
}) })
expect(watchedIds()).toEqual(['q1'])
expect(watchedIds(db)).toEqual(['q1'])
}) })
it('separates watched from archived across a realistic mix', () => { it('separates watched from archived across a realistic mix', () => {
addQuote(db, {quote: 'fresh', counterIndex: 0}) addQuote({quote: 'fresh', counterIndex: 0})
addQuote(db, {quote: 'drained', counterIndex: 1, amountPaid: 10000, amountIssued: 10000}) addQuote({quote: 'drained', counterIndex: 1, amountPaid: 10000, amountIssued: 10000})
addQuote(db, {quote: 'unpaid-expired', counterIndex: 2, watchUntil: daysFromNow(-1)}) addQuote({quote: 'unpaid-expired', counterIndex: 2, watchUntil: daysFromNow(-1)})
addQuote(db, {quote: 'has-funds', counterIndex: 3, amountPaid: 30000, amountIssued: 0}) addQuote({quote: 'has-funds', counterIndex: 3, amountPaid: 30000, amountIssued: 0})
addQuote(db, { addQuote({quote: 'partly-drained', counterIndex: 4, amountPaid: 30000, amountIssued: 10000})
quote: 'partly-drained',
counterIndex: 4,
amountPaid: 30000,
amountIssued: 10000,
})
expect(watchedIds(db)).toEqual(['fresh', 'has-funds', 'partly-drained']) expect(watchedIds()).toEqual(['fresh', 'has-funds', 'partly-drained'])
}) })
}) })
describe('amount updates are monotonic', () => { describe('amount updates are monotonic', () => {
it('applies a normal forward update', () => { it('applies a normal forward update', () => {
addQuote(db, {quote: 'q1', counterIndex: 0}) addQuote({quote: 'q1', counterIndex: 0})
Database.updateOnchainMintQuoteAmounts('q1', 50000, 0)
updateAmounts(db, 'q1', 50000, 0) expect(Database.getOnchainMintQuote('q1')).toMatchObject({amountPaid: 50000, amountIssued: 0})
expect(getQuote(db, 'q1')).toMatchObject({amountPaid: 50000, amountIssued: 0})
}) })
it('ignores a stale response that would walk amounts BACKWARDS', () => { it('ignores a stale response that would walk amounts BACKWARDS', () => {
// A slow reply landing after a fresh one must not resurrect an old view. // A slow reply landing after a fresh one must not resurrect an old view. If
// If amountIssued regressed, the wallet would see unminted money that is // amountIssued regressed, the wallet would see unminted money that is not
// not there — and mint it a second time. // there — and mint it a second time.
addQuote(db, {quote: 'q1', counterIndex: 0, amountPaid: 50000, amountIssued: 50000}) addQuote({quote: 'q1', counterIndex: 0, amountPaid: 50000, amountIssued: 50000})
updateAmounts(db, 'q1', 50000, 0) // stale: "nothing issued yet" Database.updateOnchainMintQuoteAmounts('q1', 50000, 0) // stale: "nothing issued yet"
expect(getQuote(db, 'q1')).toMatchObject({amountPaid: 50000, amountIssued: 50000}) expect(Database.getOnchainMintQuote('q1')).toMatchObject({
expect(watchedIds(db)).toEqual([]) // still archived, not resurrected amountPaid: 50000,
amountIssued: 50000,
})
expect(watchedIds()).toEqual([]) // still archived, not resurrected
}) })
it('records a second deposit arriving on the same address', () => { it('records a second deposit arriving on the same address', () => {
addQuote(db, {quote: 'q1', counterIndex: 0, amountPaid: 50000, amountIssued: 50000}) addQuote({quote: 'q1', counterIndex: 0, amountPaid: 50000, amountIssued: 50000})
expect(watchedIds(db)).toEqual([]) expect(watchedIds()).toEqual([])
updateAmounts(db, 'q1', 70000, 50000) // someone paid the address again Database.updateOnchainMintQuoteAmounts('q1', 70000, 50000) // paid again
expect(watchedIds(db)).toEqual(['q1']) // unminted balance -> back in the watch set expect(watchedIds()).toEqual(['q1']) // unminted balance -> back in the watch set
}) })
}) })
describe('archived quotes stay recoverable', () => { describe('archived quotes stay recoverable', () => {
it('keeps the row, and with it the NUT-20 counterIndex', () => { 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 // counterIndex is the only way to re-derive the key that signs a mint request
// request for this quote. Delete the row and a late deposit becomes // for this quote. Delete the row and a late deposit becomes permanently
// permanently unspendable. // unspendable.
addQuote(db, {quote: 'q1', counterIndex: 42, amountPaid: 10000, amountIssued: 10000}) addQuote({quote: 'q1', counterIndex: 42, amountPaid: 10000, amountIssued: 10000})
expect(watchedIds(db)).toEqual([]) // archived expect(watchedIds()).toEqual([]) // archived
expect(getQuote(db, 'q1')).toMatchObject({counterIndex: 42, address: 'bc1qq1'}) expect(Database.getOnchainMintQuote('q1')).toMatchObject({
counterIndex: 42,
address: 'bc1qq1',
})
}) })
it('re-opens the watch window on user request', () => { it('re-opens the watch window on user request', () => {
addQuote(db, {quote: 'q1', counterIndex: 0, watchUntil: daysFromNow(-1)}) addQuote({quote: 'q1', counterIndex: 0, watchUntil: daysFromNow(-1)})
expect(watchedIds(db)).toEqual([]) expect(watchedIds()).toEqual([])
extendWatch(db, 'q1', 7) // "check for deposits" from the transaction detail Database.extendOnchainMintQuoteWatch('q1', 7) // "check for deposits"
expect(watchedIds(db)).toEqual(['q1']) expect(watchedIds()).toEqual(['q1'])
}) })
}) })
@@ -244,17 +176,36 @@ describe('onchain mint quotes', () => {
it('keeps amountRequested separate from what was actually paid', () => { it('keeps amountRequested separate from what was actually paid', () => {
// The BIP21 amount is a suggestion the sender can ignore. Under- and // The BIP21 amount is a suggestion the sender can ignore. Under- and
// overpayment are normal, so the two must never be conflated. // overpayment are normal, so the two must never be conflated.
addQuote(db, {quote: 'q1', counterIndex: 0, amountRequested: 50000}) addQuote({quote: 'q1', counterIndex: 0, amountRequested: 50000})
updateAmounts(db, 'q1', 42000, 0) // sender underpaid Database.updateOnchainMintQuoteAmounts('q1', 42000, 0) // sender underpaid
expect(getQuote(db, 'q1')).toMatchObject({amountRequested: 50000, amountPaid: 42000}) expect(Database.getOnchainMintQuote('q1')).toMatchObject({
amountRequested: 50000,
amountPaid: 42000,
})
}) })
it('allows a null requested amount', () => { it('allows a null requested amount', () => {
addQuote(db, {quote: 'q1', counterIndex: 0, amountRequested: null}) addQuote({quote: 'q1', counterIndex: 0, amountRequested: null})
expect(Database.getOnchainMintQuote('q1')!.amountRequested).toBeNull()
})
})
expect(getQuote(db, '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)
}) })
}) })
}) })
+181 -336
View File
@@ -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`, * A reservation is the wallet's atomic-commit primitive for an outgoing operation:
* `Database.commitReservation`, `Database.rollbackReservation`, and * open it to lock proofs to PENDING, then either commit (inputs SPENT, new proofs
* `Database.getOpenReservations` implement on top of `executeBatch`. * 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 * This calls the production `Database.*` functions. It used to hand-copy both the
* can run in Jest (react-native-quick-sqlite needs a real device). * 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 ( const MINT = 'https://mint.test'
id TEXT NOT NULL, const MINT_ID = 'mint1111'
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 CREATE_RESERVATIONS = `CREATE TABLE reservations ( type Db = ReturnType<typeof Database.getInstance>
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 LockedProofSnapshot = { type LockedProofSnapshot = {
secret: string secret: string
@@ -113,47 +35,6 @@ type LockedProofSnapshot = {
originalTId: number | null 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 = { type CommitTransactionUpdate = {
id: number id: number
status?: string status?: string
@@ -166,116 +47,144 @@ type CommitTransactionUpdate = {
proof?: string 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( function commitReservation(
db: DatabaseSync, _db: Db,
reservationId: string, reservationId: string,
changes: { changes: {
toSpent?: string[] toSpent?: string[]
toUnspent?: string[] toUnspent?: string[]
newProofs?: Array<{ newProofs?: Array<{secret: string; amount: number; state: 'UNSPENT' | 'PENDING' | 'SPENT'}>
secret: string
amount: number
state: 'UNSPENT' | 'PENDING' | 'SPENT'
}>
transactionUpdate?: CommitTransactionUpdate transactionUpdate?: CommitTransactionUpdate
}, },
) { ) {
const now = '2026-05-22T00:00:00.000Z' Database.commitReservation(reservationId, {
db.exec('BEGIN') toSpent: changes.toSpent ? nodesForSecrets(changes.toSpent) : undefined,
try { toUnspent: changes.toUnspent ? nodesForSecrets(changes.toUnspent) : undefined,
const updateSpent = db.prepare( newProofs: changes.newProofs?.map(p => ({
`UPDATE proofs SET state = 'SPENT', updatedAt = ? WHERE secret = ?`, proofs: [{id: 'keyset1', amount: p.amount, secret: p.secret, C: 'C'} as any],
) state: p.state,
for (const s of changes.toSpent ?? []) updateSpent.run(now, s) mintUrl: MINT,
unit: 'sat',
const updateUnspent = db.prepare( tId: 1,
`UPDATE proofs SET state = 'UNSPENT', updatedAt = ? WHERE secret = ?`, })),
) transactionUpdate: changes.transactionUpdate as any,
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 function rollbackReservation(_db: Db, reservationId: string, lockedProofs: LockedProofSnapshot[]) {
// the supplied fields. Database.rollbackReservation(reservationId, lockedProofs as any)
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) function getOpenReservations(_db: Db): Array<{id: string; lockedProofs: LockedProofSnapshot[]}> {
db.exec('COMMIT') return Database.getOpenReservations().map(r => ({
} catch (e) { id: r.id,
db.exec('ROLLBACK') lockedProofs: r.lockedProofs as LockedProofSnapshot[],
throw e }))
}
}
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 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)}))
} }
// ── Tests ───────────────────────────────────────────────────────────────────── // ── Tests ─────────────────────────────────────────────────────────────────────
@@ -283,8 +192,7 @@ function getOpenReservations(db: DatabaseSync): Array<{
describe('Proof reservations', () => { describe('Proof reservations', () => {
describe('openReservation', () => { describe('openReservation', () => {
test('atomically inserts reservation row + locks proofs to PENDING', () => { test('atomically inserts reservation row + locks proofs to PENDING', () => {
const db = new DatabaseSync(':memory:') const db = freshDb()
createSchema(db)
insertProof(db, 'sA', 100) insertProof(db, 'sA', 100)
insertProof(db, 'sB', 200) insertProof(db, 'sB', 200)
@@ -307,13 +215,10 @@ describe('Proof reservations', () => {
expect(getProofState(db, 'sA')).toBe('PENDING') expect(getProofState(db, 'sA')).toBe('PENDING')
expect(getProofState(db, 'sB')).toBe('PENDING') expect(getProofState(db, 'sB')).toBe('PENDING')
expect(reservationCount(db)).toBe(1) expect(reservationCount(db)).toBe(1)
db.close()
}) })
test('captures originalState even when some proofs were already PENDING', () => { test('captures originalState even when some proofs were already PENDING', () => {
const db = new DatabaseSync(':memory:') const db = freshDb()
createSchema(db)
insertProof(db, 'sA', 100, 'UNSPENT') insertProof(db, 'sA', 100, 'UNSPENT')
insertProof(db, 'sB', 200, 'PENDING') // already locked by an earlier op 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: 'sA', originalState: 'UNSPENT', originalTId: null},
{secret: 'sB', originalState: 'PENDING', originalTId: null}, {secret: 'sB', originalState: 'PENDING', originalTId: null},
]) ])
db.close()
}) })
}) })
describe('commitReservation', () => { describe('commitReservation', () => {
test('marks inputs SPENT, adds new proofs, deletes reservation in one txn', () => { test('marks inputs SPENT, adds new proofs, deletes reservation in one txn', () => {
const db = new DatabaseSync(':memory:') const db = freshDb()
createSchema(db)
insertProof(db, 'input1', 100) insertProof(db, 'input1', 100)
insertProof(db, 'input2', 200) insertProof(db, 'input2', 200)
@@ -380,13 +282,10 @@ describe('Proof reservations', () => {
expect(getProofState(db, 'change1')).toBe('UNSPENT') expect(getProofState(db, 'change1')).toBe('UNSPENT')
expect(getProofState(db, 'send1')).toBe('PENDING') expect(getProofState(db, 'send1')).toBe('PENDING')
expect(reservationCount(db)).toBe(0) expect(reservationCount(db)).toBe(0)
db.close()
}) })
test('empty changes still removes the reservation row (offline-send case)', () => { test('empty changes still removes the reservation row (offline-send case)', () => {
const db = new DatabaseSync(':memory:') const db = freshDb()
createSchema(db)
insertProof(db, 's1', 100) insertProof(db, 's1', 100)
openReservation( openReservation(
@@ -407,15 +306,12 @@ describe('Proof reservations', () => {
// Proof stays PENDING (sent offline), reservation row gone // Proof stays PENDING (sent offline), reservation row gone
expect(getProofState(db, 's1')).toBe('PENDING') expect(getProofState(db, 's1')).toBe('PENDING')
expect(reservationCount(db)).toBe(0) expect(reservationCount(db)).toBe(0)
db.close()
}) })
}) })
describe('rollbackReservation', () => { describe('rollbackReservation', () => {
test('restores each proof to its originalState and deletes the row', () => { test('restores each proof to its originalState and deletes the row', () => {
const db = new DatabaseSync(':memory:') const db = freshDb()
createSchema(db)
insertProof(db, 'sA', 100, 'UNSPENT') insertProof(db, 'sA', 100, 'UNSPENT')
insertProof(db, 'sB', 200, 'UNSPENT') insertProof(db, 'sB', 200, 'UNSPENT')
@@ -445,13 +341,10 @@ describe('Proof reservations', () => {
expect(getProofState(db, 'sA')).toBe('UNSPENT') expect(getProofState(db, 'sA')).toBe('UNSPENT')
expect(getProofState(db, 'sB')).toBe('UNSPENT') expect(getProofState(db, 'sB')).toBe('UNSPENT')
expect(reservationCount(db)).toBe(0) expect(reservationCount(db)).toBe(0)
db.close()
}) })
test('preserves PENDING originalState (multi-op overlap)', () => { test('preserves PENDING originalState (multi-op overlap)', () => {
const db = new DatabaseSync(':memory:') const db = freshDb()
createSchema(db)
insertProof(db, 'sA', 100, 'PENDING') insertProof(db, 'sA', 100, 'PENDING')
// Reservation captures sA as already-PENDING // Reservation captures sA as already-PENDING
@@ -472,15 +365,12 @@ describe('Proof reservations', () => {
// Restored to PENDING (its original locked state), not to UNSPENT // Restored to PENDING (its original locked state), not to UNSPENT
expect(getProofState(db, 'sA')).toBe('PENDING') expect(getProofState(db, 'sA')).toBe('PENDING')
db.close()
}) })
}) })
describe('orphan recovery', () => { describe('orphan recovery', () => {
test('getOpenReservations returns all rows; rollback restores state', () => { test('getOpenReservations returns all rows; rollback restores state', () => {
const db = new DatabaseSync(':memory:') const db = freshDb()
createSchema(db)
insertProof(db, 'orphanA', 100) insertProof(db, 'orphanA', 100)
insertProof(db, 'orphanB', 200) insertProof(db, 'orphanB', 200)
@@ -514,22 +404,17 @@ describe('Proof reservations', () => {
expect(getProofState(db, 'orphanA')).toBe('UNSPENT') expect(getProofState(db, 'orphanA')).toBe('UNSPENT')
expect(getProofState(db, 'orphanB')).toBe('UNSPENT') expect(getProofState(db, 'orphanB')).toBe('UNSPENT')
expect(reservationCount(db)).toBe(0) expect(reservationCount(db)).toBe(0)
db.close()
}) })
test('no orphans when there are no in-flight reservations', () => { test('no orphans when there are no in-flight reservations', () => {
const db = new DatabaseSync(':memory:') const db = freshDb()
createSchema(db)
insertProof(db, 's1', 100) insertProof(db, 's1', 100)
expect(getOpenReservations(db)).toEqual([]) expect(getOpenReservations(db)).toEqual([])
db.close()
}) })
test('multiple concurrent reservations all roll back independently', () => { test('multiple concurrent reservations all roll back independently', () => {
const db = new DatabaseSync(':memory:') const db = freshDb()
createSchema(db)
insertProof(db, 'a1', 100) insertProof(db, 'a1', 100)
insertProof(db, 'a2', 100) insertProof(db, 'a2', 100)
insertProof(db, 'b1', 200) insertProof(db, 'b1', 200)
@@ -573,15 +458,12 @@ describe('Proof reservations', () => {
expect(getProofState(db, 'a2')).toBe('UNSPENT') expect(getProofState(db, 'a2')).toBe('UNSPENT')
expect(getProofState(db, 'b1')).toBe('UNSPENT') expect(getProofState(db, 'b1')).toBe('UNSPENT')
expect(reservationCount(db)).toBe(0) expect(reservationCount(db)).toBe(0)
db.close()
}) })
}) })
describe('atomicity', () => { describe('atomicity', () => {
test('openReservation: inserting a duplicate id rolls back the whole batch', () => { test('openReservation: inserting a duplicate id rolls back the whole batch', () => {
const db = new DatabaseSync(':memory:') const db = freshDb()
createSchema(db)
insertProof(db, 's1', 100, 'UNSPENT') insertProof(db, 's1', 100, 'UNSPENT')
// First reservation succeeds // First reservation succeeds
@@ -625,8 +507,6 @@ describe('Proof reservations', () => {
expect(getProofState(db, 's2')).toBe('UNSPENT') expect(getProofState(db, 's2')).toBe('UNSPENT')
// s1 is unaffected // s1 is unaffected
expect(getProofState(db, 's1')).toBe('PENDING') 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)', () => { describe('tId propagation (regression: stuck-PENDING SEND, 2026-05-22)', () => {
test('openReservation reassigns each locked proof tId to the new transactionId', () => { test('openReservation reassigns each locked proof tId to the new transactionId', () => {
const db = new DatabaseSync(':memory:') const db = freshDb()
createSchema(db)
// Two proofs received originally by tx 110 and tx 123 respectively // Two proofs received originally by tx 110 and tx 123 respectively
// (simulates the dev log). // (simulates the dev log).
db.prepare( db.execute(`INSERT INTO proofs (id, amount, secret, C, mintUrl, unit, tId, state, updatedAt)
`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')`)
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)
).run() VALUES ('keyset1', 2, 'inp_b', 'C', 'https://mint.test', 'sat', 123, 'UNSPENT', '2026-01-01')`)
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()
expect(getProofTId(db, 'inp_a')).toBe(110) expect(getProofTId(db, 'inp_a')).toBe(110)
expect(getProofTId(db, 'inp_b')).toBe(123) expect(getProofTId(db, 'inp_b')).toBe(123)
@@ -686,21 +561,14 @@ describe('Proof reservations', () => {
expect(getProofTId(db, 'inp_b')).toBe(157) expect(getProofTId(db, 'inp_b')).toBe(157)
expect(getProofState(db, 'inp_a')).toBe('PENDING') expect(getProofState(db, 'inp_a')).toBe('PENDING')
expect(getProofState(db, 'inp_b')).toBe('PENDING') expect(getProofState(db, 'inp_b')).toBe('PENDING')
db.close()
}) })
test('rollback restores each proof to its individual originalTId', () => { test('rollback restores each proof to its individual originalTId', () => {
const db = new DatabaseSync(':memory:') const db = freshDb()
createSchema(db) db.execute(`INSERT INTO proofs (id, amount, secret, C, mintUrl, unit, tId, state, updatedAt)
db.prepare( VALUES ('keyset1', 4, 'inp_a', 'C', 'https://mint.test', 'sat', 110, 'UNSPENT', '2026-01-01')`)
`INSERT INTO proofs (id, amount, secret, C, mintUrl, unit, tId, state, updatedAt) 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')`, VALUES ('keyset1', 2, 'inp_b', 'C', 'https://mint.test', 'sat', 123, '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()
openReservation( openReservation(
db, db,
@@ -731,19 +599,14 @@ describe('Proof reservations', () => {
expect(getProofState(db, 'inp_a')).toBe('UNSPENT') expect(getProofState(db, 'inp_a')).toBe('UNSPENT')
expect(getProofState(db, 'inp_b')).toBe('UNSPENT') expect(getProofState(db, 'inp_b')).toBe('UNSPENT')
expect(reservationCount(db)).toBe(0) expect(reservationCount(db)).toBe(0)
db.close()
}) })
test('proofs with no prior tId (null) are reassigned and restored to null', () => { test('proofs with no prior tId (null) are reassigned and restored to null', () => {
const db = new DatabaseSync(':memory:') const db = freshDb()
createSchema(db)
// Proof inserted without a tId — simulates an imported/restored // Proof inserted without a tId — simulates an imported/restored
// proof that was never tied to a wallet transaction. // proof that was never tied to a wallet transaction.
db.prepare( db.execute(`INSERT INTO proofs (id, amount, secret, C, mintUrl, unit, state, updatedAt)
`INSERT INTO proofs (id, amount, secret, C, mintUrl, unit, state, updatedAt) VALUES ('keyset1', 1, 'orphan', 'C', 'https://mint.test', 'sat', 'UNSPENT', '2026-01-01')`)
VALUES ('keyset1', 1, 'orphan', 'C', 'https://mint.test', 'sat', 'UNSPENT', '2026-01-01')`,
).run()
expect(getProofTId(db, 'orphan')).toBe(null) expect(getProofTId(db, 'orphan')).toBe(null)
openReservation( openReservation(
@@ -766,8 +629,6 @@ describe('Proof reservations', () => {
{secret: 'orphan', originalState: 'UNSPENT', originalTId: null}, {secret: 'orphan', originalState: 'UNSPENT', originalTId: null},
]) ])
expect(getProofTId(db, 'orphan')).toBe(null) expect(getProofTId(db, 'orphan')).toBe(null)
db.close()
}) })
}) })
@@ -781,8 +642,7 @@ describe('Proof reservations', () => {
// ───────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────
describe('atomic two-table commit (Phase 5b)', () => { describe('atomic two-table commit (Phase 5b)', () => {
test('commit with transactionUpdate writes proofs AND transaction in one txn', () => { test('commit with transactionUpdate writes proofs AND transaction in one txn', () => {
const db = new DatabaseSync(':memory:') const db = freshDb()
createSchema(db)
insertProof(db, 'input', 100) insertProof(db, 'input', 100)
insertTransaction(db, 200, 'PREPARED') insertTransaction(db, 200, 'PREPARED')
@@ -817,13 +677,10 @@ describe('Proof reservations', () => {
expect(row.data).toBe('[{"status":"COMPLETED"}]') expect(row.data).toBe('[{"status":"COMPLETED"}]')
expect(row.balanceAfter).toBe(50) expect(row.balanceAfter).toBe(50)
expect(reservationCount(db)).toBe(0) expect(reservationCount(db)).toBe(0)
db.close()
}) })
test('a failed commit batch rolls back BOTH proof and transaction writes', () => { test('a failed commit batch rolls back BOTH proof and transaction writes', () => {
const db = new DatabaseSync(':memory:') const db = freshDb()
createSchema(db)
insertProof(db, 'input', 100) insertProof(db, 'input', 100)
insertTransaction(db, 201, 'PREPARED') insertTransaction(db, 201, 'PREPARED')
@@ -846,13 +703,11 @@ describe('Proof reservations', () => {
expect(() => { expect(() => {
db.exec('BEGIN') db.exec('BEGIN')
try { try {
db.prepare(`UPDATE proofs SET state = 'SPENT' WHERE secret = ?`).run('input') db.execute(`UPDATE proofs SET state = 'SPENT' WHERE secret = ?`, ['input'])
db.prepare(`UPDATE transactions SET status = 'COMPLETED' WHERE id = ?`).run(201) db.execute(`UPDATE transactions SET status = 'COMPLETED' WHERE id = ?`, [201])
// This will conflict — reservation 'res-atomic' already exists // This will conflict — reservation 'res-atomic' already exists
db.prepare( db.execute(`INSERT INTO reservations (id, transactionId, mintUrl, unit, operationType, lockedProofs, createdAt)
`INSERT INTO reservations (id, transactionId, mintUrl, unit, operationType, lockedProofs, createdAt) VALUES ('res-atomic', 999, '', '', '', '[]', '')`)
VALUES ('res-atomic', 999, '', '', '', '[]', '')`,
).run()
db.exec('COMMIT') db.exec('COMMIT')
} catch (e) { } catch (e) {
db.exec('ROLLBACK') db.exec('ROLLBACK')
@@ -865,13 +720,10 @@ describe('Proof reservations', () => {
// statement in the batch fails, SQLite rolls back the entire txn. // statement in the batch fails, SQLite rolls back the entire txn.
expect(getProofState(db, 'input')).toBe('PENDING') // still locked expect(getProofState(db, 'input')).toBe('PENDING') // still locked
expect(getTransactionStatus(db, 201)).toBe('PREPARED') // still pre-finalize expect(getTransactionStatus(db, 201)).toBe('PREPARED') // still pre-finalize
db.close()
}) })
test('commit without transactionUpdate leaves transactions table untouched', () => { test('commit without transactionUpdate leaves transactions table untouched', () => {
const db = new DatabaseSync(':memory:') const db = freshDb()
createSchema(db)
insertProof(db, 'p1', 100) insertProof(db, 'p1', 100)
insertTransaction(db, 202, 'PENDING') insertTransaction(db, 202, 'PENDING')
@@ -894,18 +746,13 @@ describe('Proof reservations', () => {
// Transaction status untouched — the caller is responsible for // Transaction status untouched — the caller is responsible for
// any post-commit updates that don't need atomicity. // any post-commit updates that don't need atomicity.
expect(getTransactionStatus(db, 202)).toBe('PENDING') expect(getTransactionStatus(db, 202)).toBe('PENDING')
db.close()
}) })
test('partial transactionUpdate only sets the provided columns', () => { test('partial transactionUpdate only sets the provided columns', () => {
const db = new DatabaseSync(':memory:') const db = freshDb()
createSchema(db)
insertProof(db, 'p2', 100) insertProof(db, 'p2', 100)
db.prepare( db.execute(`INSERT INTO transactions (id, status, data, balanceAfter)
`INSERT INTO transactions (id, status, data, balanceAfter) VALUES (203, 'PREPARED', 'old-data', 999)`)
VALUES (203, 'PREPARED', 'old-data', 999)`,
).run()
openReservation( openReservation(
db, db,
@@ -930,8 +777,6 @@ describe('Proof reservations', () => {
expect(row.status).toBe('REVERTED') expect(row.status).toBe('REVERTED')
expect(row.data).toBe('old-data') expect(row.data).toBe('old-data')
expect(row.balanceAfter).toBe(999) expect(row.balanceAfter).toBe(999)
db.close()
}) })
}) })
}) })
+12 -34
View File
@@ -7,39 +7,20 @@
* Uses Node.js built-in node:sqlite (requires Node 22.5+). * Uses Node.js built-in node:sqlite (requires Node 22.5+).
* @jest-environment node * @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 {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 ( const MIGRATION_25 = MIGRATIONS.find(m => m.version === 25)!.queries.map(([sql]) => sql)
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`
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
@@ -79,10 +60,7 @@ function insertOldProof(
} }
function runMigration25(db: DatabaseSync) { function runMigration25(db: DatabaseSync) {
db.exec(CREATE_V25) for (const sql of MIGRATION_25) db.exec(sql)
db.exec(INSERT_V25)
db.exec(DROP_OLD)
db.exec(RENAME)
} }
function getState(db: DatabaseSync, secret: string): string { function getState(db: DatabaseSync, secret: string): string {
+12 -19
View File
@@ -23,24 +23,20 @@
* Uses Node.js built-in node:sqlite (requires Node 22.5+). * Uses Node.js built-in node:sqlite (requires Node 22.5+).
* @jest-environment node * @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 {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 ( const MIGRATION_32 = MIGRATIONS.find(m => m.version === 32)!.queries.map(([sql]) => sql)
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`
// ── Helpers ────────────────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────────────
@@ -78,10 +74,7 @@ function insertOld(
function runMigration32(db: DatabaseSync) { function runMigration32(db: DatabaseSync) {
db.exec('BEGIN') db.exec('BEGIN')
try { try {
db.exec(CREATE_V32) for (const sql of MIGRATION_32) db.exec(sql)
db.exec(INSERT_V32)
db.exec(DROP_OLD)
db.exec(RENAME)
db.exec('COMMIT') db.exec('COMMIT')
} catch (e) { } catch (e) {
db.exec('ROLLBACK') db.exec('ROLLBACK')
+11 -5
View File
@@ -17,7 +17,12 @@
* Uses Node.js built-in node:sqlite (requires Node 22.5+). * Uses Node.js built-in node:sqlite (requires Node 22.5+).
* @jest-environment node * @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 {DatabaseSync} from 'node:sqlite'
import {MIGRATIONS} from '../src/services/db/migrations'
// ── Historical shapes, as v26/v31 create them (frozen in migrations.ts) ─────── // ── 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 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 = [ const MIGRATION_33 = MIGRATIONS.find(m => m.version === 33)!.queries.map(([sql]) => sql)
`ALTER TABLE onchain_mint_quotes ADD COLUMN mintId TEXT`,
`ALTER TABLE reservations ADD COLUMN mintId TEXT`,
]
// ── Backfill, mirroring onchainQuotesRepo / reservationsRepo ───────────────── // ── Backfill, mirroring onchainQuotesRepo / reservationsRepo ─────────────────
+11 -24
View File
@@ -17,7 +17,12 @@
* Uses Node.js built-in node:sqlite (requires Node 22.5+). * Uses Node.js built-in node:sqlite (requires Node 22.5+).
* @jest-environment node * @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 {DatabaseSync} from 'node:sqlite'
import {MIGRATIONS} from '../src/services/db/migrations'
// ── Pre-34 shapes (as v28/v29 create them; frozen in migrations.ts) ────────── // ── 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 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 = [ const MIGRATION_34 = MIGRATIONS.find(m => m.version === 34)!.queries.map(([sql]) => sql)
`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`,
]
/** Mirrors transactionsRepo.backfillTransactionMintIds (the v38 seed). */ /** Mirrors transactionsRepo.backfillTransactionMintIds (the v38 seed). */
function backfillTransactionMintIds( function backfillTransactionMintIds(