mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-10-05 11:18:24 +00:00
A mint URL is a network locator, not an identity, but it had become the de
facto foreign key for most persisted state. Changing a mint's URL therefore
had to rewrite every one of those references, and only proofs were ever
rewritten. This removes the URL from the key space where it never belonged,
and repairs the rename for what remains.
Counters (the fund-losing one)
mint_counters was PRIMARY KEY (mintUrl, keysetId), which asserts a key space
that does not exist: NUT-13 derives from (seed, keysetId, counter), with no
mint component in either path (`m/129372'/0'/{keysetIdInt}'/{counter}'` for
`00` ids, HMAC-SHA256 over the id for v2 `01`). Two rows could track ONE
derivation path independently, and a URL edit left the row unaddressable —
hydration matched on URL, found nothing, and silently restarted the counter
at 0, reusing blinded secrets the mint had already signed.
Re-keyed on keysetId alone, which is sound because keyset ids are already
globally unique wallet-wide, enforced at the door by isCollidingKeysetId per
NUT-02. Migration 32 collapses duplicates to MAX(counter), so it HEALS
wallets already split by this rather than only preventing new splits — a
too-high counter skips indices, a too-low one reuses them.
This also deletes code: persistCounter no longer walks getParent() for a URL,
so a counter detached from its Mint now persists instead of dropping its
write.
Keyset collisions and NUT-02 v2
isCollidingKeysetId applied the mod-2^31-1 keysetIdInt check to every id. That
integer only exists on the deprecated BIP-32 path; v2 ids derive by HMAC over
the full 32 bytes and never compute it. Checking it there would reject a
legitimate mint over a number nothing consumes, and re-impose v1's ~2^31
birthday bound on ids whose whole point is full-width SHA-256 resistance. The
check is now gated on derivation kind; exact-id equality still always applies.
Mint URL change
- Validation is shared with addMint via a new normalizeMintUrl, so adding and
renaming can no longer disagree. The rename previously did neither the
trailing-slash strip (NUT-00 MUST) nor the https check.
- Canonical form matches cashu-ts normalizeUrl (`href` then strip trailing
slashes). WalletStore compares our stored string to CashuMint.mintUrl to
find cached instances, so normalizing the raw input would let
`https://Mint.Example` be stored while cashu-ts held `https://mint.example`:
every cache lookup missing, two spellings looking like two mints. Pinned by
tests asserting agreement with CashuMint.mintUrl.
- The onion exemption tested `includes('.onion')`, so `http://evil.example/.onion`
bought a plain-http exemption for an ordinary host. It now tests the parsed
hostname. `startsWith('https')` also passed `https-evil://host`; now protocol
equality.
- Duplicate detection uses mintExists (normalized), not alreadyExists
(literal), which missed a trailing-slash twin and let one real mint become
two Mint nodes. Renaming to the URL already held is now a no-op, not an error.
- hostname is recomputed; it used to keep the old mint's host forever.
- transactions.mint is repointed for IN-FLIGHT rows only. That column means two
things by status: for a terminal row it is a historical record of where the
payment happened, but for an open one it is a live pointer the wallet still
calls (checkLightningMintQuote, checkLightningMeltQuote/checkOnchainMeltQuote,
findByUrl on revert/receive). Stale, it strands a paid topup at a dead URL
forever. One UPDATE, so the status test cannot straddle a transition.
- ProofsStore.updateMintUrl now writes SQLite before memory; the reverse left
the UI showing a balance the database never received.
Still URL-keyed, and documented on setMintUrl: onchain mint quotes, in-flight
requests, melt recovery and open reservations. Renaming a mint with any of
those outstanding still strands them. They need a stable mint id, which is the
next step.
Tests: 413 pass. The two new v2 collision tests fail against the previous
code and pass here, while the v1 cases pass in both. counters.test.ts mirrored
the production SQL by hand and so had asserted the old key — including a test
that two mints sharing a keyset id keep independent counters, exactly the
unsound behaviour removed here.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
204 lines
7.3 KiB
TypeScript
204 lines
7.3 KiB
TypeScript
/**
|
|
* 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)
|
|
})
|
|
})
|