mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-09-14 00:55:09 +00:00
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a5ac3d7487
commit
6d7cd0f299
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
+17
-22
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user