mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-08-11 17:07:44 +00:00
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>
350 lines
13 KiB
TypeScript
350 lines
13 KiB
TypeScript
/**
|
|
* Derivation-counter tests (mint_counters).
|
|
*
|
|
* Verifies the SQL-level semantics that `Database.setCounter`, `bumpCounter`,
|
|
* `seedCounters`, and the `counterUpdate` folded into `commitReservation`
|
|
* implement on top of `executeBatch`. The counter is the NUT-13 derivation
|
|
* high-water mark; the single most important invariant is that it is MONOTONIC
|
|
* — a stored counter can never move backward — because a regression would let
|
|
* the next derivation reuse a blinded secret.
|
|
*
|
|
* Rows are keyed by keysetId ALONE. NUT-13 derives from (seed, keysetId,
|
|
* counter) with no mint component, so one keyset id means one derivation
|
|
* sequence; see MINT_COUNTERS_COLUMNS.
|
|
*
|
|
* 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 (
|
|
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'
|
|
|
|
// ── Mirrored Database primitives (exact production SQL) ─────────────────────
|
|
|
|
/** countersRepo.buildCounterUpsert / setCounter — monotonic absolute write. */
|
|
function setCounter(db: DatabaseSync, keysetId: string, unit: string | null, value: number) {
|
|
db.prepare(
|
|
`INSERT INTO mint_counters (keysetId, unit, counter, updatedAt)
|
|
VALUES (?, ?, ?, ?)
|
|
ON CONFLICT(keysetId) DO UPDATE SET
|
|
counter = MAX(counter, excluded.counter),
|
|
unit = excluded.unit,
|
|
updatedAt = excluded.updatedAt`,
|
|
).run(keysetId, unit, value, NOW)
|
|
}
|
|
|
|
/** countersRepo.bumpCounter — relative advance (no-op for delta <= 0). */
|
|
function bumpCounter(db: DatabaseSync, keysetId: string, unit: string | null, delta: number) {
|
|
if (delta <= 0) return
|
|
db.prepare(
|
|
`INSERT INTO mint_counters (keysetId, unit, counter, updatedAt)
|
|
VALUES (?, ?, ?, ?)
|
|
ON CONFLICT(keysetId) DO UPDATE SET
|
|
counter = counter + ?,
|
|
updatedAt = excluded.updatedAt`,
|
|
).run(keysetId, unit, delta, NOW, delta)
|
|
}
|
|
|
|
function getCounter(db: DatabaseSync, keysetId: string): number | undefined {
|
|
const row = db
|
|
.prepare('SELECT counter FROM mint_counters WHERE keysetId = ?')
|
|
.get(keysetId) as {counter: number} | undefined
|
|
return row?.counter
|
|
}
|
|
|
|
function counterRowCount(db: DatabaseSync): number {
|
|
const {n} = db.prepare('SELECT COUNT(*) AS n FROM mint_counters').get() as {n: number}
|
|
return n
|
|
}
|
|
|
|
/** countersRepo.seedCounters — idempotent monotonic batch. */
|
|
function seedCounters(
|
|
db: DatabaseSync,
|
|
seeds: Array<{keysetId: string; unit?: string; counter: number}>,
|
|
) {
|
|
db.exec('BEGIN')
|
|
try {
|
|
for (const s of seeds) setCounter(db, s.keysetId, s.unit ?? null, s.counter)
|
|
db.exec('COMMIT')
|
|
} catch (e) {
|
|
db.exec('ROLLBACK')
|
|
throw e
|
|
}
|
|
}
|
|
|
|
function insertProof(db: DatabaseSync, secret: string, amount: number, state = 'UNSPENT') {
|
|
db.prepare(
|
|
`INSERT INTO proofs (id, amount, secret, C, mintUrl, unit, tId, state, updatedAt)
|
|
VALUES ('keyset1', ?, ?, 'C', '${MINT}', 'sat', 1, ?, '2026-01-01')`,
|
|
).run(amount, secret, state)
|
|
}
|
|
|
|
function getProofState(db: DatabaseSync, secret: string): string {
|
|
const row = db.prepare('SELECT state FROM proofs WHERE secret = ?').get(secret) as
|
|
| {state: string}
|
|
| undefined
|
|
return row?.state ?? ''
|
|
}
|
|
|
|
/**
|
|
* commitReservation with a folded counterUpdate (the step-4 atomic commit):
|
|
* new proofs + a monotonic counter upsert + reservation delete, all in one txn.
|
|
*/
|
|
function commitWithCounter(
|
|
db: DatabaseSync,
|
|
reservationId: string,
|
|
changes: {
|
|
newProofs?: Array<{secret: string; amount: number; state: string}>
|
|
counterUpdate?: Array<{keysetId: string; unit?: string; counter: number}>
|
|
},
|
|
) {
|
|
db.exec('BEGIN')
|
|
try {
|
|
const insertNew = db.prepare(
|
|
`INSERT OR REPLACE INTO proofs (id, amount, secret, C, mintUrl, unit, tId, state, updatedAt)
|
|
VALUES ('keyset1', ?, ?, 'C', '${MINT}', 'sat', 1, ?, ?)`,
|
|
)
|
|
for (const p of changes.newProofs ?? []) insertNew.run(p.amount, p.secret, p.state, NOW)
|
|
|
|
for (const cu of changes.counterUpdate ?? []) {
|
|
setCounter(db, 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, 'k1', 'sat', 42)
|
|
expect(getCounter(db, 'k1')).toBe(42)
|
|
db.close()
|
|
})
|
|
|
|
test('raises to a higher value', () => {
|
|
const db = freshDb()
|
|
setCounter(db, 'k1', 'sat', 100)
|
|
setCounter(db, 'k1', 'sat', 150)
|
|
expect(getCounter(db, 'k1')).toBe(150)
|
|
db.close()
|
|
})
|
|
|
|
test('NEVER lowers — a smaller value is ignored (the core safety invariant)', () => {
|
|
const db = freshDb()
|
|
setCounter(db, 'k1', 'sat', 100)
|
|
setCounter(db, 'k1', 'sat', 50) // stale / replayed writer
|
|
expect(getCounter(db, 'k1')).toBe(100)
|
|
db.close()
|
|
})
|
|
|
|
test('an equal value is a no-op', () => {
|
|
const db = freshDb()
|
|
setCounter(db, 'k1', 'sat', 100)
|
|
setCounter(db, 'k1', 'sat', 100)
|
|
expect(getCounter(db, 'k1')).toBe(100)
|
|
db.close()
|
|
})
|
|
})
|
|
|
|
describe('bumpCounter — relative advance', () => {
|
|
test('inserts from 0 when no row exists', () => {
|
|
const db = freshDb()
|
|
bumpCounter(db, 'k1', 'sat', 10)
|
|
expect(getCounter(db, 'k1')).toBe(10)
|
|
db.close()
|
|
})
|
|
|
|
test('adds to the existing value', () => {
|
|
const db = freshDb()
|
|
setCounter(db, 'k1', 'sat', 100)
|
|
bumpCounter(db, 'k1', 'sat', 10)
|
|
expect(getCounter(db, 'k1')).toBe(110)
|
|
db.close()
|
|
})
|
|
|
|
test('a non-positive delta is a no-op', () => {
|
|
const db = freshDb()
|
|
setCounter(db, 'k1', 'sat', 100)
|
|
bumpCounter(db, 'k1', 'sat', 0)
|
|
bumpCounter(db, 'k1', 'sat', -5)
|
|
expect(getCounter(db, 'k1')).toBe(100)
|
|
db.close()
|
|
})
|
|
})
|
|
|
|
describe('primary key isolation', () => {
|
|
test('different keysets are independent', () => {
|
|
const db = freshDb()
|
|
setCounter(db, 'k1', 'sat', 100)
|
|
setCounter(db, 'k2', 'sat', 7)
|
|
expect(getCounter(db, 'k1')).toBe(100)
|
|
expect(getCounter(db, 'k2')).toBe(7)
|
|
expect(counterRowCount(db)).toBe(2)
|
|
db.close()
|
|
})
|
|
|
|
// The inverse of this used to be asserted (and implemented): a
|
|
// (mintUrl, keysetId) key let ONE keyset carry two counters. NUT-13
|
|
// derives from (seed, keysetId, counter) with no mint component, so both
|
|
// rows drove the same derivation path and the lower one reused blinded
|
|
// secrets the mint had already signed. One keyset id, one counter — no
|
|
// matter which mint url served the keyset.
|
|
test('one keyset id has exactly ONE counter, whatever mint served it', () => {
|
|
const db = freshDb()
|
|
setCounter(db, 'k1', 'sat', 100)
|
|
// The same keyset seen again after a mint-url edit / via a mirror.
|
|
setCounter(db, 'k1', 'sat', 5)
|
|
expect(getCounter(db, 'k1')).toBe(100) // monotonic, not a second row
|
|
expect(counterRowCount(db)).toBe(1)
|
|
db.close()
|
|
})
|
|
})
|
|
|
|
describe('seedCounters — one-time MMKV→SQLite copy', () => {
|
|
test('seeds every supplied counter', () => {
|
|
const db = freshDb()
|
|
seedCounters(db, [
|
|
{keysetId: 'k1', unit: 'sat', counter: 100},
|
|
{keysetId: 'k2', unit: 'sat', counter: 50},
|
|
])
|
|
expect(getCounter(db, 'k1')).toBe(100)
|
|
expect(getCounter(db, 'k2')).toBe(50)
|
|
db.close()
|
|
})
|
|
|
|
test('is idempotent — re-running never lowers an advanced counter', () => {
|
|
const db = freshDb()
|
|
// First upgrade seed copies the (then current) MMKV values.
|
|
seedCounters(db, [{keysetId: 'k1', unit: 'sat', counter: 100}])
|
|
// Wallet advances past it during normal use.
|
|
setCounter(db, 'k1', 'sat', 175)
|
|
// A later launch re-runs the seed with the now-stale snapshot value.
|
|
seedCounters(db, [{keysetId: 'k1', unit: 'sat', counter: 100}])
|
|
// The advanced SQLite value wins — the seed cannot regress it.
|
|
expect(getCounter(db, 'k1')).toBe(175)
|
|
db.close()
|
|
})
|
|
|
|
test('a too-high seed is kept (conservative-safe: skips indices, never reuses)', () => {
|
|
const db = freshDb()
|
|
setCounter(db, 'k1', 'sat', 100)
|
|
seedCounters(db, [{keysetId: 'k1', unit: 'sat', counter: 9999}])
|
|
expect(getCounter(db, 'k1')).toBe(9999)
|
|
db.close()
|
|
})
|
|
})
|
|
|
|
describe('atomic commit (counterUpdate folded into commitReservation)', () => {
|
|
test('persists the counter in the SAME txn as the new proofs', () => {
|
|
const db = freshDb()
|
|
setCounter(db, 'k1', 'sat', 100)
|
|
|
|
commitWithCounter(db, 'res-1', {
|
|
newProofs: [{secret: 'new1', amount: 50, state: 'UNSPENT'}],
|
|
counterUpdate: [{keysetId: 'k1', unit: 'sat', counter: 110}],
|
|
})
|
|
|
|
expect(getProofState(db, 'new1')).toBe('UNSPENT')
|
|
expect(getCounter(db, 'k1')).toBe(110)
|
|
db.close()
|
|
})
|
|
|
|
test('a failed commit batch rolls back BOTH the proofs and the counter', () => {
|
|
const db = freshDb()
|
|
setCounter(db, 'k1', 'sat', 100)
|
|
|
|
// Force a failure mid-batch (NOT NULL violation on amount) AFTER the
|
|
// proof insert and counter upsert have run in the same transaction.
|
|
expect(() => {
|
|
db.exec('BEGIN')
|
|
try {
|
|
db.prepare(
|
|
`INSERT OR REPLACE INTO proofs (id, amount, secret, C, mintUrl, unit, tId, state, updatedAt)
|
|
VALUES ('keyset1', 50, 'new1', 'C', '${MINT}', 'sat', 1, 'UNSPENT', '${NOW}')`,
|
|
).run()
|
|
setCounter(db, 'k1', 'sat', 110)
|
|
// Violates NOT NULL on amount → aborts the whole batch.
|
|
db.prepare(
|
|
`INSERT INTO proofs (id, amount, secret, C, state) VALUES ('keyset1', NULL, 'bad', 'C', 'UNSPENT')`,
|
|
).run()
|
|
db.exec('COMMIT')
|
|
} catch (e) {
|
|
db.exec('ROLLBACK')
|
|
throw e
|
|
}
|
|
}).toThrow()
|
|
|
|
// Neither the proof nor the counter advance survived.
|
|
expect(getProofState(db, 'new1')).toBe('')
|
|
expect(getCounter(db, 'k1')).toBe(100)
|
|
db.close()
|
|
})
|
|
|
|
test('counterUpdate stays monotonic inside the commit batch', () => {
|
|
const db = freshDb()
|
|
setCounter(db, 'k1', 'sat', 200)
|
|
|
|
// A commit carrying a stale (lower) counter must not regress it.
|
|
commitWithCounter(db, 'res-2', {
|
|
newProofs: [{secret: 'new2', amount: 10, state: 'UNSPENT'}],
|
|
counterUpdate: [{keysetId: 'k1', unit: 'sat', counter: 150}],
|
|
})
|
|
|
|
expect(getProofState(db, 'new2')).toBe('UNSPENT')
|
|
expect(getCounter(db, 'k1')).toBe(200)
|
|
db.close()
|
|
})
|
|
})
|
|
})
|