mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-10-05 19:28:26 +00:00
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:
co-authored by
Claude Opus 4.8
parent
c56729aa38
commit
73610e4db8
+95
-29
@@ -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}
|
||||||
delete: () => {},
|
}
|
||||||
})
|
|
||||||
|
const bound = (params ?? []).map(toBindable)
|
||||||
|
const statement = db.prepare(query)
|
||||||
|
|
||||||
|
if (returnsRows(query)) {
|
||||||
|
return {rows: statement.all(...bound), rowsAffected: 0}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = statement.run(...bound)
|
||||||
|
return {
|
||||||
|
rows: [],
|
||||||
|
rowsAffected: Number(result.changes ?? 0),
|
||||||
|
// Only meaningful for INSERT; harmless elsewhere and matches op-sqlite.
|
||||||
|
insertId: result.lastInsertRowid != null ? Number(result.lastInsertRowid) : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
executeSync,
|
||||||
|
execute: async (query, params) => executeSync(query, params),
|
||||||
|
// op-sqlite's own batch is async and all-or-nothing. connection.ts does not use
|
||||||
|
// it for the synchronous path (it emulates that with BEGIN/COMMIT over
|
||||||
|
// executeSync), so this only serves executeBatchAsync.
|
||||||
|
executeBatch: async commands => {
|
||||||
|
let rowsAffected = 0
|
||||||
|
db.exec('BEGIN')
|
||||||
|
try {
|
||||||
|
for (const [query, params] of commands) {
|
||||||
|
rowsAffected += executeSync(query, params).rowsAffected ?? 0
|
||||||
|
}
|
||||||
|
db.exec('COMMIT')
|
||||||
|
} catch (e) {
|
||||||
|
db.exec('ROLLBACK')
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
return {rowsAffected}
|
||||||
|
},
|
||||||
|
close: () => db.close(),
|
||||||
|
delete: () => {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
open,
|
open,
|
||||||
|
|||||||
+210
-321
@@ -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 insertNew = db.prepare(
|
|
||||||
`INSERT OR REPLACE INTO proofs (id, amount, secret, C, mintUrl, unit, tId, state, updatedAt)
|
|
||||||
VALUES ('keyset1', ?, ?, 'C', '${MINT}', 'sat', 1, ?, ?)`,
|
|
||||||
)
|
|
||||||
for (const p of changes.newProofs ?? []) insertNew.run(p.amount, p.secret, p.state, NOW)
|
|
||||||
|
|
||||||
for (const cu of changes.counterUpdate ?? []) {
|
const counterOf = (keysetId: string) => Database.getCounter(keysetId)?.counter
|
||||||
setCounter(db, cu.keysetId, cu.unit ?? null, cu.counter)
|
|
||||||
}
|
|
||||||
|
|
||||||
db.prepare('DELETE FROM reservations WHERE id = ?').run(reservationId)
|
const insertProof = (secret: string, amount: number, tId = 1, state: 'UNSPENT' | 'PENDING' = 'UNSPENT') =>
|
||||||
db.exec('COMMIT')
|
Database.addOrUpdateProofs(
|
||||||
} catch (e) {
|
[{id: 'keyset1', amount, secret, C: 'C' + secret, mintUrl: MINT, unit: 'sat', tId} as any],
|
||||||
db.exec('ROLLBACK')
|
state,
|
||||||
throw e
|
)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function freshDb(): DatabaseSync {
|
const proofStateOf = (secret: string): string | undefined =>
|
||||||
const db = new DatabaseSync(':memory:')
|
Database.getInstance().execute('SELECT state FROM proofs WHERE secret = ?', [secret]).rows?.item(0)
|
||||||
db.exec(CREATE_MINT_COUNTERS)
|
?.state
|
||||||
db.exec(CREATE_PROOFS)
|
|
||||||
db.exec(CREATE_RESERVATIONS)
|
|
||||||
return db
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Tests ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('Derivation counters (mint_counters)', () => {
|
describe('Derivation counters (mint_counters)', () => {
|
||||||
describe('setCounter — monotonic', () => {
|
test('the database is at the current schema version', () => {
|
||||||
test('inserts a new row when none exists', () => {
|
// Guards the whole suite: these run against instance.ts's real schema + the
|
||||||
const db = freshDb()
|
// real migration registry, so a version mismatch means the rest is testing
|
||||||
setCounter(db, 'k1', 'sat', 42)
|
// something other than production.
|
||||||
expect(getCounter(db, 'k1')).toBe(42)
|
expect(Database.getDatabaseVersion(Database.getInstance()).version).toBe(34)
|
||||||
db.close()
|
})
|
||||||
})
|
|
||||||
|
|
||||||
test('raises to a higher value', () => {
|
describe('setCounter — monotonic', () => {
|
||||||
const db = freshDb()
|
test('inserts a new row when none exists', () => {
|
||||||
setCounter(db, 'k1', 'sat', 100)
|
Database.setCounter('k1', 'sat', 42)
|
||||||
setCounter(db, 'k1', 'sat', 150)
|
expect(counterOf('k1')).toBe(42)
|
||||||
expect(getCounter(db, 'k1')).toBe(150)
|
|
||||||
db.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('NEVER lowers — a smaller value is ignored (the core safety invariant)', () => {
|
|
||||||
const db = freshDb()
|
|
||||||
setCounter(db, 'k1', 'sat', 100)
|
|
||||||
setCounter(db, 'k1', 'sat', 50) // stale / replayed writer
|
|
||||||
expect(getCounter(db, 'k1')).toBe(100)
|
|
||||||
db.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('an equal value is a no-op', () => {
|
|
||||||
const db = freshDb()
|
|
||||||
setCounter(db, 'k1', 'sat', 100)
|
|
||||||
setCounter(db, 'k1', 'sat', 100)
|
|
||||||
expect(getCounter(db, 'k1')).toBe(100)
|
|
||||||
db.close()
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('bumpCounter — relative advance', () => {
|
test('raises to a higher value', () => {
|
||||||
test('inserts from 0 when no row exists', () => {
|
Database.setCounter('k1', 'sat', 100)
|
||||||
const db = freshDb()
|
Database.setCounter('k1', 'sat', 150)
|
||||||
bumpCounter(db, 'k1', 'sat', 10)
|
expect(counterOf('k1')).toBe(150)
|
||||||
expect(getCounter(db, 'k1')).toBe(10)
|
|
||||||
db.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('adds to the existing value', () => {
|
|
||||||
const db = freshDb()
|
|
||||||
setCounter(db, 'k1', 'sat', 100)
|
|
||||||
bumpCounter(db, 'k1', 'sat', 10)
|
|
||||||
expect(getCounter(db, 'k1')).toBe(110)
|
|
||||||
db.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('a non-positive delta is a no-op', () => {
|
|
||||||
const db = freshDb()
|
|
||||||
setCounter(db, 'k1', 'sat', 100)
|
|
||||||
bumpCounter(db, 'k1', 'sat', 0)
|
|
||||||
bumpCounter(db, 'k1', 'sat', -5)
|
|
||||||
expect(getCounter(db, 'k1')).toBe(100)
|
|
||||||
db.close()
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('primary key isolation', () => {
|
test('NEVER lowers — a smaller value is ignored (the core safety invariant)', () => {
|
||||||
test('different keysets are independent', () => {
|
Database.setCounter('k1', 'sat', 100)
|
||||||
const db = freshDb()
|
Database.setCounter('k1', 'sat', 50) // stale / replayed writer
|
||||||
setCounter(db, 'k1', 'sat', 100)
|
expect(counterOf('k1')).toBe(100)
|
||||||
setCounter(db, 'k2', 'sat', 7)
|
|
||||||
expect(getCounter(db, 'k1')).toBe(100)
|
|
||||||
expect(getCounter(db, 'k2')).toBe(7)
|
|
||||||
expect(counterRowCount(db)).toBe(2)
|
|
||||||
db.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
// The inverse of this used to be asserted (and implemented): a
|
|
||||||
// (mintUrl, keysetId) key let ONE keyset carry two counters. NUT-13
|
|
||||||
// derives from (seed, keysetId, counter) with no mint component, so both
|
|
||||||
// rows drove the same derivation path and the lower one reused blinded
|
|
||||||
// secrets the mint had already signed. One keyset id, one counter — no
|
|
||||||
// matter which mint url served the keyset.
|
|
||||||
test('one keyset id has exactly ONE counter, whatever mint served it', () => {
|
|
||||||
const db = freshDb()
|
|
||||||
setCounter(db, 'k1', 'sat', 100)
|
|
||||||
// The same keyset seen again after a mint-url edit / via a mirror.
|
|
||||||
setCounter(db, 'k1', 'sat', 5)
|
|
||||||
expect(getCounter(db, 'k1')).toBe(100) // monotonic, not a second row
|
|
||||||
expect(counterRowCount(db)).toBe(1)
|
|
||||||
db.close()
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('seedCounters — one-time MMKV→SQLite copy', () => {
|
test('an equal value is a no-op', () => {
|
||||||
test('seeds every supplied counter', () => {
|
Database.setCounter('k1', 'sat', 100)
|
||||||
const db = freshDb()
|
Database.setCounter('k1', 'sat', 100)
|
||||||
seedCounters(db, [
|
expect(counterOf('k1')).toBe(100)
|
||||||
{keysetId: 'k1', unit: 'sat', counter: 100},
|
})
|
||||||
{keysetId: 'k2', unit: 'sat', counter: 50},
|
})
|
||||||
])
|
|
||||||
expect(getCounter(db, 'k1')).toBe(100)
|
|
||||||
expect(getCounter(db, 'k2')).toBe(50)
|
|
||||||
db.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('is idempotent — re-running never lowers an advanced counter', () => {
|
describe('bumpCounter — relative advance', () => {
|
||||||
const db = freshDb()
|
test('inserts from 0 when no row exists', () => {
|
||||||
// First upgrade seed copies the (then current) MMKV values.
|
Database.bumpCounter('k1', 'sat', 10)
|
||||||
seedCounters(db, [{keysetId: 'k1', unit: 'sat', counter: 100}])
|
expect(counterOf('k1')).toBe(10)
|
||||||
// Wallet advances past it during normal use.
|
|
||||||
setCounter(db, 'k1', 'sat', 175)
|
|
||||||
// A later launch re-runs the seed with the now-stale snapshot value.
|
|
||||||
seedCounters(db, [{keysetId: 'k1', unit: 'sat', counter: 100}])
|
|
||||||
// The advanced SQLite value wins — the seed cannot regress it.
|
|
||||||
expect(getCounter(db, 'k1')).toBe(175)
|
|
||||||
db.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('a too-high seed is kept (conservative-safe: skips indices, never reuses)', () => {
|
|
||||||
const db = freshDb()
|
|
||||||
setCounter(db, 'k1', 'sat', 100)
|
|
||||||
seedCounters(db, [{keysetId: 'k1', unit: 'sat', counter: 9999}])
|
|
||||||
expect(getCounter(db, 'k1')).toBe(9999)
|
|
||||||
db.close()
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('atomic commit (counterUpdate folded into commitReservation)', () => {
|
test('adds to the existing value', () => {
|
||||||
test('persists the counter in the SAME txn as the new proofs', () => {
|
Database.setCounter('k1', 'sat', 100)
|
||||||
const db = freshDb()
|
Database.bumpCounter('k1', 'sat', 10)
|
||||||
setCounter(db, 'k1', 'sat', 100)
|
expect(counterOf('k1')).toBe(110)
|
||||||
|
|
||||||
commitWithCounter(db, 'res-1', {
|
|
||||||
newProofs: [{secret: 'new1', amount: 50, state: 'UNSPENT'}],
|
|
||||||
counterUpdate: [{keysetId: 'k1', unit: 'sat', counter: 110}],
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(getProofState(db, 'new1')).toBe('UNSPENT')
|
|
||||||
expect(getCounter(db, 'k1')).toBe(110)
|
|
||||||
db.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('a failed commit batch rolls back BOTH the proofs and the counter', () => {
|
|
||||||
const db = freshDb()
|
|
||||||
setCounter(db, 'k1', 'sat', 100)
|
|
||||||
|
|
||||||
// Force a failure mid-batch (NOT NULL violation on amount) AFTER the
|
|
||||||
// proof insert and counter upsert have run in the same transaction.
|
|
||||||
expect(() => {
|
|
||||||
db.exec('BEGIN')
|
|
||||||
try {
|
|
||||||
db.prepare(
|
|
||||||
`INSERT OR REPLACE INTO proofs (id, amount, secret, C, mintUrl, unit, tId, state, updatedAt)
|
|
||||||
VALUES ('keyset1', 50, 'new1', 'C', '${MINT}', 'sat', 1, 'UNSPENT', '${NOW}')`,
|
|
||||||
).run()
|
|
||||||
setCounter(db, 'k1', 'sat', 110)
|
|
||||||
// Violates NOT NULL on amount → aborts the whole batch.
|
|
||||||
db.prepare(
|
|
||||||
`INSERT INTO proofs (id, amount, secret, C, state) VALUES ('keyset1', NULL, 'bad', 'C', 'UNSPENT')`,
|
|
||||||
).run()
|
|
||||||
db.exec('COMMIT')
|
|
||||||
} catch (e) {
|
|
||||||
db.exec('ROLLBACK')
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}).toThrow()
|
|
||||||
|
|
||||||
// Neither the proof nor the counter advance survived.
|
|
||||||
expect(getProofState(db, 'new1')).toBe('')
|
|
||||||
expect(getCounter(db, 'k1')).toBe(100)
|
|
||||||
db.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('counterUpdate stays monotonic inside the commit batch', () => {
|
|
||||||
const db = freshDb()
|
|
||||||
setCounter(db, 'k1', 'sat', 200)
|
|
||||||
|
|
||||||
// A commit carrying a stale (lower) counter must not regress it.
|
|
||||||
commitWithCounter(db, 'res-2', {
|
|
||||||
newProofs: [{secret: 'new2', amount: 10, state: 'UNSPENT'}],
|
|
||||||
counterUpdate: [{keysetId: 'k1', unit: 'sat', counter: 150}],
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(getProofState(db, 'new2')).toBe('UNSPENT')
|
|
||||||
expect(getCounter(db, 'k1')).toBe(200)
|
|
||||||
db.close()
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('a non-positive delta is a no-op', () => {
|
||||||
|
Database.setCounter('k1', 'sat', 100)
|
||||||
|
Database.bumpCounter('k1', 'sat', 0)
|
||||||
|
Database.bumpCounter('k1', 'sat', -5)
|
||||||
|
expect(counterOf('k1')).toBe(100)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('primary key — one keyset, one counter', () => {
|
||||||
|
test('different keysets are independent', () => {
|
||||||
|
Database.setCounter('k1', 'sat', 100)
|
||||||
|
Database.setCounter('k2', 'sat', 7)
|
||||||
|
expect(counterOf('k1')).toBe(100)
|
||||||
|
expect(counterOf('k2')).toBe(7)
|
||||||
|
expect(Database.getCounters()).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
// The inverse of this used to be asserted (and implemented): a
|
||||||
|
// (mintUrl, keysetId) key let ONE keyset carry two counters. Both drove the
|
||||||
|
// same derivation path, so the lower one handed out indices the mint had
|
||||||
|
// already signed against the higher.
|
||||||
|
test('one keyset id has exactly ONE counter, whatever mint served it', () => {
|
||||||
|
Database.setCounter('k1', 'sat', 100)
|
||||||
|
Database.setCounter('k1', 'sat', 5) // same keyset seen via another url
|
||||||
|
expect(counterOf('k1')).toBe(100) // monotonic, not a second row
|
||||||
|
expect(Database.getCounters()).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('getCounter returns undefined for an unknown keyset', () => {
|
||||||
|
expect(Database.getCounter('nope')).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('seedCounters — one-time MST/MMKV copy', () => {
|
||||||
|
test('seeds every supplied counter', () => {
|
||||||
|
Database.seedCounters([
|
||||||
|
{keysetId: 'k1', unit: 'sat', counter: 100},
|
||||||
|
{keysetId: 'k2', unit: 'sat', counter: 50},
|
||||||
|
])
|
||||||
|
expect(counterOf('k1')).toBe(100)
|
||||||
|
expect(counterOf('k2')).toBe(50)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('is idempotent — re-running never lowers an advanced counter', () => {
|
||||||
|
Database.seedCounters([{keysetId: 'k1', unit: 'sat', counter: 100}])
|
||||||
|
Database.setCounter('k1', 'sat', 175) // wallet advances during normal use
|
||||||
|
Database.seedCounters([{keysetId: 'k1', unit: 'sat', counter: 100}]) // stale re-run
|
||||||
|
expect(counterOf('k1')).toBe(175)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a too-high seed is kept (conservative-safe: skips indices, never reuses)', () => {
|
||||||
|
Database.setCounter('k1', 'sat', 100)
|
||||||
|
Database.seedCounters([{keysetId: 'k1', unit: 'sat', counter: 9999}])
|
||||||
|
expect(counterOf('k1')).toBe(9999)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an empty seed is a no-op', () => {
|
||||||
|
expect(Database.seedCounters([])).toEqual({seeded: 0})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('atomic commit (counterUpdate folded into commitReservation)', () => {
|
||||||
|
const openReservation = (id: string, transactionId: number) =>
|
||||||
|
Database.openReservation(
|
||||||
|
{id, transactionId, mintId: 'mint1', mintUrl: MINT, unit: 'sat', operationType: 'send', lockedProofs: []},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
test('persists the counter in the SAME txn as the new proofs', () => {
|
||||||
|
Database.setCounter('k1', 'sat', 100)
|
||||||
|
openReservation('res-1', 1)
|
||||||
|
|
||||||
|
Database.commitReservation('res-1', {
|
||||||
|
newProofs: [
|
||||||
|
{
|
||||||
|
proofs: [{id: 'keyset1', amount: 50, secret: 'new1', C: 'C'} as any],
|
||||||
|
state: 'UNSPENT',
|
||||||
|
mintUrl: MINT,
|
||||||
|
unit: 'sat',
|
||||||
|
tId: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
counterUpdate: [{keysetId: 'k1', unit: 'sat', counter: 110}],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(proofStateOf('new1')).toBe('UNSPENT')
|
||||||
|
expect(counterOf('k1')).toBe(110)
|
||||||
|
// Committing also clears the reservation row.
|
||||||
|
expect(Database.getOpenReservations()).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('counterUpdate stays monotonic inside the commit batch', () => {
|
||||||
|
Database.setCounter('k1', 'sat', 200)
|
||||||
|
openReservation('res-2', 2)
|
||||||
|
|
||||||
|
// A commit carrying a stale (lower) counter must not regress it.
|
||||||
|
Database.commitReservation('res-2', {
|
||||||
|
newProofs: [
|
||||||
|
{
|
||||||
|
proofs: [{id: 'keyset1', amount: 10, secret: 'new2', C: 'C'} as any],
|
||||||
|
state: 'UNSPENT',
|
||||||
|
mintUrl: MINT,
|
||||||
|
unit: 'sat',
|
||||||
|
tId: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
counterUpdate: [{keysetId: 'k1', unit: 'sat', counter: 150}],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(proofStateOf('new2')).toBe('UNSPENT')
|
||||||
|
expect(counterOf('k1')).toBe(200)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a failing commit rolls back BOTH the proofs and the counter', () => {
|
||||||
|
Database.setCounter('k1', 'sat', 100)
|
||||||
|
openReservation('res-3', 3)
|
||||||
|
|
||||||
|
// A non-finite amount is rejected by connection.ts's param sanitizing, mid
|
||||||
|
// batch — after the counter upsert has already run inside the transaction.
|
||||||
|
expect(() =>
|
||||||
|
Database.commitReservation('res-3', {
|
||||||
|
newProofs: [
|
||||||
|
{
|
||||||
|
proofs: [{id: 'keyset1', amount: Number.NaN, secret: 'bad', C: 'C'} as any],
|
||||||
|
state: 'UNSPENT',
|
||||||
|
mintUrl: MINT,
|
||||||
|
unit: 'sat',
|
||||||
|
tId: 3,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
counterUpdate: [{keysetId: 'k1', unit: 'sat', counter: 110}],
|
||||||
|
}),
|
||||||
|
).toThrow()
|
||||||
|
|
||||||
|
// All-or-nothing: no proof, no counter advance, and the reservation stands.
|
||||||
|
expect(proofStateOf('bad')).toBeUndefined()
|
||||||
|
expect(counterOf('k1')).toBe(100)
|
||||||
|
expect(Database.getOpenReservations()).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+101
-163
@@ -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) =>
|
||||||
|
Database.getInstance().execute(
|
||||||
|
`INSERT OR REPLACE INTO transactions (id, type, amount, unit, data, mint, mintId, status, createdAt)
|
||||||
|
VALUES (?, 'TOPUP', 100, 'sat', '{}', ?, ?, 'PENDING', '2026-01-01')`,
|
||||||
|
[id, MINT, mintId],
|
||||||
|
)
|
||||||
|
|
||||||
function addTransaction(db: DatabaseSync, id: number, mintId: string | null) {
|
beforeEach(() => {
|
||||||
db.prepare(`INSERT OR REPLACE INTO transactions (id, mintId, mint, status) VALUES (?, ?, ?, 'PENDING')`)
|
Database.getInstance().executeBatch([['DELETE FROM inflight_requests'], ['DELETE FROM transactions']])
|
||||||
.run(id, mintId, MINT)
|
})
|
||||||
}
|
|
||||||
|
|
||||||
function addInFlightRequest(db: DatabaseSync, transactionId: number, request: object) {
|
|
||||||
db.prepare(
|
|
||||||
`INSERT OR REPLACE INTO inflight_requests (transactionId, request, createdAt)
|
|
||||||
VALUES (?, ?, ?)`,
|
|
||||||
).run(transactionId, JSON.stringify(request), NOW)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getInFlightRequest(db: DatabaseSync, transactionId: number) {
|
|
||||||
const row = db
|
|
||||||
.prepare(`SELECT transactionId, request, createdAt FROM inflight_requests WHERE transactionId = ?`)
|
|
||||||
.get(transactionId) as {transactionId: number; request: string; createdAt: string | null} | undefined
|
|
||||||
if (!row) return undefined
|
|
||||||
return {...row, request: JSON.parse(row.request)}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The mint-scoped sweep. Joins through the owning transaction: this table keeps no
|
|
||||||
* mint reference of its own, so `transactions.mintId` is the single owner of that
|
|
||||||
* fact — and being an id, it survives the mint changing url.
|
|
||||||
*/
|
|
||||||
function getInFlightRequestsByMintId(db: DatabaseSync, mintId: string) {
|
|
||||||
const rows = db
|
|
||||||
.prepare(
|
|
||||||
`SELECT r.transactionId, r.request, r.createdAt
|
|
||||||
FROM inflight_requests r
|
|
||||||
JOIN transactions t ON t.id = r.transactionId
|
|
||||||
WHERE t.mintId = ?`,
|
|
||||||
)
|
|
||||||
.all(mintId) as Array<{transactionId: number; request: string; createdAt: string | null}>
|
|
||||||
return rows.map(r => ({...r, request: JSON.parse(r.request)}))
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeInFlightRequest(db: DatabaseSync, transactionId: number) {
|
|
||||||
db.prepare(`DELETE FROM inflight_requests WHERE transactionId = ?`).run(transactionId)
|
|
||||||
}
|
|
||||||
|
|
||||||
function seedInFlightRequest(db: DatabaseSync, transactionId: number, request: object) {
|
|
||||||
db.prepare(
|
|
||||||
`INSERT INTO inflight_requests (transactionId, request, createdAt)
|
|
||||||
VALUES (?, ?, ?)
|
|
||||||
ON CONFLICT(transactionId) DO NOTHING`,
|
|
||||||
).run(transactionId, JSON.stringify(request), NOW)
|
|
||||||
}
|
|
||||||
|
|
||||||
function freshDb(): DatabaseSync {
|
|
||||||
const db = new DatabaseSync(':memory:')
|
|
||||||
db.exec(CREATE_INFLIGHT)
|
|
||||||
db.exec(CREATE_TRANSACTIONS)
|
|
||||||
return db
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Tests ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
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)
|
expect(rec.transactionId).toBe(101)
|
||||||
const rec = getInFlightRequest(db, 101)!
|
expect(rec.request).toEqual(request)
|
||||||
|
})
|
||||||
|
|
||||||
expect(rec.transactionId).toBe(101)
|
test('returns undefined when no entry exists', () => {
|
||||||
expect(rec.request).toEqual(request)
|
expect(Database.getInFlightRequest(999)).toBeUndefined()
|
||||||
db.close()
|
})
|
||||||
|
|
||||||
|
test('add OVERWRITES an existing entry (set semantics)', () => {
|
||||||
|
addTransaction(101, MINT_ID)
|
||||||
|
Database.addInFlightRequest(101, {v: 'first'})
|
||||||
|
Database.addInFlightRequest(101, {v: 'second'})
|
||||||
|
|
||||||
|
expect(Database.getInFlightRequest(101)!.request).toEqual({v: 'second'})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('remove deletes the entry', () => {
|
||||||
|
addTransaction(101, MINT_ID)
|
||||||
|
Database.addInFlightRequest(101, {v: 1})
|
||||||
|
Database.removeInFlightRequest(101)
|
||||||
|
|
||||||
|
expect(Database.getInFlightRequest(101)).toBeUndefined()
|
||||||
|
expect(Database.getInFlightRequestsByMintId(MINT_ID)).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getInFlightRequestsByMintId — the per-mint recovery sweep', () => {
|
||||||
|
test('returns all rows of a mint, and only that mint', () => {
|
||||||
|
addTransaction(101, MINT_ID)
|
||||||
|
addTransaction(102, MINT_ID)
|
||||||
|
addTransaction(103, OTHER_MINT_ID)
|
||||||
|
Database.addInFlightRequest(101, {v: 1})
|
||||||
|
Database.addInFlightRequest(102, {v: 2})
|
||||||
|
Database.addInFlightRequest(103, {v: 3})
|
||||||
|
|
||||||
|
expect(
|
||||||
|
Database.getInFlightRequestsByMintId(MINT_ID)
|
||||||
|
.map(r => r.transactionId)
|
||||||
|
.sort(),
|
||||||
|
).toEqual([101, 102])
|
||||||
|
expect(Database.getInFlightRequestsByMintId(OTHER_MINT_ID)).toHaveLength(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('returns undefined when no entry exists', () => {
|
// The point of joining on mintId rather than a url copy: a mint moving must
|
||||||
const db = freshDb()
|
// never hide its own in-flight work. transactions.mint stays frozen as history;
|
||||||
expect(getInFlightRequest(db, 999)).toBeUndefined()
|
// only the mint's live url moves, and the id is unaffected.
|
||||||
db.close()
|
test("still finds a mint's requests regardless of the url on the transaction", () => {
|
||||||
})
|
addTransaction(101, MINT_ID)
|
||||||
|
Database.addInFlightRequest(101, {v: 1})
|
||||||
|
|
||||||
test('add OVERWRITES an existing entry (set semantics)', () => {
|
Database.getInstance().execute('UPDATE transactions SET mint = ? WHERE id = ?', [
|
||||||
const db = freshDb()
|
'https://some-other.url',
|
||||||
addTransaction(db, 101, MINT_ID)
|
101,
|
||||||
addInFlightRequest(db, 101, {v: 'first'})
|
])
|
||||||
addInFlightRequest(db, 101, {v: 'second'})
|
|
||||||
|
|
||||||
expect(getInFlightRequest(db, 101)!.request).toEqual({v: 'second'})
|
expect(Database.getInFlightRequestsByMintId(MINT_ID)).toHaveLength(1)
|
||||||
db.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('getInFlightRequestsByMintId returns all rows of a mint', () => {
|
|
||||||
const db = freshDb()
|
|
||||||
addTransaction(db, 101, MINT_ID)
|
|
||||||
addTransaction(db, 102, MINT_ID)
|
|
||||||
addTransaction(db, 103, OTHER_MINT_ID)
|
|
||||||
addInFlightRequest(db, 101, {v: 1})
|
|
||||||
addInFlightRequest(db, 102, {v: 2})
|
|
||||||
addInFlightRequest(db, 103, {v: 3})
|
|
||||||
|
|
||||||
const forMint = getInFlightRequestsByMintId(db, MINT_ID)
|
|
||||||
expect(forMint.map(r => r.transactionId).sort()).toEqual([101, 102])
|
|
||||||
expect(getInFlightRequestsByMintId(db, OTHER_MINT_ID)).toHaveLength(1)
|
|
||||||
db.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
// The point of joining on mintId rather than a url copy: the mint moving must
|
|
||||||
// not hide its own in-flight work.
|
|
||||||
test('still finds a mint\'s requests after its url changes', () => {
|
|
||||||
const db = freshDb()
|
|
||||||
addTransaction(db, 101, MINT_ID)
|
|
||||||
addInFlightRequest(db, 101, {v: 1})
|
|
||||||
|
|
||||||
// transactions.mint is frozen history and never rewritten; only the mint's
|
|
||||||
// live url moves. The id is unaffected, so the join is too.
|
|
||||||
expect(getInFlightRequestsByMintId(db, MINT_ID)).toHaveLength(1)
|
|
||||||
db.close()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 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()
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('seedInFlightRequests — one-time MST/MMKV copy', () => {
|
||||||
|
test('is idempotent — does not overwrite an existing entry', () => {
|
||||||
|
addTransaction(101, MINT_ID)
|
||||||
|
Database.addInFlightRequest(101, {v: 'live'})
|
||||||
|
Database.seedInFlightRequests([{transactionId: 101, request: {v: 'snapshot'}}])
|
||||||
|
|
||||||
|
expect(Database.getInFlightRequest(101)!.request).toEqual({v: 'live'})
|
||||||
})
|
})
|
||||||
|
|
||||||
test('remove deletes the entry', () => {
|
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: 1})
|
|
||||||
removeInFlightRequest(db, 101)
|
|
||||||
expect(getInFlightRequest(db, 101)).toBeUndefined()
|
|
||||||
expect(getInFlightRequestsByMintId(db, MINT_ID)).toHaveLength(0)
|
|
||||||
db.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('seed is idempotent — does not overwrite an existing entry', () => {
|
|
||||||
const db = freshDb()
|
|
||||||
addTransaction(db, 101, MINT_ID)
|
|
||||||
addInFlightRequest(db, 101, {v: 'live'})
|
|
||||||
seedInFlightRequest(db, 101, {v: 'snapshot'})
|
|
||||||
expect(getInFlightRequest(db, 101)!.request).toEqual({v: 'live'})
|
|
||||||
db.close()
|
|
||||||
})
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+87
-127
@@ -1,148 +1,108 @@
|
|||||||
/**
|
/**
|
||||||
* Melt recovery tests (meltCounterValues → SQLite migration).
|
* Melt recovery (melt_recovery), against the REAL repo and a real database.
|
||||||
*
|
*
|
||||||
* Verifies the SQL-level semantics of meltRecoveryRepo: a per-transaction
|
* 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: [
|
||||||
{
|
{
|
||||||
blindedMessage: {amount: '2', id: keysetId, B_: 'B_' + secret},
|
blindedMessage: {amount: '2', id: keysetId, B_: 'B_' + secret},
|
||||||
blindingFactor: 'deadbeef',
|
blindingFactor: 'deadbeef',
|
||||||
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.meltPreview).toEqual(preview)
|
||||||
|
// The keyset lives INSIDE the preview — the row never duplicated it.
|
||||||
|
expect(rec.meltPreview.keysetId).toBe('k1')
|
||||||
|
})
|
||||||
|
|
||||||
expect(rec.transactionId).toBe(101)
|
test('returns undefined when no entry exists', () => {
|
||||||
expect(rec.meltPreview).toEqual(preview)
|
expect(Database.getMeltRecovery(999)).toBeUndefined()
|
||||||
// The keyset lives INSIDE the preview — the row never duplicated it.
|
})
|
||||||
expect(rec.meltPreview.keysetId).toBe('k1')
|
|
||||||
db.close()
|
test('the FIRST stored preview wins (ON CONFLICT DO NOTHING)', () => {
|
||||||
|
Database.addMeltRecovery(101, previewFor('k1', 'first'))
|
||||||
|
// A second attempt for the same tx must not overwrite: the preview describes
|
||||||
|
// outputs the mint may already have signed.
|
||||||
|
Database.addMeltRecovery(101, previewFor('k1', 'second'))
|
||||||
|
|
||||||
|
expect(Database.getMeltRecovery(101)!.meltPreview.outputData[0].secret).toBe('first')
|
||||||
|
expect(rowCount()).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('remove deletes the entry (terminal success/failure)', () => {
|
||||||
|
Database.addMeltRecovery(101, previewFor('k1'))
|
||||||
|
expect(rowCount()).toBe(1)
|
||||||
|
|
||||||
|
Database.removeMeltRecovery(101)
|
||||||
|
expect(Database.getMeltRecovery(101)).toBeUndefined()
|
||||||
|
expect(rowCount()).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('entries for different transactions are independent', () => {
|
||||||
|
Database.addMeltRecovery(101, previewFor('k1'))
|
||||||
|
Database.addMeltRecovery(102, previewFor('k2'))
|
||||||
|
|
||||||
|
expect(Database.getMeltRecovery(101)!.meltPreview.keysetId).toBe('k1')
|
||||||
|
expect(Database.getMeltRecovery(102)!.meltPreview.keysetId).toBe('k2')
|
||||||
|
|
||||||
|
Database.removeMeltRecovery(101)
|
||||||
|
expect(Database.getMeltRecovery(101)).toBeUndefined()
|
||||||
|
expect(Database.getMeltRecovery(102)!.meltPreview.keysetId).toBe('k2') // unaffected
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('seedMeltRecoveries — one-time MST/MMKV copy', () => {
|
||||||
|
test('is idempotent — does not overwrite an existing entry', () => {
|
||||||
|
Database.addMeltRecovery(101, previewFor('k1', 'live'))
|
||||||
|
Database.seedMeltRecoveries([{transactionId: 101, meltPreview: previewFor('k1', 'snapshot')}])
|
||||||
|
|
||||||
|
expect(Database.getMeltRecovery(101)!.meltPreview.outputData[0].secret).toBe('live')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('returns undefined when no entry exists', () => {
|
test('carries over an entry that does not exist yet', () => {
|
||||||
const db = freshDb()
|
Database.seedMeltRecoveries([{transactionId: 202, meltPreview: previewFor('k9', 'seeded')}])
|
||||||
expect(getMeltRecovery(db, 999)).toBeUndefined()
|
expect(Database.getMeltRecovery(202)!.meltPreview.outputData[0].secret).toBe('seeded')
|
||||||
db.close()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('the FIRST stored preview wins (ON CONFLICT DO NOTHING)', () => {
|
test('an empty seed is a no-op', () => {
|
||||||
const db = freshDb()
|
expect(Database.seedMeltRecoveries([])).toEqual({seeded: 0})
|
||||||
addMeltRecovery(db, 101, previewFor('k1', 'first'))
|
|
||||||
// A second attempt for the same tx must not overwrite.
|
|
||||||
addMeltRecovery(db, 101, previewFor('k1', 'second'))
|
|
||||||
|
|
||||||
const rec = getMeltRecovery(db, 101)!
|
|
||||||
expect(rec.meltPreview.outputData[0].secret).toBe('first')
|
|
||||||
expect(rowCount(db)).toBe(1)
|
|
||||||
db.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('remove deletes the entry (terminal success/failure)', () => {
|
|
||||||
const db = freshDb()
|
|
||||||
addMeltRecovery(db, 101, previewFor('k1'))
|
|
||||||
expect(rowCount(db)).toBe(1)
|
|
||||||
|
|
||||||
removeMeltRecovery(db, 101)
|
|
||||||
expect(getMeltRecovery(db, 101)).toBeUndefined()
|
|
||||||
expect(rowCount(db)).toBe(0)
|
|
||||||
db.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('entries for different transactions are independent', () => {
|
|
||||||
const db = freshDb()
|
|
||||||
addMeltRecovery(db, 101, previewFor('k1'))
|
|
||||||
addMeltRecovery(db, 102, previewFor('k2'))
|
|
||||||
|
|
||||||
expect(getMeltRecovery(db, 101)!.meltPreview.keysetId).toBe('k1')
|
|
||||||
expect(getMeltRecovery(db, 102)!.meltPreview.keysetId).toBe('k2')
|
|
||||||
|
|
||||||
removeMeltRecovery(db, 101)
|
|
||||||
expect(getMeltRecovery(db, 101)).toBeUndefined()
|
|
||||||
expect(getMeltRecovery(db, 102)!.meltPreview.keysetId).toBe('k2') // unaffected
|
|
||||||
db.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('seed is idempotent — does not overwrite an existing entry', () => {
|
|
||||||
const db = freshDb()
|
|
||||||
// Live entry already advanced/stored.
|
|
||||||
addMeltRecovery(db, 101, previewFor('k1', 'live'))
|
|
||||||
// Upgrade seed re-runs with the snapshot copy.
|
|
||||||
addMeltRecovery(db, 101, previewFor('k1', 'snapshot'))
|
|
||||||
|
|
||||||
expect(getMeltRecovery(db, 101)!.meltPreview.outputData[0].secret).toBe('live')
|
|
||||||
db.close()
|
|
||||||
})
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+42
-73
@@ -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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+179
-228
@@ -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,246 +11,201 @@
|
|||||||
* 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`
|
quote: string
|
||||||
|
counterIndex: number
|
||||||
|
amountRequested?: number | null
|
||||||
|
amountPaid?: number
|
||||||
|
amountIssued?: number
|
||||||
|
watchUntil?: string
|
||||||
|
mintId?: string
|
||||||
|
}) =>
|
||||||
|
Database.addOnchainMintQuote({
|
||||||
|
quote: q.quote,
|
||||||
|
mintId: q.mintId ?? MINT_ID,
|
||||||
|
mintUrl: MINT,
|
||||||
|
unit: 'sat',
|
||||||
|
address: 'bc1q' + q.quote,
|
||||||
|
counterIndex: q.counterIndex,
|
||||||
|
pubkey: '02' + 'a'.repeat(64),
|
||||||
|
amountRequested: q.amountRequested ?? null,
|
||||||
|
amountPaid: q.amountPaid,
|
||||||
|
amountIssued: q.amountIssued,
|
||||||
|
expiry: null, // the mint returns null — the address never dies
|
||||||
|
watchUntil: q.watchUntil,
|
||||||
|
})
|
||||||
|
|
||||||
/** Mint.id of the owning mint — the reference that survives a mint-url edit. */
|
const watchedIds = () =>
|
||||||
const MINT_ID = 'mint1234'
|
Database.getWatchedOnchainMintQuotes()
|
||||||
|
.map(r => r.quote)
|
||||||
|
.sort()
|
||||||
|
|
||||||
function addQuote(
|
beforeEach(() => {
|
||||||
db: DatabaseSync,
|
Database.getInstance().executeBatch([['DELETE FROM onchain_mint_quotes']])
|
||||||
q: {
|
})
|
||||||
quote: string
|
|
||||||
counterIndex: number
|
|
||||||
amountRequested?: number | null
|
|
||||||
amountPaid?: number
|
|
||||||
amountIssued?: number
|
|
||||||
watchUntil?: string
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
db.prepare(
|
|
||||||
`INSERT OR REPLACE INTO onchain_mint_quotes (${COLS})
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
).run(
|
|
||||||
q.quote,
|
|
||||||
MINT_ID,
|
|
||||||
MINT,
|
|
||||||
'sat',
|
|
||||||
`bc1q${q.quote}`,
|
|
||||||
q.counterIndex,
|
|
||||||
`02${'a'.repeat(64)}`,
|
|
||||||
q.amountRequested ?? null,
|
|
||||||
q.amountPaid ?? 0,
|
|
||||||
q.amountIssued ?? 0,
|
|
||||||
null, // expiry: the mint returns null
|
|
||||||
q.watchUntil ?? daysFromNow(7),
|
|
||||||
iso(NOW),
|
|
||||||
iso(NOW),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const getQuote = (db: DatabaseSync, quote: string): any =>
|
|
||||||
db.prepare(`SELECT ${COLS} FROM onchain_mint_quotes WHERE quote = ?`).get(quote)
|
|
||||||
|
|
||||||
function updateAmounts(db: DatabaseSync, quote: string, paid: number, issued: number) {
|
|
||||||
db.prepare(
|
|
||||||
`UPDATE onchain_mint_quotes
|
|
||||||
SET amountPaid = MAX(amountPaid, ?),
|
|
||||||
amountIssued = MAX(amountIssued, ?),
|
|
||||||
updatedAt = ?
|
|
||||||
WHERE quote = ?`,
|
|
||||||
).run(paid, issued, iso(NOW), quote)
|
|
||||||
}
|
|
||||||
|
|
||||||
const getWatched = (db: DatabaseSync, now: Date = NOW): any[] =>
|
|
||||||
db
|
|
||||||
.prepare(
|
|
||||||
`SELECT ${COLS} FROM onchain_mint_quotes
|
|
||||||
WHERE amountPaid > amountIssued
|
|
||||||
OR (amountIssued = 0 AND watchUntil > ?)
|
|
||||||
ORDER BY createdAt DESC`,
|
|
||||||
)
|
|
||||||
.all(iso(now))
|
|
||||||
|
|
||||||
function extendWatch(db: DatabaseSync, quote: string, days: number) {
|
|
||||||
db.prepare(`UPDATE onchain_mint_quotes SET watchUntil = ?, updatedAt = ? WHERE quote = ?`).run(
|
|
||||||
daysFromNow(days),
|
|
||||||
iso(NOW),
|
|
||||||
quote,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const watchedIds = (db: DatabaseSync, now?: Date) => getWatched(db, now).map(r => r.quote).sort()
|
|
||||||
|
|
||||||
describe('onchain mint quotes', () => {
|
describe('onchain mint quotes', () => {
|
||||||
let db: DatabaseSync
|
describe('the watch rule', () => {
|
||||||
|
it('watches a fresh unpaid quote (window still open)', () => {
|
||||||
beforeEach(() => {
|
addQuote({quote: 'q1', counterIndex: 0})
|
||||||
db = new DatabaseSync(':memory:')
|
expect(watchedIds()).toEqual(['q1'])
|
||||||
db.exec(CREATE_ONCHAIN_MINT_QUOTES)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('the watch rule', () => {
|
it('stops watching an unpaid quote once the window closes', () => {
|
||||||
it('watches a fresh unpaid quote (window still open)', () => {
|
// nothing ever arrived, and the mint will never expire the address for us —
|
||||||
addQuote(db, {quote: 'q1', counterIndex: 0})
|
// so the wallet has to draw the line itself
|
||||||
|
addQuote({quote: 'q1', counterIndex: 0, watchUntil: daysFromNow(-1)})
|
||||||
expect(watchedIds(db)).toEqual(['q1'])
|
expect(watchedIds()).toEqual([])
|
||||||
})
|
|
||||||
|
|
||||||
it('stops watching an unpaid quote once the window closes', () => {
|
|
||||||
// nothing ever arrived, and the mint will never expire the address for
|
|
||||||
// us — so the wallet has to draw the line itself
|
|
||||||
addQuote(db, {quote: 'q1', counterIndex: 0, watchUntil: daysFromNow(-1)})
|
|
||||||
|
|
||||||
expect(watchedIds(db)).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('watches a quote with credited-but-unminted funds', () => {
|
|
||||||
addQuote(db, {quote: 'q1', counterIndex: 0, amountPaid: 50000, amountIssued: 0})
|
|
||||||
|
|
||||||
expect(watchedIds(db)).toEqual(['q1'])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('stops watching once the quote is fully drained (the "stop after first mint" case)', () => {
|
|
||||||
addQuote(db, {quote: 'q1', counterIndex: 0, amountPaid: 50000, amountIssued: 50000})
|
|
||||||
|
|
||||||
expect(watchedIds(db)).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('KEEPS watching a PARTIALLY drained quote — money is still credited', () => {
|
|
||||||
// The reason the rule keys on the unminted BALANCE and not on "has ever
|
|
||||||
// minted". Keying on amountIssued > 0 would archive this and abandon
|
|
||||||
// 20k sat the mint is holding for us.
|
|
||||||
addQuote(db, {quote: 'q1', counterIndex: 0, amountPaid: 70000, amountIssued: 50000})
|
|
||||||
|
|
||||||
expect(watchedIds(db)).toEqual(['q1'])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('watches credited funds even after the window has closed', () => {
|
|
||||||
// the deadline bounds quotes nobody paid; it must never override money
|
|
||||||
// that is actually sitting at the mint
|
|
||||||
addQuote(db, {
|
|
||||||
quote: 'q1',
|
|
||||||
counterIndex: 0,
|
|
||||||
amountPaid: 50000,
|
|
||||||
amountIssued: 0,
|
|
||||||
watchUntil: daysFromNow(-30),
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(watchedIds(db)).toEqual(['q1'])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('separates watched from archived across a realistic mix', () => {
|
|
||||||
addQuote(db, {quote: 'fresh', counterIndex: 0})
|
|
||||||
addQuote(db, {quote: 'drained', counterIndex: 1, amountPaid: 10000, amountIssued: 10000})
|
|
||||||
addQuote(db, {quote: 'unpaid-expired', counterIndex: 2, watchUntil: daysFromNow(-1)})
|
|
||||||
addQuote(db, {quote: 'has-funds', counterIndex: 3, amountPaid: 30000, amountIssued: 0})
|
|
||||||
addQuote(db, {
|
|
||||||
quote: 'partly-drained',
|
|
||||||
counterIndex: 4,
|
|
||||||
amountPaid: 30000,
|
|
||||||
amountIssued: 10000,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(watchedIds(db)).toEqual(['fresh', 'has-funds', 'partly-drained'])
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('amount updates are monotonic', () => {
|
it('watches a quote with credited-but-unminted funds', () => {
|
||||||
it('applies a normal forward update', () => {
|
addQuote({quote: 'q1', counterIndex: 0, amountPaid: 50000, amountIssued: 0})
|
||||||
addQuote(db, {quote: 'q1', counterIndex: 0})
|
expect(watchedIds()).toEqual(['q1'])
|
||||||
|
|
||||||
updateAmounts(db, 'q1', 50000, 0)
|
|
||||||
|
|
||||||
expect(getQuote(db, 'q1')).toMatchObject({amountPaid: 50000, amountIssued: 0})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('ignores a stale response that would walk amounts BACKWARDS', () => {
|
|
||||||
// A slow reply landing after a fresh one must not resurrect an old view.
|
|
||||||
// If amountIssued regressed, the wallet would see unminted money that is
|
|
||||||
// not there — and mint it a second time.
|
|
||||||
addQuote(db, {quote: 'q1', counterIndex: 0, amountPaid: 50000, amountIssued: 50000})
|
|
||||||
|
|
||||||
updateAmounts(db, 'q1', 50000, 0) // stale: "nothing issued yet"
|
|
||||||
|
|
||||||
expect(getQuote(db, 'q1')).toMatchObject({amountPaid: 50000, amountIssued: 50000})
|
|
||||||
expect(watchedIds(db)).toEqual([]) // still archived, not resurrected
|
|
||||||
})
|
|
||||||
|
|
||||||
it('records a second deposit arriving on the same address', () => {
|
|
||||||
addQuote(db, {quote: 'q1', counterIndex: 0, amountPaid: 50000, amountIssued: 50000})
|
|
||||||
expect(watchedIds(db)).toEqual([])
|
|
||||||
|
|
||||||
updateAmounts(db, 'q1', 70000, 50000) // someone paid the address again
|
|
||||||
|
|
||||||
expect(watchedIds(db)).toEqual(['q1']) // unminted balance -> back in the watch set
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('archived quotes stay recoverable', () => {
|
it('stops watching once the quote is fully drained (the "stop after first mint" case)', () => {
|
||||||
it('keeps the row, and with it the NUT-20 counterIndex', () => {
|
addQuote({quote: 'q1', counterIndex: 0, amountPaid: 50000, amountIssued: 50000})
|
||||||
// counterIndex is the only way to re-derive the key that signs a mint
|
expect(watchedIds()).toEqual([])
|
||||||
// request for this quote. Delete the row and a late deposit becomes
|
|
||||||
// permanently unspendable.
|
|
||||||
addQuote(db, {quote: 'q1', counterIndex: 42, amountPaid: 10000, amountIssued: 10000})
|
|
||||||
|
|
||||||
expect(watchedIds(db)).toEqual([]) // archived
|
|
||||||
expect(getQuote(db, 'q1')).toMatchObject({counterIndex: 42, address: 'bc1qq1'})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('re-opens the watch window on user request', () => {
|
|
||||||
addQuote(db, {quote: 'q1', counterIndex: 0, watchUntil: daysFromNow(-1)})
|
|
||||||
expect(watchedIds(db)).toEqual([])
|
|
||||||
|
|
||||||
extendWatch(db, 'q1', 7) // "check for deposits" from the transaction detail
|
|
||||||
|
|
||||||
expect(watchedIds(db)).toEqual(['q1'])
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('the requested amount is only a hint', () => {
|
it('KEEPS watching a PARTIALLY drained quote — money is still credited', () => {
|
||||||
it('keeps amountRequested separate from what was actually paid', () => {
|
// The reason the rule keys on the unminted BALANCE and not on "has ever
|
||||||
// The BIP21 amount is a suggestion the sender can ignore. Under- and
|
// minted". Keying on amountIssued > 0 would archive this and abandon 20k sat
|
||||||
// overpayment are normal, so the two must never be conflated.
|
// the mint is holding for us.
|
||||||
addQuote(db, {quote: 'q1', counterIndex: 0, amountRequested: 50000})
|
addQuote({quote: 'q1', counterIndex: 0, amountPaid: 70000, amountIssued: 50000})
|
||||||
|
expect(watchedIds()).toEqual(['q1'])
|
||||||
updateAmounts(db, 'q1', 42000, 0) // sender underpaid
|
|
||||||
|
|
||||||
expect(getQuote(db, 'q1')).toMatchObject({amountRequested: 50000, amountPaid: 42000})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('allows a null requested amount', () => {
|
|
||||||
addQuote(db, {quote: 'q1', counterIndex: 0, amountRequested: null})
|
|
||||||
|
|
||||||
expect(getQuote(db, 'q1').amountRequested).toBeNull()
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('watches credited funds even after the window has closed', () => {
|
||||||
|
// the deadline bounds quotes nobody paid; it must never override money that
|
||||||
|
// is actually sitting at the mint
|
||||||
|
addQuote({
|
||||||
|
quote: 'q1',
|
||||||
|
counterIndex: 0,
|
||||||
|
amountPaid: 50000,
|
||||||
|
amountIssued: 0,
|
||||||
|
watchUntil: daysFromNow(-30),
|
||||||
|
})
|
||||||
|
expect(watchedIds()).toEqual(['q1'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('separates watched from archived across a realistic mix', () => {
|
||||||
|
addQuote({quote: 'fresh', counterIndex: 0})
|
||||||
|
addQuote({quote: 'drained', counterIndex: 1, amountPaid: 10000, amountIssued: 10000})
|
||||||
|
addQuote({quote: 'unpaid-expired', counterIndex: 2, watchUntil: daysFromNow(-1)})
|
||||||
|
addQuote({quote: 'has-funds', counterIndex: 3, amountPaid: 30000, amountIssued: 0})
|
||||||
|
addQuote({quote: 'partly-drained', counterIndex: 4, amountPaid: 30000, amountIssued: 10000})
|
||||||
|
|
||||||
|
expect(watchedIds()).toEqual(['fresh', 'has-funds', 'partly-drained'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('amount updates are monotonic', () => {
|
||||||
|
it('applies a normal forward update', () => {
|
||||||
|
addQuote({quote: 'q1', counterIndex: 0})
|
||||||
|
Database.updateOnchainMintQuoteAmounts('q1', 50000, 0)
|
||||||
|
|
||||||
|
expect(Database.getOnchainMintQuote('q1')).toMatchObject({amountPaid: 50000, amountIssued: 0})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores a stale response that would walk amounts BACKWARDS', () => {
|
||||||
|
// A slow reply landing after a fresh one must not resurrect an old view. If
|
||||||
|
// amountIssued regressed, the wallet would see unminted money that is not
|
||||||
|
// there — and mint it a second time.
|
||||||
|
addQuote({quote: 'q1', counterIndex: 0, amountPaid: 50000, amountIssued: 50000})
|
||||||
|
|
||||||
|
Database.updateOnchainMintQuoteAmounts('q1', 50000, 0) // stale: "nothing issued yet"
|
||||||
|
|
||||||
|
expect(Database.getOnchainMintQuote('q1')).toMatchObject({
|
||||||
|
amountPaid: 50000,
|
||||||
|
amountIssued: 50000,
|
||||||
|
})
|
||||||
|
expect(watchedIds()).toEqual([]) // still archived, not resurrected
|
||||||
|
})
|
||||||
|
|
||||||
|
it('records a second deposit arriving on the same address', () => {
|
||||||
|
addQuote({quote: 'q1', counterIndex: 0, amountPaid: 50000, amountIssued: 50000})
|
||||||
|
expect(watchedIds()).toEqual([])
|
||||||
|
|
||||||
|
Database.updateOnchainMintQuoteAmounts('q1', 70000, 50000) // paid again
|
||||||
|
|
||||||
|
expect(watchedIds()).toEqual(['q1']) // unminted balance -> back in the watch set
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('archived quotes stay recoverable', () => {
|
||||||
|
it('keeps the row, and with it the NUT-20 counterIndex', () => {
|
||||||
|
// counterIndex is the only way to re-derive the key that signs a mint request
|
||||||
|
// for this quote. Delete the row and a late deposit becomes permanently
|
||||||
|
// unspendable.
|
||||||
|
addQuote({quote: 'q1', counterIndex: 42, amountPaid: 10000, amountIssued: 10000})
|
||||||
|
|
||||||
|
expect(watchedIds()).toEqual([]) // archived
|
||||||
|
expect(Database.getOnchainMintQuote('q1')).toMatchObject({
|
||||||
|
counterIndex: 42,
|
||||||
|
address: 'bc1qq1',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('re-opens the watch window on user request', () => {
|
||||||
|
addQuote({quote: 'q1', counterIndex: 0, watchUntil: daysFromNow(-1)})
|
||||||
|
expect(watchedIds()).toEqual([])
|
||||||
|
|
||||||
|
Database.extendOnchainMintQuoteWatch('q1', 7) // "check for deposits"
|
||||||
|
|
||||||
|
expect(watchedIds()).toEqual(['q1'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('the requested amount is only a hint', () => {
|
||||||
|
it('keeps amountRequested separate from what was actually paid', () => {
|
||||||
|
// The BIP21 amount is a suggestion the sender can ignore. Under- and
|
||||||
|
// overpayment are normal, so the two must never be conflated.
|
||||||
|
addQuote({quote: 'q1', counterIndex: 0, amountRequested: 50000})
|
||||||
|
|
||||||
|
Database.updateOnchainMintQuoteAmounts('q1', 42000, 0) // sender underpaid
|
||||||
|
|
||||||
|
expect(Database.getOnchainMintQuote('q1')).toMatchObject({
|
||||||
|
amountRequested: 50000,
|
||||||
|
amountPaid: 42000,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows a null requested amount', () => {
|
||||||
|
addQuote({quote: 'q1', counterIndex: 0, amountRequested: null})
|
||||||
|
expect(Database.getOnchainMintQuote('q1')!.amountRequested).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('the mint reference', () => {
|
||||||
|
it('finds a mint\'s quotes by id, not by the url they were created at', () => {
|
||||||
|
addQuote({quote: 'q1', counterIndex: 0})
|
||||||
|
addQuote({quote: 'q2', counterIndex: 1, mintId: 'other-mint'})
|
||||||
|
|
||||||
|
expect(Database.getOnchainMintQuotesByMintId(MINT_ID).map(r => r.quote)).toEqual(['q1'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps mintUrl as the record of where the quote was created', () => {
|
||||||
|
addQuote({quote: 'q1', counterIndex: 0})
|
||||||
|
const row = Database.getOnchainMintQuote('q1')!
|
||||||
|
|
||||||
|
expect(row.mintId).toBe(MINT_ID)
|
||||||
|
expect(row.mintUrl).toBe(MINT)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+181
-336
@@ -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
|
|
||||||
// the supplied fields.
|
|
||||||
if (changes.transactionUpdate) {
|
|
||||||
const tu = changes.transactionUpdate
|
|
||||||
const setClauses: string[] = []
|
|
||||||
const params: (string | number | null)[] = []
|
|
||||||
const setIfDefined = (col: string, value: string | number | undefined) => {
|
|
||||||
if (value !== undefined) {
|
|
||||||
setClauses.push(`${col} = ?`)
|
|
||||||
params.push(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setIfDefined('status', tu.status)
|
|
||||||
setIfDefined('data', tu.data)
|
|
||||||
setIfDefined('amount', tu.amount)
|
|
||||||
setIfDefined('fee', tu.fee)
|
|
||||||
setIfDefined('balanceAfter', tu.balanceAfter)
|
|
||||||
setIfDefined('outputToken', tu.outputToken)
|
|
||||||
setIfDefined('keysetId', tu.keysetId)
|
|
||||||
setIfDefined('proof', tu.proof)
|
|
||||||
if (setClauses.length > 0) {
|
|
||||||
params.push(tu.id)
|
|
||||||
db.prepare(
|
|
||||||
`UPDATE transactions SET ${setClauses.join(', ')} WHERE id = ?`,
|
|
||||||
).run(...params)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
db.prepare('DELETE FROM reservations WHERE id = ?').run(reservationId)
|
|
||||||
db.exec('COMMIT')
|
|
||||||
} catch (e) {
|
|
||||||
db.exec('ROLLBACK')
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function rollbackReservation(
|
function rollbackReservation(_db: Db, reservationId: string, lockedProofs: LockedProofSnapshot[]) {
|
||||||
db: DatabaseSync,
|
Database.rollbackReservation(reservationId, lockedProofs as any)
|
||||||
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 {
|
function getOpenReservations(_db: Db): Array<{id: string; lockedProofs: LockedProofSnapshot[]}> {
|
||||||
const row = db.prepare('SELECT tId FROM proofs WHERE secret = ?').get(secret) as
|
return Database.getOpenReservations().map(r => ({
|
||||||
| {tId: number | null}
|
id: r.id,
|
||||||
| undefined
|
lockedProofs: r.lockedProofs as LockedProofSnapshot[],
|
||||||
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()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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 ─────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user