diff --git a/__tests__/counters.test.ts b/__tests__/counters.test.ts index 0e8ce40c..a0471628 100644 --- a/__tests__/counters.test.ts +++ b/__tests__/counters.test.ts @@ -1,13 +1,17 @@ /** - * Derivation-counter tests (mint_counters migration). + * Derivation-counter tests (mint_counters). * * Verifies the SQL-level semantics that `Database.setCounter`, `bumpCounter`, * `seedCounters`, and the `counterUpdate` folded into `commitReservation` - * implement on top of `executeBatch`. The counter is the 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 * — a stored counter can never move backward — because a regression would let * the next derivation reuse a blinded secret. * + * Rows are keyed by keysetId ALONE. NUT-13 derives from (seed, keysetId, + * counter) with no mint component, so one keyset id means one derivation + * sequence; see MINT_COUNTERS_COLUMNS. + * * As with proofReservation.test.ts we mirror the exact production SQL using * node:sqlite + explicit BEGIN/COMMIT, since the native driver needs a device. * @@ -20,12 +24,10 @@ const NOW = '2026-06-04T00:00:00.000Z' // ── Schema (mirrors schema.ts) ────────────────────────────────────────────── const CREATE_MINT_COUNTERS = `CREATE TABLE mint_counters ( - mintUrl TEXT NOT NULL, - keysetId TEXT NOT NULL, + keysetId TEXT PRIMARY KEY NOT NULL, unit TEXT, counter INTEGER NOT NULL DEFAULT 0, - updatedAt TEXT, - PRIMARY KEY (mintUrl, keysetId) + updatedAt TEXT )` const CREATE_PROOFS = `CREATE TABLE proofs ( @@ -55,33 +57,33 @@ const MINT = 'https://mint.test' // ── Mirrored Database primitives (exact production SQL) ───────────────────── /** countersRepo.buildCounterUpsert / setCounter — monotonic absolute write. */ -function setCounter(db: DatabaseSync, mintUrl: string, keysetId: string, unit: string | null, value: number) { +function setCounter(db: DatabaseSync, keysetId: string, unit: string | null, value: number) { db.prepare( - `INSERT INTO mint_counters (mintUrl, keysetId, unit, counter, updatedAt) - VALUES (?, ?, ?, ?, ?) - ON CONFLICT(mintUrl, keysetId) DO UPDATE SET + `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(mintUrl, keysetId, unit, value, NOW) + ).run(keysetId, unit, value, NOW) } /** countersRepo.bumpCounter — relative advance (no-op for delta <= 0). */ -function bumpCounter(db: DatabaseSync, mintUrl: string, keysetId: string, unit: string | null, delta: number) { +function bumpCounter(db: DatabaseSync, keysetId: string, unit: string | null, delta: number) { if (delta <= 0) return db.prepare( - `INSERT INTO mint_counters (mintUrl, keysetId, unit, counter, updatedAt) - VALUES (?, ?, ?, ?, ?) - ON CONFLICT(mintUrl, keysetId) DO UPDATE SET + `INSERT INTO mint_counters (keysetId, unit, counter, updatedAt) + VALUES (?, ?, ?, ?) + ON CONFLICT(keysetId) DO UPDATE SET counter = counter + ?, 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 - .prepare('SELECT counter FROM mint_counters WHERE mintUrl = ? AND keysetId = ?') - .get(mintUrl, keysetId) as {counter: number} | undefined + .prepare('SELECT counter FROM mint_counters WHERE keysetId = ?') + .get(keysetId) as {counter: number} | undefined return row?.counter } @@ -93,11 +95,11 @@ function counterRowCount(db: DatabaseSync): number { /** countersRepo.seedCounters — idempotent monotonic batch. */ function seedCounters( db: DatabaseSync, - seeds: Array<{mintUrl: string; keysetId: string; unit?: string; counter: number}>, + seeds: Array<{keysetId: string; unit?: string; counter: number}>, ) { db.exec('BEGIN') try { - for (const s of seeds) setCounter(db, s.mintUrl, s.keysetId, s.unit ?? null, s.counter) + for (const s of seeds) setCounter(db, s.keysetId, s.unit ?? null, s.counter) db.exec('COMMIT') } catch (e) { db.exec('ROLLBACK') @@ -128,7 +130,7 @@ function commitWithCounter( reservationId: string, changes: { 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') @@ -140,7 +142,7 @@ function commitWithCounter( for (const p of changes.newProofs ?? []) insertNew.run(p.amount, p.secret, p.state, NOW) for (const cu of changes.counterUpdate ?? []) { - setCounter(db, cu.mintUrl, cu.keysetId, cu.unit ?? null, cu.counter) + setCounter(db, cu.keysetId, cu.unit ?? null, cu.counter) } db.prepare('DELETE FROM reservations WHERE id = ?').run(reservationId) @@ -165,32 +167,32 @@ describe('Derivation counters (mint_counters)', () => { describe('setCounter — monotonic', () => { test('inserts a new row when none exists', () => { const db = freshDb() - setCounter(db, MINT, 'k1', 'sat', 42) - expect(getCounter(db, MINT, 'k1')).toBe(42) + setCounter(db, 'k1', 'sat', 42) + expect(getCounter(db, 'k1')).toBe(42) db.close() }) test('raises to a higher value', () => { const db = freshDb() - setCounter(db, MINT, 'k1', 'sat', 100) - setCounter(db, MINT, 'k1', 'sat', 150) - expect(getCounter(db, MINT, 'k1')).toBe(150) + setCounter(db, 'k1', 'sat', 100) + setCounter(db, 'k1', 'sat', 150) + expect(getCounter(db, 'k1')).toBe(150) db.close() }) test('NEVER lowers — a smaller value is ignored (the core safety invariant)', () => { const db = freshDb() - setCounter(db, MINT, 'k1', 'sat', 100) - setCounter(db, MINT, 'k1', 'sat', 50) // stale / replayed writer - expect(getCounter(db, MINT, 'k1')).toBe(100) + setCounter(db, 'k1', 'sat', 100) + setCounter(db, 'k1', 'sat', 50) // stale / replayed writer + expect(getCounter(db, 'k1')).toBe(100) db.close() }) test('an equal value is a no-op', () => { const db = freshDb() - setCounter(db, MINT, 'k1', 'sat', 100) - setCounter(db, MINT, 'k1', 'sat', 100) - expect(getCounter(db, MINT, 'k1')).toBe(100) + setCounter(db, 'k1', 'sat', 100) + setCounter(db, 'k1', 'sat', 100) + expect(getCounter(db, 'k1')).toBe(100) db.close() }) }) @@ -198,46 +200,53 @@ describe('Derivation counters (mint_counters)', () => { describe('bumpCounter — relative advance', () => { test('inserts from 0 when no row exists', () => { const db = freshDb() - bumpCounter(db, MINT, 'k1', 'sat', 10) - expect(getCounter(db, MINT, 'k1')).toBe(10) + bumpCounter(db, 'k1', 'sat', 10) + expect(getCounter(db, 'k1')).toBe(10) db.close() }) test('adds to the existing value', () => { const db = freshDb() - setCounter(db, MINT, 'k1', 'sat', 100) - bumpCounter(db, MINT, 'k1', 'sat', 10) - expect(getCounter(db, MINT, 'k1')).toBe(110) + setCounter(db, 'k1', 'sat', 100) + bumpCounter(db, 'k1', 'sat', 10) + expect(getCounter(db, 'k1')).toBe(110) db.close() }) test('a non-positive delta is a no-op', () => { const db = freshDb() - setCounter(db, MINT, 'k1', 'sat', 100) - bumpCounter(db, MINT, 'k1', 'sat', 0) - bumpCounter(db, MINT, 'k1', 'sat', -5) - expect(getCounter(db, MINT, 'k1')).toBe(100) + setCounter(db, 'k1', 'sat', 100) + bumpCounter(db, 'k1', 'sat', 0) + bumpCounter(db, 'k1', 'sat', -5) + expect(getCounter(db, 'k1')).toBe(100) db.close() }) }) describe('primary key isolation', () => { - test('different keysets on the same mint are independent', () => { + test('different keysets are independent', () => { const db = freshDb() - setCounter(db, MINT, 'k1', 'sat', 100) - setCounter(db, MINT, 'k2', 'sat', 7) - expect(getCounter(db, MINT, 'k1')).toBe(100) - expect(getCounter(db, MINT, 'k2')).toBe(7) + setCounter(db, 'k1', 'sat', 100) + setCounter(db, 'k2', 'sat', 7) + expect(getCounter(db, 'k1')).toBe(100) + expect(getCounter(db, 'k2')).toBe(7) expect(counterRowCount(db)).toBe(2) db.close() }) - 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() - setCounter(db, MINT, 'k1', 'sat', 100) - setCounter(db, 'https://other.test', 'k1', 'sat', 5) - expect(getCounter(db, MINT, 'k1')).toBe(100) - expect(getCounter(db, 'https://other.test', 'k1')).toBe(5) + setCounter(db, 'k1', 'sat', 100) + // The same keyset seen again after a mint-url edit / via a mirror. + setCounter(db, 'k1', 'sat', 5) + expect(getCounter(db, 'k1')).toBe(100) // monotonic, not a second row + expect(counterRowCount(db)).toBe(1) db.close() }) }) @@ -246,32 +255,32 @@ describe('Derivation counters (mint_counters)', () => { test('seeds every supplied counter', () => { const db = freshDb() seedCounters(db, [ - {mintUrl: MINT, keysetId: 'k1', unit: 'sat', counter: 100}, - {mintUrl: MINT, keysetId: 'k2', unit: 'sat', counter: 50}, + {keysetId: 'k1', unit: 'sat', counter: 100}, + {keysetId: 'k2', unit: 'sat', counter: 50}, ]) - expect(getCounter(db, MINT, 'k1')).toBe(100) - expect(getCounter(db, MINT, 'k2')).toBe(50) + expect(getCounter(db, 'k1')).toBe(100) + expect(getCounter(db, 'k2')).toBe(50) db.close() }) test('is idempotent — re-running never lowers an advanced counter', () => { const db = freshDb() // First upgrade seed copies the (then current) MMKV values. - seedCounters(db, [{mintUrl: MINT, keysetId: 'k1', unit: 'sat', counter: 100}]) + seedCounters(db, [{keysetId: 'k1', unit: 'sat', counter: 100}]) // 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. - 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. - expect(getCounter(db, MINT, 'k1')).toBe(175) + expect(getCounter(db, 'k1')).toBe(175) db.close() }) test('a too-high seed is kept (conservative-safe: skips indices, never reuses)', () => { const db = freshDb() - setCounter(db, MINT, 'k1', 'sat', 100) - seedCounters(db, [{mintUrl: MINT, keysetId: 'k1', unit: 'sat', counter: 9999}]) - expect(getCounter(db, MINT, 'k1')).toBe(9999) + setCounter(db, 'k1', 'sat', 100) + seedCounters(db, [{keysetId: 'k1', unit: 'sat', counter: 9999}]) + expect(getCounter(db, 'k1')).toBe(9999) db.close() }) }) @@ -279,21 +288,21 @@ describe('Derivation counters (mint_counters)', () => { describe('atomic commit (counterUpdate folded into commitReservation)', () => { test('persists the counter in the SAME txn as the new proofs', () => { const db = freshDb() - setCounter(db, MINT, 'k1', 'sat', 100) + setCounter(db, 'k1', 'sat', 100) commitWithCounter(db, 'res-1', { 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(getCounter(db, MINT, 'k1')).toBe(110) + expect(getCounter(db, 'k1')).toBe(110) db.close() }) test('a failed commit batch rolls back BOTH the proofs and the counter', () => { const db = freshDb() - setCounter(db, MINT, 'k1', 'sat', 100) + setCounter(db, 'k1', 'sat', 100) // Force a failure mid-batch (NOT NULL violation on amount) AFTER the // proof insert and counter upsert have run in the same transaction. @@ -304,7 +313,7 @@ describe('Derivation counters (mint_counters)', () => { `INSERT OR REPLACE INTO proofs (id, amount, secret, C, mintUrl, unit, tId, state, updatedAt) VALUES ('keyset1', 50, 'new1', 'C', '${MINT}', 'sat', 1, 'UNSPENT', '${NOW}')`, ).run() - setCounter(db, MINT, 'k1', 'sat', 110) + setCounter(db, 'k1', 'sat', 110) // Violates NOT NULL on amount → aborts the whole batch. db.prepare( `INSERT INTO proofs (id, amount, secret, C, state) VALUES ('keyset1', NULL, 'bad', 'C', 'UNSPENT')`, @@ -318,22 +327,22 @@ describe('Derivation counters (mint_counters)', () => { // Neither the proof nor the counter advance survived. expect(getProofState(db, 'new1')).toBe('') - expect(getCounter(db, MINT, 'k1')).toBe(100) + expect(getCounter(db, 'k1')).toBe(100) db.close() }) test('counterUpdate stays monotonic inside the commit batch', () => { 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. commitWithCounter(db, 'res-2', { 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(getCounter(db, MINT, 'k1')).toBe(200) + expect(getCounter(db, 'k1')).toBe(200) db.close() }) }) diff --git a/__tests__/keysetIdCollision.test.ts b/__tests__/keysetIdCollision.test.ts new file mode 100644 index 00000000..877277be --- /dev/null +++ b/__tests__/keysetIdCollision.test.ts @@ -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) + }) + }) +}) diff --git a/__tests__/mintUrl.test.ts b/__tests__/mintUrl.test.ts new file mode 100644 index 00000000..79481e62 --- /dev/null +++ b/__tests__/mintUrl.test.ts @@ -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) + }) +}) diff --git a/__tests__/sqliteMigration32.test.ts b/__tests__/sqliteMigration32.test.ts new file mode 100644 index 00000000..1c00d302 --- /dev/null +++ b/__tests__/sqliteMigration32.test.ts @@ -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() + }) +}) diff --git a/__tests__/transactionsMintUrl.test.ts b/__tests__/transactionsMintUrl.test.ts new file mode 100644 index 00000000..5c729b45 --- /dev/null +++ b/__tests__/transactionsMintUrl.test.ts @@ -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', + ]) + }) +}) diff --git a/src/models/Mint.ts b/src/models/Mint.ts index 102188e6..69cdc7d3 100644 --- a/src/models/Mint.ts +++ b/src/models/Mint.ts @@ -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 { type GetInfoResponse, @@ -17,6 +17,7 @@ import { getRootStore } from './helpers/getRootStore' import { generateId } from '../utils/utils' import { Proof } from './Proof' import { CashuProof, CashuUtils } from '../services/cashu/cashuUtils' +import { normalizeMintUrl } from '../services/cashu/mintUrl' /** * A mint payment method — the rail the mint settles on. @@ -74,9 +75,11 @@ const migrateSnapshot = (snapshot: any): any => { * counter persistence ("W1"). * * `mode: 'set'` persists an absolute value monotonically (never lowers); - * `mode: 'bump'` advances by a relative delta. The (mint, keyset) identity is - * read from the parent Mint node — a counter is always nested two levels up - * (counter -> proofsCounters array -> Mint). + * `mode: 'bump'` advances by a relative delta. The keyset id is the entire + * identity: NUT-13 derives from (seed, keysetId, counter), so a counter needs no + * 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 * 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 — * commitReservation re-persists this same value ATOMICALLY with the proofs * ("W2", see reservationsRepo), so even if this write is dropped a successful - * commit cannot leave the counter behind its proofs. A detached instance (a - * counter freshly created by createProofsCounter, not yet pushed onto a Mint) - * has no parent and is a no-op here. + * commit cannot leave the counter behind its proofs. */ const persistCounter = (self: any, mode: 'set' | 'bump', value: number): void => { - let mintUrl: string | undefined - try { - mintUrl = getParent(self, 2)?.mintUrl - } catch { - return // not attached to a Mint (freshly created counter) — nothing to persist - } - if (!mintUrl) return - try { if (mode === 'set') { - Database.setCounter(mintUrl, self.keyset, self.unit, value) + Database.setCounter(self.keyset, self.unit, value) } else { - Database.bumpCounter(mintUrl, self.keyset, self.unit, value) + Database.bumpCounter(self.keyset, self.unit, value) } } catch (e: any) { log.error('[persistCounter]', 'Counter write-through failed', { error: e?.message, - mintUrl, keyset: self.keyset, }) } @@ -457,16 +449,8 @@ export const MintModel = types return existing } - self.addKeys(key) - log.trace('[initKeys]', {newKeys: key.id}) - }, - validateURL(url: string) { - try { - new URL(url) - return true - } catch (e) { - return false - } + self.addKeys(key) + log.trace('[initKeys]', {newKeys: key.id}) }, })) .actions(self => ({ @@ -520,26 +504,6 @@ export const MintModel = types 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 self.id = generateId(8) }, @@ -602,7 +566,68 @@ export const MintModel = types }, get keysetIds(): string[] { 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 + }, })) diff --git a/src/models/MintsStore.ts b/src/models/MintsStore.ts index c939320d..7a0638c4 100644 --- a/src/models/MintsStore.ts +++ b/src/models/MintsStore.ts @@ -8,7 +8,8 @@ import { flow, } from 'mobx-state-tree' 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 {Database} from '../services' 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 * cache (startup / foreground resume). Monotonic per counter, so a value * 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() { const rows = Database.getCounters() + if (rows.length === 0) return + + const countersByKeyset = new Map() + for (const mint of self.mints) { + for (const counter of mint.proofsCounters) { + countersByKeyset.set(counter.keyset, counter) + } + } for (const row of rows) { - const mint = self.mints.find(m => m.mintUrl === row.mintUrl) - const counter = mint?.proofsCounters.find(c => c.keyset === row.keysetId) - if (counter) { - counter.hydrateCounterFromDb(row.counter) - } + countersByKeyset.get(row.keysetId)?.hydrateCounterFromDb(row.counter) } }, })) .actions(self => ({ addMint: flow(function* addMint(mintUrl: string) { - if(!mintUrl) { - throw new AppError(Err.VALIDATION_ERROR, 'Mint URL is required.') - } - - // 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.') - } + // Strips trailing slashes (cashu spec) and requires https outside Tor. + // Shared with Mint.setMintUrl so adding and renaming cannot disagree + // about what a valid mint url is. + mintUrl = normalizeMintUrl(mintUrl) if(self.mintExists(mintUrl)) { throw new AppError(Err.VALIDATION_ERROR, 'Mint URL already exists.', {mintUrl}) @@ -144,10 +148,11 @@ export const MintsStoreModel = types self.mints.push(mintInstance) - // SQLite retains derivation counters by (mintUrl, keysetId) across - // mint removal (rows are never deleted), so a re-added mint recovers - // its real counter from the authority here. Monotonic, so a genuinely - // new mint (no row) simply stays at 0. + // SQLite retains derivation counters by keysetId across mint removal + // (rows are never deleted), so a re-added mint recovers its real + // counter from the authority here — now also when it is re-added under + // 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() return mintInstance diff --git a/src/models/ProofsStore.ts b/src/models/ProofsStore.ts index d46d2dbb..6ee89d7c 100644 --- a/src/models/ProofsStore.ts +++ b/src/models/ProofsStore.ts @@ -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) { + Database.updateProofsMintUrl(currentMintUrl, updatedMintUrl) + const updateInMap = (map: typeof self.proofs) => { for (const proof of map.values()) { if (proof.mintUrl === currentMintUrl) { @@ -219,7 +229,6 @@ import { updateInMap(self.proofs) - Database.updateProofsMintUrl(currentMintUrl, updatedMintUrl) 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 // the W1 write-through was dropped. Monotonic, so the normal-path // 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() for (const group of changes.newProofs ?? []) { for (const proof of group.proofs) { @@ -363,7 +372,6 @@ import { const counter = mintInstance.getProofsCounter(proof.id) if (counter) { counterUpdate.push({ - mintUrl: reservation.mintUrl, keysetId: proof.id, unit: counter.unit, counter: counter.counter, diff --git a/src/models/TransactionStates.ts b/src/models/TransactionStates.ts index bcfb7a5d..e5eebe8b 100644 --- a/src/models/TransactionStates.ts +++ b/src/models/TransactionStates.ts @@ -204,7 +204,14 @@ const TERMINAL_STATUSES: ReadonlySet = new Set([ TransactionStatus.RECOVERED, ]) -const IN_FLIGHT_STATUSES: ReadonlySet = 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 = new Set([ TransactionStatus.DRAFT, TransactionStatus.PREPARED, TransactionStatus.PREPARED_OFFLINE, diff --git a/src/models/TransactionsStore.ts b/src/models/TransactionsStore.ts index e0cd30e9..cf38c7b6 100644 --- a/src/models/TransactionsStore.ts +++ b/src/models/TransactionsStore.ts @@ -12,6 +12,7 @@ import { TransactionStatus, TransactionType, } from './Transaction' +import { isInFlight } from './TransactionStates' import { Database } from '../services' import { log } from '../services/logService' import { getRootStore } from './helpers/getRootStore' @@ -154,6 +155,28 @@ export const TransactionsStoreModel = types 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() { const { mintsStore } = getRootStore(self) const validUrls = new Set(mintsStore.allMints.map(m => m.mintUrl)) diff --git a/src/models/helpers/setupRootStore.ts b/src/models/helpers/setupRootStore.ts index 2eb9d394..9d8623fc 100644 --- a/src/models/helpers/setupRootStore.ts +++ b/src/models/helpers/setupRootStore.ts @@ -265,7 +265,7 @@ async function _runMigrations(rootStore: RootStore, restoredState: any) { for (const mint of restoredState?.mintsStore?.mints ?? []) { for (const counter of mint?.proofsCounters ?? []) { 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 c of backup?.counters ?? []) { 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}) } } } diff --git a/src/screens/ImportBackupScreen.tsx b/src/screens/ImportBackupScreen.tsx index 0a6a6148..08f888f4 100644 --- a/src/screens/ImportBackupScreen.tsx +++ b/src/screens/ImportBackupScreen.tsx @@ -217,7 +217,7 @@ export const ImportBackupScreen = observer(function ImportBackupScreen({ route } // backup JSON still carries it, so read it off the raw value. const counter = (pc as any).counter 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}) } } } diff --git a/src/screens/MintsScreen.tsx b/src/screens/MintsScreen.tsx index 3311bd10..181ca4e9 100644 --- a/src/screens/MintsScreen.tsx +++ b/src/screens/MintsScreen.tsx @@ -23,6 +23,7 @@ import {Mint} from '../models/Mint' import {useStores} from '../models' import {useHeader} from '../utils/useHeader' import {log} from '../services/logService' +import {normalizeMintUrl} from '../services/cashu/mintUrl' import AppError, { Err } from '../utils/AppError' import {translate} from '../i18n' import {MintListItem} from './Mints/MintListItem' @@ -157,31 +158,40 @@ export const MintsScreen = observer(function MintsScreen({ route }: Props) { const updateMintUrl = async function () { - if (!selectedMint) {return} + if (!selectedMint) {return} try { 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 - if(mintsStore.alreadyExists(mintUrl)) { + // Normalize before anything else: rejects a malformed or non-https url + // 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.') } toggleAddMintModal() // close 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)) if(!matchingKeyset) { 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) - } finally { + } finally { setMintUrl('') setIsLoading(false) - onMintUnselect() // close + onMintUnselect() // close } } diff --git a/src/services/cashu/cashuUtils.ts b/src/services/cashu/cashuUtils.ts index be343a70..5bd875f3 100644 --- a/src/services/cashu/cashuUtils.ts +++ b/src/services/cashu/cashuUtils.ts @@ -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[] => { @@ -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( newKeysetId: string, storedKeysetIds: string[], ) { - const newKeysetIdInt = getKeysetIdInt(newKeysetId) + const newUsesBip32 = usesBip32Derivation(newKeysetId) + const newKeysetIdInt = newUsesBip32 ? getKeysetIdInt(newKeysetId) : undefined + return storedKeysetIds.some((storedId) => { - + if (storedId === newKeysetId) { // Colliding keyset ID! log.error('[isCollidingKeysetId] Colliding keyset ID', { @@ -473,14 +514,18 @@ function isCollidingKeysetId( return true } + if (!newUsesBip32 || !usesBip32Derivation(storedId)) { + return false + } + const storedKeysetIdInt = getKeysetIdInt(storedId) - + if (storedKeysetIdInt === newKeysetIdInt) { // Colliding keyset ID integer! log.error('[isCollidingKeysetId] Colliding keyset ID integer', { newKeysetId, storedId, - newKeysetIdInt: newKeysetIdInt.toString(), + newKeysetIdInt: newKeysetIdInt!.toString(), storedKeysetIdInt: storedKeysetIdInt.toString(), }) diff --git a/src/services/cashu/mintUrl.ts b/src/services/cashu/mintUrl.ts new file mode 100644 index 00000000..b549e103 --- /dev/null +++ b/src/services/cashu/mintUrl.ts @@ -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(/\/+$/, '') +} diff --git a/src/services/cashu/nut20.ts b/src/services/cashu/nut20.ts index f1c65b7c..fcea67a4 100644 --- a/src/services/cashu/nut20.ts +++ b/src/services/cashu/nut20.ts @@ -15,9 +15,9 @@ * m/129373'/20'/0'/0'/{counter} * * 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 - * component, so the counter is wallet-global — see walletCountersRepo, which owns - * it (mint_counters is for the mint-scoped NUT-13 counters and is unrelated). + * incrementing NON-hardened child index. The path has no keyset component, so the + * counter is wallet-global — see walletCountersRepo, which owns it (mint_counters + * 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 * quotes to each other. `allocateQuoteKeypair` is the only thing call sites diff --git a/src/services/db/countersRepo.ts b/src/services/db/countersRepo.ts index b2c698b2..aca77b15 100644 --- a/src/services/db/countersRepo.ts +++ b/src/services/db/countersRepo.ts @@ -6,31 +6,32 @@ import {log} from '../logService' // ───────────────────────────────────────────────────────────────────────────── // Per-keyset deterministic-derivation counters. // -// The `counter` is the BIP32 derivation high-water mark for a (mint, keyset) -// pair. It was previously held only in the MST `MintProofsCounter` model and -// persisted to MMKV via the whole-tree snapshot — a separate persistence engine -// from the proofs the counter derives, committed at a different moment. That -// cross-engine gap meant a crash between "counter advanced" (MMKV) and "proofs -// written" (SQLite) could desync them and risk blinded-secret reuse. +// The `counter` is the NUT-13 derivation high-water mark for a keyset. It was +// previously held only in the MST `MintProofsCounter` model and persisted to +// MMKV via the whole-tree snapshot — a separate persistence engine from the +// proofs the counter derives, committed at a different moment. That cross-engine +// gap meant a crash between "counter advanced" (MMKV) and "proofs written" +// (SQLite) could desync them and risk blinded-secret reuse. // // 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 // 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 // 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 = { - mintUrl: string keysetId: string unit: string | null counter: number 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 = { - mintUrl: string keysetId: string unit?: string counter: number @@ -43,20 +44,19 @@ export type CounterSeed = { * only ever rises to MAX(existing, value); a lower value is a no-op. */ export const buildCounterUpsert = function ( - mintUrl: string, keysetId: string, unit: string | undefined, value: number, now: string = new Date().toISOString(), ): SQLBatchTuple { return [ - `INSERT INTO mint_counters (mintUrl, keysetId, unit, counter, updatedAt) - VALUES (?, ?, ?, ?, ?) - ON CONFLICT(mintUrl, keysetId) DO UPDATE SET + `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`, - [mintUrl, keysetId, unit ?? null, value, now], + [keysetId, unit ?? null, value, now], ] } @@ -64,7 +64,7 @@ export const buildCounterUpsert = function ( export const getCounters = function (): CounterRecord[] { try { 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[] } catch (e: any) { 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. */ -export const getCounter = function ( - mintUrl: string, - keysetId: string, -): CounterRecord | undefined { +export const getCounter = function (keysetId: string): CounterRecord | undefined { try { const db = getInstance() const {rows} = db.execute( - `SELECT mintUrl, keysetId, unit, counter, updatedAt FROM mint_counters WHERE mintUrl = ? AND keysetId = ?`, - [mintUrl, keysetId], + `SELECT keysetId, unit, counter, updatedAt FROM mint_counters WHERE keysetId = ?`, + [keysetId], ) return rows?.item(0) as CounterRecord | undefined } catch (e: any) { @@ -95,13 +92,12 @@ export const getCounter = function ( * built on. A lower `value` (stale cache, replayed op) is silently ignored. */ export const setCounter = function ( - mintUrl: string, keysetId: string, unit: string | undefined, value: number, ): void { try { - const [sql, params] = buildCounterUpsert(mintUrl, keysetId, unit, value) + const [sql, params] = buildCounterUpsert(keysetId, unit, value) getInstance().execute(sql, params) } catch (e: any) { throw dbError('Counter could not be saved to the database', e) @@ -114,7 +110,6 @@ export const setCounter = function ( * from 0 and becomes `delta`. */ export const bumpCounter = function ( - mintUrl: string, keysetId: string, unit: string | undefined, delta: number, @@ -123,12 +118,12 @@ export const bumpCounter = function ( try { const db = getInstance() db.execute( - `INSERT INTO mint_counters (mintUrl, keysetId, unit, counter, updatedAt) - VALUES (?, ?, ?, ?, ?) - ON CONFLICT(mintUrl, keysetId) DO UPDATE SET + `INSERT INTO mint_counters (keysetId, unit, counter, updatedAt) + VALUES (?, ?, ?, ?) + ON CONFLICT(keysetId) DO UPDATE SET counter = counter + ?, updatedAt = excluded.updatedAt`, - [mintUrl, keysetId, unit ?? null, delta, new Date().toISOString(), delta], + [keysetId, unit ?? null, delta, new Date().toISOString(), delta], ) } catch (e: any) { throw dbError('Counter could not be advanced in the database', e) @@ -150,7 +145,7 @@ export const seedCounters = function (seeds: CounterSeed[]): {seeded: number} { try { const now = new Date().toISOString() 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() diff --git a/src/services/db/index.ts b/src/services/db/index.ts index 38bfdb89..4854778b 100644 --- a/src/services/db/index.ts +++ b/src/services/db/index.ts @@ -22,6 +22,7 @@ import { getPendingOnchainTransfers, addTransactionAsync, updateTransaction, + updateInFlightTransactionsMintUrl, expireAllAfterRecovery, updateStatusesAsync, deleteTransactionsByStatus, @@ -111,6 +112,7 @@ export const Database = { getPendingOnchainTransfers, addTransactionAsync, updateTransaction, + updateInFlightTransactionsMintUrl, expireAllAfterRecovery, updateStatusesAsync, deleteTransactionsByStatus, diff --git a/src/services/db/migrations.ts b/src/services/db/migrations.ts index fdc34320..c62aa18d 100644 --- a/src/services/db/migrations.ts +++ b/src/services/db/migrations.ts @@ -1,10 +1,10 @@ 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 {log} from '../logService' /** 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[]} @@ -110,6 +110,35 @@ const MIGRATIONS: Migration[] = [ [`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`], + ], + }, ] /** diff --git a/src/services/db/reservationsRepo.ts b/src/services/db/reservationsRepo.ts index 0ffd611f..17401bf1 100644 --- a/src/services/db/reservationsRepo.ts +++ b/src/services/db/reservationsRepo.ts @@ -173,7 +173,6 @@ export const commitReservation = function ( * next derivation reuse a blinded secret. Each upsert is monotonic. */ counterUpdate?: Array<{ - mintUrl: string keysetId: string unit?: string counter: number @@ -262,7 +261,7 @@ export const commitReservation = function ( } 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]]) diff --git a/src/services/db/schema.ts b/src/services/db/schema.ts index 2e409955..c67b2d29 100644 --- a/src/services/db/schema.ts +++ b/src/services/db/schema.ts @@ -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 * `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, * 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 - * url keeps the row addressable across mint-url edits. + * Keyed by keysetId ALONE, because that is the real key space: NUT-13 derives + * 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 = ` - mintUrl TEXT NOT NULL, - keysetId TEXT NOT NULL, + keysetId TEXT PRIMARY KEY NOT NULL, unit TEXT, counter INTEGER NOT NULL DEFAULT 0, - updatedAt TEXT, - PRIMARY KEY (mintUrl, keysetId) + updatedAt TEXT ` /** @@ -132,11 +142,11 @@ export const INFLIGHT_REQUESTS_COLUMNS = ` /** * Wallet-global deterministic-derivation counters, keyed by purpose name. * - * Distinct from `mint_counters`, which is keyed by (mintUrl, keysetId) because - * NUT-13 keyset counters are mint-scoped. The counters here belong to derivation - * paths that have NO mint or keyset component, so a single value serves the whole - * wallet. The first is NUT-20 quote-locking (`m/129373'/20'/0'/0'/{counter}`); - * the table is keyed by name so future wallet-global counters need no migration. + * Distinct from `mint_counters`, which is keyed by keysetId because a NUT-13 + * counter is keyset-scoped. The counters here belong to derivation paths that + * have NO keyset component either, so a single value serves the whole wallet. The + * first is NUT-20 quote-locking (`m/129373'/20'/0'/0'/{counter}`); the table is + * 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 * `mint_counters` (which stores cashu-ts's `next`). Allocation increments and @@ -198,6 +208,9 @@ export const createTable = ( export const PROOFS_COLUMN_NAMES = '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. */ export const createSchemaQueries: SQLBatchTuple[] = [ [createTable('transactions', TRANSACTIONS_COLUMNS)], diff --git a/src/services/db/transactionsRepo.ts b/src/services/db/transactionsRepo.ts index 6c8f97c8..57c7e2a2 100644 --- a/src/services/db/transactionsRepo.ts +++ b/src/services/db/transactionsRepo.ts @@ -1,4 +1,5 @@ import {Transaction, TransactionStatus} from '../../models/Transaction' +import {IN_FLIGHT_STATUSES} from '../../models/TransactionStates' import AppError, {Err} from '../../utils/AppError' import {log} from '../logService' import {getInstance} from './instance' @@ -48,6 +49,54 @@ export const updateTransaction = function (id: number, fields: Partial '?').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) { let query: string = '' diff --git a/src/services/db/walletCountersRepo.ts b/src/services/db/walletCountersRepo.ts index 197f038b..a40bc4e1 100644 --- a/src/services/db/walletCountersRepo.ts +++ b/src/services/db/walletCountersRepo.ts @@ -4,9 +4,9 @@ import {dbError} from './errors' // ───────────────────────────────────────────────────────────────────────────── // Wallet-global deterministic-derivation counters, keyed by purpose name. // -// Sibling of countersRepo, which owns the per-(mint, keyset) NUT-13 counters. -// The counters here belong to derivation paths with NO mint or keyset component, -// so one value serves the whole wallet. First user: NUT-20 quote-locking keys +// Sibling of countersRepo, which owns the per-keyset NUT-13 counters. The +// counters here belong to derivation paths with NO keyset component, so one value +// serves the whole wallet. First user: NUT-20 quote-locking keys // (`m/129373'/20'/0'/0'/{counter}`). // // The stored `counter` is the NEXT FREE index. Allocation is BURN-FORWARD: the