Files
minibits_wallet/__tests__/sqliteMigration32.test.ts
T
minibits-cashandClaude Opus 4.8 9d759f83ab Key derivation counters by keysetId; make mint URL change safe
A mint URL is a network locator, not an identity, but it had become the de
facto foreign key for most persisted state. Changing a mint's URL therefore
had to rewrite every one of those references, and only proofs were ever
rewritten. This removes the URL from the key space where it never belonged,
and repairs the rename for what remains.

Counters (the fund-losing one)

mint_counters was PRIMARY KEY (mintUrl, keysetId), which asserts a key space
that does not exist: NUT-13 derives from (seed, keysetId, counter), with no
mint component in either path (`m/129372'/0'/{keysetIdInt}'/{counter}'` for
`00` ids, HMAC-SHA256 over the id for v2 `01`). Two rows could track ONE
derivation path independently, and a URL edit left the row unaddressable —
hydration matched on URL, found nothing, and silently restarted the counter
at 0, reusing blinded secrets the mint had already signed.

Re-keyed on keysetId alone, which is sound because keyset ids are already
globally unique wallet-wide, enforced at the door by isCollidingKeysetId per
NUT-02. Migration 32 collapses duplicates to MAX(counter), so it HEALS
wallets already split by this rather than only preventing new splits — a
too-high counter skips indices, a too-low one reuses them.

This also deletes code: persistCounter no longer walks getParent() for a URL,
so a counter detached from its Mint now persists instead of dropping its
write.

Keyset collisions and NUT-02 v2

isCollidingKeysetId applied the mod-2^31-1 keysetIdInt check to every id. That
integer only exists on the deprecated BIP-32 path; v2 ids derive by HMAC over
the full 32 bytes and never compute it. Checking it there would reject a
legitimate mint over a number nothing consumes, and re-impose v1's ~2^31
birthday bound on ids whose whole point is full-width SHA-256 resistance. The
check is now gated on derivation kind; exact-id equality still always applies.

Mint URL change

- Validation is shared with addMint via a new normalizeMintUrl, so adding and
  renaming can no longer disagree. The rename previously did neither the
  trailing-slash strip (NUT-00 MUST) nor the https check.
- Canonical form matches cashu-ts normalizeUrl (`href` then strip trailing
  slashes). WalletStore compares our stored string to CashuMint.mintUrl to
  find cached instances, so normalizing the raw input would let
  `https://Mint.Example` be stored while cashu-ts held `https://mint.example`:
  every cache lookup missing, two spellings looking like two mints. Pinned by
  tests asserting agreement with CashuMint.mintUrl.
- The onion exemption tested `includes('.onion')`, so `http://evil.example/.onion`
  bought a plain-http exemption for an ordinary host. It now tests the parsed
  hostname. `startsWith('https')` also passed `https-evil://host`; now protocol
  equality.
- Duplicate detection uses mintExists (normalized), not alreadyExists
  (literal), which missed a trailing-slash twin and let one real mint become
  two Mint nodes. Renaming to the URL already held is now a no-op, not an error.
- hostname is recomputed; it used to keep the old mint's host forever.
- transactions.mint is repointed for IN-FLIGHT rows only. That column means two
  things by status: for a terminal row it is a historical record of where the
  payment happened, but for an open one it is a live pointer the wallet still
  calls (checkLightningMintQuote, checkLightningMeltQuote/checkOnchainMeltQuote,
  findByUrl on revert/receive). Stale, it strands a paid topup at a dead URL
  forever. One UPDATE, so the status test cannot straddle a transition.
- ProofsStore.updateMintUrl now writes SQLite before memory; the reverse left
  the UI showing a balance the database never received.

Still URL-keyed, and documented on setMintUrl: onchain mint quotes, in-flight
requests, melt recovery and open reservations. Renaming a mint with any of
those outstanding still strands them. They need a stable mint id, which is the
next step.

Tests: 413 pass. The two new v2 collision tests fail against the previous
code and pass here, while the v1 cases pass in both. counters.test.ts mirrored
the production SQL by hand and so had asserted the old key — including a test
that two mints sharing a keyset id keep independent counters, exactly the
unsound behaviour removed here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 17:41:02 +02:00

