mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-10-05 11:18:24 +00:00
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:
co-authored by
Claude Opus 4.8
parent
e5286973aa
commit
bfd4c44c8c
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user