mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-10-05 11:18:24 +00:00
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:
co-authored by
Claude Opus 4.8
parent
714a3bc3d0
commit
9d759f83ab
+82
-73
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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<any>(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
|
||||
},
|
||||
}))
|
||||
|
||||
|
||||
|
||||
+25
-20
@@ -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<string, MintProofsCounter>()
|
||||
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
|
||||
|
||||
@@ -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<string>()
|
||||
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,
|
||||
|
||||
@@ -204,7 +204,14 @@ const TERMINAL_STATUSES: ReadonlySet<TransactionStatus> = new Set([
|
||||
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.PREPARED,
|
||||
TransactionStatus.PREPARED_OFFLINE,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
|
||||
@@ -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(/\/+$/, '')
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`],
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
|
||||
@@ -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]])
|
||||
|
||||
+25
-12
@@ -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)],
|
||||
|
||||
@@ -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<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) {
|
||||
let query: string = ''
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user