diff --git a/__tests__/inactiveKeysetKeys.test.ts b/__tests__/inactiveKeysetKeys.test.ts index c7e64908..b84f636a 100644 --- a/__tests__/inactiveKeysetKeys.test.ts +++ b/__tests__/inactiveKeysetKeys.test.ts @@ -11,9 +11,14 @@ * 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. + * 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 @@ -25,6 +30,7 @@ import { deriveKeysetId, getPubKeyFromPrivKey, KeyChain, + Wallet, } from '@cashu/cashu-ts' import type {MintKeys, MintKeyset} from '@cashu/cashu-ts' import {bytesToHex} from '@noble/curves/utils.js' @@ -32,6 +38,20 @@ 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, @@ -100,7 +120,7 @@ describe('a keychain that also has the inactive keyset keys', () => { }) }) -describe('ensureKeysetKeys — the fix WalletStore.receive relies on', () => { +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. @@ -147,3 +167,88 @@ describe('ensureKeysetKeys — the fix WalletStore.receive relies on', () => { 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() + }) +}) diff --git a/src/models/WalletStore.ts b/src/models/WalletStore.ts index 1e18d59e..22cbc3b3 100644 --- a/src/models/WalletStore.ts +++ b/src/models/WalletStore.ts @@ -576,28 +576,23 @@ export const WalletStoreModel = types } ) - // 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}) - } - } - + // NOTE: keys for the keysets that SIGNED the incoming proofs are loaded by + // cashu-ts itself, so nothing is done here. + // + // The wallet only ever fetches ACTIVE keys (getKeys with no id), but a + // received proof can come from an inactive keyset — most acutely after a + // mint migration, where the keyset that signed all existing ecash goes + // inactive (nutshell -> cdk: 00107937… deactivates, a new v2 keyset is + // issued). cashu-ts DLEQ-verifies every input proof carrying a DLEQ, + // regardless of requireDleq, and used to throw "Undefined key for amount N + // in keyset X" when X's keys were absent. This block used to pre-load them + // with keyChain.ensureKeysetKeys. + // + // Since 4.9, wallet.receive -> prepareSwapToReceive calls + // _ensureOperableKeysets over the token's own proof ids BEFORE that DLEQ + // loop, which fetches keys for exactly the input keysets that lack them — + // and additionally repairs ids it does not recognise at all with a + // loadMint(true), which the loop here could never do. const currentCounter = mintInstance.getProofsCounterByKeysetId!(cashuWallet.keysetId) // outputs error healing