Ensure up to date keysets on receive

This commit is contained in:
minibits-cash
2026-08-11 14:25:47 +02:00
parent 66108db73e
commit 7d6be05eab
8 changed files with 341 additions and 30 deletions
+160
View File
@@ -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)
})
})
+1 -1
View File
@@ -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",
+86 -19
View File
@@ -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<GetKeysetsResponse>
// 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<GetKeysResponse>
// 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<GetKeysResponse>
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?: {
+1 -1
View File
@@ -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<undefined>
+70
View File
@@ -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<Token> {
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())
}
}
@@ -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,
@@ -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<CompletedTransact
throw new ValidationError('Missing mint', {mintUrl: prepared.mintUrl})
}
const token = getDecodedToken(tx.inputToken, mintInstance.keysetIds ?? [])
// Refreshes the keysets and retries when the stored token turns out to carry a
// keyset id the wallet does not know — an offline-prepared receive can be
// executed long after it was prepared, by which time the mint may have rotated.
const token = await decodeTokenWithKeysets(tx.inputToken, mintInstance.mintUrl)
// ── Swap proofs with the mint (with outputs-error healing retry) ────
const {proofs, swapFeePaid} = await _receiveWithHealing(
@@ -1,6 +1,7 @@
import {Token, TokenMetadata, getDecodedToken, getEncodedToken} from '@cashu/cashu-ts'
import {Token, TokenMetadata, getEncodedToken} from '@cashu/cashu-ts'
import {Mint} from '../../../models/Mint'
import {CashuUtils} from '../../cashu/cashuUtils'
import {decodeTokenWithKeysets} from '../decodeToken'
import {MintUnit} from '../currency'
import {
receiveTask,
@@ -93,7 +94,7 @@ const receiveQueueAwaitable = (
taskFunction: useBatch ? 'receiveBatchTask' : 'receiveTask',
timeoutMessage: 'receiveQueue timed out',
task: async () => {
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