Files
minibits_wallet/__tests__/inactiveKeysetKeys.test.ts
T
minibits-cashandClaude Opus 5 6d7cd0f299 Delete the receive-path ensureKeysetKeys loop, now redundant
WalletStore.receive pre-loaded keys for every keyset that signed an incoming
token's proofs, because cashu-ts DLEQ-verifies each input proof carrying a
DLEQ and threw "Undefined key for amount N in keyset X" when those keys were
absent. That bites after a mint migration, where the keyset that signed all
existing ecash goes inactive and getKeys() — which returns ACTIVE keysets
only, per NUT-01 — stops returning it.

cashu-ts 4.9 made the loop redundant. Traced in the shipped bundle:
wallet.receive -> prepareSwapToReceive calls _ensureOperableKeysets over the
token's own proof ids, and it runs BEFORE the DLEQ loop that used to throw.
With fetchKeys unset it filters to exactly the input keysets lacking keys and
calls the same keyChain.ensureKeysetKeys the loop called, then additionally
repairs ids it does not recognise at all via loadMint(true) — which the loop
could not do, since ensureKeysetKeys only resolves keysets already known.

inactiveKeysetKeys.test.ts is re-pointed accordingly. Its KeyChain-level tests
stay, reframed as the mechanism cashu-ts uses internally rather than as the
fix the wallet relies on, and a new describe supplies the evidence that
licenses the deletion: a Wallet loaded from an active-keys-only cache — what
WalletStore.getWallet really builds — is handed a token signed by the inactive
keyset, and prepareSwapToReceive is shown to fetch that keyset by id, land the
keys in the keychain, and prepare without throwing.

That block also pins the precondition the deletion depends on:
_ensureOperableKeysets returns early when the wallet has no mint info, so on
such a wallet there is no safety net at all. WalletStore.getWallet always
loads mint info first, via loadMintFromCache or loadMint, which is what makes
removing the loop safe — asserted so the invariant cannot break silently.

Not reachable on a device: it needs proofs signed by a keyset the mint has
retired, which a mint will not issue on request. The unit test is the coverage.

Verified: tsc --noEmit unchanged against baseline (89 pre-existing, none new),
47 suites / 642 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 23:27:58 +02:00

255 lines
9.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 then proves that a
* receive resolves it WITHOUT the wallet's help.
*
* WalletStore.receive used to pre-load those keys itself, looping
* `keyChain.ensureKeysetKeys` over the token's proof ids. cashu-ts 4.9 made that
* redundant: `wallet.receive` -> `prepareSwapToReceive` calls
* `_ensureOperableKeysets` over the same ids before its DLEQ loop. The loop was
* removed, and the last describe below is what licenses that removal.
*
* 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,
Wallet,
} 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]
/** Minimal NUT-06 info — enough for MintInfo to construct; no capabilities are read. */
const MINT_INFO = {
name: 'test mint',
pubkey: '02'.padEnd(66, 'a'),
version: 'test/1.0',
description: '',
contact: [],
nuts: {
'4': {methods: [], disabled: false},
'5': {methods: [], disabled: false},
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any
/** 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('KeyChain.ensureKeysetKeys — the mechanism cashu-ts uses internally', () => {
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()
})
})
describe('prepareSwapToReceive loads input keyset keys on its own', () => {
// The evidence for deleting WalletStore.receive's ensureKeysetKeys loop. A wallet
// is given the cache the wallet really builds — every keyset's metadata, but keys
// for the ACTIVE one only — and handed a token signed by the INACTIVE keyset. If
// cashu-ts did not fetch those keys itself, its own DLEQ loop would throw
// "Undefined key for amount 16".
const buildWallet = () => {
const getKeys = jest.fn(async (id?: string) => ({
keysets: [id === inactive.meta.id ? inactive.keys : active.keys],
}))
const fakeMint = {
mintUrl: MINT_URL,
getKeys,
getInfo: jest.fn(async () => MINT_INFO),
setMintInfo: jest.fn(),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any
const wallet = new Wallet(fakeMint, {unit: 'sat'})
wallet.loadMintFromCache(
MINT_INFO,
// Active keys only — exactly what getKeys() with no id returns (NUT-01).
KeyChain.mintToCacheDTO(MINT_URL, [inactive.meta, active.meta], [active.keys]),
)
return {wallet, getKeys}
}
/** A token from the inactive keyset. No DLEQ: absent is valid, only INVALID throws. */
const tokenFromInactive = () =>
({
mint: MINT_URL,
unit: 'sat',
proofs: [{id: inactive.meta.id, amount: 16, secret: 's1', C: '02' + '11'.repeat(32)}],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any
test('the cache really is missing the inactive keys to begin with', () => {
const {wallet} = buildWallet()
expect(wallet.keyChain.getKeyset(inactive.meta.id).hasKeys).toBe(false)
})
test('it fetches the inactive keyset by id', async () => {
const {wallet, getKeys} = buildWallet()
await wallet.prepareSwapToReceive(tokenFromInactive())
expect(getKeys).toHaveBeenCalledWith(inactive.meta.id)
})
test('the keys land in the keychain, so the DLEQ loop can read them', async () => {
const {wallet} = buildWallet()
await wallet.prepareSwapToReceive(tokenFromInactive())
const loaded = wallet.keyChain.getKeyset(inactive.meta.id)
expect(loaded.hasKeys).toBe(true)
expect(loaded.keys['16']).toBeDefined()
})
test('so the receive prepares without the wallet pre-loading anything', async () => {
const {wallet} = buildWallet()
await expect(wallet.prepareSwapToReceive(tokenFromInactive())).resolves.toBeDefined()
})
test('PRECONDITION: it is a no-op when mint info was never loaded', async () => {
// _ensureOperableKeysets returns early on a wallet with no mint info, so the
// safety net simply is not there. WalletStore.getWallet always loads it — via
// loadMintFromCache or loadMint — before any receive, which is what makes the
// deletion safe. Pinned so that invariant cannot be broken silently.
const getKeys = jest.fn(async () => ({keysets: [inactive.keys]}))
const fakeMint = {mintUrl: MINT_URL, getKeys, setMintInfo: jest.fn()}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const wallet = new Wallet(fakeMint as any, {unit: 'sat'})
// No loadMint / loadMintFromCache, so no keysets and no mint info.
await expect(wallet.prepareSwapToReceive(tokenFromInactive())).rejects.toThrow()
expect(getKeys).not.toHaveBeenCalled()
})
})