@ledgerhq/cryptoassets 13.33.0-nightly.20251125074637 → 13.34.0-nightly.20251126023856

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,7 +13,7 @@ import type { ThunkDispatch } from "@reduxjs/toolkit";
13
13
  * Current version of the persistence format
14
14
  * Increment this when making breaking changes to the format
15
15
  */
16
- export const PERSISTENCE_VERSION = 1;
16
+ export const PERSISTENCE_VERSION = 2;
17
17
 
18
18
  /**
19
19
  * Serializable token format
@@ -45,11 +45,13 @@ export interface PersistedTokenEntry {
45
45
  /**
46
46
  * Root persistence format with versioning
47
47
  */
48
- export interface PersistedTokens {
48
+ export interface PersistedCAL {
49
49
  /** Format version for migration handling */
50
50
  version: number;
51
51
  /** Array of persisted tokens */
52
52
  tokens: PersistedTokenEntry[];
53
+ /** Mapping of currencyId to X-Ledger-Commit hash */
54
+ hashes?: Record<string, string>;
53
55
  }
54
56
 
55
57
  /**
@@ -120,9 +122,9 @@ export function extractTokensFromState(state: StateWithCryptoAssets): PersistedT
120
122
  const seenIds = new Set<string>();
121
123
 
122
124
  // Extract tokens from fulfilled queries
123
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
124
- for (const [_queryKey, query] of Object.entries(rtkState.queries as Record<string, any>)) {
125
+ for (const [_queryKey, query] of Object.entries(rtkState.queries)) {
125
126
  if (
127
+ query &&
126
128
  query.status === "fulfilled" &&
127
129
  query.data &&
128
130
  (query.endpointName === "findTokenById" ||
@@ -133,6 +135,7 @@ export function extractTokensFromState(state: StateWithCryptoAssets): PersistedT
133
135
  // Deduplicate by token ID
134
136
  if (token && token.id && !seenIds.has(token.id)) {
135
137
  seenIds.add(token.id);
138
+
136
139
  tokens.push({
137
140
  data: toTokenCurrencyRaw(token),
138
141
  timestamp: query.fulfilledTimeStamp || Date.now(),
@@ -145,6 +148,54 @@ export function extractTokensFromState(state: StateWithCryptoAssets): PersistedT
145
148
  return tokens;
146
149
  }
147
150
 
151
+ /**
152
+ * Extracts hashes from getTokensSyncHash queries in RTK Query state
153
+ * Returns a mapping of currencyId to hash
154
+ */
155
+ export function extractHashesFromState(state: StateWithCryptoAssets): Record<string, string> {
156
+ const rtkState = state[cryptoAssetsApi.reducerPath];
157
+
158
+ if (!rtkState || !rtkState.queries) {
159
+ return {};
160
+ }
161
+
162
+ const hashes: Record<string, string> = {};
163
+
164
+ // Extract hashes from fulfilled getTokensSyncHash queries
165
+ for (const [queryKey, query] of Object.entries(rtkState.queries)) {
166
+ if (
167
+ query &&
168
+ query.status === "fulfilled" &&
169
+ query.endpointName === "getTokensSyncHash" &&
170
+ query.data &&
171
+ typeof query.data === "string"
172
+ ) {
173
+ // Extract currencyId from query key (format: 'getTokensSyncHash("ethereum")')
174
+ const match = queryKey.match(/getTokensSyncHash\("([^"]+)"\)/);
175
+ if (match && match[1]) {
176
+ hashes[match[1]] = query.data;
177
+ }
178
+ }
179
+ }
180
+
181
+ return hashes;
182
+ }
183
+
184
+ /**
185
+ * Extracts all persisted data (tokens and hashes) from RTK Query state
186
+ * Returns a complete PersistedCAL object ready for serialization
187
+ */
188
+ export function extractPersistedCALFromState(state: StateWithCryptoAssets): PersistedCAL {
189
+ const tokens = extractTokensFromState(state);
190
+ const hashes = extractHashesFromState(state);
191
+
192
+ return {
193
+ version: PERSISTENCE_VERSION,
194
+ tokens,
195
+ ...(Object.keys(hashes).length > 0 && { hashes }),
196
+ };
197
+ }
198
+
148
199
  /**
149
200
  * Filters out expired tokens based on TTL
150
201
  */
@@ -167,35 +218,71 @@ export function filterExpiredTokens(
167
218
  }
168
219
 
169
220
  /**
170
- * Restores tokens from persisted data to RTK Query cache
171
- * Uses upsertQueryEntries to insert final TokenCurrency values (no transformResponse)
172
- * Implements cross-caching: stores tokens by both ID and address
173
- *
174
- * @param dispatch - Redux dispatch function
175
- * @param tokens - Array of persisted token entries
176
- * @param ttl - Time-to-live in milliseconds (tokens older than this are skipped)
221
+ * Restores tokens from persisted data to RTK Query cache.
222
+ * Validates persisted hashes against current hashes and evicts cache if they differ.
177
223
  */
178
- export function restoreTokensToCache(
224
+ export async function restoreTokensToCache(
179
225
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
180
226
  dispatch: ThunkDispatch<any, any, any>,
181
- tokens: PersistedTokenEntry[],
227
+ persistedData: PersistedCAL,
182
228
  ttl: number,
183
- ): void {
184
- const validTokens = filterExpiredTokens(tokens, ttl);
185
-
229
+ ): Promise<void> {
230
+ const validTokens = filterExpiredTokens(persistedData.tokens, ttl);
186
231
  if (validTokens.length === 0) {
187
232
  log("persistence", "No valid tokens to restore");
188
233
  return;
189
234
  }
190
235
 
191
- // Build entries for upsertQueryEntries
192
- // Each token needs to be cached under both ID and address lookups
193
- const entries: Array<
194
- | {
195
- endpointName: "findTokenById";
196
- arg: { id: string };
197
- value: TokenCurrency | undefined;
236
+ const tokensByCurrency = new Map<string, PersistedTokenEntry[]>();
237
+ for (const entry of validTokens) {
238
+ const currencyId = entry.data.parentCurrencyId;
239
+ if (!tokensByCurrency.has(currencyId)) {
240
+ tokensByCurrency.set(currencyId, []);
241
+ }
242
+ tokensByCurrency.get(currencyId)!.push(entry);
243
+ }
244
+
245
+ const currencyIdsToEvict = new Set<string>();
246
+ const hashes = persistedData.hashes || {};
247
+
248
+ for (const currencyId of tokensByCurrency.keys()) {
249
+ const storedHash = hashes[currencyId];
250
+ if (!storedHash) continue;
251
+
252
+ try {
253
+ const currentHashResult = await dispatch(
254
+ cryptoAssetsApi.endpoints.getTokensSyncHash.initiate(currencyId, { forceRefetch: false }),
255
+ );
256
+ const currentHash = currentHashResult.data;
257
+
258
+ if (currentHash && currentHash !== storedHash) {
259
+ log(
260
+ "persistence",
261
+ `Hash changed for currencyId ${currencyId}: ${storedHash} -> ${currentHash}, skipping restore`,
262
+ );
263
+ currencyIdsToEvict.add(currencyId);
198
264
  }
265
+ } catch (error) {
266
+ log(
267
+ "persistence",
268
+ `Failed to validate hash for currencyId ${currencyId}, skipping restore`,
269
+ error,
270
+ );
271
+ currencyIdsToEvict.add(currencyId);
272
+ }
273
+ }
274
+
275
+ const tokensToRestore = validTokens.filter(
276
+ entry => !currencyIdsToEvict.has(entry.data.parentCurrencyId),
277
+ );
278
+
279
+ if (tokensToRestore.length === 0) {
280
+ log("persistence", "No tokens to restore after hash validation");
281
+ return;
282
+ }
283
+
284
+ const entries: Array<
285
+ | { endpointName: "findTokenById"; arg: { id: string }; value: TokenCurrency | undefined }
199
286
  | {
200
287
  endpointName: "findTokenByAddressInCurrency";
201
288
  arg: { contract_address: string; network: string };
@@ -204,25 +291,19 @@ export function restoreTokensToCache(
204
291
  > = [];
205
292
 
206
293
  let skipped = 0;
207
-
208
- for (const entry of validTokens) {
209
- // Convert Raw format back to TokenCurrency
294
+ for (const entry of tokensToRestore) {
210
295
  const token = fromTokenCurrencyRaw(entry.data);
211
-
212
296
  if (!token) {
213
- // Conversion failed (e.g., parent currency not found), skip this token
214
297
  skipped++;
215
298
  continue;
216
299
  }
217
300
 
218
- // Cache by ID
219
301
  entries.push({
220
302
  endpointName: "findTokenById",
221
303
  arg: { id: token.id },
222
304
  value: token,
223
305
  });
224
306
 
225
- // Cross-cache by address (for findTokenByAddressInCurrency queries)
226
307
  entries.push({
227
308
  endpointName: "findTokenByAddressInCurrency",
228
309
  arg: {
@@ -233,13 +314,12 @@ export function restoreTokensToCache(
233
314
  });
234
315
  }
235
316
 
236
- // Dispatch single upsertQueryEntries action with all entries
237
317
  if (entries.length > 0) {
238
318
  dispatch(cryptoAssetsApi.util.upsertQueryEntries(entries));
239
319
  }
240
320
 
241
321
  log(
242
322
  "persistence",
243
- `Restored ${validTokens.length - skipped} tokens to cache (${entries.length} entries, ${skipped} skipped)`,
323
+ `Restored ${tokensToRestore.length - skipped} tokens (${entries.length} entries, ${skipped} skipped, ${currencyIdsToEvict.size} currencies evicted)`,
244
324
  );
245
325
  }
@@ -1,4 +1,4 @@
1
- import { createApi, fetchBaseQuery, FetchBaseQueryMeta } from "@reduxjs/toolkit/query/react";
1
+ import { createApi, fetchBaseQuery, FetchBaseQueryMeta, retry } from "@reduxjs/toolkit/query/react";
2
2
  import type { ApiTokenResponse } from "../entities";
3
3
  import { ApiTokenResponseSchema } from "../entities";
4
4
  import { getEnv } from "@ledgerhq/live-env";
@@ -6,6 +6,7 @@ import { GetTokensDataParams, PageParam, TokensDataTags, TokensDataWithPaginatio
6
6
  import { TOKEN_OUTPUT_FIELDS } from "./fields";
7
7
  import { TokenCurrency } from "@ledgerhq/types-cryptoassets";
8
8
  import { convertApiToken, legacyIdToApiId } from "../../api-token-converter";
9
+ import { log } from "@ledgerhq/logs";
9
10
  import { z } from "zod";
10
11
 
11
12
  /**
@@ -81,9 +82,8 @@ function validateAndTransformSingleTokenResponse(response: unknown): TokenCurren
81
82
  return result;
82
83
  }
83
84
 
84
- export const cryptoAssetsApi = createApi({
85
- reducerPath: "cryptoAssetsApi",
86
- baseQuery: fetchBaseQuery({
85
+ const baseQueryWithRetry = retry(
86
+ fetchBaseQuery({
87
87
  baseUrl: "",
88
88
  prepareHeaders: headers => {
89
89
  headers.set("Content-Type", "application/json");
@@ -91,6 +91,14 @@ export const cryptoAssetsApi = createApi({
91
91
  return headers;
92
92
  },
93
93
  }),
94
+ {
95
+ maxRetries: 3,
96
+ },
97
+ );
98
+
99
+ export const cryptoAssetsApi = createApi({
100
+ reducerPath: "cryptoAssetsApi",
101
+ baseQuery: baseQueryWithRetry,
94
102
  tagTypes: [TokensDataTags.Tokens],
95
103
  endpoints: build => ({
96
104
  findTokenById: build.query<TokenCurrency | undefined, TokenByIdParams>({
@@ -193,6 +201,22 @@ export const cryptoAssetsApi = createApi({
193
201
  };
194
202
  }
195
203
  },
204
+ async onQueryStarted(currencyId, { dispatch, queryFulfilled, getCacheEntry }) {
205
+ try {
206
+ const previousHash = getCacheEntry()?.data as string | undefined;
207
+ const { data: newHash } = await queryFulfilled;
208
+
209
+ if (previousHash && newHash && previousHash !== newHash) {
210
+ log(
211
+ "cryptoassets",
212
+ `Hash changed for currencyId ${currencyId}: ${previousHash} -> ${newHash}, evicting token cache`,
213
+ );
214
+ dispatch(cryptoAssetsApi.util.invalidateTags([TokensDataTags.Tokens]));
215
+ }
216
+ } catch {
217
+ // Query failed, skip eviction
218
+ }
219
+ },
196
220
  }),
197
221
 
198
222
  getTokensData: build.infiniteQuery<TokensDataWithPagination, GetTokensDataParams, PageParam>({