Files
minibits_wallet/__tests__/counters.test.ts
T
minibits-cashandClaude Opus 4.8 8ffce9e46a test(counters): cover mint_counters invariants
Add unit coverage for the derivation-counter migration (mirrors the
production SQL against node:sqlite, like proofReservation.test.ts):

- setCounter is monotonic — raises, ignores a lower value, no-ops on equal
- bumpCounter advances relatively; non-positive delta is a no-op
- (mintUrl, keysetId) primary key isolates keysets and mints
- seedCounters is idempotent and never regresses an advanced counter;
  a too-high seed is kept (conservative-safe)
- the folded counterUpdate persists the counter atomically with new proofs,
  rolls back with them on a failed batch, and stays monotonic in-batch

Locks in the no-secret-reuse safety property of the migration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 15:23:23 +02:00

341 lines
13 KiB
TypeScript

/**
* Derivation-counter tests (mint_counters migration).
*
* Verifies the SQL-level semantics that `Database.setCounter`, `bumpCounter`,
* `seedCounters`, and the `counterUpdate` folded into `commitReservation`
* implement on top of `executeBatch`. The counter is the BIP32 derivation
* high-water mark; the single most important invariant is that it is MONOTONIC
* — a stored counter can never move backward — because a regression would let
* the next derivation reuse a blinded secret.
*
* As with proofReservation.test.ts we mirror the exact production SQL using
* node:sqlite + explicit BEGIN/COMMIT, since the native driver needs a device.
*
* @jest-environment node
*/
import {DatabaseSync} from 'node:sqlite'
const NOW = '2026-06-04T00:00:00.000Z'
// ── Schema (mirrors schema.ts) ──────────────────────────────────────────────
const CREATE_MINT_COUNTERS = `CREATE TABLE mint_counters (
mintUrl TEXT NOT NULL,
keysetId TEXT NOT NULL,
unit TEXT,
counter INTEGER NOT NULL DEFAULT 0,
updatedAt TEXT,
PRIMARY KEY (mintUrl, keysetId)
)`
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'
// ── Mirrored Database primitives (exact production SQL) ─────────────────────
/** countersRepo.buildCounterUpsert / setCounter — monotonic absolute write. */
function setCounter(db: DatabaseSync, mintUrl: string, keysetId: string, unit: string | null, value: number) {
db.prepare(
`INSERT INTO mint_counters (mintUrl, keysetId, unit, counter, updatedAt)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(mintUrl, keysetId) DO UPDATE SET
counter = MAX(counter, excluded.counter),
unit = excluded.unit,
updatedAt = excluded.updatedAt`,
).run(mintUrl, keysetId, unit, value, NOW)
}
/** countersRepo.bumpCounter — relative advance (no-op for delta <= 0). */
function bumpCounter(db: DatabaseSync, mintUrl: string, keysetId: string, unit: string | null, delta: number) {
if (delta <= 0) return
db.prepare(
`INSERT INTO mint_counters (mintUrl, keysetId, unit, counter, updatedAt)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(mintUrl, keysetId) DO UPDATE SET
counter = counter + ?,
updatedAt = excluded.updatedAt`,
).run(mintUrl, keysetId, unit, delta, NOW, delta)
}
function getCounter(db: DatabaseSync, mintUrl: string, keysetId: string): number | undefined {
const row = db
.prepare('SELECT counter FROM mint_counters WHERE mintUrl = ? AND keysetId = ?')
.get(mintUrl, 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<{mintUrl: string; keysetId: string; unit?: string; counter: number}>,
) {
db.exec('BEGIN')
try {
for (const s of seeds) setCounter(db, s.mintUrl, s.keysetId, s.unit ?? null, s.counter)
db.exec('COMMIT')
} catch (e) {
db.exec('ROLLBACK')
throw e
}
}
function insertProof(db: DatabaseSync, secret: string, amount: number, state = 'UNSPENT') {
db.prepare(
`INSERT INTO proofs (id, amount, secret, C, mintUrl, unit, tId, state, updatedAt)
VALUES ('keyset1', ?, ?, 'C', '${MINT}', 'sat', 1, ?, '2026-01-01')`,
).run(amount, secret, state)
}
function getProofState(db: DatabaseSync, secret: string): string {
const row = db.prepare('SELECT state FROM proofs WHERE secret = ?').get(secret) as
| {state: string}
| undefined
return row?.state ?? ''
}
/**
* commitReservation with a folded counterUpdate (the step-4 atomic commit):
* new proofs + a monotonic counter upsert + reservation delete, all in one txn.
*/
function commitWithCounter(
db: DatabaseSync,
reservationId: string,
changes: {
newProofs?: Array<{secret: string; amount: number; state: string}>
counterUpdate?: Array<{mintUrl: string; keysetId: string; unit?: string; counter: number}>
},
) {
db.exec('BEGIN')
try {
const insertNew = db.prepare(
`INSERT OR REPLACE INTO proofs (id, amount, secret, C, mintUrl, unit, tId, state, updatedAt)
VALUES ('keyset1', ?, ?, 'C', '${MINT}', 'sat', 1, ?, ?)`,
)
for (const p of changes.newProofs ?? []) insertNew.run(p.amount, p.secret, p.state, NOW)
for (const cu of changes.counterUpdate ?? []) {
setCounter(db, cu.mintUrl, cu.keysetId, cu.unit ?? null, cu.counter)
}
db.prepare('DELETE FROM reservations WHERE id = ?').run(reservationId)
db.exec('COMMIT')
} catch (e) {
db.exec('ROLLBACK')
throw e
}
}
function freshDb(): DatabaseSync {
const db = new DatabaseSync(':memory:')
db.exec(CREATE_MINT_COUNTERS)
db.exec(CREATE_PROOFS)
db.exec(CREATE_RESERVATIONS)
return db
}
// ── Tests ───────────────────────────────────────────────────────────────────
describe('Derivation counters (mint_counters)', () => {
describe('setCounter — monotonic', () => {
test('inserts a new row when none exists', () => {
const db = freshDb()
setCounter(db, MINT, 'k1', 'sat', 42)
expect(getCounter(db, MINT, 'k1')).toBe(42)
db.close()
})
test('raises to a higher value', () => {
const db = freshDb()
setCounter(db, MINT, 'k1', 'sat', 100)
setCounter(db, MINT, 'k1', 'sat', 150)
expect(getCounter(db, MINT, 'k1')).toBe(150)
db.close()
})
test('NEVER lowers — a smaller value is ignored (the core safety invariant)', () => {
const db = freshDb()
setCounter(db, MINT, 'k1', 'sat', 100)
setCounter(db, MINT, 'k1', 'sat', 50) // stale / replayed writer
expect(getCounter(db, MINT, 'k1')).toBe(100)
db.close()
})
test('an equal value is a no-op', () => {
const db = freshDb()
setCounter(db, MINT, 'k1', 'sat', 100)
setCounter(db, MINT, 'k1', 'sat', 100)
expect(getCounter(db, MINT, 'k1')).toBe(100)
db.close()
})
})
describe('bumpCounter — relative advance', () => {
test('inserts from 0 when no row exists', () => {
const db = freshDb()
bumpCounter(db, MINT, 'k1', 'sat', 10)
expect(getCounter(db, MINT, 'k1')).toBe(10)
db.close()
})
test('adds to the existing value', () => {
const db = freshDb()
setCounter(db, MINT, 'k1', 'sat', 100)
bumpCounter(db, MINT, 'k1', 'sat', 10)
expect(getCounter(db, MINT, 'k1')).toBe(110)
db.close()
})
test('a non-positive delta is a no-op', () => {
const db = freshDb()
setCounter(db, MINT, 'k1', 'sat', 100)
bumpCounter(db, MINT, 'k1', 'sat', 0)
bumpCounter(db, MINT, 'k1', 'sat', -5)
expect(getCounter(db, MINT, 'k1')).toBe(100)
db.close()
})
})
describe('primary key isolation', () => {
test('different keysets on the same mint are independent', () => {
const db = freshDb()
setCounter(db, MINT, 'k1', 'sat', 100)
setCounter(db, MINT, 'k2', 'sat', 7)
expect(getCounter(db, MINT, 'k1')).toBe(100)
expect(getCounter(db, MINT, 'k2')).toBe(7)
expect(counterRowCount(db)).toBe(2)
db.close()
})
test('the same keyset id on different mints is independent', () => {
const db = freshDb()
setCounter(db, MINT, 'k1', 'sat', 100)
setCounter(db, 'https://other.test', 'k1', 'sat', 5)
expect(getCounter(db, MINT, 'k1')).toBe(100)
expect(getCounter(db, 'https://other.test', 'k1')).toBe(5)
db.close()
})
})
describe('seedCounters — one-time MMKV→SQLite copy', () => {
test('seeds every supplied counter', () => {
const db = freshDb()
seedCounters(db, [
{mintUrl: MINT, keysetId: 'k1', unit: 'sat', counter: 100},
{mintUrl: MINT, keysetId: 'k2', unit: 'sat', counter: 50},
])
expect(getCounter(db, MINT, 'k1')).toBe(100)
expect(getCounter(db, MINT, 'k2')).toBe(50)
db.close()
})
test('is idempotent — re-running never lowers an advanced counter', () => {
const db = freshDb()
// First upgrade seed copies the (then current) MMKV values.
seedCounters(db, [{mintUrl: MINT, keysetId: 'k1', unit: 'sat', counter: 100}])
// Wallet advances past it during normal use.
setCounter(db, MINT, 'k1', 'sat', 175)
// A later launch re-runs the seed with the now-stale snapshot value.
seedCounters(db, [{mintUrl: MINT, keysetId: 'k1', unit: 'sat', counter: 100}])
// The advanced SQLite value wins — the seed cannot regress it.
expect(getCounter(db, MINT, 'k1')).toBe(175)
db.close()
})
test('a too-high seed is kept (conservative-safe: skips indices, never reuses)', () => {
const db = freshDb()
setCounter(db, MINT, 'k1', 'sat', 100)
seedCounters(db, [{mintUrl: MINT, keysetId: 'k1', unit: 'sat', counter: 9999}])
expect(getCounter(db, MINT, 'k1')).toBe(9999)
db.close()
})
})
describe('atomic commit (counterUpdate folded into commitReservation)', () => {
test('persists the counter in the SAME txn as the new proofs', () => {
const db = freshDb()
setCounter(db, MINT, 'k1', 'sat', 100)
commitWithCounter(db, 'res-1', {
newProofs: [{secret: 'new1', amount: 50, state: 'UNSPENT'}],
counterUpdate: [{mintUrl: MINT, keysetId: 'k1', unit: 'sat', counter: 110}],
})
expect(getProofState(db, 'new1')).toBe('UNSPENT')
expect(getCounter(db, MINT, 'k1')).toBe(110)
db.close()
})
test('a failed commit batch rolls back BOTH the proofs and the counter', () => {
const db = freshDb()
setCounter(db, MINT, '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, MINT, '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, MINT, 'k1')).toBe(100)
db.close()
})
test('counterUpdate stays monotonic inside the commit batch', () => {
const db = freshDb()
setCounter(db, MINT, '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: [{mintUrl: MINT, keysetId: 'k1', unit: 'sat', counter: 150}],
})
expect(getProofState(db, 'new2')).toBe('UNSPENT')
expect(getCounter(db, MINT, 'k1')).toBe(200)
db.close()
})
})
})