Files
minibits_wallet/__tests__/mintKeysetActiveSync.test.ts
minibits-cashandClaude Opus 4.8 bfd4c44c8c Load inactive keyset keys on receive; sync keys[].active
Receiving ecash after a mint migration failed with "Undefined key for
amount N in keyset X". Diagnosed against the live nutshell->cdk migration
(mint.minibits.cash/Bitcoin -> /sat): cdk migrates the keyset that signed
all existing ecash (00107937...) as INACTIVE and issues a new active v2
keyset.

The wallet only ever fetches ACTIVE keys — getKeys() with no id, which by
NUT-01 returns active keysets only. cashu-ts DLEQ-verifies every input proof
that carries a DLEQ (nutshell attaches one), regardless of requireDleq, and
throws when the signing keyset's keys are not loaded. So every incoming
proof from the now-inactive keyset is unreceivable.

Fix: before receive, ensure the wallet's keychain holds keys for every
keyset that signed the incoming proofs, via cashu-ts ensureKeysetKeys, which
fetches /v1/keys/{id} (served for inactive keysets too), verifies, and
no-ops once present. Proven end to end against the live cdk mint: the
inactive keyset goes from no-keys to verified-with-amount-16.

Also a second, lower-severity bug it exposed: Mint.setIsActive updated only
the keysets array, never the parallel keys array — which carries its own
MintKeys.active. getKeys() never re-fetches an inactive keyset, so its keys
entry kept a stale active:true forever after a migration. setIsActive now
mirrors the flag onto the keys entry too.

Not device-tested — the receive path needs a real device against a mint with
an inactive signing keyset, i.e. the cdk test mint while it is still up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 15:35:37 +02:00

79 lines
3.0 KiB
TypeScript

/**
* Mint.setIsActive keeps the `keys` array's active flag in lockstep with `keysets`.
*
* A mint carries two parallel arrays: `keysets` (metadata, the authority on active)
* and `keys` (the amount→pubkey maps, which ALSO carry MintKeys.active). getKeys()
* only ever returns ACTIVE keysets, so a keyset going inactive is never re-fetched —
* and before this fix its `keys` entry kept a stale active:true forever. That surfaces
* after a mint migration flips the formerly-active keyset inactive: the keysets array
* updates, the keys array does not.
*
* @jest-environment node
*/
jest.mock('../src/services/logService', () => ({
log: {debug: jest.fn(), error: jest.fn(), info: jest.fn(), trace: jest.fn(), warn: jest.fn()},
LogLevel: {ERROR: 'ERROR', WARN: 'WARN', INFO: 'INFO', DEBUG: 'DEBUG', TRACE: 'TRACE'},
}))
jest.mock('../src/services', () => ({
log: {debug: jest.fn(), error: jest.fn(), info: jest.fn(), trace: jest.fn(), warn: jest.fn()},
Database: {},
}))
jest.mock('../src/theme', () => ({
colors: {palette: {iconBlue200: '#4dabf7'}},
getRandomIconColor: () => '#4dabf7',
}))
jest.mock('../src/services/wallet/currency', () => ({
MintUnits: ['btc', 'sat', 'msat', 'usd', 'eur'],
}))
jest.mock('../src/utils/utils', () => ({
generateId: () => 'testmint',
}))
jest.mock('../src/services/cashu/cashuUtils', () => ({
CashuUtils: {},
}))
import {MintModel} from '../src/models/Mint'
const KEYSET_ID = '00107937db0cc865'
const buildMint = () =>
MintModel.create({
mintUrl: 'https://mint.test/sat',
keysets: [{id: KEYSET_ID, unit: 'sat', active: true, input_fee_ppk: 0} as any],
keys: [{id: KEYSET_ID, unit: 'sat', active: true, keys: {'1': 'aa', '2': 'bb'}} as any],
})
describe('Mint.setIsActive', () => {
test('flips the keys entry inactive alongside the keyset', () => {
const mint = buildMint()
mint.setIsActive({id: KEYSET_ID, unit: 'sat', active: false} as any)
expect(mint.keysets.find(k => k.id === KEYSET_ID)?.active).toBe(false)
// The regression: this used to stay true because getKeys() never re-fetches an
// inactive keyset, so nothing else would ever correct it.
expect((mint.keys.find(k => k.id === KEYSET_ID) as any)?.active).toBe(false)
})
test('flips back to active symmetrically', () => {
const mint = buildMint()
mint.setIsActive({id: KEYSET_ID, unit: 'sat', active: false} as any)
mint.setIsActive({id: KEYSET_ID, unit: 'sat', active: true} as any)
expect(mint.keysets.find(k => k.id === KEYSET_ID)?.active).toBe(true)
expect((mint.keys.find(k => k.id === KEYSET_ID) as any)?.active).toBe(true)
})
test('does not choke when only the keyset is present (no keys entry yet)', () => {
const mint = MintModel.create({
mintUrl: 'https://mint.test/sat',
keysets: [{id: KEYSET_ID, unit: 'sat', active: true, input_fee_ppk: 0} as any],
keys: [],
})
expect(() => mint.setIsActive({id: KEYSET_ID, unit: 'sat', active: false} as any)).not.toThrow()
expect(mint.keysets.find(k => k.id === KEYSET_ID)?.active).toBe(false)
})
})