From bfd4c44c8c604f2e9c3c020f89009b63fd00b97a Mon Sep 17 00:00:00 2001 From: minibits-cash Date: Wed, 5 Aug 2026 15:35:37 +0200 Subject: [PATCH] Load inactive keyset keys on receive; sync keys[].active MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- __tests__/inactiveKeysetKeys.test.ts | 149 +++++++++++++++++++++++++ __tests__/mintKeysetActiveSync.test.ts | 78 +++++++++++++ src/models/Mint.ts | 16 +++ src/models/WalletStore.ts | 28 ++++- 4 files changed, 268 insertions(+), 3 deletions(-) create mode 100644 __tests__/inactiveKeysetKeys.test.ts create mode 100644 __tests__/mintKeysetActiveSync.test.ts diff --git a/__tests__/inactiveKeysetKeys.test.ts b/__tests__/inactiveKeysetKeys.test.ts new file mode 100644 index 00000000..c7e64908 --- /dev/null +++ b/__tests__/inactiveKeysetKeys.test.ts @@ -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 = {} + 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() + }) +}) diff --git a/__tests__/mintKeysetActiveSync.test.ts b/__tests__/mintKeysetActiveSync.test.ts new file mode 100644 index 00000000..4c608837 --- /dev/null +++ b/__tests__/mintKeysetActiveSync.test.ts @@ -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) + }) +}) diff --git a/src/models/Mint.ts b/src/models/Mint.ts index 788692cf..a9625b21 100644 --- a/src/models/Mint.ts +++ b/src/models/Mint.ts @@ -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) diff --git a/src/models/WalletStore.ts b/src/models/WalletStore.ts index 56df75a6..fa4bd18a 100644 --- a/src/models/WalletStore.ts +++ b/src/models/WalletStore.ts @@ -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