Files
minibits_wallet/__tests__/inactiveKeysetKeys.test.ts
T
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

150 lines
5.2 KiB
TypeScript

/**
* Receiving ecash signed by an INACTIVE keyset (Mint.ts / WalletStore.receive).
*
* The wallet only ever fetches ACTIVE keys — `getKeys()` with no keyset id, which by
* NUT-01 returns only active keysets. That is fine until a proof arrives from an
* inactive keyset, which is exactly what a mint MIGRATION produces: cdk migrates the
* nutshell keyset that signed every existing proof (e.g. 00107937…) as INACTIVE and
* issues a new active v2 keyset. cashu-ts DLEQ-verifies every input proof that carries
* a DLEQ — regardless of `requireDleq` — and throws
*
* Undefined key for amount N in keyset X
*
* when X's keys are not loaded. This test reproduces that precondition at the cashu-ts
* KeyChain level (the layer WalletStore.getWallet builds) and proves that
* `ensureKeysetKeys` — which WalletStore.receive now calls for every input proof's
* keyset — loads the missing keys.
*
* Deterministic and offline: keysets are generated with cashu-ts crypto primitives, so
* the derived ids genuinely verify against their keys (a partial or fake keyset would
* be wiped by KeyChain's own `verify() || (keys = {})`).
*
* @jest-environment node
*/
import {
deriveKeysetId,
getPubKeyFromPrivKey,
KeyChain,
} from '@cashu/cashu-ts'
import type {MintKeys, MintKeyset} from '@cashu/cashu-ts'
import {bytesToHex} from '@noble/curves/utils.js'
const MINT_URL = 'https://mint.test/sat'
const AMOUNTS = [1, 2, 4, 8, 16, 32]
/** A valid v0 keyset whose id genuinely derives from its keys. */
const makeKeyset = (
seedByte: number,
active: boolean,
): {meta: MintKeyset; keys: MintKeys} => {
const keys: Record<string, string> = {}
for (let i = 0; i < AMOUNTS.length; i++) {
// Distinct, deterministic private keys → real public keys.
const priv = new Uint8Array(32)
priv[31] = seedByte
priv[30] = i + 1
keys[String(AMOUNTS[i])] = bytesToHex(getPubKeyFromPrivKey(priv))
}
const id = deriveKeysetId(keys, {unit: 'sat', input_fee_ppk: 0, versionByte: 0})
return {
meta: {id, unit: 'sat', active, input_fee_ppk: 0},
keys: {id, unit: 'sat', active, keys},
}
}
// The keyset that signed the received ecash, now INACTIVE (the migrated one), and the
// mint's new ACTIVE keyset.
const inactive = makeKeyset(0x11, false)
const active = makeKeyset(0x22, true)
describe('a keychain built from active keys only', () => {
const kc = KeyChain.fromCache(
MINT_URL,
'sat',
// What the wallet loads: every keyset's metadata, but keys for the ACTIVE keyset
// only. This is `getKeys()` (active) + `getKeySets()` (all).
KeyChain.mintToCacheDTO(MINT_URL, [inactive.meta, active.meta], [active.keys]),
)
test('the inactive keyset ends up with no keys — the "Undefined key" precondition', () => {
expect(kc.getKeyset(inactive.meta.id).hasKeys).toBe(false)
})
test('the active keyset is fine', () => {
const ks = kc.getKeyset(active.meta.id)
expect(ks.hasKeys).toBe(true)
expect(ks.verify()).toBe(true)
expect(ks.keys['16']).toBeDefined()
})
})
describe('a keychain that also has the inactive keyset keys', () => {
const kc = KeyChain.fromCache(
MINT_URL,
'sat',
KeyChain.mintToCacheDTO(
MINT_URL,
[inactive.meta, active.meta],
[inactive.keys, active.keys],
),
)
test('the inactive keyset verifies and amount 16 is present', () => {
const ks = kc.getKeyset(inactive.meta.id)
expect(ks.hasKeys).toBe(true)
expect(ks.verify()).toBe(true)
// The exact lookup cashu-ts does during DLEQ verify of an amount-16 input proof.
expect(ks.keys['16']).toBeDefined()
})
})
describe('ensureKeysetKeys — the fix WalletStore.receive relies on', () => {
test('loads keys for an inactive keyset that the active-only cache omitted', async () => {
// A mint that serves the inactive keyset's keys on the per-id endpoint — cdk does
// exactly this at /v1/keys/{id}, verified against the live migration mint.
const fakeMint = {
mintUrl: MINT_URL,
getKeys: jest.fn(async (id: string) => ({
keysets: [id === inactive.meta.id ? inactive.keys : active.keys],
})),
}
const kc = KeyChain.fromCache(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fakeMint as any,
'sat',
KeyChain.mintToCacheDTO(MINT_URL, [inactive.meta, active.meta], [active.keys]),
)
// Before: the receive would throw "Undefined key for amount 16".
expect(kc.getKeyset(inactive.meta.id).hasKeys).toBe(false)
const loaded = await kc.ensureKeysetKeys(inactive.meta.id)
expect(loaded.hasKeys).toBe(true)
expect(loaded.verify()).toBe(true)
expect(kc.getKeyset(inactive.meta.id).keys['16']).toBeDefined()
expect(fakeMint.getKeys).toHaveBeenCalledWith(inactive.meta.id)
})
test('is a no-op when the keys are already present', async () => {
const fakeMint = {
mintUrl: MINT_URL,
getKeys: jest.fn(async () => ({keysets: [active.keys]})),
}
const kc = KeyChain.fromCache(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fakeMint as any,
'sat',
KeyChain.mintToCacheDTO(MINT_URL, [active.meta], [active.keys]),
)
await kc.ensureKeysetKeys(active.meta.id)
expect(fakeMint.getKeys).not.toHaveBeenCalled()
})
})