258 lines
8.1 KiB
TypeScript

/**
* Repeatable migration tests for SQLite migration 32.
*
* Migration 32 re-keys `mint_counters` from PRIMARY KEY (mintUrl, keysetId) to
* PRIMARY KEY (keysetId).
*
* WHY: NUT-13 derives from (seed, keysetId, counter) — the mint url is not an
* input to any derivation path. `00` ids derive at
* `m/129372'/0'/{keysetIdInt}'/{counter}'`, v2 `01` ids by HMAC-SHA256 over the
* id. So (mintUrl, keysetId) asserted a key space that does not exist, with two
* consequences this migration closes:
*
* 1. Two rows could track ONE derivation path independently — the lower row
* would hand out indices the mint had already signed against the higher one.
* 2. A mint-url edit orphaned the row: hydration matched on url, found nothing,
* and silently restarted the counter at 0 — reusing blinded secrets from the
* very beginning of the keyset.
*
* The collapse to MAX(counter) HEALS a wallet already split by (2) rather than
* merely preventing new splits: a too-high counter skips indices (harmless), a
* too-low one reuses them (fund loss).
*
* Uses Node.js built-in node:sqlite (requires Node 22.5+).
* @jest-environment node
*/
import {DatabaseSync} from 'node:sqlite'
// ── SQL copied verbatim from src/services/db/migrations.ts migration 32 ───────
const CREATE_V32 = `CREATE TABLE mint_counters_v32 (
keysetId TEXT PRIMARY KEY NOT NULL,
unit TEXT,
counter INTEGER NOT NULL DEFAULT 0,
updatedAt TEXT
)`
const INSERT_V32 = `INSERT INTO mint_counters_v32 (keysetId, unit, counter, updatedAt)
SELECT keysetId, unit, MAX(counter), updatedAt
FROM mint_counters
GROUP BY keysetId`
const DROP_OLD = `DROP TABLE mint_counters`
const RENAME = `ALTER TABLE mint_counters_v32 RENAME TO mint_counters`
// ── Helpers ──────────────────────────────────────────────────────────────────
const MINT_A = 'https://mint-a.test'
const MINT_B = 'https://mint-b.test'
/** The pre-32 schema: keyed by (mintUrl, keysetId). */
function createOldSchema(db: DatabaseSync) {
db.exec(`
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)
)
`)
}
function insertOld(
db: DatabaseSync,
mintUrl: string,
keysetId: string,
unit: string | null,
counter: number,
updatedAt: string,
) {
db.prepare(
`INSERT INTO mint_counters (mintUrl, keysetId, unit, counter, updatedAt)
VALUES (?, ?, ?, ?, ?)`,
).run(mintUrl, keysetId, unit, counter, updatedAt)
}
function runMigration32(db: DatabaseSync) {
db.exec('BEGIN')
try {
db.exec(CREATE_V32)
db.exec(INSERT_V32)
db.exec(DROP_OLD)
db.exec(RENAME)
db.exec('COMMIT')
} catch (e) {
db.exec('ROLLBACK')
throw e
}
}
type CounterRow = {keysetId: string; unit: string | null; counter: number; updatedAt: string | null}
function allRows(db: DatabaseSync): CounterRow[] {
return db
.prepare('SELECT keysetId, unit, counter, updatedAt FROM mint_counters ORDER BY keysetId')
.all() as unknown as CounterRow[]
}
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 freshDb(): DatabaseSync {
const db = new DatabaseSync(':memory:')
createOldSchema(db)
return db
}
// ── Tests ────────────────────────────────────────────────────────────────────
describe('SQLite migration 32 — re-key mint_counters on keysetId', () => {
test('carries a single-mint wallet across unchanged', () => {
const db = freshDb()
insertOld(db, MINT_A, 'k1', 'sat', 342, '2026-01-01')
insertOld(db, MINT_A, 'k2', 'sat', 7, '2026-01-02')
runMigration32(db)
expect(allRows(db)).toEqual([
{keysetId: 'k1', unit: 'sat', counter: 342, updatedAt: '2026-01-01'},
{keysetId: 'k2', unit: 'sat', counter: 7, updatedAt: '2026-01-02'},
])
db.close()
})
test('HEALS a wallet split by a mint-url edit: collapses to MAX(counter)', () => {
const db = freshDb()
// The exact damage the old key allowed: mint renamed A -> B, wallet kept
// transacting under B while A's row stayed frozen at the pre-rename value.
insertOld(db, MINT_A, 'k1', 'sat', 342, '2026-01-01')
insertOld(db, MINT_B, 'k1', 'sat', 400, '2026-02-01')
runMigration32(db)
const rows = allRows(db)
expect(rows).toHaveLength(1)
// 400, never 342: skipping indices is safe, reusing them is fund loss.
expect(rows[0].counter).toBe(400)
db.close()
})
test('the surviving row takes unit/updatedAt from the MAX(counter) row', () => {
const db = freshDb()
// Bare columns in a single-aggregate GROUP BY come from the row that matched
// the aggregate (documented SQLite behaviour), so the row stays internally
// consistent rather than mixing fields across rows.
insertOld(db, MINT_A, 'k1', 'sat', 342, '2026-01-01')
insertOld(db, MINT_B, 'k1', 'sat', 400, '2026-02-01')
runMigration32(db)
expect(allRows(db)[0]).toEqual({
keysetId: 'k1',
unit: 'sat',
counter: 400,
updatedAt: '2026-02-01', // the winning row's timestamp
})
db.close()
})
test('collapses a three-way split to the single highest counter', () => {
const db = freshDb()
insertOld(db, MINT_A, 'k1', 'sat', 10, '2026-01-01')
insertOld(db, MINT_B, 'k1', 'sat', 900, '2026-02-01')
insertOld(db, 'https://mint-c.test', 'k1', 'sat', 55, '2026-03-01')
runMigration32(db)
expect(allRows(db)).toHaveLength(1)
expect(getCounter(db, 'k1')).toBe(900)
db.close()
})
test('distinct keysets are never merged, even across mints', () => {
const db = freshDb()
insertOld(db, MINT_A, 'k1', 'sat', 100, '2026-01-01')
insertOld(db, MINT_B, 'k2', 'sat', 200, '2026-01-01')
runMigration32(db)
expect(getCounter(db, 'k1')).toBe(100)
expect(getCounter(db, 'k2')).toBe(200)
expect(allRows(db)).toHaveLength(2)
db.close()
})
test('an empty table migrates cleanly', () => {
const db = freshDb()
runMigration32(db)
expect(allRows(db)).toEqual([])
db.close()
})
test('preserves a null unit', () => {
const db = freshDb()
insertOld(db, MINT_A, 'k1', null, 42, '2026-01-01')
runMigration32(db)
expect(allRows(db)[0]).toEqual({
keysetId: 'k1',
unit: null,
counter: 42,
updatedAt: '2026-01-01',
})
db.close()
})
test('the new table rejects a duplicate keysetId (the key is enforced, not just declared)', () => {
const db = freshDb()
insertOld(db, MINT_A, 'k1', 'sat', 100, '2026-01-01')
runMigration32(db)
expect(() =>
db
.prepare(`INSERT INTO mint_counters (keysetId, unit, counter, updatedAt) VALUES (?, ?, ?, ?)`)
.run('k1', 'sat', 5, '2026-04-01'),
).toThrow()
// The original value is untouched by the rejected write.
expect(getCounter(db, 'k1')).toBe(100)
db.close()
})
test('post-migration upserts stay monotonic on the new key', () => {
const db = freshDb()
insertOld(db, MINT_A, 'k1', 'sat', 342, '2026-01-01')
insertOld(db, MINT_B, 'k1', 'sat', 400, '2026-02-01')
runMigration32(db)
// countersRepo.buildCounterUpsert against the healed row.
const upsert = (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('k1', 'sat', value, '2026-05-01')
// A writer still holding the pre-migration low value cannot regress it.
upsert(342)
expect(getCounter(db, 'k1')).toBe(400)
upsert(410)
expect(getCounter(db, 'k1')).toBe(410)
db.close()
})
})