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>
This commit is contained in:
minibits-cash
2026-07-16 17:41:02 +02:00
co-authored by Claude Opus 4.8
parent 714a3bc3d0
commit 9d759f83ab
23 changed files with 1320 additions and 212 deletions
+82 -73
View File
@@ -1,13 +1,17 @@
/** /**
* Derivation-counter tests (mint_counters migration). * Derivation-counter tests (mint_counters).
* *
* Verifies the SQL-level semantics that `Database.setCounter`, `bumpCounter`, * Verifies the SQL-level semantics that `Database.setCounter`, `bumpCounter`,
* `seedCounters`, and the `counterUpdate` folded into `commitReservation` * `seedCounters`, and the `counterUpdate` folded into `commitReservation`
* implement on top of `executeBatch`. The counter is the BIP32 derivation * 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 * 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 * — a stored counter can never move backward — because a regression would let
* the next derivation reuse a blinded secret. * 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 * As with proofReservation.test.ts we mirror the exact production SQL using
* node:sqlite + explicit BEGIN/COMMIT, since the native driver needs a device. * node:sqlite + explicit BEGIN/COMMIT, since the native driver needs a device.
* *
@@ -20,12 +24,10 @@ const NOW = '2026-06-04T00:00:00.000Z'
// ── Schema (mirrors schema.ts) ────────────────────────────────────────────── // ── Schema (mirrors schema.ts) ──────────────────────────────────────────────
const CREATE_MINT_COUNTERS = `CREATE TABLE mint_counters ( const CREATE_MINT_COUNTERS = `CREATE TABLE mint_counters (
mintUrl TEXT NOT NULL, keysetId TEXT PRIMARY KEY NOT NULL,
keysetId TEXT NOT NULL,
unit TEXT, unit TEXT,
counter INTEGER NOT NULL DEFAULT 0, counter INTEGER NOT NULL DEFAULT 0,
updatedAt TEXT, updatedAt TEXT
PRIMARY KEY (mintUrl, keysetId)
)` )`
const CREATE_PROOFS = `CREATE TABLE proofs ( const CREATE_PROOFS = `CREATE TABLE proofs (
@@ -55,33 +57,33 @@ const MINT = 'https://mint.test'
// ── Mirrored Database primitives (exact production SQL) ───────────────────── // ── Mirrored Database primitives (exact production SQL) ─────────────────────
/** countersRepo.buildCounterUpsert / setCounter — monotonic absolute write. */ /** countersRepo.buildCounterUpsert / setCounter — monotonic absolute write. */
function setCounter(db: DatabaseSync, mintUrl: string, keysetId: string, unit: string | null, value: number) { function setCounter(db: DatabaseSync, keysetId: string, unit: string | null, value: number) {
db.prepare( db.prepare(
`INSERT INTO mint_counters (mintUrl, keysetId, unit, counter, updatedAt) `INSERT INTO mint_counters (keysetId, unit, counter, updatedAt)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?)
ON CONFLICT(mintUrl, keysetId) DO UPDATE SET ON CONFLICT(keysetId) DO UPDATE SET
counter = MAX(counter, excluded.counter), counter = MAX(counter, excluded.counter),
unit = excluded.unit, unit = excluded.unit,
updatedAt = excluded.updatedAt`, updatedAt = excluded.updatedAt`,
).run(mintUrl, keysetId, unit, value, NOW) ).run(keysetId, unit, value, NOW)
} }
/** countersRepo.bumpCounter — relative advance (no-op for delta <= 0). */ /** countersRepo.bumpCounter — relative advance (no-op for delta <= 0). */
function bumpCounter(db: DatabaseSync, mintUrl: string, keysetId: string, unit: string | null, delta: number) { function bumpCounter(db: DatabaseSync, keysetId: string, unit: string | null, delta: number) {
if (delta <= 0) return if (delta <= 0) return
db.prepare( db.prepare(
`INSERT INTO mint_counters (mintUrl, keysetId, unit, counter, updatedAt) `INSERT INTO mint_counters (keysetId, unit, counter, updatedAt)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?)
ON CONFLICT(mintUrl, keysetId) DO UPDATE SET ON CONFLICT(keysetId) DO UPDATE SET
counter = counter + ?, counter = counter + ?,
updatedAt = excluded.updatedAt`, updatedAt = excluded.updatedAt`,
).run(mintUrl, keysetId, unit, delta, NOW, delta) ).run(keysetId, unit, delta, NOW, delta)
} }
function getCounter(db: DatabaseSync, mintUrl: string, keysetId: string): number | undefined { function getCounter(db: DatabaseSync, keysetId: string): number | undefined {
const row = db const row = db
.prepare('SELECT counter FROM mint_counters WHERE mintUrl = ? AND keysetId = ?') .prepare('SELECT counter FROM mint_counters WHERE keysetId = ?')
.get(mintUrl, keysetId) as {counter: number} | undefined .get(keysetId) as {counter: number} | undefined
return row?.counter return row?.counter
} }
@@ -93,11 +95,11 @@ function counterRowCount(db: DatabaseSync): number {
/** countersRepo.seedCounters — idempotent monotonic batch. */ /** countersRepo.seedCounters — idempotent monotonic batch. */
function seedCounters( function seedCounters(
db: DatabaseSync, db: DatabaseSync,
seeds: Array<{mintUrl: string; keysetId: string; unit?: string; counter: number}>, seeds: Array<{keysetId: string; unit?: string; counter: number}>,
) { ) {
db.exec('BEGIN') db.exec('BEGIN')
try { try {
for (const s of seeds) setCounter(db, s.mintUrl, s.keysetId, s.unit ?? null, s.counter) for (const s of seeds) setCounter(db, s.keysetId, s.unit ?? null, s.counter)
db.exec('COMMIT') db.exec('COMMIT')
} catch (e) { } catch (e) {
db.exec('ROLLBACK') db.exec('ROLLBACK')
@@ -128,7 +130,7 @@ function commitWithCounter(
reservationId: string, reservationId: string,
changes: { changes: {
newProofs?: Array<{secret: string; amount: number; state: string}> newProofs?: Array<{secret: string; amount: number; state: string}>
counterUpdate?: Array<{mintUrl: string; keysetId: string; unit?: string; counter: number}> counterUpdate?: Array<{keysetId: string; unit?: string; counter: number}>
}, },
) { ) {
db.exec('BEGIN') db.exec('BEGIN')
@@ -140,7 +142,7 @@ function commitWithCounter(
for (const p of changes.newProofs ?? []) insertNew.run(p.amount, p.secret, p.state, NOW) for (const p of changes.newProofs ?? []) insertNew.run(p.amount, p.secret, p.state, NOW)
for (const cu of changes.counterUpdate ?? []) { for (const cu of changes.counterUpdate ?? []) {
setCounter(db, cu.mintUrl, cu.keysetId, cu.unit ?? null, cu.counter) setCounter(db, cu.keysetId, cu.unit ?? null, cu.counter)
} }
db.prepare('DELETE FROM reservations WHERE id = ?').run(reservationId) db.prepare('DELETE FROM reservations WHERE id = ?').run(reservationId)
@@ -165,32 +167,32 @@ describe('Derivation counters (mint_counters)', () => {
describe('setCounter — monotonic', () => { describe('setCounter — monotonic', () => {
test('inserts a new row when none exists', () => { test('inserts a new row when none exists', () => {
const db = freshDb() const db = freshDb()
setCounter(db, MINT, 'k1', 'sat', 42) setCounter(db, 'k1', 'sat', 42)
expect(getCounter(db, MINT, 'k1')).toBe(42) expect(getCounter(db, 'k1')).toBe(42)
db.close() db.close()
}) })
test('raises to a higher value', () => { test('raises to a higher value', () => {
const db = freshDb() const db = freshDb()
setCounter(db, MINT, 'k1', 'sat', 100) setCounter(db, 'k1', 'sat', 100)
setCounter(db, MINT, 'k1', 'sat', 150) setCounter(db, 'k1', 'sat', 150)
expect(getCounter(db, MINT, 'k1')).toBe(150) expect(getCounter(db, 'k1')).toBe(150)
db.close() db.close()
}) })
test('NEVER lowers — a smaller value is ignored (the core safety invariant)', () => { test('NEVER lowers — a smaller value is ignored (the core safety invariant)', () => {
const db = freshDb() const db = freshDb()
setCounter(db, MINT, 'k1', 'sat', 100) setCounter(db, 'k1', 'sat', 100)
setCounter(db, MINT, 'k1', 'sat', 50) // stale / replayed writer setCounter(db, 'k1', 'sat', 50) // stale / replayed writer
expect(getCounter(db, MINT, 'k1')).toBe(100) expect(getCounter(db, 'k1')).toBe(100)
db.close() db.close()
}) })
test('an equal value is a no-op', () => { test('an equal value is a no-op', () => {
const db = freshDb() const db = freshDb()
setCounter(db, MINT, 'k1', 'sat', 100) setCounter(db, 'k1', 'sat', 100)
setCounter(db, MINT, 'k1', 'sat', 100) setCounter(db, 'k1', 'sat', 100)
expect(getCounter(db, MINT, 'k1')).toBe(100) expect(getCounter(db, 'k1')).toBe(100)
db.close() db.close()
}) })
}) })
@@ -198,46 +200,53 @@ describe('Derivation counters (mint_counters)', () => {
describe('bumpCounter — relative advance', () => { describe('bumpCounter — relative advance', () => {
test('inserts from 0 when no row exists', () => { test('inserts from 0 when no row exists', () => {
const db = freshDb() const db = freshDb()
bumpCounter(db, MINT, 'k1', 'sat', 10) bumpCounter(db, 'k1', 'sat', 10)
expect(getCounter(db, MINT, 'k1')).toBe(10) expect(getCounter(db, 'k1')).toBe(10)
db.close() db.close()
}) })
test('adds to the existing value', () => { test('adds to the existing value', () => {
const db = freshDb() const db = freshDb()
setCounter(db, MINT, 'k1', 'sat', 100) setCounter(db, 'k1', 'sat', 100)
bumpCounter(db, MINT, 'k1', 'sat', 10) bumpCounter(db, 'k1', 'sat', 10)
expect(getCounter(db, MINT, 'k1')).toBe(110) expect(getCounter(db, 'k1')).toBe(110)
db.close() db.close()
}) })
test('a non-positive delta is a no-op', () => { test('a non-positive delta is a no-op', () => {
const db = freshDb() const db = freshDb()
setCounter(db, MINT, 'k1', 'sat', 100) setCounter(db, 'k1', 'sat', 100)
bumpCounter(db, MINT, 'k1', 'sat', 0) bumpCounter(db, 'k1', 'sat', 0)
bumpCounter(db, MINT, 'k1', 'sat', -5) bumpCounter(db, 'k1', 'sat', -5)
expect(getCounter(db, MINT, 'k1')).toBe(100) expect(getCounter(db, 'k1')).toBe(100)
db.close() db.close()
}) })
}) })
describe('primary key isolation', () => { describe('primary key isolation', () => {
test('different keysets on the same mint are independent', () => { test('different keysets are independent', () => {
const db = freshDb() const db = freshDb()
setCounter(db, MINT, 'k1', 'sat', 100) setCounter(db, 'k1', 'sat', 100)
setCounter(db, MINT, 'k2', 'sat', 7) setCounter(db, 'k2', 'sat', 7)
expect(getCounter(db, MINT, 'k1')).toBe(100) expect(getCounter(db, 'k1')).toBe(100)
expect(getCounter(db, MINT, 'k2')).toBe(7) expect(getCounter(db, 'k2')).toBe(7)
expect(counterRowCount(db)).toBe(2) expect(counterRowCount(db)).toBe(2)
db.close() db.close()
}) })
test('the same keyset id on different mints is independent', () => { // 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() const db = freshDb()
setCounter(db, MINT, 'k1', 'sat', 100) setCounter(db, 'k1', 'sat', 100)
setCounter(db, 'https://other.test', 'k1', 'sat', 5) // The same keyset seen again after a mint-url edit / via a mirror.
expect(getCounter(db, MINT, 'k1')).toBe(100) setCounter(db, 'k1', 'sat', 5)
expect(getCounter(db, 'https://other.test', 'k1')).toBe(5) expect(getCounter(db, 'k1')).toBe(100) // monotonic, not a second row
expect(counterRowCount(db)).toBe(1)
db.close() db.close()
}) })
}) })
@@ -246,32 +255,32 @@ describe('Derivation counters (mint_counters)', () => {
test('seeds every supplied counter', () => { test('seeds every supplied counter', () => {
const db = freshDb() const db = freshDb()
seedCounters(db, [ seedCounters(db, [
{mintUrl: MINT, keysetId: 'k1', unit: 'sat', counter: 100}, {keysetId: 'k1', unit: 'sat', counter: 100},
{mintUrl: MINT, keysetId: 'k2', unit: 'sat', counter: 50}, {keysetId: 'k2', unit: 'sat', counter: 50},
]) ])
expect(getCounter(db, MINT, 'k1')).toBe(100) expect(getCounter(db, 'k1')).toBe(100)
expect(getCounter(db, MINT, 'k2')).toBe(50) expect(getCounter(db, 'k2')).toBe(50)
db.close() db.close()
}) })
test('is idempotent — re-running never lowers an advanced counter', () => { test('is idempotent — re-running never lowers an advanced counter', () => {
const db = freshDb() const db = freshDb()
// First upgrade seed copies the (then current) MMKV values. // First upgrade seed copies the (then current) MMKV values.
seedCounters(db, [{mintUrl: MINT, keysetId: 'k1', unit: 'sat', counter: 100}]) seedCounters(db, [{keysetId: 'k1', unit: 'sat', counter: 100}])
// Wallet advances past it during normal use. // Wallet advances past it during normal use.
setCounter(db, MINT, 'k1', 'sat', 175) setCounter(db, 'k1', 'sat', 175)
// A later launch re-runs the seed with the now-stale snapshot value. // A later launch re-runs the seed with the now-stale snapshot value.
seedCounters(db, [{mintUrl: MINT, keysetId: 'k1', unit: 'sat', counter: 100}]) seedCounters(db, [{keysetId: 'k1', unit: 'sat', counter: 100}])
// The advanced SQLite value wins — the seed cannot regress it. // The advanced SQLite value wins — the seed cannot regress it.
expect(getCounter(db, MINT, 'k1')).toBe(175) expect(getCounter(db, 'k1')).toBe(175)
db.close() db.close()
}) })
test('a too-high seed is kept (conservative-safe: skips indices, never reuses)', () => { test('a too-high seed is kept (conservative-safe: skips indices, never reuses)', () => {
const db = freshDb() const db = freshDb()
setCounter(db, MINT, 'k1', 'sat', 100) setCounter(db, 'k1', 'sat', 100)
seedCounters(db, [{mintUrl: MINT, keysetId: 'k1', unit: 'sat', counter: 9999}]) seedCounters(db, [{keysetId: 'k1', unit: 'sat', counter: 9999}])
expect(getCounter(db, MINT, 'k1')).toBe(9999) expect(getCounter(db, 'k1')).toBe(9999)
db.close() db.close()
}) })
}) })
@@ -279,21 +288,21 @@ describe('Derivation counters (mint_counters)', () => {
describe('atomic commit (counterUpdate folded into commitReservation)', () => { describe('atomic commit (counterUpdate folded into commitReservation)', () => {
test('persists the counter in the SAME txn as the new proofs', () => { test('persists the counter in the SAME txn as the new proofs', () => {
const db = freshDb() const db = freshDb()
setCounter(db, MINT, 'k1', 'sat', 100) setCounter(db, 'k1', 'sat', 100)
commitWithCounter(db, 'res-1', { commitWithCounter(db, 'res-1', {
newProofs: [{secret: 'new1', amount: 50, state: 'UNSPENT'}], newProofs: [{secret: 'new1', amount: 50, state: 'UNSPENT'}],
counterUpdate: [{mintUrl: MINT, keysetId: 'k1', unit: 'sat', counter: 110}], counterUpdate: [{keysetId: 'k1', unit: 'sat', counter: 110}],
}) })
expect(getProofState(db, 'new1')).toBe('UNSPENT') expect(getProofState(db, 'new1')).toBe('UNSPENT')
expect(getCounter(db, MINT, 'k1')).toBe(110) expect(getCounter(db, 'k1')).toBe(110)
db.close() db.close()
}) })
test('a failed commit batch rolls back BOTH the proofs and the counter', () => { test('a failed commit batch rolls back BOTH the proofs and the counter', () => {
const db = freshDb() const db = freshDb()
setCounter(db, MINT, 'k1', 'sat', 100) setCounter(db, 'k1', 'sat', 100)
// Force a failure mid-batch (NOT NULL violation on amount) AFTER the // Force a failure mid-batch (NOT NULL violation on amount) AFTER the
// proof insert and counter upsert have run in the same transaction. // proof insert and counter upsert have run in the same transaction.
@@ -304,7 +313,7 @@ describe('Derivation counters (mint_counters)', () => {
`INSERT OR REPLACE INTO proofs (id, amount, secret, C, mintUrl, unit, tId, state, updatedAt) `INSERT OR REPLACE INTO proofs (id, amount, secret, C, mintUrl, unit, tId, state, updatedAt)
VALUES ('keyset1', 50, 'new1', 'C', '${MINT}', 'sat', 1, 'UNSPENT', '${NOW}')`, VALUES ('keyset1', 50, 'new1', 'C', '${MINT}', 'sat', 1, 'UNSPENT', '${NOW}')`,
).run() ).run()
setCounter(db, MINT, 'k1', 'sat', 110) setCounter(db, 'k1', 'sat', 110)
// Violates NOT NULL on amount → aborts the whole batch. // Violates NOT NULL on amount → aborts the whole batch.
db.prepare( db.prepare(
`INSERT INTO proofs (id, amount, secret, C, state) VALUES ('keyset1', NULL, 'bad', 'C', 'UNSPENT')`, `INSERT INTO proofs (id, amount, secret, C, state) VALUES ('keyset1', NULL, 'bad', 'C', 'UNSPENT')`,
@@ -318,22 +327,22 @@ describe('Derivation counters (mint_counters)', () => {
// Neither the proof nor the counter advance survived. // Neither the proof nor the counter advance survived.
expect(getProofState(db, 'new1')).toBe('') expect(getProofState(db, 'new1')).toBe('')
expect(getCounter(db, MINT, 'k1')).toBe(100) expect(getCounter(db, 'k1')).toBe(100)
db.close() db.close()
}) })
test('counterUpdate stays monotonic inside the commit batch', () => { test('counterUpdate stays monotonic inside the commit batch', () => {
const db = freshDb() const db = freshDb()
setCounter(db, MINT, 'k1', 'sat', 200) setCounter(db, 'k1', 'sat', 200)
// A commit carrying a stale (lower) counter must not regress it. // A commit carrying a stale (lower) counter must not regress it.
commitWithCounter(db, 'res-2', { commitWithCounter(db, 'res-2', {
newProofs: [{secret: 'new2', amount: 10, state: 'UNSPENT'}], newProofs: [{secret: 'new2', amount: 10, state: 'UNSPENT'}],
counterUpdate: [{mintUrl: MINT, keysetId: 'k1', unit: 'sat', counter: 150}], counterUpdate: [{keysetId: 'k1', unit: 'sat', counter: 150}],
}) })
expect(getProofState(db, 'new2')).toBe('UNSPENT') expect(getProofState(db, 'new2')).toBe('UNSPENT')
expect(getCounter(db, MINT, 'k1')).toBe(200) expect(getCounter(db, 'k1')).toBe(200)
db.close() db.close()
}) })
}) })
+141
View File
@@ -0,0 +1,141 @@
/**
* Keyset-id collision tests (CashuUtils.isCollidingKeysetId).
*
* NUT-02 requires that a wallet "reject any attempt at importing new keysets
* which IDs collide with any of the previously added keysets". This wallet
* enforces that WALLET-WIDE (every keyset of every mint), which is what makes a
* keyset id a sound primary key for a NUT-13 derivation counter — see
* MINT_COUNTERS_COLUMNS.
*
* Two distinct collision classes are covered here:
*
* - Exact id equality: always a collision. One id means one derivation
* sequence, so two mints sharing an id would share one counter.
*
* - keysetIdInt equality: a collision ONLY between ids that both derive via the
* deprecated BIP-32 path, where the id is reduced mod 2^31-1 to fit a
* hardened index and two distinct ids that are congruent therefore land on
* the SAME derivation path. NUT-02 v2 (`01`) ids derive by HMAC-SHA256 over
* the full id and never compute that integer, so the reduction cannot alias
* them and applying the check to them would reject a legitimate mint.
*/
jest.mock('../src/services/logService', () => ({
log: {
debug: jest.fn(),
error: jest.fn(),
info: jest.fn(),
trace: jest.fn(),
warn: jest.fn(),
},
}))
jest.mock('../src/services/nostrService', () => ({
NostrClient: {
getFirstTagValue: jest.fn(),
},
}))
import {CashuUtils} from '../src/services/cashu/cashuUtils'
const {isCollidingKeysetId} = CashuUtils
const MOD = BigInt(2 ** 31 - 1)
/** Build a v1 (`00`-prefixed, 8-byte) keyset id from its 7 data bytes. */
const v1 = (dataHex: string) => `00${dataHex.padStart(14, '0')}`
/** Build a v2 (`01`-prefixed, 33-byte) keyset id from its 32 data bytes. */
const v2 = (dataHex: string) => `01${dataHex.padStart(64, '0')}`
const keysetIdInt = (id: string) => BigInt(`0x${id}`) % MOD
describe('isCollidingKeysetId', () => {
describe('exact id equality — always a collision', () => {
test('detects a v1 id already held', () => {
const id = v1('a1b2c3d4e5f601')
expect(isCollidingKeysetId(id, [v1('0000000000ff01'), id])).toBe(true)
})
test('detects a v2 id already held', () => {
const id = v2('deadbeef')
expect(isCollidingKeysetId(id, [v2('feedface'), id])).toBe(true)
})
test('passes an id the wallet does not hold', () => {
expect(isCollidingKeysetId(v1('a1b2c3d4e5f601'), [v1('0000000000ff01')])).toBe(false)
})
test('passes against an empty wallet', () => {
expect(isCollidingKeysetId(v2('deadbeef'), [])).toBe(false)
})
})
describe('keysetIdInt aliasing — v1 (deprecated BIP-32 derivation)', () => {
// Two DISTINCT v1 ids that are congruent mod 2^31-1 derive at the same
// `m/129372'/0'/{int}'/...` path, so the second must be rejected even though
// the ids differ.
test('rejects two distinct v1 ids that reduce to the same derivation index', () => {
const stored = v1('00000000000001')
const colliding = (BigInt(`0x${stored}`) + MOD).toString(16).padStart(16, '0')
expect(colliding).not.toBe(stored)
expect(keysetIdInt(colliding)).toBe(keysetIdInt(stored))
expect(isCollidingKeysetId(colliding, [stored])).toBe(true)
})
test('passes two v1 ids that reduce to different indices', () => {
expect(isCollidingKeysetId(v1('00000000000002'), [v1('00000000000001')])).toBe(false)
})
})
describe('keysetIdInt aliasing — v2 (HMAC derivation) must NOT be int-checked', () => {
// The regression this guards: applying the mod-2^31-1 check to v2 ids
// rejects a legitimate mint over an integer nothing consumes, and re-imposes
// v1's ~2^31 birthday bound on ids whose whole point is full-width SHA-256
// collision resistance.
test('accepts two distinct v2 ids that happen to be congruent mod 2^31-1', () => {
const stored = v2('01')
const congruent = (BigInt(`0x${stored}`) + MOD).toString(16).padStart(66, '0')
expect(congruent).not.toBe(stored)
expect(congruent.startsWith('01')).toBe(true)
expect(keysetIdInt(congruent)).toBe(keysetIdInt(stored)) // the alias exists...
expect(isCollidingKeysetId(congruent, [stored])).toBe(false) // ...and is correctly ignored
})
test('a v2 id congruent with a stored v1 id is not a collision (different derivation paths)', () => {
const storedV1 = v1('00000000000001')
const target = keysetIdInt(storedV1)
// Craft a v2 id reducing to the same int as the v1 id above.
const base = BigInt(`0x${v2('00')}`)
const delta = (target - (base % MOD) + MOD) % MOD
const congruentV2 = (base + delta).toString(16).padStart(66, '0')
expect(congruentV2.startsWith('01')).toBe(true)
expect(keysetIdInt(congruentV2)).toBe(target)
// A v2 id never derives at m/129372'/0'/{int}'/…, so it cannot share a
// path with a v1 id no matter what the ints do.
expect(isCollidingKeysetId(congruentV2, [storedV1])).toBe(false)
})
test('exact equality still wins for v2 — int gating does not disable the real check', () => {
const id = v2('cafebabe')
expect(isCollidingKeysetId(id, [id])).toBe(true)
})
})
describe('mixed wallets', () => {
test('checks every stored id, not just the first', () => {
const target = v2('deadbeef')
expect(isCollidingKeysetId(target, [v1('00000000000001'), v2('feedface'), target])).toBe(true)
})
test('a v1 int alias is still caught when the wallet also holds v2 ids', () => {
const storedV1 = v1('00000000000001')
const colliding = (BigInt(`0x${storedV1}`) + MOD).toString(16).padStart(16, '0')
expect(isCollidingKeysetId(colliding, [v2('feedface'), storedV1])).toBe(true)
})
})
})
+203
View File
@@ -0,0 +1,203 @@
/**
* Mint URL normalization + validation (services/cashu/mintUrl).
*
* The single definition of what a mint url may look like, shared by
* `MintsStore.addMint` and `Mint.setMintUrl`. Those two had drifted — adding a
* mint stripped the trailing slash and demanded https, renaming one did neither
* — so a rename could install a url that adding the same mint would have
* rejected.
*
* @jest-environment node
*/
// AppError pulls in logService -> Sentry, which is not loadable under the node
// test environment.
jest.mock('../src/services/logService', () => ({
log: {
debug: jest.fn(),
error: jest.fn(),
info: jest.fn(),
trace: jest.fn(),
warn: jest.fn(),
},
}))
import {Mint as CashuMint} from '@cashu/cashu-ts'
import {normalizeMintUrl, isOnionMintUrl} from '../src/services/cashu/mintUrl'
import AppError, {Err} from '../src/utils/AppError'
const expectValidationError = (fn: () => unknown) => {
expect(fn).toThrow(AppError)
try {
fn()
} catch (e: any) {
expect(e.name).toBe(Err.VALIDATION_ERROR)
}
}
describe('normalizeMintUrl', () => {
describe('trailing slashes (cashu spec: canonical form)', () => {
test('strips a single trailing slash', () => {
expect(normalizeMintUrl('https://mint.example/')).toBe('https://mint.example')
})
test('strips repeated trailing slashes', () => {
expect(normalizeMintUrl('https://mint.example///')).toBe('https://mint.example')
})
test('leaves a url with no trailing slash alone', () => {
expect(normalizeMintUrl('https://mint.example')).toBe('https://mint.example')
})
test('does NOT leave the slash the URL parser appends', () => {
// new URL('https://mint.example').href === 'https://mint.example/', so the
// strip has to happen AFTER canonicalization, not before it.
expect(normalizeMintUrl('https://mint.example')).not.toMatch(/\/$/)
})
test('preserves a path while stripping its trailing slash', () => {
expect(normalizeMintUrl('https://mint.example/cashu/')).toBe('https://mint.example/cashu')
})
test('trims surrounding whitespace (pasted urls)', () => {
expect(normalizeMintUrl(' https://mint.example/ ')).toBe('https://mint.example')
})
test('the two spellings of one mint normalize to the same string', () => {
// This is what makes the duplicate check able to see a trailing-slash twin.
expect(normalizeMintUrl('https://mint.example/')).toBe(normalizeMintUrl('https://mint.example'))
})
})
describe('canonical form (must equal what cashu-ts stores)', () => {
// WalletStore finds cached CashuMint/CashuWallet instances by comparing our
// stored string to CashuMint.mintUrl. If the two normalizations disagree,
// every cache lookup misses and the two spellings look like two mints.
test('lowercases the host', () => {
expect(normalizeMintUrl('https://Mint.Example')).toBe('https://mint.example')
})
test('lowercases the scheme', () => {
expect(normalizeMintUrl('HTTPS://mint.example')).toBe('https://mint.example')
})
test('drops the default https port', () => {
expect(normalizeMintUrl('https://mint.example:443')).toBe('https://mint.example')
})
test('keeps a non-default port', () => {
expect(normalizeMintUrl('https://mint.example:8443')).toBe('https://mint.example:8443')
})
test('preserves path case (paths are case-sensitive)', () => {
expect(normalizeMintUrl('https://mint.example/Cashu')).toBe('https://mint.example/Cashu')
})
test('host-case variants converge on one string', () => {
expect(normalizeMintUrl('https://MINT.example/')).toBe(normalizeMintUrl('https://mint.example'))
})
})
describe('agreement with cashu-ts', () => {
// Pins our output to the library's own normalizeUrl (which is @internal, so
// it can only be observed through the CashuMint constructor). If cashu-ts
// changes its canonical form, this fails rather than silently splitting the
// wallet's cache keys.
test.each([
'https://mint.example',
'https://mint.example/',
'https://mint.example///',
'https://Mint.Example',
'HTTPS://MINT.EXAMPLE/',
'https://mint.example:443/',
'https://mint.example:8443/cashu/',
'https://mint.example/Cashu',
])('normalizeMintUrl(%s) === new CashuMint(...).mintUrl', url => {
expect(normalizeMintUrl(url)).toBe(new CashuMint(url).mintUrl)
})
})
describe('https requirement', () => {
test('accepts https', () => {
expect(normalizeMintUrl('https://mint.example')).toBe('https://mint.example')
})
test('rejects plain http', () => {
expectValidationError(() => normalizeMintUrl('http://mint.example'))
})
test('rejects a non-http scheme', () => {
expectValidationError(() => normalizeMintUrl('ftp://mint.example'))
})
test('rejects a scheme merely PREFIXED with https', () => {
// `startsWith('https')` — the old check — passes this; it parses as scheme
// "https-evil:", which is not https at all.
expectValidationError(() => normalizeMintUrl('https-evil://mint.example'))
})
})
describe('onion exemption', () => {
test('accepts http for a .onion host (Tor authenticates the endpoint)', () => {
expect(normalizeMintUrl('http://abcdef.onion')).toBe('http://abcdef.onion')
})
test('accepts https for a .onion host', () => {
expect(normalizeMintUrl('https://abcdef.onion/')).toBe('https://abcdef.onion')
})
// The regression the hostname check closes: addMint tested
// `mintUrl.includes('.onion')`, so a '.onion' ANYWHERE in the string bought a
// plain-http exemption for an ordinary host.
test('does NOT let ".onion" in the PATH exempt a plain-http host', () => {
expectValidationError(() => normalizeMintUrl('http://evil.example/.onion'))
})
test('does NOT let ".onion" in the QUERY exempt a plain-http host', () => {
expectValidationError(() => normalizeMintUrl('http://evil.example?x=.onion'))
})
test('does NOT let a ".onion." subdomain prefix exempt a plain-http host', () => {
expectValidationError(() => normalizeMintUrl('http://x.onion.evil.example'))
})
})
describe('malformed input', () => {
test('rejects an empty string', () => {
expectValidationError(() => normalizeMintUrl(''))
})
test('rejects whitespace only', () => {
expectValidationError(() => normalizeMintUrl(' '))
})
test('rejects a non-url string', () => {
expectValidationError(() => normalizeMintUrl('not a url'))
})
test('rejects a scheme-less host', () => {
expectValidationError(() => normalizeMintUrl('mint.example'))
})
})
})
describe('isOnionMintUrl', () => {
test('true for a .onion hostname', () => {
expect(isOnionMintUrl('http://abcdef.onion')).toBe(true)
})
test('true for a .onion hostname with a port and path', () => {
expect(isOnionMintUrl('http://abcdef.onion:8080/cashu')).toBe(true)
})
test('false when .onion appears only in the path', () => {
expect(isOnionMintUrl('https://evil.example/.onion')).toBe(false)
})
test('false for an ordinary host', () => {
expect(isOnionMintUrl('https://mint.example')).toBe(false)
})
test('false (not a throw) for an unparseable url', () => {
expect(isOnionMintUrl('not a url')).toBe(false)
})
})
+257
View File
@@ -0,0 +1,257 @@
/**
* 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()
})
})
+203
View File
@@ -0,0 +1,203 @@
/**
* Mint-url rewrite scoping for transactions
* (Database.updateInFlightTransactionsMintUrl).
*
* `transactions.mint` carries two meanings, switched by status:
*
* - TERMINAL: a historical record of where the payment actually happened.
* Rewriting it would falsify history, so a mint-url edit must leave it alone.
* - IN-FLIGHT: a LIVE pointer the wallet still calls —
* checkLightningMintQuote(tx.mint, tx.quote) in topupOperationApi,
* checkLightningMeltQuote / checkOnchainMeltQuote in transferOperationApi,
* findByUrl(tx.mint) on the revert/receive paths. Left stale after an edit, an
* open transaction is stranded at a dead url — a paid topup whose ecash the
* wallet can never mint.
*
* Mirrors the production SQL with node:sqlite, as the native driver needs a
* device — but takes the status list from the REAL IN_FLIGHT_STATUSES rather
* than a copy, so the mirror cannot drift from what production actually runs.
*
* @jest-environment node
*/
jest.mock('../src/services/logService', () => ({
log: {debug: jest.fn(), error: jest.fn(), info: jest.fn(), trace: jest.fn(), warn: jest.fn()},
}))
jest.mock('../src/services', () => ({
Database: {},
log: {debug: jest.fn(), error: jest.fn(), info: jest.fn(), trace: jest.fn(), warn: jest.fn()},
}))
import {DatabaseSync} from 'node:sqlite'
import {IN_FLIGHT_STATUSES} from '../src/models/TransactionStates'
import {TransactionStatus} from '../src/models/Transaction'
const OLD_URL = 'https://old.mint.test'
const NEW_URL = 'https://new.mint.test'
const OTHER_URL = 'https://other.mint.test'
/** The exact set the production UPDATE binds. */
const IN_FLIGHT = [...IN_FLIGHT_STATUSES] as string[]
/** Everything else — by construction, so a new enum member lands here loudly. */
const TERMINAL = Object.values(TransactionStatus).filter(s => !IN_FLIGHT.includes(s)) as string[]
const CREATE_TRANSACTIONS = `CREATE TABLE transactions (
id INTEGER PRIMARY KEY NOT NULL,
type TEXT,
amount INTEGER,
unit TEXT,
data TEXT,
mint TEXT,
status TEXT,
createdAt TEXT
)`
/** Mirrors transactionsRepo.updateInFlightTransactionsMintUrl. */
function updateInFlightTransactionsMintUrl(
db: DatabaseSync,
currentMintUrl: string,
updatedMintUrl: string,
): number {
const placeholders = IN_FLIGHT.map(() => '?').join(', ')
const {changes} = db
.prepare(
`UPDATE transactions
SET mint = ?
WHERE mint = ? AND status IN (${placeholders})`,
)
.run(updatedMintUrl, currentMintUrl, ...IN_FLIGHT)
return Number(changes)
}
let nextId = 1
function insertTx(db: DatabaseSync, mint: string, status: string): number {
const id = nextId++
db.prepare(
`INSERT INTO transactions (id, type, amount, unit, data, mint, status, createdAt)
VALUES (?, 'TOPUP', 100, 'sat', '{}', ?, ?, '2026-01-01')`,
).run(id, mint, status)
return id
}
function mintOf(db: DatabaseSync, id: number): string {
const row = db.prepare('SELECT mint FROM transactions WHERE id = ?').get(id) as {mint: string}
return row.mint
}
function freshDb(): DatabaseSync {
const db = new DatabaseSync(':memory:')
db.exec(CREATE_TRANSACTIONS)
nextId = 1
return db
}
describe('updateInFlightTransactionsMintUrl', () => {
describe('in-flight transactions are repointed', () => {
test.each(IN_FLIGHT)('%s is repointed to the new url', status => {
const db = freshDb()
const id = insertTx(db, OLD_URL, status)
expect(updateInFlightTransactionsMintUrl(db, OLD_URL, NEW_URL)).toBe(1)
expect(mintOf(db, id)).toBe(NEW_URL)
db.close()
})
})
describe('terminal transactions keep their historical url', () => {
test.each(TERMINAL)('%s is left untouched', status => {
const db = freshDb()
const id = insertTx(db, OLD_URL, status)
expect(updateInFlightTransactionsMintUrl(db, OLD_URL, NEW_URL)).toBe(0)
expect(mintOf(db, id)).toBe(OLD_URL)
db.close()
})
})
test('splits a mixed history: open rows move, closed rows stay', () => {
const db = freshDb()
const pending = insertTx(db, OLD_URL, 'PENDING')
const executing = insertTx(db, OLD_URL, 'EXECUTING')
const completed = insertTx(db, OLD_URL, 'COMPLETED')
const reverted = insertTx(db, OLD_URL, 'REVERTED')
expect(updateInFlightTransactionsMintUrl(db, OLD_URL, NEW_URL)).toBe(2)
expect(mintOf(db, pending)).toBe(NEW_URL)
expect(mintOf(db, executing)).toBe(NEW_URL)
expect(mintOf(db, completed)).toBe(OLD_URL)
expect(mintOf(db, reverted)).toBe(OLD_URL)
db.close()
})
test('does not touch another mint\'s in-flight transactions', () => {
const db = freshDb()
const mine = insertTx(db, OLD_URL, 'PENDING')
const theirs = insertTx(db, OTHER_URL, 'PENDING')
expect(updateInFlightTransactionsMintUrl(db, OLD_URL, NEW_URL)).toBe(1)
expect(mintOf(db, mine)).toBe(NEW_URL)
expect(mintOf(db, theirs)).toBe(OTHER_URL)
db.close()
})
test('is a no-op when the mint has no transactions', () => {
const db = freshDb()
expect(updateInFlightTransactionsMintUrl(db, OLD_URL, NEW_URL)).toBe(0)
db.close()
})
test('is idempotent — re-running finds nothing left at the old url', () => {
const db = freshDb()
const pending = insertTx(db, OLD_URL, 'PENDING')
expect(updateInFlightTransactionsMintUrl(db, OLD_URL, NEW_URL)).toBe(1)
expect(updateInFlightTransactionsMintUrl(db, OLD_URL, NEW_URL)).toBe(0)
expect(mintOf(db, pending)).toBe(NEW_URL)
db.close()
})
test('a second rename chains correctly (A -> B -> C)', () => {
const db = freshDb()
const pending = insertTx(db, OLD_URL, 'PENDING')
const completed = insertTx(db, OLD_URL, 'COMPLETED')
updateInFlightTransactionsMintUrl(db, OLD_URL, NEW_URL)
updateInFlightTransactionsMintUrl(db, NEW_URL, OTHER_URL)
expect(mintOf(db, pending)).toBe(OTHER_URL)
// Still frozen at the url its payment actually used, two renames later.
expect(mintOf(db, completed)).toBe(OLD_URL)
db.close()
})
test('the in-flight set matches TransactionStates exactly', () => {
// Pins the classification itself: a status moved between the sets, or a new
// one added to neither, changes whether a rename repoints that transaction.
expect([...IN_FLIGHT].sort()).toEqual([
'DRAFT',
'EXECUTING',
'PENDING',
'PREPARED',
'PREPARED_OFFLINE',
'ROLLING_BACK',
])
})
test('every status is classified as either live or historical', () => {
// TERMINAL is derived as the complement, so this asserts the enum has not
// grown a member that silently falls into "historical" without anyone
// deciding that is right for it.
expect([...IN_FLIGHT, ...TERMINAL].sort()).toEqual(Object.values(TransactionStatus).sort())
expect(TERMINAL.sort()).toEqual([
'BLOCKED',
'COMPLETED',
'ERROR',
'EXPIRED',
'RECOVERED',
'REVERTED',
])
})
})
+74 -49
View File
@@ -1,4 +1,4 @@
import {cast, detach, flow, getParent, getRoot, getSnapshot, IAnyStateTreeNode, Instance, isAlive, IStateTreeNode, SnapshotIn, SnapshotOut, types} from 'mobx-state-tree' import {cast, detach, flow, getRoot, getSnapshot, IAnyStateTreeNode, Instance, isAlive, IStateTreeNode, SnapshotIn, SnapshotOut, types} from 'mobx-state-tree'
import {withSetPropAction} from './helpers/withSetPropAction' import {withSetPropAction} from './helpers/withSetPropAction'
import { import {
type GetInfoResponse, type GetInfoResponse,
@@ -17,6 +17,7 @@ import { getRootStore } from './helpers/getRootStore'
import { generateId } from '../utils/utils' import { generateId } from '../utils/utils'
import { Proof } from './Proof' import { Proof } from './Proof'
import { CashuProof, CashuUtils } from '../services/cashu/cashuUtils' import { CashuProof, CashuUtils } from '../services/cashu/cashuUtils'
import { normalizeMintUrl } from '../services/cashu/mintUrl'
/** /**
* A mint payment method — the rail the mint settles on. * A mint payment method — the rail the mint settles on.
@@ -74,9 +75,11 @@ const migrateSnapshot = (snapshot: any): any => {
* counter persistence ("W1"). * counter persistence ("W1").
* *
* `mode: 'set'` persists an absolute value monotonically (never lowers); * `mode: 'set'` persists an absolute value monotonically (never lowers);
* `mode: 'bump'` advances by a relative delta. The (mint, keyset) identity is * `mode: 'bump'` advances by a relative delta. The keyset id is the entire
* read from the parent Mint node — a counter is always nested two levels up * identity: NUT-13 derives from (seed, keysetId, counter), so a counter needs no
* (counter -> proofsCounters array -> Mint). * mint reference. This used to walk two levels up the tree for the parent Mint's
* url — which meant a counter not yet attached to a Mint silently dropped its
* write, and a mint-url edit orphaned the row it had been writing to.
* *
* This fires the instant cashu derives (right after onCountersReserved, BEFORE * This fires the instant cashu derives (right after onCountersReserved, BEFORE
* the reservation commit), so the advance is durable the moment the mint could * the reservation commit), so the advance is durable the moment the mint could
@@ -89,29 +92,18 @@ const migrateSnapshot = (snapshot: any): any => {
* not break a wallet flow, and there is a complementary safety net — * not break a wallet flow, and there is a complementary safety net —
* commitReservation re-persists this same value ATOMICALLY with the proofs * commitReservation re-persists this same value ATOMICALLY with the proofs
* ("W2", see reservationsRepo), so even if this write is dropped a successful * ("W2", see reservationsRepo), so even if this write is dropped a successful
* commit cannot leave the counter behind its proofs. A detached instance (a * commit cannot leave the counter behind its proofs.
* counter freshly created by createProofsCounter, not yet pushed onto a Mint)
* has no parent and is a no-op here.
*/ */
const persistCounter = (self: any, mode: 'set' | 'bump', value: number): void => { const persistCounter = (self: any, mode: 'set' | 'bump', value: number): void => {
let mintUrl: string | undefined
try {
mintUrl = getParent<any>(self, 2)?.mintUrl
} catch {
return // not attached to a Mint (freshly created counter) — nothing to persist
}
if (!mintUrl) return
try { try {
if (mode === 'set') { if (mode === 'set') {
Database.setCounter(mintUrl, self.keyset, self.unit, value) Database.setCounter(self.keyset, self.unit, value)
} else { } else {
Database.bumpCounter(mintUrl, self.keyset, self.unit, value) Database.bumpCounter(self.keyset, self.unit, value)
} }
} catch (e: any) { } catch (e: any) {
log.error('[persistCounter]', 'Counter write-through failed', { log.error('[persistCounter]', 'Counter write-through failed', {
error: e?.message, error: e?.message,
mintUrl,
keyset: self.keyset, keyset: self.keyset,
}) })
} }
@@ -457,16 +449,8 @@ export const MintModel = types
return existing return existing
} }
self.addKeys(key) self.addKeys(key)
log.trace('[initKeys]', {newKeys: key.id}) log.trace('[initKeys]', {newKeys: key.id})
},
validateURL(url: string) {
try {
new URL(url)
return true
} catch (e) {
return false
}
}, },
})) }))
.actions(self => ({ .actions(self => ({
@@ -520,26 +504,6 @@ export const MintModel = types
return false return false
} }
}, },
setMintUrl(url: string) {
if(self.validateURL(url)) {
const mintsStore = getRootStore(self).mintsStore
//log.trace('[setMintUrl]', {mintsStore})
if(!mintsStore.alreadyExists(url)) {
const proofsStore = getRootStore(self).proofsStore
proofsStore.updateMintUrl(self.mintUrl, url) // update mintUrl on mint's proofs
self.mintUrl = url
return true
}
throw new AppError(Err.VALIDATION_ERROR, 'Mint URL already exists.', {url})
} else {
throw new AppError(Err.VALIDATION_ERROR, 'Invalid Mint URL.', {url})
}
},
setId() { // migration setId() { // migration
self.id = generateId(8) self.id = generateId(8)
}, },
@@ -602,7 +566,68 @@ export const MintModel = types
}, },
get keysetIds(): string[] { get keysetIds(): string[] {
return self.keysets.map(k => k.id) return self.keysets.map(k => k.id)
} }
}))
// Declared after the block holding setHostname so it can call it — MST types
// `self` without the actions of its own block.
.actions(self => ({
/**
* Move this mint to a new url, carrying over the state that points at it
* BY url.
*
* A mint url is a network LOCATOR, not an identity — the mint here is the
* same mint (callers confirm that by matching keysets), it just answers
* somewhere else now. So everything addressed by the old location has to
* follow. What follows, and what deliberately does not:
*
* - proofs — repointed. `proofs.mintUrl` is how a balance is found.
* - in-flight transactions — repointed. Their `mint` is a live pointer the
* wallet still calls; stale, it strands an open payment at a dead url.
* - terminal transactions — NOT repointed. There the url is a historical
* record of where the payment happened. See
* Database.updateInFlightTransactionsMintUrl.
* - derivation counters — nothing to do: keyed by keysetId, which no url
* edit can disturb (see MINT_COUNTERS_COLUMNS).
*
* NOT yet carried over — each still keyed by url, and tracked as step 3 of
* the mint-identity work: onchain mint quotes, in-flight requests, melt
* recovery rows, and open reservations. Renaming a mint with any of those
* outstanding still strands them.
*
* Validation runs through the same normalizer as `addMint`, so a rename can
* no longer install a url that adding the same mint would have rejected.
*/
setMintUrl(url: string) {
// Throws AppError(VALIDATION_ERROR) on a malformed or non-https url.
const normalized = normalizeMintUrl(url)
const currentMintUrl = self.mintUrl
// Renaming to the url already held (or merely its trailing-slash
// spelling) is a no-op, not a duplicate — the check below would
// otherwise match this very mint and reject.
if (normalized === currentMintUrl) {
return true
}
const {mintsStore, proofsStore, transactionsStore} = getRootStore(self)
// mintExists (normalized), not alreadyExists (literal string compare):
// the latter misses a twin differing only by a trailing slash, which
// would let one real mint become two Mint nodes.
if (mintsStore.mintExists(normalized)) {
throw new AppError(Err.VALIDATION_ERROR, 'Mint URL already exists.', {url: normalized})
}
proofsStore.updateMintUrl(currentMintUrl, normalized)
transactionsStore.updateMintUrl(currentMintUrl, normalized)
self.mintUrl = normalized
self.setHostname() // derived from mintUrl — stale until recomputed
log.debug('[setMintUrl]', 'Mint url updated', {currentMintUrl, updatedMintUrl: normalized})
return true
},
})) }))
+25 -20
View File
@@ -8,7 +8,8 @@ import {
flow, flow,
} from 'mobx-state-tree' } from 'mobx-state-tree'
import {withSetPropAction} from './helpers/withSetPropAction' import {withSetPropAction} from './helpers/withSetPropAction'
import {MintModel, Mint} from './Mint' import {MintModel, Mint, MintProofsCounter} from './Mint'
import {normalizeMintUrl} from '../services/cashu/mintUrl'
import {log} from '../services/logService' import {log} from '../services/logService'
import {Database} from '../services' import {Database} from '../services'
import AppError, { Err } from '../utils/AppError' import AppError, { Err } from '../utils/AppError'
@@ -67,31 +68,34 @@ export const MintsStoreModel = types
* Load the authoritative counter values from SQLite into the in-memory * Load the authoritative counter values from SQLite into the in-memory
* cache (startup / foreground resume). Monotonic per counter, so a value * cache (startup / foreground resume). Monotonic per counter, so a value
* already advanced in memory is never lowered. * already advanced in memory is never lowered.
*
* Matched on keysetId alone. Keyset ids are unique wallet-wide (enforced by
* CashuUtils.isCollidingKeysetId), so the owning mint is whichever one holds
* the keyset — and a mint-url edit no longer strands the row, which used to
* leave the counter at its volatile 0 and risk blinded-secret reuse.
*/ */
hydrateCountersFromDatabase() { hydrateCountersFromDatabase() {
const rows = Database.getCounters() const rows = Database.getCounters()
if (rows.length === 0) return
const countersByKeyset = new Map<string, MintProofsCounter>()
for (const mint of self.mints) {
for (const counter of mint.proofsCounters) {
countersByKeyset.set(counter.keyset, counter)
}
}
for (const row of rows) { for (const row of rows) {
const mint = self.mints.find(m => m.mintUrl === row.mintUrl) countersByKeyset.get(row.keysetId)?.hydrateCounterFromDb(row.counter)
const counter = mint?.proofsCounters.find(c => c.keyset === row.keysetId)
if (counter) {
counter.hydrateCounterFromDb(row.counter)
}
} }
}, },
})) }))
.actions(self => ({ .actions(self => ({
addMint: flow(function* addMint(mintUrl: string) { addMint: flow(function* addMint(mintUrl: string) {
if(!mintUrl) { // Strips trailing slashes (cashu spec) and requires https outside Tor.
throw new AppError(Err.VALIDATION_ERROR, 'Mint URL is required.') // Shared with Mint.setMintUrl so adding and renaming cannot disagree
} // about what a valid mint url is.
mintUrl = normalizeMintUrl(mintUrl)
// Cashu spec: mint URL must be stripped of trailing slashes
mintUrl = mintUrl.replace(/\/$/, '')
if(!mintUrl.includes('.onion') && !mintUrl.startsWith('https')) {
throw new AppError(Err.VALIDATION_ERROR, 'Mint URL needs to start with https.')
}
if(self.mintExists(mintUrl)) { if(self.mintExists(mintUrl)) {
throw new AppError(Err.VALIDATION_ERROR, 'Mint URL already exists.', {mintUrl}) throw new AppError(Err.VALIDATION_ERROR, 'Mint URL already exists.', {mintUrl})
@@ -144,10 +148,11 @@ export const MintsStoreModel = types
self.mints.push(mintInstance) self.mints.push(mintInstance)
// SQLite retains derivation counters by (mintUrl, keysetId) across // SQLite retains derivation counters by keysetId across mint removal
// mint removal (rows are never deleted), so a re-added mint recovers // (rows are never deleted), so a re-added mint recovers its real
// its real counter from the authority here. Monotonic, so a genuinely // counter from the authority here — now also when it is re-added under
// new mint (no row) simply stays at 0. // a DIFFERENT url, since the row is keyed by keyset, not location.
// Monotonic, so a genuinely new mint (no row) simply stays at 0.
self.hydrateCountersFromDatabase() self.hydrateCountersFromDatabase()
return mintInstance return mintInstance
+11 -3
View File
@@ -203,7 +203,17 @@ import {
} }
}, },
/**
* Repoint this mint's proofs at its new url (see Mint.setMintUrl).
*
* SQLite first: a failed write then propagates with the MST tree still
* matching the database. The reverse order would leave memory holding a url
* SQLite never got — the UI would show the balance under the new mint until
* the next restart silently moved it back.
*/
updateMintUrl(currentMintUrl: string, updatedMintUrl: string) { updateMintUrl(currentMintUrl: string, updatedMintUrl: string) {
Database.updateProofsMintUrl(currentMintUrl, updatedMintUrl)
const updateInMap = (map: typeof self.proofs) => { const updateInMap = (map: typeof self.proofs) => {
for (const proof of map.values()) { for (const proof of map.values()) {
if (proof.mintUrl === currentMintUrl) { if (proof.mintUrl === currentMintUrl) {
@@ -219,7 +229,6 @@ import {
updateInMap(self.proofs) updateInMap(self.proofs)
Database.updateProofsMintUrl(currentMintUrl, updatedMintUrl)
log.trace('[updateMintUrl] Updated mint URL in proofs') log.trace('[updateMintUrl] Updated mint URL in proofs')
}, },
@@ -354,7 +363,7 @@ import {
// backstop, so a committed proof can never outlive its counter even if // backstop, so a committed proof can never outlive its counter even if
// the W1 write-through was dropped. Monotonic, so the normal-path // the W1 write-through was dropped. Monotonic, so the normal-path
// double write is a harmless no-op. // double write is a harmless no-op.
const counterUpdate: Array<{mintUrl: string; keysetId: string; unit?: string; counter: number}> = [] const counterUpdate: Array<{keysetId: string; unit?: string; counter: number}> = []
const seenKeysets = new Set<string>() const seenKeysets = new Set<string>()
for (const group of changes.newProofs ?? []) { for (const group of changes.newProofs ?? []) {
for (const proof of group.proofs) { for (const proof of group.proofs) {
@@ -363,7 +372,6 @@ import {
const counter = mintInstance.getProofsCounter(proof.id) const counter = mintInstance.getProofsCounter(proof.id)
if (counter) { if (counter) {
counterUpdate.push({ counterUpdate.push({
mintUrl: reservation.mintUrl,
keysetId: proof.id, keysetId: proof.id,
unit: counter.unit, unit: counter.unit,
counter: counter.counter, counter: counter.counter,
+8 -1
View File
@@ -204,7 +204,14 @@ const TERMINAL_STATUSES: ReadonlySet<TransactionStatus> = new Set([
TransactionStatus.RECOVERED, TransactionStatus.RECOVERED,
]) ])
const IN_FLIGHT_STATUSES: ReadonlySet<TransactionStatus> = new Set([ /**
* Statuses in which a transaction is still open — the complement of
* TERMINAL_STATUSES. Exported because it is not only a type-narrowing concern:
* an open transaction's `mint` url is a LIVE pointer the wallet still calls,
* whereas a terminal one's is a historical record (see
* Database.updateInFlightTransactionsMintUrl).
*/
export const IN_FLIGHT_STATUSES: ReadonlySet<TransactionStatus> = new Set([
TransactionStatus.DRAFT, TransactionStatus.DRAFT,
TransactionStatus.PREPARED, TransactionStatus.PREPARED,
TransactionStatus.PREPARED_OFFLINE, TransactionStatus.PREPARED_OFFLINE,
+23
View File
@@ -12,6 +12,7 @@ import {
TransactionStatus, TransactionStatus,
TransactionType, TransactionType,
} from './Transaction' } from './Transaction'
import { isInFlight } from './TransactionStates'
import { Database } from '../services' import { Database } from '../services'
import { log } from '../services/logService' import { log } from '../services/logService'
import { getRootStore } from './helpers/getRootStore' import { getRootStore } from './helpers/getRootStore'
@@ -154,6 +155,28 @@ export const TransactionsStoreModel = types
log.trace('[pruneRecentByUnit]', `${recent.length - keepIds.size} pruned for unit ${unit}`) log.trace('[pruneRecentByUnit]', `${recent.length - keepIds.size} pruned for unit ${unit}`)
}, },
/**
* Mirror a mint-url edit onto in-flight transactions only — see
* Database.updateInFlightTransactionsMintUrl for why terminal rows keep
* the url their payment actually used.
*
* SQLite first: if the write throws, the exception propagates with the MST
* tree still matching the database. Updating memory first would leave the
* UI reading a url the database does not have, which the next restart
* would silently revert.
*/
updateMintUrl(currentMintUrl: string, updatedMintUrl: string) {
Database.updateInFlightTransactionsMintUrl(currentMintUrl, updatedMintUrl)
for (const tx of self.transactionsMap.values()) {
if (tx.mint === currentMintUrl && isInFlight(tx)) {
tx.setProp('mint', updatedMintUrl)
}
}
log.trace('[updateMintUrl] Repointed in-flight transactions', {currentMintUrl, updatedMintUrl})
},
pruneRecentWithoutCurrentMint() { pruneRecentWithoutCurrentMint() {
const { mintsStore } = getRootStore(self) const { mintsStore } = getRootStore(self)
const validUrls = new Set(mintsStore.allMints.map(m => m.mintUrl)) const validUrls = new Set(mintsStore.allMints.map(m => m.mintUrl))
+2 -2
View File
@@ -265,7 +265,7 @@ async function _runMigrations(rootStore: RootStore, restoredState: any) {
for (const mint of restoredState?.mintsStore?.mints ?? []) { for (const mint of restoredState?.mintsStore?.mints ?? []) {
for (const counter of mint?.proofsCounters ?? []) { for (const counter of mint?.proofsCounters ?? []) {
if (counter?.keyset && typeof counter.counter === 'number' && counter.counter > 0) { if (counter?.keyset && typeof counter.counter === 'number' && counter.counter > 0) {
seeds.push({mintUrl: mint.mintUrl, keysetId: counter.keyset, unit: counter.unit, counter: counter.counter}) seeds.push({keysetId: counter.keyset, unit: counter.unit, counter: counter.counter})
} }
} }
} }
@@ -342,7 +342,7 @@ async function _runMigrations(rootStore: RootStore, restoredState: any) {
for (const backup of restoredState?.mintsStore?.counterBackups ?? []) { for (const backup of restoredState?.mintsStore?.counterBackups ?? []) {
for (const c of backup?.counters ?? []) { for (const c of backup?.counters ?? []) {
if (c?.keyset && typeof c.counter === 'number' && c.counter > 0) { if (c?.keyset && typeof c.counter === 'number' && c.counter > 0) {
seeds.push({mintUrl: backup.mintUrl, keysetId: c.keyset, unit: c.unit, counter: c.counter}) seeds.push({keysetId: c.keyset, unit: c.unit, counter: c.counter})
} }
} }
} }
+1 -1
View File
@@ -217,7 +217,7 @@ export const ImportBackupScreen = observer(function ImportBackupScreen({ route }
// backup JSON still carries it, so read it off the raw value. // backup JSON still carries it, so read it off the raw value.
const counter = (pc as any).counter const counter = (pc as any).counter
if (pc?.keyset && typeof counter === 'number' && counter > 0) { if (pc?.keyset && typeof counter === 'number' && counter > 0) {
counterSeeds.push({mintUrl: mint.mintUrl, keysetId: pc.keyset, unit: pc.unit, counter}) counterSeeds.push({keysetId: pc.keyset, unit: pc.unit, counter})
} }
} }
} }
+18 -8
View File
@@ -23,6 +23,7 @@ import {Mint} from '../models/Mint'
import {useStores} from '../models' import {useStores} from '../models'
import {useHeader} from '../utils/useHeader' import {useHeader} from '../utils/useHeader'
import {log} from '../services/logService' import {log} from '../services/logService'
import {normalizeMintUrl} from '../services/cashu/mintUrl'
import AppError, { Err } from '../utils/AppError' import AppError, { Err } from '../utils/AppError'
import {translate} from '../i18n' import {translate} from '../i18n'
import {MintListItem} from './Mints/MintListItem' import {MintListItem} from './Mints/MintListItem'
@@ -157,31 +158,40 @@ export const MintsScreen = observer(function MintsScreen({ route }: Props) {
const updateMintUrl = async function () { const updateMintUrl = async function () {
if (!selectedMint) {return} if (!selectedMint) {return}
try { try {
if (isStateTreeNode(selectedMint)) { // update URL of existing mint if (isStateTreeNode(selectedMint)) { // update URL of existing mint
// checks if mint is reachable on new url, if it the same mint by checking keysets and syncs local data // Normalize before anything else: rejects a malformed or non-https url
if(mintsStore.alreadyExists(mintUrl)) { // without a network round-trip, and yields the exact string that will be
// stored — so the keyset check below runs against that same url.
const normalized = normalizeMintUrl(mintUrl)
// Fast-fail ahead of the fetch. setMintUrl re-checks and remains the
// authority; renaming to the url already held is a no-op there, so it
// must not be reported as a duplicate here either.
if(normalized !== selectedMint.mintUrl && mintsStore.mintExists(normalized)) {
throw new AppError(Err.VALIDATION_ERROR, 'Mint with this URL already exists.') throw new AppError(Err.VALIDATION_ERROR, 'Mint with this URL already exists.')
} }
toggleAddMintModal() // close toggleAddMintModal() // close
setIsLoading(true) setIsLoading(true)
const keysets: MintKeyset[] = await walletStore.getMintKeysets(mintUrl) // Checks the mint is reachable on the new url AND is the same mint, by
// matching keysets against the ones we already hold.
const keysets: MintKeyset[] = await walletStore.getMintKeysets(normalized)
const matchingKeyset = keysets.find(keyset => selectedMint.keysets?.some(k => k.id === keyset.id)) const matchingKeyset = keysets.find(keyset => selectedMint.keysets?.some(k => k.id === keyset.id))
if(!matchingKeyset) { if(!matchingKeyset) {
throw new AppError(Err.VALIDATION_ERROR, 'No keyset match, provided URL likely points to a different mint.') throw new AppError(Err.VALIDATION_ERROR, 'No keyset match, provided URL likely points to a different mint.')
} }
selectedMint.setMintUrl!(mintUrl) selectedMint.setMintUrl!(normalized)
} }
} catch (e: any) { } catch (e: any) {
handleError(e) handleError(e)
} finally { } finally {
setMintUrl('') setMintUrl('')
setIsLoading(false) setIsLoading(false)
onMintUnselect() // close onMintUnselect() // close
} }
} }
+49 -4
View File
@@ -440,6 +440,21 @@ function getKeysetIdInt(keysetId: string): bigint {
} }
} }
/**
* Whether a keyset id derives via the deprecated BIP-32 path (NUT-13).
*
* Mirrors cashu-ts `getDerivationKind`: legacy base64 ids and hex ids carrying the
* `00` version byte derive at `m/129372'/0'/{keysetIdInt}'/{counter}'`, where
* `keysetIdInt` is the id reduced mod 2^31-1 to fit a hardened BIP-32 index.
* NUT-02 v2 ids (`01`) instead derive by HMAC-SHA256 over the FULL id, so no
* integer is ever computed from them and that reduction cannot alias.
*/
function usesBip32Derivation(keysetId: string): boolean {
const isHex = /^[0-9a-fA-F]+$/.test(keysetId)
if (!isHex) return true // legacy base64 keyset id
return keysetId.startsWith('00')
}
const exportProofs = (proofs: Proof[]): CashuProof[] => { const exportProofs = (proofs: Proof[]): CashuProof[] => {
@@ -457,13 +472,39 @@ const exportProofs = (proofs: Proof[]): CashuProof[] => {
} }
/**
* Reject a keyset whose id collides with one the wallet already holds, per NUT-02:
* "Wallet implementations should reject any attempt at importing new keysets which
* IDs collide with any of the previously added keysets."
*
* `storedKeysetIds` is wallet-wide (every keyset of every mint), and upholding this
* across mints is what makes a keyset id a sound primary key for a derivation
* counter — see MINT_COUNTERS_COLUMNS.
*
* Two checks, guarding different things:
*
* - Exact id equality — always applies. One id means one NUT-13 derivation
* sequence, so two mints sharing an id would share (and burn through) one
* counter.
*
* - keysetIdInt equality — ONLY between ids that both derive via the deprecated
* BIP-32 path, where the id is reduced mod 2^31-1 to fit a hardened index and
* two distinct ids that are congruent therefore land on the SAME derivation
* path. NUT-02 v2 (`01`) ids derive by HMAC over the full 32-byte id and never
* compute that integer, so applying the check to them would reject a legitimate
* mint over a number nothing consumes — and would re-impose the ~2^31 birthday
* bound on ids whose whole point is full-width SHA-256 collision resistance.
* Ids of different derivation kinds can never share a path, so they are skipped.
*/
function isCollidingKeysetId( function isCollidingKeysetId(
newKeysetId: string, newKeysetId: string,
storedKeysetIds: string[], storedKeysetIds: string[],
) { ) {
const newKeysetIdInt = getKeysetIdInt(newKeysetId) const newUsesBip32 = usesBip32Derivation(newKeysetId)
const newKeysetIdInt = newUsesBip32 ? getKeysetIdInt(newKeysetId) : undefined
return storedKeysetIds.some((storedId) => { return storedKeysetIds.some((storedId) => {
if (storedId === newKeysetId) { if (storedId === newKeysetId) {
// Colliding keyset ID! // Colliding keyset ID!
log.error('[isCollidingKeysetId] Colliding keyset ID', { log.error('[isCollidingKeysetId] Colliding keyset ID', {
@@ -473,14 +514,18 @@ function isCollidingKeysetId(
return true return true
} }
if (!newUsesBip32 || !usesBip32Derivation(storedId)) {
return false
}
const storedKeysetIdInt = getKeysetIdInt(storedId) const storedKeysetIdInt = getKeysetIdInt(storedId)
if (storedKeysetIdInt === newKeysetIdInt) { if (storedKeysetIdInt === newKeysetIdInt) {
// Colliding keyset ID integer! // Colliding keyset ID integer!
log.error('[isCollidingKeysetId] Colliding keyset ID integer', { log.error('[isCollidingKeysetId] Colliding keyset ID integer', {
newKeysetId, newKeysetId,
storedId, storedId,
newKeysetIdInt: newKeysetIdInt.toString(), newKeysetIdInt: newKeysetIdInt!.toString(),
storedKeysetIdInt: storedKeysetIdInt.toString(), storedKeysetIdInt: storedKeysetIdInt.toString(),
}) })
+85
View File
@@ -0,0 +1,85 @@
import AppError, {Err} from '../../utils/AppError'
/**
* Mint URL normalization and validation — the single definition of what a mint
* url may look like.
*
* Extracted because the two entry points had drifted: `MintsStore.addMint`
* stripped the trailing slash and demanded https, while `Mint.setMintUrl` did
* neither (it only checked `new URL()` parsed). A RENAME could therefore install
* a url that ADDING the same mint would have rejected — most damagingly a
* trailing-slash twin of a mint already held, since the duplicate check compares
* urls literally: two Mint nodes for one real mint, each accumulating its own
* state.
*
* Kept free of model imports so both callers can use it without a cycle.
*/
/**
* Whether the url points at a Tor hidden service, which is exempt from the https
* requirement (onion routing already authenticates the endpoint).
*
* Tests the parsed HOSTNAME, not the raw string. A substring test for '.onion'
* (what addMint used to do) also matches a path or query — `http://evil.com/.onion`
* would earn a plain-http exemption for an ordinary host.
*/
export const isOnionMintUrl = function (mintUrl: string): boolean {
try {
return new URL(mintUrl).hostname.endsWith('.onion')
} catch {
return false
}
}
/**
* Normalize a mint url to its canonical form, or throw AppError(VALIDATION_ERROR).
*
* Two rules, from different authorities:
*
* 1. NUT-00 requires the trailing slash be gone. On the v3 token: "The mint URL
* must be stripped of any trailing slashes (/)"; on v4: "The mint URL MUST be
* normalized by stripping any trailing slashes (/)". That is the whole of what
* the spec mandates — it says nothing about case or any other form.
*
* 2. cashu-ts canonicalizes further, and we MUST match it. `new CashuMint(url)`
* stores `normalizeUrl(url)` = `parsed.href` with trailing slashes stripped,
* which also lowercases scheme and host and drops a default port. WalletStore
* compares our stored string against that value directly (`m.mintUrl ===
* mintUrl`, `w.mint.mintUrl === mintUrl`) to find cached CashuMint/CashuWallet
* instances. Normalizing the raw input instead would let `https://Mint.Example`
* be stored while cashu-ts holds `https://mint.example`: every cache lookup
* misses, and the wallet would treat the two spellings as two different mints.
* cashu-ts's normalizeUrl is @internal (not exported), hence the reimplementation
* here — it must be kept in step with it.
*
* The https requirement is ours alone and stricter than cashu-ts, which permits
* http for any host.
*
* cashu-ts additionally rejects credentials, query strings, fragments and
* percent-encoded paths. Those are deliberately NOT re-checked here: both callers
* construct a CashuMint against the url before anything is stored, so cashu-ts
* raises them itself — duplicating the rules would only invite drift.
*/
export const normalizeMintUrl = function (mintUrl: string): string {
if (!mintUrl || !mintUrl.trim()) {
throw new AppError(Err.VALIDATION_ERROR, 'Mint URL is required.')
}
let parsed: URL
try {
parsed = new URL(mintUrl.trim())
} catch {
throw new AppError(Err.VALIDATION_ERROR, 'Invalid Mint URL.', {mintUrl})
}
// Protocol equality, not `startsWith('https')` — the latter also accepts a
// scheme merely PREFIXED with https (`https-evil://host` parses fine).
if (parsed.protocol !== 'https:' && !isOnionMintUrl(parsed.href)) {
throw new AppError(Err.VALIDATION_ERROR, 'Mint URL needs to start with https.', {mintUrl})
}
// `href` first (canonical), THEN strip: the parser appends a trailing slash to
// an origin-only url, so stripping last removes both that and any the caller
// typed. Identical to cashu-ts normalizeUrl.
return parsed.href.replace(/\/+$/, '')
}
+3 -3
View File
@@ -15,9 +15,9 @@
* m/129373'/20'/0'/0'/{counter} * m/129373'/20'/0'/0'/{counter}
* *
* where 129373' is the Cashu namespace, 20' the NUT-20 index, and {counter} an * where 129373' is the Cashu namespace, 20' the NUT-20 index, and {counter} an
* incrementing NON-hardened child index. The path has no mint or keyset * incrementing NON-hardened child index. The path has no keyset component, so the
* component, so the counter is wallet-global — see walletCountersRepo, which owns * counter is wallet-global — see walletCountersRepo, which owns it (mint_counters
* it (mint_counters is for the mint-scoped NUT-13 counters and is unrelated). * is for the keyset-scoped NUT-13 counters and is unrelated).
* *
* The spec asks for a UNIQUE key per quote, so the mint cannot link a wallet's * The spec asks for a UNIQUE key per quote, so the mint cannot link a wallet's
* quotes to each other. `allocateQuoteKeypair` is the only thing call sites * quotes to each other. `allocateQuoteKeypair` is the only thing call sites
+24 -29
View File
@@ -6,31 +6,32 @@ import {log} from '../logService'
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Per-keyset deterministic-derivation counters. // Per-keyset deterministic-derivation counters.
// //
// The `counter` is the BIP32 derivation high-water mark for a (mint, keyset) // The `counter` is the NUT-13 derivation high-water mark for a keyset. It was
// pair. It was previously held only in the MST `MintProofsCounter` model and // previously held only in the MST `MintProofsCounter` model and persisted to
// persisted to MMKV via the whole-tree snapshot — a separate persistence engine // MMKV via the whole-tree snapshot — a separate persistence engine from the
// from the proofs the counter derives, committed at a different moment. That // proofs the counter derives, committed at a different moment. That cross-engine
// cross-engine gap meant a crash between "counter advanced" (MMKV) and "proofs // gap meant a crash between "counter advanced" (MMKV) and "proofs written"
// written" (SQLite) could desync them and risk blinded-secret reuse. // (SQLite) could desync them and risk blinded-secret reuse.
// //
// This repo makes SQLite the authority for the counter so the advance can later // This repo makes SQLite the authority for the counter so the advance can later
// be folded into the SAME transaction as the proof writes. Every write here is // be folded into the SAME transaction as the proof writes. Every write here is
// MONOTONIC: a counter can never move backward. That single invariant is what // MONOTONIC: a counter can never move backward. That single invariant is what
// makes the MMKV→SQLite migration safe — a stale or racing writer can only ever // makes the MMKV→SQLite migration safe — a stale or racing writer can only ever
// be a no-op, never a regression. // be a no-op, never a regression.
//
// Rows are keyed by keysetId ALONE — the mint url is not an input to NUT-13
// derivation, so it was never part of this key space. See MINT_COUNTERS_COLUMNS.
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
export type CounterRecord = { export type CounterRecord = {
mintUrl: string
keysetId: string keysetId: string
unit: string | null unit: string | null
counter: number counter: number
updatedAt: string | null updatedAt: string | null
} }
/** A single (mint, keyset, value) tuple for the one-time seed from MST/MMKV. */ /** A single (keyset, value) tuple for the one-time seed from MST/MMKV. */
export type CounterSeed = { export type CounterSeed = {
mintUrl: string
keysetId: string keysetId: string
unit?: string unit?: string
counter: number counter: number
@@ -43,20 +44,19 @@ export type CounterSeed = {
* only ever rises to MAX(existing, value); a lower value is a no-op. * only ever rises to MAX(existing, value); a lower value is a no-op.
*/ */
export const buildCounterUpsert = function ( export const buildCounterUpsert = function (
mintUrl: string,
keysetId: string, keysetId: string,
unit: string | undefined, unit: string | undefined,
value: number, value: number,
now: string = new Date().toISOString(), now: string = new Date().toISOString(),
): SQLBatchTuple { ): SQLBatchTuple {
return [ return [
`INSERT INTO mint_counters (mintUrl, keysetId, unit, counter, updatedAt) `INSERT INTO mint_counters (keysetId, unit, counter, updatedAt)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?)
ON CONFLICT(mintUrl, keysetId) DO UPDATE SET ON CONFLICT(keysetId) DO UPDATE SET
counter = MAX(counter, excluded.counter), counter = MAX(counter, excluded.counter),
unit = excluded.unit, unit = excluded.unit,
updatedAt = excluded.updatedAt`, updatedAt = excluded.updatedAt`,
[mintUrl, keysetId, unit ?? null, value, now], [keysetId, unit ?? null, value, now],
] ]
} }
@@ -64,7 +64,7 @@ export const buildCounterUpsert = function (
export const getCounters = function (): CounterRecord[] { export const getCounters = function (): CounterRecord[] {
try { try {
const db = getInstance() const db = getInstance()
const {rows} = db.execute(`SELECT mintUrl, keysetId, unit, counter, updatedAt FROM mint_counters`) const {rows} = db.execute(`SELECT keysetId, unit, counter, updatedAt FROM mint_counters`)
return (rows?._array ?? []) as CounterRecord[] return (rows?._array ?? []) as CounterRecord[]
} catch (e: any) { } catch (e: any) {
throw dbError('Counters could not be retrieved from the database', e) throw dbError('Counters could not be retrieved from the database', e)
@@ -72,15 +72,12 @@ export const getCounters = function (): CounterRecord[] {
} }
/** Read a single counter, or undefined when no row exists yet. */ /** Read a single counter, or undefined when no row exists yet. */
export const getCounter = function ( export const getCounter = function (keysetId: string): CounterRecord | undefined {
mintUrl: string,
keysetId: string,
): CounterRecord | undefined {
try { try {
const db = getInstance() const db = getInstance()
const {rows} = db.execute( const {rows} = db.execute(
`SELECT mintUrl, keysetId, unit, counter, updatedAt FROM mint_counters WHERE mintUrl = ? AND keysetId = ?`, `SELECT keysetId, unit, counter, updatedAt FROM mint_counters WHERE keysetId = ?`,
[mintUrl, keysetId], [keysetId],
) )
return rows?.item(0) as CounterRecord | undefined return rows?.item(0) as CounterRecord | undefined
} catch (e: any) { } catch (e: any) {
@@ -95,13 +92,12 @@ export const getCounter = function (
* built on. A lower `value` (stale cache, replayed op) is silently ignored. * built on. A lower `value` (stale cache, replayed op) is silently ignored.
*/ */
export const setCounter = function ( export const setCounter = function (
mintUrl: string,
keysetId: string, keysetId: string,
unit: string | undefined, unit: string | undefined,
value: number, value: number,
): void { ): void {
try { try {
const [sql, params] = buildCounterUpsert(mintUrl, keysetId, unit, value) const [sql, params] = buildCounterUpsert(keysetId, unit, value)
getInstance().execute(sql, params) getInstance().execute(sql, params)
} catch (e: any) { } catch (e: any) {
throw dbError('Counter could not be saved to the database', e) throw dbError('Counter could not be saved to the database', e)
@@ -114,7 +110,6 @@ export const setCounter = function (
* from 0 and becomes `delta`. * from 0 and becomes `delta`.
*/ */
export const bumpCounter = function ( export const bumpCounter = function (
mintUrl: string,
keysetId: string, keysetId: string,
unit: string | undefined, unit: string | undefined,
delta: number, delta: number,
@@ -123,12 +118,12 @@ export const bumpCounter = function (
try { try {
const db = getInstance() const db = getInstance()
db.execute( db.execute(
`INSERT INTO mint_counters (mintUrl, keysetId, unit, counter, updatedAt) `INSERT INTO mint_counters (keysetId, unit, counter, updatedAt)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?)
ON CONFLICT(mintUrl, keysetId) DO UPDATE SET ON CONFLICT(keysetId) DO UPDATE SET
counter = counter + ?, counter = counter + ?,
updatedAt = excluded.updatedAt`, updatedAt = excluded.updatedAt`,
[mintUrl, keysetId, unit ?? null, delta, new Date().toISOString(), delta], [keysetId, unit ?? null, delta, new Date().toISOString(), delta],
) )
} catch (e: any) { } catch (e: any) {
throw dbError('Counter could not be advanced in the database', e) throw dbError('Counter could not be advanced in the database', e)
@@ -150,7 +145,7 @@ export const seedCounters = function (seeds: CounterSeed[]): {seeded: number} {
try { try {
const now = new Date().toISOString() const now = new Date().toISOString()
const batch: SQLBatchTuple[] = seeds.map(s => const batch: SQLBatchTuple[] = seeds.map(s =>
buildCounterUpsert(s.mintUrl, s.keysetId, s.unit, s.counter, now), buildCounterUpsert(s.keysetId, s.unit, s.counter, now),
) )
const db = getInstance() const db = getInstance()
+2
View File
@@ -22,6 +22,7 @@ import {
getPendingOnchainTransfers, getPendingOnchainTransfers,
addTransactionAsync, addTransactionAsync,
updateTransaction, updateTransaction,
updateInFlightTransactionsMintUrl,
expireAllAfterRecovery, expireAllAfterRecovery,
updateStatusesAsync, updateStatusesAsync,
deleteTransactionsByStatus, deleteTransactionsByStatus,
@@ -111,6 +112,7 @@ export const Database = {
getPendingOnchainTransfers, getPendingOnchainTransfers,
addTransactionAsync, addTransactionAsync,
updateTransaction, updateTransaction,
updateInFlightTransactionsMintUrl,
expireAllAfterRecovery, expireAllAfterRecovery,
updateStatusesAsync, updateStatusesAsync,
deleteTransactionsByStatus, deleteTransactionsByStatus,
+31 -2
View File
@@ -1,10 +1,10 @@
import {DbConnection, SQLBatchTuple} from './connection' import {DbConnection, SQLBatchTuple} from './connection'
import {createTable, PROOFS_COLUMNS, PROOFS_COLUMN_NAMES, RESERVATIONS_COLUMNS, MINT_COUNTERS_COLUMNS, MELT_RECOVERY_COLUMNS, INFLIGHT_REQUESTS_COLUMNS, WALLET_COUNTERS_COLUMNS, ONCHAIN_MINT_QUOTES_COLUMNS} from './schema' import {createTable, PROOFS_COLUMNS, PROOFS_COLUMN_NAMES, RESERVATIONS_COLUMNS, MINT_COUNTERS_COLUMNS, MINT_COUNTERS_COLUMN_NAMES, MELT_RECOVERY_COLUMNS, INFLIGHT_REQUESTS_COLUMNS, WALLET_COUNTERS_COLUMNS, ONCHAIN_MINT_QUOTES_COLUMNS} from './schema'
import {dbError} from './errors' import {dbError} from './errors'
import {log} from '../logService' import {log} from '../logService'
/** Bump this when a schema change requires a migration, then add an entry below. */ /** Bump this when a schema change requires a migration, then add an entry below. */
export const _dbVersion = 31 export const _dbVersion = 32
type Migration = {version: number; queries: SQLBatchTuple[]} type Migration = {version: number; queries: SQLBatchTuple[]}
@@ -110,6 +110,35 @@ const MIGRATIONS: Migration[] = [
[`ALTER TABLE transactions ADD COLUMN outpoint TEXT`], [`ALTER TABLE transactions ADD COLUMN outpoint TEXT`],
], ],
}, },
{
// Re-key mint_counters on keysetId alone, dropping mintUrl from the primary
// key. NUT-13 derives from (seed, keysetId, counter) with no mint component,
// so (mintUrl, keysetId) described a key space that does not exist: it let two
// rows track ONE derivation path independently, and left a counter
// unaddressable after a mint-url edit (hydration matched on url, found no row,
// and silently restarted the counter at 0 — reusing blinded secrets).
//
// Any such split is healed here rather than merely prevented: duplicates
// collapse to MAX(counter), the conservative direction, which can skip indices
// but never reuse them. SQLite's bare-column rule for a single-aggregate query
// takes `unit`/`updatedAt` from the same row that supplied MAX(counter), so
// the surviving row is internally consistent.
//
// No DROP COLUMN (unsupported on older SQLite), so the table is rebuilt from
// the canonical column definition.
version: 32,
queries: [
[createTable('mint_counters_v32', MINT_COUNTERS_COLUMNS, false)],
[
`INSERT INTO mint_counters_v32 (${MINT_COUNTERS_COLUMN_NAMES})
SELECT keysetId, unit, MAX(counter), updatedAt
FROM mint_counters
GROUP BY keysetId`,
],
[`DROP TABLE mint_counters`],
[`ALTER TABLE mint_counters_v32 RENAME TO mint_counters`],
],
},
] ]
/** /**
+1 -2
View File
@@ -173,7 +173,6 @@ export const commitReservation = function (
* next derivation reuse a blinded secret. Each upsert is monotonic. * next derivation reuse a blinded secret. Each upsert is monotonic.
*/ */
counterUpdate?: Array<{ counterUpdate?: Array<{
mintUrl: string
keysetId: string keysetId: string
unit?: string unit?: string
counter: number counter: number
@@ -262,7 +261,7 @@ export const commitReservation = function (
} }
for (const cu of changes.counterUpdate ?? []) { for (const cu of changes.counterUpdate ?? []) {
batch.push(buildCounterUpsert(cu.mintUrl, cu.keysetId, cu.unit, cu.counter, now)) batch.push(buildCounterUpsert(cu.keysetId, cu.unit, cu.counter, now))
} }
batch.push([`DELETE FROM reservations WHERE id = ?`, [reservationId]]) batch.push([`DELETE FROM reservations WHERE id = ?`, [reservationId]])
+25 -12
View File
@@ -70,7 +70,7 @@ export const RESERVATIONS_COLUMNS = `
` `
/** /**
* Per-keyset deterministic-derivation counter (the BIP32 high-water mark). * Per-keyset deterministic-derivation counter (the NUT-13 high-water mark).
* *
* Authoritative store for the counter previously held only in the MST * Authoritative store for the counter previously held only in the MST
* `MintProofsCounter` model and persisted to MMKV via the whole-tree snapshot. * `MintProofsCounter` model and persisted to MMKV via the whole-tree snapshot.
@@ -78,16 +78,26 @@ export const RESERVATIONS_COLUMNS = `
* derives (same SQLite transaction) and makes SQLite the single source of truth, * derives (same SQLite transaction) and makes SQLite the single source of truth,
* closing the cross-engine non-atomicity that risked blinded-secret reuse. * closing the cross-engine non-atomicity that risked blinded-secret reuse.
* *
* Keyed by (mintUrl, keysetId): a keyset id is mint-scoped, and keying on the * Keyed by keysetId ALONE, because that is the real key space: NUT-13 derives
* url keeps the row addressable across mint-url edits. * from (seed, keysetId, counter) and the mint url is not an input. A `00` id
* derives at `m/129372'/0'/{keysetIdInt}'/{counter}'`, a v2 `01` id by HMAC-SHA256
* over the id — neither path has a mint component, so one keyset id means exactly
* one derivation sequence regardless of which url served it.
*
* This was `PRIMARY KEY (mintUrl, keysetId)`, which asserted a key space that does
* not exist. Two rows could track one derivation path independently, and a
* mint-url edit left the counter unaddressable — hydration found no row, silently
* restarted the counter at 0, and risked blinded-secret reuse.
*
* Sound because global keyset-id uniqueness is enforced at the door by
* CashuUtils.isCollidingKeysetId, per NUT-02's requirement that wallets reject
* keysets colliding with any already held.
*/ */
export const MINT_COUNTERS_COLUMNS = ` export const MINT_COUNTERS_COLUMNS = `
mintUrl TEXT NOT NULL, keysetId TEXT PRIMARY KEY NOT NULL,
keysetId TEXT NOT NULL,
unit TEXT, unit TEXT,
counter INTEGER NOT NULL DEFAULT 0, counter INTEGER NOT NULL DEFAULT 0,
updatedAt TEXT, updatedAt TEXT
PRIMARY KEY (mintUrl, keysetId)
` `
/** /**
@@ -132,11 +142,11 @@ export const INFLIGHT_REQUESTS_COLUMNS = `
/** /**
* Wallet-global deterministic-derivation counters, keyed by purpose name. * Wallet-global deterministic-derivation counters, keyed by purpose name.
* *
* Distinct from `mint_counters`, which is keyed by (mintUrl, keysetId) because * Distinct from `mint_counters`, which is keyed by keysetId because a NUT-13
* NUT-13 keyset counters are mint-scoped. The counters here belong to derivation * counter is keyset-scoped. The counters here belong to derivation paths that
* paths that have NO mint or keyset component, so a single value serves the whole * have NO keyset component either, so a single value serves the whole wallet. The
* wallet. The first is NUT-20 quote-locking (`m/129373'/20'/0'/0'/{counter}`); * first is NUT-20 quote-locking (`m/129373'/20'/0'/0'/{counter}`); the table is
* the table is keyed by name so future wallet-global counters need no migration. * keyed by name so future wallet-global counters need no migration.
* *
* `counter` is the NEXT FREE index (a high-water mark), matching the semantics of * `counter` is the NEXT FREE index (a high-water mark), matching the semantics of
* `mint_counters` (which stores cashu-ts's `next`). Allocation increments and * `mint_counters` (which stores cashu-ts's `next`). Allocation increments and
@@ -198,6 +208,9 @@ export const createTable = (
export const PROOFS_COLUMN_NAMES = export const PROOFS_COLUMN_NAMES =
'id, amount, secret, C, dleq_r, dleq_s, dleq_e, unit, tId, mintUrl, state, updatedAt' 'id, amount, secret, C, dleq_r, dleq_s, dleq_e, unit, tId, mintUrl, state, updatedAt'
/** Ordered column names for mint_counters (drives the v32 re-key rebuild). */
export const MINT_COUNTERS_COLUMN_NAMES = 'keysetId, unit, counter, updatedAt'
/** First-run schema creation, run inside a single batch transaction. */ /** First-run schema creation, run inside a single batch transaction. */
export const createSchemaQueries: SQLBatchTuple[] = [ export const createSchemaQueries: SQLBatchTuple[] = [
[createTable('transactions', TRANSACTIONS_COLUMNS)], [createTable('transactions', TRANSACTIONS_COLUMNS)],
+49
View File
@@ -1,4 +1,5 @@
import {Transaction, TransactionStatus} from '../../models/Transaction' import {Transaction, TransactionStatus} from '../../models/Transaction'
import {IN_FLIGHT_STATUSES} from '../../models/TransactionStates'
import AppError, {Err} from '../../utils/AppError' import AppError, {Err} from '../../utils/AppError'
import {log} from '../logService' import {log} from '../logService'
import {getInstance} from './instance' import {getInstance} from './instance'
@@ -48,6 +49,54 @@ export const updateTransaction = function (id: number, fields: Partial<Transacti
} }
} }
/**
* Repoint a mint's IN-FLIGHT transactions at its new url, leaving terminal ones
* untouched.
*
* `transactions.mint` carries two meanings, switched by status:
*
* - TERMINAL (completed, reverted, …): a historical record of where the payment
* actually happened. Rewriting it would falsify history, so it is frozen.
* - IN-FLIGHT: a LIVE pointer the wallet still calls — checkLightningMintQuote
* (topupOperationApi), checkLightningMeltQuote / checkOnchainMeltQuote
* (transferOperationApi), and findByUrl(tx.mint) on the revert/receive paths.
* Leaving it stale after a mint-url edit strands an open transaction at a dead
* url: a paid topup whose ecash the wallet can never mint.
*
* One statement, so the status test and the write cannot straddle a concurrent
* status transition — a transaction that goes terminal mid-rename is either fully
* in or fully out.
*/
export const updateInFlightTransactionsMintUrl = function (
currentMintUrl: string,
updatedMintUrl: string,
): {updated: number} {
try {
const statuses = [...IN_FLIGHT_STATUSES]
const placeholders = statuses.map(() => '?').join(', ')
const db = getInstance()
const {rowsAffected} = db.execute(
`UPDATE transactions
SET mint = ?
WHERE mint = ? AND status IN (${placeholders})`,
[updatedMintUrl, currentMintUrl, ...statuses],
)
const updated = rowsAffected ?? 0
log.debug('[updateInFlightTransactionsMintUrl]', 'Repointed in-flight transactions', {
currentMintUrl,
updatedMintUrl,
updated,
})
return {updated}
} catch (e: any) {
throw dbError('Could not update transactions mintUrl in database', e)
}
}
export const getTransactionsAsync = async function (limit: number, offset: number, onlyPending: boolean = false) { export const getTransactionsAsync = async function (limit: number, offset: number, onlyPending: boolean = false) {
let query: string = '' let query: string = ''
+3 -3
View File
@@ -4,9 +4,9 @@ import {dbError} from './errors'
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Wallet-global deterministic-derivation counters, keyed by purpose name. // Wallet-global deterministic-derivation counters, keyed by purpose name.
// //
// Sibling of countersRepo, which owns the per-(mint, keyset) NUT-13 counters. // Sibling of countersRepo, which owns the per-keyset NUT-13 counters. The
// The counters here belong to derivation paths with NO mint or keyset component, // counters here belong to derivation paths with NO keyset component, so one value
// so one value serves the whole wallet. First user: NUT-20 quote-locking keys // serves the whole wallet. First user: NUT-20 quote-locking keys
// (`m/129373'/20'/0'/0'/{counter}`). // (`m/129373'/20'/0'/0'/{counter}`).
// //
// The stored `counter` is the NEXT FREE index. Allocation is BURN-FORWARD: the // The stored `counter` is the NEXT FREE index. Allocation is BURN-FORWARD: the