From 7d6be05eab7d328a706305f7df5b956f5cbdfc5a Mon Sep 17 00:00:00 2001 From: minibits-cash Date: Tue, 11 Aug 2026 14:25:47 +0200 Subject: [PATCH] Ensure up to date keysets on receive --- __tests__/staleKeysetDecode.test.ts | 160 ++++++++++++++++++ package.json | 2 +- src/models/WalletStore.ts | 105 +++++++++--- src/screens/DeveloperScreen.tsx | 2 +- src/services/wallet/decodeToken.ts | 70 ++++++++ .../wallet/operations/nostrOperations.ts | 21 ++- .../wallet/operations/receiveOperationApi.ts | 6 +- .../wallet/operations/receiveOperations.ts | 5 +- 8 files changed, 341 insertions(+), 30 deletions(-) create mode 100644 __tests__/staleKeysetDecode.test.ts create mode 100644 src/services/wallet/decodeToken.ts diff --git a/__tests__/staleKeysetDecode.test.ts b/__tests__/staleKeysetDecode.test.ts new file mode 100644 index 00000000..bbb95a8b --- /dev/null +++ b/__tests__/staleKeysetDecode.test.ts @@ -0,0 +1,160 @@ +/** + * Decoding a token whose v2 keyset the wallet has not seen yet + * (services/wallet/decodeToken.ts). + * + * A NUT-02 v2 keyset id is 33 bytes, but a v4 token carries only its first 8 — so + * cashu-ts can hand a proof its real id only by matching that prefix against ids the + * wallet already holds for the mint, and throws when none match. That is precisely + * what a mint rotating keysets produces (nutshell -> cdk migrates the old `00…` + * keysets as inactive and signs with a new `01…` one): every wallet that has not + * touched the mint since holds a list that cannot decode the ecash now arriving, + * including background lightning-address claims and nostr receives. + * + * These tests use REAL cashu-ts encoding — the truncation under test is its own — and + * prove the decode heals itself by re-pulling the keysets exactly once, and only when + * a decode has actually failed on an unmappable id. + * + * @jest-environment node + */ +jest.mock('../src/services/logService', () => ({ + log: {debug: jest.fn(), error: jest.fn(), info: jest.fn(), trace: jest.fn(), warn: jest.fn()}, +})) + +// `mock`-prefixed so jest's hoisting of the factory below can reach them. +const mockMintsStore = {findByUrl: jest.fn()} +const mockWalletStore = {refreshKeysetsNow: jest.fn()} + +jest.mock('../src/models', () => ({ + rootStoreInstance: { + get mintsStore() { + return mockMintsStore + }, + get walletStore() { + return mockWalletStore + }, + }, +})) + +import {deriveKeysetId, getDecodedToken, getEncodedToken} from '@cashu/cashu-ts' +import {decodeTokenWithKeysets} from '../src/services/wallet/decodeToken' + +const MINT_URL = 'https://mint.test/Bitcoin' +const AMOUNTS = [1, 2, 4, 8] + +/** The keyset that signed every proof before the migration — a v0 id, 8 bytes. */ +const LEGACY_KEYSET_ID = '00107937db0cc865' + +/** The mint's new v2 keyset id, genuinely derived so it carries the `01` version. */ +const V2_KEYSET_ID = deriveKeysetId( + Object.fromEntries( + AMOUNTS.map((amount, i) => [ + String(amount), + `02${String(i + 1).padStart(2, '0').repeat(31)}`, + ]), + ), + {unit: 'sat', input_fee_ppk: 0, versionByte: 1}, +) + +const encodeTokenFromKeyset = (keysetId: string): string => + getEncodedToken({ + mint: MINT_URL, + unit: 'sat', + proofs: [ + { + id: keysetId, + amount: 2, + secret: 'a'.repeat(64), + C: `02${'11'.repeat(32)}`, + } as any, + ], + }) + +/** Point the store at a mint holding exactly these keyset ids. */ +const setStoredKeysetIds = (keysetIds: string[]) => { + mockMintsStore.findByUrl.mockReturnValue({mintUrl: MINT_URL, keysetIds}) +} + +beforeEach(() => { + jest.clearAllMocks() +}) + +describe('the failure being healed', () => { + test('a v2 keyset id is truncated to 8 bytes by v4 encoding', () => { + expect(V2_KEYSET_ID).toMatch(/^01[0-9a-f]{64}$/) + + // Decoding against the FULL id works, which is the whole mechanism: the short id + // in the token is a prefix of it. + const decoded = getDecodedToken(encodeTokenFromKeyset(V2_KEYSET_ID), [V2_KEYSET_ID]) + expect(decoded.proofs[0].id).toBe(V2_KEYSET_ID) + }) + + test('cashu-ts throws when the wallet holds only the pre-migration keyset', () => { + expect(() => + getDecodedToken(encodeTokenFromKeyset(V2_KEYSET_ID), [LEGACY_KEYSET_ID]), + ).toThrow(/short keyset id/i) + }) +}) + +describe('decodeTokenWithKeysets', () => { + test('refreshes the keysets and retries when the id cannot be mapped', async () => { + setStoredKeysetIds([LEGACY_KEYSET_ID]) + // The refresh is what teaches the wallet about the rotated-in keyset. + mockWalletStore.refreshKeysetsNow.mockImplementation(async () => { + setStoredKeysetIds([LEGACY_KEYSET_ID, V2_KEYSET_ID]) + }) + + const token = await decodeTokenWithKeysets(encodeTokenFromKeyset(V2_KEYSET_ID), MINT_URL) + + // The proof carries the FULL id, not the 8-byte prefix — everything downstream + // (counters, keys, DLEQ) is keyed by it. + expect(token.proofs[0].id).toBe(V2_KEYSET_ID) + expect(mockWalletStore.refreshKeysetsNow).toHaveBeenCalledTimes(1) + expect(mockWalletStore.refreshKeysetsNow).toHaveBeenCalledWith(MINT_URL) + }) + + test('costs nothing when the keyset is already known', async () => { + setStoredKeysetIds([LEGACY_KEYSET_ID, V2_KEYSET_ID]) + + const token = await decodeTokenWithKeysets(encodeTokenFromKeyset(V2_KEYSET_ID), MINT_URL) + + expect(token.proofs[0].id).toBe(V2_KEYSET_ID) + expect(mockWalletStore.refreshKeysetsNow).not.toHaveBeenCalled() + }) + + test('does not touch the mint for a v0 keyset, which needs no mapping', async () => { + // A stale list cannot break a `00…` id: it travels whole. + setStoredKeysetIds([]) + + const token = await decodeTokenWithKeysets(encodeTokenFromKeyset(LEGACY_KEYSET_ID), MINT_URL) + + expect(token.proofs[0].id).toBe(LEGACY_KEYSET_ID) + expect(mockWalletStore.refreshKeysetsNow).not.toHaveBeenCalled() + }) + + test('propagates an unrelated decode failure without calling the mint', async () => { + setStoredKeysetIds([LEGACY_KEYSET_ID]) + + await expect(decodeTokenWithKeysets('not-a-cashu-token', MINT_URL)).rejects.toThrow() + expect(mockWalletStore.refreshKeysetsNow).not.toHaveBeenCalled() + }) + + test('surfaces a refresh failure rather than a confusing decode error', async () => { + setStoredKeysetIds([LEGACY_KEYSET_ID]) + mockWalletStore.refreshKeysetsNow.mockRejectedValue(new Error('Mint is offline')) + + await expect( + decodeTokenWithKeysets(encodeTokenFromKeyset(V2_KEYSET_ID), MINT_URL), + ).rejects.toThrow('Mint is offline') + }) + + test('still fails if the mint does not know the keyset either', async () => { + setStoredKeysetIds([LEGACY_KEYSET_ID]) + // Refresh succeeds but brings nothing new — a token from a different mint, say. + mockWalletStore.refreshKeysetsNow.mockResolvedValue(undefined) + + await expect( + decodeTokenWithKeysets(encodeTokenFromKeyset(V2_KEYSET_ID), MINT_URL), + ).rejects.toThrow(/short keyset id/i) + expect(mockWalletStore.refreshKeysetsNow).toHaveBeenCalledTimes(1) + }) +}) diff --git a/package.json b/package.json index c2525c47..edc6ff0d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "minibits_wallet", - "version": "0.4.3-beta.17", + "version": "0.4.3-beta.19", "private": true, "scripts": { "android:clean": "cd android && ./gradlew clean", diff --git a/src/models/WalletStore.ts b/src/models/WalletStore.ts index fa4bd18a..6f32ace2 100644 --- a/src/models/WalletStore.ts +++ b/src/models/WalletStore.ts @@ -34,7 +34,7 @@ import { MINT_INFO_TTL_SECONDS, isMintInfoStale } from './helpers/mintInfoStale' import { getRootStore } from './helpers/getRootStore' import { Transaction } from './Transaction' -// refresh +// /* Not persisted, in-memory only model of the cashu-ts wallet instances and wallet keys persisted in the device secure store. @@ -273,6 +273,59 @@ export const WalletStoreModel = types log.warn('[WalletStore.refreshMintInfoIfStale]', {mintUrl, error: e.message}) } }), + /** + * Pull the mint's keyset list into the Mint model. + * + * Extracted from getMint, which is still the only routine caller: keysets are + * synced ONCE per process, on the first touch of a mint. There is deliberately + * no periodic refresh — the wallet is told when its list is wrong instead. A + * mint that rotates keysets (a nutshell -> cdk migration issues a new v2 `01…` + * keyset and flips the old `00…` ones inactive) makes the stale list fail + * loudly at exactly one place, the decode of an incoming token, because a v4 + * token carries only an 8-byte PREFIX of a v2 id and getDecodedToken can map + * it back only through ids the wallet already knows. That failure calls + * refreshKeysetsNow and retries; polling would spend a /v1/keysets GET on + * every background wake to shorten a window that a cold start closes anyway. + * + * The keyset fetch runs BEFORE the model lookup on purpose: it is also how + * getMint learns the mint is reachable at all, and callers rely on that + * throw. `getKeys()` returns ACTIVE keys only (NUT-01), so it is worth + * calling only when a keyset we have never seen showed up. + */ + syncKeysets: flow(function* syncKeysets(mintUrl: string, cashuMint: CashuMint) { + // All keysets, active and inactive. + const {keysets} = yield cashuMint.getKeySets() as Promise + + // No model yet — this is a mint being added, and addMint seeds keysets and + // keys itself. + const mintInstance = self.getMintModelInstance(mintUrl) + if (!mintInstance) return + + const newKeysets = keysets.filter((freshKeyset: MintKeyset) => { + return !mintInstance.keysets!.some((keyset: MintKeyset) => keyset.id === freshKeyset.id) + }) + + if (newKeysets.length > 0) { + const {keysets: keys} = yield cashuMint.getKeys() as Promise + + // The mint may have been removed while the calls were in flight. + if (!isAlive(mintInstance)) return + + mintInstance.refreshKeys!(keys) + } + + if (!isAlive(mintInstance)) return + + // Adds what is new, and syncs active flags and input fees on what is not. + mintInstance.refreshKeysets!(keysets) + + if (newKeysets.length > 0) { + log.debug('[WalletStore.syncKeysets]', 'New keysets synced', { + mintUrl, + newKeysetIds: newKeysets.map((k: MintKeyset) => k.id), + }) + } + }), })) .actions(self => ({ getMint: flow(function* getMint(mintUrl: string) { @@ -286,6 +339,12 @@ export const WalletStoreModel = types // once in a session never refreshed again for the rest of it. Fire-and-forget // so the caller's operation is not delayed by a getInfo() round trip; // observers re-render when fresher info lands. + // + // Keysets deliberately get NO periodic refresh to match: they are pulled + // once per process (below) and then only when something proves the list + // wrong — see refreshKeysetsNow. Polling them would put a /v1/keysets GET + // on every NWC background wake to cover a window (a session alive across a + // mint's keyset rotation) that a cold start closes by itself. void self.refreshMintInfoIfStale(mintUrl, mint as CashuMint) return mint as CashuMint } @@ -296,29 +355,16 @@ export const WalletStoreModel = types // create cashu-ts mint instance const newMint = new CashuMint(mintUrl) - // get fresh keysets - returns all keysets, both active and inactive - const {keysets} = yield newMint.getKeySets() + // First touch in this process: sync keysets, keys and active statuses. + // Throws when the mint is unreachable, which is how callers learn it is + // offline — so this is awaited, not fired off. + yield self.syncKeysets(mintUrl, newMint) // get persisted mint model from wallet state const mintInstance = self.getMintModelInstance(mintUrl) // skip checks if this is new mint being added if(mintInstance) { - const newKeysets = keysets.filter((freshKeyset: MintKeyset) => { - return !mintInstance.keysets!.some((keyset: MintKeyset) => keyset.id === freshKeyset.id) - }) - - if(newKeysets.length > 0) { - // if we have new keysets, get and sync new keys - // this, for perf reasons, returns ONLY active keys so - // mintInstance can not be directly used to restore from inactive keysets - const {keysets: keys} = yield newMint.getKeys() as Promise - mintInstance.refreshKeys!(keys) - } - - // sync wallet state with fresh keysets, active statuses and keys - mintInstance.refreshKeysets!(keysets) - // fetch and cache mintInfo if not already cached or gone stale if(isMintInfoStale(mintInstance.mintInfo)) { const info: GetInfoResponse = yield newMint.getInfo() @@ -333,7 +379,28 @@ export const WalletStoreModel = types }) })) .actions(self => ({ - getWallet: flow(function* getWallet( + /** + * Re-pull the mint's keyset list NOW, and wait for it. + * + * The wallet's one signal that its list has gone stale: a token decode that + * failed because a v2 keyset id — which travels through a v4 token as an + * 8-byte prefix — could not be expanded against any id the wallet holds. Since + * keysets are otherwise pulled once per process, this is what closes the gap + * after a mint rotates its keysets, and it is why nothing has to poll. + * + * getMint's own cold path has just synced when it had to CREATE the cashu-ts + * instance, so the second pull is skipped in that case rather than fetching + * the same list twice. + */ + refreshKeysetsNow: flow(function* refreshKeysetsNow(mintUrl: string) { + const wasCached = self.mints.some(m => m.mintUrl === mintUrl) + const cashuMint: CashuMint = yield self.getMint(mintUrl) + + if (wasCached) { + yield self.syncKeysets(mintUrl, cashuMint) + } + }), + getWallet: flow(function* getWallet( mintUrl: string, unit: MintUnit, options?: { diff --git a/src/screens/DeveloperScreen.tsx b/src/screens/DeveloperScreen.tsx index af7c8a22..1bac9ccb 100644 --- a/src/screens/DeveloperScreen.tsx +++ b/src/screens/DeveloperScreen.tsx @@ -38,7 +38,7 @@ import { TransactionStatus } from '../models/Transaction' import { maxTransactionsInHistory } from '../models/TransactionsStore' import { StaticScreenProps, useNavigation } from '@react-navigation/native' -// refresh +// type Props = StaticScreenProps diff --git a/src/services/wallet/decodeToken.ts b/src/services/wallet/decodeToken.ts new file mode 100644 index 00000000..3638bce5 --- /dev/null +++ b/src/services/wallet/decodeToken.ts @@ -0,0 +1,70 @@ +import {Token, getDecodedToken} from '@cashu/cashu-ts' +import {log} from '../logService' +import {rootStoreInstance} from '../../models' + +/** + * Did this decode fail because a v2 keyset id could not be mapped? + * + * Matched on the message because cashu-ts raises both variants as a plain error with + * no code to switch on. Kept narrow — any other decode failure (malformed token, + * unsupported version) must propagate untouched rather than trigger a mint call. + */ +const isUnmappableKeysetIdError = function (e: any): boolean { + const message: string = typeof e?.message === 'string' ? e.message : '' + return /short keyset id/i.test(message) +} + +/** + * Decode an incoming cashu token, recovering from a keyset list that has gone stale. + * + * NUT-00 v4 tokens do not carry a full keyset id. A v0 id (`00…`) is 8 bytes and + * survives the round trip intact, but a NUT-02 v2 id (`01…`) is 33 bytes and is + * truncated to its first 8 bytes on encode — so cashu-ts can only give a proof its + * real id by matching that prefix against the ids the wallet already holds for the + * mint. Hand it a list without the match and it throws: + * + * Couldn't map short keyset ID 01fc0ec0e59cd6fa to any known keysets of the current Mint + * A short keyset ID v2 was encountered, but got no keysets to map it to. + * + * Which is exactly what a mint issuing a new keyset produces (a nutshell -> cdk + * migration flips the old `00…` keysets inactive and starts signing with a v2 one): + * every wallet that has not touched that mint since the rotation holds a list that + * cannot decode the ecash now being sent to it — background lightning-address claims + * and nostr receives included. Nothing in the receive path needed the mint before the + * decode, so nothing refreshed the list, and the user had no way to know that + * performing some unrelated operation on the mint was what would fix it. + * + * So: try the list we have — the free path, and the only path a v0 id ever needs — + * and only once a decode has PROVED the list wrong, re-pull the keysets and try + * again. No extra request is spent on the common case, and the retry needs the ids + * only, not the keys (keys for a proof's own keyset are loaded later, by + * WalletStore.receive's ensureKeysetKeys). + */ +export const decodeTokenWithKeysets = async function ( + encodedToken: string, + mintUrl: string, +): Promise { + const {mintsStore, walletStore} = rootStoreInstance + + const keysetIds = () => mintsStore.findByUrl(mintUrl)?.keysetIds ?? [] + + try { + return getDecodedToken(encodedToken, keysetIds()) + } catch (e: any) { + if (!isUnmappableKeysetIdError(e)) { + throw e + } + + log.info( + '[decodeTokenWithKeysets]', + 'Token references an unknown keyset, refreshing mint keysets', + {mintUrl, error: e.message}, + ) + + // A failure here surfaces: offline, or a mint that genuinely does not know + // this keyset. Either way the decode below could not have succeeded. + await walletStore.refreshKeysetsNow(mintUrl) + + return getDecodedToken(encodedToken, keysetIds()) + } +} diff --git a/src/services/wallet/operations/nostrOperations.ts b/src/services/wallet/operations/nostrOperations.ts index 744b9d7a..ba68612a 100644 --- a/src/services/wallet/operations/nostrOperations.ts +++ b/src/services/wallet/operations/nostrOperations.ts @@ -4,7 +4,6 @@ import {UnsignedEvent} from 'nostr-tools' import { PaymentRequestPayload, Token, - getDecodedToken, getTokenMetadata, decodePaymentRequest, } from '@cashu/cashu-ts' @@ -33,6 +32,7 @@ import { receiveTask, receiveByCashuPaymentRequestTask, } from '../receiveTask' +import {decodeTokenWithKeysets} from '../decodeToken' import {WalletUtils} from '../utils' import { HANDLE_CLAIM_TASK, @@ -129,10 +129,16 @@ const handleClaimTask = async function (params: { }, Err.NOTFOUND_ERROR) } - decoded = getDecodedToken(encodedToken, mintKeysetIds) + // Refreshes the keysets and retries if the server minted this on a keyset + // the wallet has not seen yet — the claim is already consumed server-side + // at this point, so a decode failure here strands the ecash. + const decodedToken = await decodeTokenWithKeysets(encodedToken, tokenInfo.mint) + // Mirrored into the outer binding purely so the catch below can still report + // which mint the claim was for. + decoded = decodedToken const result: TransactionTaskResult = await receiveTask( - decoded, + decodedToken, Number(tokenInfo.amount), tokenInfo.memo || 'Received to Lightning address', encodedToken, @@ -160,11 +166,11 @@ const handleClaimTask = async function (params: { } return { - mintUrl: decoded.mint, + mintUrl: decodedToken.mint, taskFunction: HANDLE_CLAIM_TASK, message: result.error ? result.error.message : 'Ecash sent to your lightning address has been received.', error: result.error || undefined, - proofsCount: decoded.proofs.length, + proofsCount: decodedToken.proofs.length, proofsAmount: result.transaction?.amount, } as WalletTaskResult @@ -434,7 +440,10 @@ const handleReceivedEventTask = async function (encryptedEvent: NostrEvent): Pro }, Err.NOTFOUND_ERROR) } - const decoded = getDecodedToken(incoming.encoded, mintKeysetIds) + // Refreshes the keysets and retries if the sender's ecash comes from a + // keyset this wallet has not seen yet (a mint that rotated keysets since + // the last time anything here touched it). + const decoded = await decodeTokenWithKeysets(incoming.encoded as string, mintUrl) const {transaction, receivedAmount} = await receiveTask( decoded, diff --git a/src/services/wallet/operations/receiveOperationApi.ts b/src/services/wallet/operations/receiveOperationApi.ts index 53efee48..6feca224 100644 --- a/src/services/wallet/operations/receiveOperationApi.ts +++ b/src/services/wallet/operations/receiveOperationApi.ts @@ -34,6 +34,7 @@ import { normalizeProofAmounts, } from '@cashu/cashu-ts' import {log} from '../../logService' +import {decodeTokenWithKeysets} from '../decodeToken' import {MintError, ValidationError, WalletError} from '../../../utils/AppError' import {rootStoreInstance} from '../../../models' import { @@ -304,7 +305,10 @@ async function execute(prepared: PreparedReceiveData): Promise { - const token = getDecodedToken(encodedToken, mint.keysetIds ?? []) + const token = await decodeTokenWithKeysets(encodedToken, mint.mintUrl) return (useBatch ? await receiveBatchTask(token, amount, memo || '', encodedToken) : await receiveTask(token, amount, memo || '', encodedToken)) as TransactionTaskResult