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>
This commit is contained in:
minibits-cash
2026-08-05 15:35:37 +02:00
co-authored by Claude Opus 4.8
parent e5286973aa
commit bfd4c44c8c
4 changed files with 268 additions and 3 deletions
+149
View File
@@ -0,0 +1,149 @@
/**
* 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()
})
})
+78
View File
@@ -0,0 +1,78 @@
/**
* 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)
})
})
+16
View File
@@ -284,6 +284,22 @@ export const MintModel = types
self.keysets[index] = updatedKeyset
self.keysets = cast(self.keysets)
}
// Keep the parallel `keys` entry's active flag in lockstep. MintKeys carries
// its own `active`, and getKeys() only ever returns ACTIVE keysets — so a
// keyset going inactive is never re-fetched, and its keys entry would keep a
// stale active:true forever (visible after a mint migration flips the old
// active keyset inactive). The keyset metadata is the authority; mirror it.
const keysIndex = self.keys.findIndex(k => k.id === freshKeyset.id)
if(keysIndex !== -1) {
const updatedKeys = {
...self.keys[keysIndex],
active: freshKeyset.active
}
self.keys[keysIndex] = updatedKeys
self.keys = cast(self.keys)
}
},
setInputFeePpk(keysetId: string, inputFeePpk: number) {
const index = self.keysets.findIndex(k => k.id === keysetId)
+25 -3
View File
@@ -502,13 +502,35 @@ export const WalletStoreModel = types
}
const cashuWallet: CashuWallet = yield self.getWallet(
mintUrl,
unit,
mintUrl,
unit,
{
withSeed: true,
withSeed: true,
}
)
// Load keys for every keyset that SIGNED the incoming proofs, including
// inactive ones. The wallet only ever fetches ACTIVE keys (getKeys with no
// id), but a received proof can be from an inactive keyset — most acutely
// after a mint migration, where the keyset that signed all existing ecash
// becomes inactive (e.g. nutshell -> cdk, 00107937... goes inactive while a
// new v2 keyset is issued). 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.
// ensureKeysetKeys fetches /v1/keys/{id}, verifies, and is a no-op once the
// keys are present, so this is cheap on the common path.
const inputKeysetIds = [...new Set(decodedToken.proofs.map(p => p.id))]
for (const keysetId of inputKeysetIds) {
try {
yield cashuWallet.keyChain.ensureKeysetKeys(keysetId)
} catch (e: any) {
// Leave it to cashu-ts to raise its own precise error if the keyset
// is genuinely unknown; only the loadable-but-unloaded case matters
// here and that one now succeeds.
log.warn('[WalletStore.receive]', 'Could not load keys for input keyset', {keysetId, error: e.message})
}
}
const currentCounter = mintInstance.getProofsCounterByKeysetId!(cashuWallet.keysetId)
// outputs error healing