@pancakeswap/token-lists 0.0.17 → 0.1.1

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.
@@ -0,0 +1,54 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+ import { getTokenList } from './getTokenList'
3
+
4
+ const validList = {
5
+ name: 'Test List',
6
+ timestamp: '2026-04-29T00:00:00.000Z',
7
+ version: {
8
+ major: 1,
9
+ minor: 0,
10
+ patch: 0,
11
+ },
12
+ tokens: [
13
+ {
14
+ chainId: 56,
15
+ address: '0x0000000000000000000000000000000000000001',
16
+ decimals: 18,
17
+ name: 'Valid Token',
18
+ symbol: 'VALID',
19
+ },
20
+ ],
21
+ }
22
+
23
+ describe('getTokenList', () => {
24
+ afterEach(() => {
25
+ vi.restoreAllMocks()
26
+ })
27
+
28
+ it('filters invalid tokens without warning when the list is recoverable', async () => {
29
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
30
+ ok: true,
31
+ json: async () => ({
32
+ ...validList,
33
+ tokens: [
34
+ ...validList.tokens,
35
+ {
36
+ chainId: 56,
37
+ address: '0x0000000000000000000000000000000000000002',
38
+ decimals: 18,
39
+ name: 'Broken Token',
40
+ symbol: 'BROKEN🚨',
41
+ },
42
+ ],
43
+ }),
44
+ } as Response)
45
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
46
+
47
+ const list = await getTokenList('https://tokens.example.com/list.json')
48
+
49
+ expect(fetchSpy).toHaveBeenCalledTimes(1)
50
+ expect(list?.tokens).toHaveLength(1)
51
+ expect(list?.tokens[0]?.symbol).toBe('VALID')
52
+ expect(warnSpy).not.toHaveBeenCalled()
53
+ })
54
+ })
@@ -17,19 +17,18 @@ export async function getTokenList(listUrl: string): Promise<TokenList | undefin
17
17
  try {
18
18
  const json = await fetchJson(url)
19
19
  if (!validator(json)) {
20
- const preFilterErrors = validator.errors
21
20
  json.tokens = json.tokens.filter((token: any) => validator({ ...json, tokens: [token] }))
22
21
  if (!validator(json)) {
23
- const errors = validator.errors
22
+ const { errors } = validator
24
23
  throw new Error(`Validation failed after filtering: ${JSON.stringify(errors)}`)
25
24
  }
26
- console.warn(`Pre-filter validation errors: ${JSON.stringify(preFilterErrors)}`)
27
25
  }
28
26
  return json as TokenList
29
27
  } catch (error) {
30
28
  // if (i === urls.length - 1) {
31
29
  // throw new Error(`Failed to download list ${listUrl}`)
32
30
  // }
31
+ console.warn(`Failed to download or validate list from ${url}:`, error)
33
32
 
34
33
  return undefined
35
34
  }
@@ -12,7 +12,13 @@ test('exports', () => {
12
12
  "acceptListUpdate",
13
13
  "rejectVersionUpdate",
14
14
  "updateListVersion",
15
+ "batchFetchTokenListPending",
16
+ "batchFetchTokenListFulfilled",
17
+ "batchFetchTokenListRejected",
15
18
  "getTokenList",
19
+ "getGeoBlockTokenKey",
20
+ "getGeoBlockedTokenKeys",
21
+ "isGeoBlockedToken",
16
22
  "findTokenByAddress",
17
23
  "findTokenBySymbol",
18
24
  "createListsAtom",
package/react/lists.ts CHANGED
@@ -3,10 +3,85 @@ import { atom, useAtom, useAtomValue } from 'jotai'
3
3
  import { atomFamily, atomWithStorage, loadable } from 'jotai/utils'
4
4
  import { type AsyncStorage } from 'jotai/vanilla/utils/atomWithStorage'
5
5
  import localForage from 'localforage'
6
- import { fetchTokenList } from './actions'
6
+ import debounce from 'lodash/debounce'
7
+ import {
8
+ fetchTokenList,
9
+ batchFetchTokenListPending,
10
+ batchFetchTokenListFulfilled,
11
+ batchFetchTokenListRejected,
12
+ } from './actions'
7
13
  import { getTokenList } from './getTokenList'
8
14
  import { ListsState } from './reducer'
9
15
 
16
+ export type GeoBlockList = {
17
+ rules?: Record<string, string[]>
18
+ tokens?: Record<string, string[]>
19
+ }
20
+
21
+ export type CreateListsAtomOptions = {
22
+ geoBlockListUrl?: string
23
+ }
24
+
25
+ // Persisted payload shape. `activeListUrls` was added later - existing IndexedDB
26
+ // entries only have the flat `{ [url]: TokenList }` shape and must still be read.
27
+ type StoredListsPayload = {
28
+ byUrl: Record<string, any>
29
+ activeListUrls?: string[]
30
+ }
31
+
32
+ type TokenKeyLike = {
33
+ chainId: number
34
+ address: string
35
+ }
36
+
37
+ function normalizeTokenAddress(address: string) {
38
+ return address.startsWith('0x') ? address.toLowerCase() : address
39
+ }
40
+
41
+ export function getGeoBlockTokenKey(token: TokenKeyLike) {
42
+ return `${token.chainId}:${normalizeTokenAddress(token.address)}`
43
+ }
44
+
45
+ function normalizeTokenKey(key: string) {
46
+ const separatorIndex = key.indexOf(':')
47
+ if (separatorIndex < 0) return key
48
+ const chainId = key.slice(0, separatorIndex)
49
+ const address = key.slice(separatorIndex + 1)
50
+ return `${chainId}:${normalizeTokenAddress(address)}`
51
+ }
52
+
53
+ export function getGeoBlockedTokenKeys(geoBlockList?: GeoBlockList, countryCode?: string) {
54
+ const blockedTokenKeys = new Set<string>()
55
+
56
+ if (!countryCode || !geoBlockList?.rules || !geoBlockList.tokens) {
57
+ return blockedTokenKeys
58
+ }
59
+
60
+ const country = countryCode.toUpperCase()
61
+ const blockedRuleIds = new Set(
62
+ Object.entries(geoBlockList.rules)
63
+ .filter(([, countries]) => countries.includes(country))
64
+ .map(([ruleId]) => ruleId),
65
+ )
66
+
67
+ Object.entries(geoBlockList.tokens).forEach(([tokenKey, ruleIds]) => {
68
+ if (ruleIds.some((ruleId) => blockedRuleIds.has(ruleId))) {
69
+ blockedTokenKeys.add(normalizeTokenKey(tokenKey))
70
+ }
71
+ })
72
+
73
+ return blockedTokenKeys
74
+ }
75
+
76
+ export function isGeoBlockedToken(token: TokenKeyLike, geoBlockList?: GeoBlockList, countryCode?: string) {
77
+ return getGeoBlockedTokenKeys(geoBlockList, countryCode).has(getGeoBlockTokenKey(token))
78
+ }
79
+
80
+ async function fetchJson(url: string): Promise<any> {
81
+ const res = await fetch(url)
82
+ if (!res.ok) throw new Error(`Failed to fetch: ${url}`)
83
+ return res.json()
84
+ }
10
85
  // eslint-disable-next-line @typescript-eslint/no-empty-function
11
86
  function noop() {}
12
87
 
@@ -39,9 +114,14 @@ export function findTokenBySymbol(state: ListsState, chainId: number, symbol: st
39
114
  return undefined
40
115
  }
41
116
 
42
- export const createListsAtom = (storeName: string, reducer: any, initialState: any) => {
117
+ export const createListsAtom = (
118
+ storeName: string,
119
+ reducer: any,
120
+ initialState: any,
121
+ options: CreateListsAtomOptions = {},
122
+ ) => {
43
123
  /**
44
- * Persist you redux state using IndexedDB
124
+ * Persist only token lists using IndexedDB - optimized storage format
45
125
  * @param {string} dbName - IndexedDB database name
46
126
  */
47
127
  function IndexedDBStorage<Value>(dbName: string): AsyncStorage<Value> {
@@ -50,17 +130,27 @@ export const createListsAtom = (storeName: string, reducer: any, initialState: a
50
130
  name: dbName,
51
131
  storeName,
52
132
  })
133
+ const mem = new Map<string, any>()
134
+
135
+ const debouncedSetItem = debounce(async (k: string, v: any) => {
136
+ db.setItem(k, v)
137
+ }, 300)
138
+
53
139
  return {
54
140
  getItem: async (key: string) => {
141
+ if (mem.has(key)) {
142
+ return mem.get(key)
143
+ }
55
144
  const value = await db.getItem(key)
56
145
  if (value) {
57
146
  return value
58
147
  }
59
- return initialState
148
+ return undefined as any
60
149
  },
61
150
  setItem: async (k: string, v: any) => {
62
151
  if (v === EMPTY) return
63
- await db.setItem(k, v)
152
+ mem.set(k, v)
153
+ debouncedSetItem(k, v)
64
154
  },
65
155
  removeItem: db.removeItem,
66
156
  }
@@ -68,63 +158,253 @@ export const createListsAtom = (storeName: string, reducer: any, initialState: a
68
158
  return noopStorage
69
159
  }
70
160
 
71
- const listsStorageAtom = atomWithStorage<ListsState | typeof EMPTY>('lists', EMPTY, IndexedDBStorage('lists'))
161
+ // Storage for token lists and the user's enabled/disabled list selection.
162
+ // `getOnInit: true` starts the IndexedDB read as soon as this atom is created (module load)
163
+ // instead of waiting for the first component subscribe - by the time a consumer like the
164
+ // Manage Tokens modal actually mounts, the read has usually already resolved, avoiding a
165
+ // visible flash of the (all-enabled) default state before the persisted selection applies.
166
+ const tokenListsStorageAtom = atomWithStorage<StoredListsPayload | typeof EMPTY>(
167
+ 'tokenLists',
168
+ EMPTY,
169
+ IndexedDBStorage('tokenLists'),
170
+ { getOnInit: true },
171
+ )
72
172
 
73
- const defaultStateAtom = atom<ListsState, any, void>(
74
- (get) => {
75
- const value = get(loadable(listsStorageAtom))
76
- if (value.state === 'hasData' && value.data !== EMPTY) {
77
- return value.data
78
- }
79
- return initialState
80
- },
81
- async (get, set, action) => {
82
- set(listsStorageAtom, reducer(await get(defaultStateAtom), action))
83
- },
173
+ const geoBlockListStorageAtom = atomWithStorage<GeoBlockList | typeof EMPTY>(
174
+ 'geoBlockList',
175
+ EMPTY,
176
+ IndexedDBStorage('geoBlockList'),
84
177
  )
85
178
 
86
- const isReadyAtom = loadable(listsStorageAtom)
179
+ const geoBlockListAtom = atom<GeoBlockList | undefined>((get) => {
180
+ const value = get(loadable(geoBlockListStorageAtom))
181
+ if (value.state === 'hasData' && value.data && value.data !== EMPTY) {
182
+ return value.data as GeoBlockList
183
+ }
184
+ return undefined
185
+ })
186
+
187
+ const fetchGeoBlockListAtom = atom<null, [], Promise<void>>(null, async (_get, set) => {
188
+ if (!options.geoBlockListUrl) {
189
+ return
190
+ }
191
+
192
+ try {
193
+ set(geoBlockListStorageAtom, await fetchJson(options.geoBlockListUrl))
194
+ } catch (error) {
195
+ console.warn(`Failed to download geo block list from ${options.geoBlockListUrl}:`, error)
196
+ }
197
+ })
198
+
199
+ // Memory state atom that holds the full ListsState
200
+ const memoryStateAtom = atom<ListsState>(initialState as ListsState)
201
+
202
+ // Merges a resolved storage payload's token list content and active-list selection onto a
203
+ // base ListsState. Shared by `listStateAtom` (read-time overlay, for display before the first
204
+ // dispatch has hydrated memory) and `updateListStateAtom` (one-time hydration into memory, so
205
+ // reducer calls - e.g. "this list's content was already fetched" - see accurate `byUrl.current`
206
+ // and don't treat an already-cached-but-disabled list as newly discovered and re-activate it).
207
+ const mergeStoredData = (baseState: ListsState, storedData: StoredListsPayload | Record<string, any>) => {
208
+ // Backward compat: pre-existing IndexedDB entries are a flat { [url]: TokenList } map with
209
+ // no `byUrl`/`activeListUrls` wrapper.
210
+ const storedTokenLists: Record<string, any> = storedData.byUrl ?? storedData
211
+ const storedActiveListUrls = Array.isArray((storedData as StoredListsPayload).activeListUrls)
212
+ ? (storedData as StoredListsPayload).activeListUrls
213
+ : undefined
214
+
215
+ const mergedState = { ...baseState }
216
+
217
+ const updatedByUrl = { ...mergedState.byUrl }
218
+ Object.keys(storedTokenLists).forEach((url) => {
219
+ if (storedTokenLists[url]) {
220
+ updatedByUrl[url] = {
221
+ ...updatedByUrl[url],
222
+ current: storedTokenLists[url],
223
+ // Keep existing memory state for loading, error, pendingUpdate
224
+ }
225
+ }
226
+ })
227
+ mergedState.byUrl = updatedByUrl
228
+
229
+ if (storedActiveListUrls) {
230
+ mergedState.activeListUrls = storedActiveListUrls
231
+ }
232
+
233
+ return mergedState
234
+ }
235
+
236
+ const listStateAtom = atom<ListsState>((get) => {
237
+ // Separate this(mem/storage) is a prepare for refactor of the list part
238
+ const memoryState = get(memoryStateAtom)
239
+ const value = get(loadable(tokenListsStorageAtom))
240
+
241
+ if (value.state === 'hasData' && value.data && value.data !== EMPTY) {
242
+ return mergeStoredData(memoryState, value.data as StoredListsPayload | Record<string, any>)
243
+ }
244
+
245
+ return memoryState
246
+ })
247
+
248
+ // Set once the persisted storage read has resolved (whether it held data or was genuinely
249
+ // empty) and been merged into `memoryStateAtom` (both `byUrl.current` and `activeListUrls`).
250
+ // Two things depend on this:
251
+ // 1. Every reducer call after hydration bases itself on `memoryStateAtom` alone - a single,
252
+ // synchronously-consistent source of truth - instead of re-reading the async, loadable
253
+ // wrapped storage atom on every dispatch, which could otherwise let one action's write race
254
+ // another action's read of a not-yet-updated storage snapshot.
255
+ // 2. Until hydration completes, writes are NOT persisted (see below) - otherwise the very first
256
+ // action dispatched on boot (typically `updateListVersion`, fired as soon as the app
257
+ // considers storage "ready") can itself run before the storage read has resolved, compute a
258
+ // default-only state, and persist it - permanently clobbering the real persisted value the
259
+ // read just hadn't returned yet. Hydrating `byUrl.current` too (not just `activeListUrls`)
260
+ // matters because `setFulfilledTokenList` treats a null `current` as "list never loaded
261
+ // before" and auto re-activates it if it's in the default set - without this, refetching an
262
+ // already-cached-but-user-disabled list on boot would silently re-enable it.
263
+ let hasHydratedActiveListUrls = false
264
+
265
+ const updateListStateAtom = atom<null, any, void>(null, async (get, set, action) => {
266
+ const baseState = get(memoryStateAtom)
267
+
268
+ if (!hasHydratedActiveListUrls) {
269
+ const storageValue = get(loadable(tokenListsStorageAtom))
270
+
271
+ if (storageValue.state !== 'hasData') {
272
+ // Storage hasn't resolved yet - update memory optimistically so the UI stays responsive,
273
+ // but skip persisting until we know what (if anything) was already stored.
274
+ const optimisticState = reducer(baseState, action)
275
+ set(memoryStateAtom, { ...optimisticState })
276
+ return
277
+ }
278
+
279
+ if (storageValue.data && storageValue.data !== EMPTY) {
280
+ set(memoryStateAtom, mergeStoredData(baseState, storageValue.data as StoredListsPayload | Record<string, any>))
281
+ }
282
+ hasHydratedActiveListUrls = true
283
+ }
284
+
285
+ const currentState = get(memoryStateAtom)
286
+ const newState = reducer(currentState, action)
287
+
288
+ // Update memory state
289
+ set(memoryStateAtom, { ...newState })
290
+
291
+ // Extract current token lists and the active list selection for storage
292
+ const tokenListsToStore: Record<string, any> = {}
293
+ Object.keys(newState.byUrl).forEach((url) => {
294
+ if (newState.byUrl[url]?.current) {
295
+ tokenListsToStore[url] = newState.byUrl[url].current
296
+ }
297
+ })
298
+
299
+ set(tokenListsStorageAtom, { byUrl: tokenListsToStore, activeListUrls: newState.activeListUrls })
300
+ })
301
+
302
+ const isReadyAtom = loadable(tokenListsStorageAtom)
87
303
 
88
304
  const tokenAtom = atomFamily((key: { chainId: number; address: string }) =>
89
- atom((get) => findTokenByAddress(get(defaultStateAtom), key.chainId, key.address)),
305
+ atom((get) => findTokenByAddress(get(listStateAtom), key.chainId, key.address)),
90
306
  )
91
307
 
92
308
  const tokenBySymbolAtom = atomFamily((key: { chainId: number; symbol: string }) =>
93
- atom((get) => findTokenBySymbol(get(defaultStateAtom), key.chainId, key.symbol)),
309
+ atom((get) => findTokenBySymbol(get(listStateAtom), key.chainId, key.symbol)),
94
310
  )
95
311
 
96
312
  const fetchListAtom = atom<null, [string], Promise<void>>(null, async (get, set, url) => {
97
- const state = get(defaultStateAtom)
313
+ const state = get(listStateAtom)
98
314
  const listState = state.byUrl[url]
99
315
  if (listState?.current || listState?.loadingRequestId) {
100
316
  return
101
317
  }
102
318
 
103
319
  const requestId = nanoid()
104
- set(defaultStateAtom, fetchTokenList.pending({ url, requestId }))
320
+ set(updateListStateAtom, fetchTokenList.pending({ url, requestId }))
105
321
 
106
322
  try {
107
323
  const tokenList = await getTokenList(url)
108
- set(defaultStateAtom, fetchTokenList.fulfilled({ url, tokenList: tokenList!, requestId }))
324
+ set(updateListStateAtom, fetchTokenList.fulfilled({ url, tokenList: tokenList!, requestId }))
325
+ } catch (error: any) {
326
+ set(updateListStateAtom, fetchTokenList.rejected({ url, requestId, errorMessage: error.message }))
327
+ }
328
+ })
329
+
330
+ const fetchListBatchAtom = atom<null, [string[]], Promise<void>>(null, async (get, set, urls) => {
331
+ const state = get(listStateAtom)
332
+
333
+ // Filter out URLs that are already loaded or loading
334
+ const urlsToFetch = urls.filter((url) => {
335
+ const listState = state.byUrl[url]
336
+ return !listState?.current && !listState?.loadingRequestId
337
+ })
338
+
339
+ if (urlsToFetch.length === 0) {
340
+ return
341
+ }
342
+
343
+ const requestId = nanoid()
344
+ set(updateListStateAtom, batchFetchTokenListPending({ urls: urlsToFetch, requestId }))
345
+
346
+ try {
347
+ // Fetch all token lists in parallel
348
+ const results = await Promise.allSettled(
349
+ urlsToFetch.map(async (url) => {
350
+ const tokenList = await getTokenList(url)
351
+ return { url, tokenList: tokenList!, requestId }
352
+ }),
353
+ )
354
+
355
+ // Separate successful and failed results
356
+ const fulfilled: Array<{ url: string; tokenList: any; requestId: string }> = []
357
+ const rejected: Array<{ url: string; errorMessage: string; requestId: string }> = []
358
+
359
+ results.forEach((result, index) => {
360
+ const url = urlsToFetch[index]
361
+ if (result.status === 'fulfilled') {
362
+ fulfilled.push(result.value)
363
+ } else {
364
+ rejected.push({
365
+ url,
366
+ errorMessage: result.reason?.message || 'Unknown error',
367
+ requestId,
368
+ })
369
+ }
370
+ })
371
+
372
+ // Dispatch batch actions
373
+ if (fulfilled.length > 0) {
374
+ set(updateListStateAtom, batchFetchTokenListFulfilled({ results: fulfilled }))
375
+ }
376
+ if (rejected.length > 0) {
377
+ set(updateListStateAtom, batchFetchTokenListRejected({ errors: rejected }))
378
+ }
109
379
  } catch (error: any) {
110
- set(defaultStateAtom, fetchTokenList.rejected({ url, requestId, errorMessage: error.message }))
380
+ // Fallback to individual rejections if batch processing fails
381
+ const errors = urlsToFetch.map((url) => ({
382
+ url,
383
+ errorMessage: error.message,
384
+ requestId,
385
+ }))
386
+ set(updateListStateAtom, batchFetchTokenListRejected({ errors }))
111
387
  }
112
388
  })
113
389
 
114
390
  function useListState() {
115
- return useAtom(defaultStateAtom)
391
+ return useAtom(listStateAtom)
116
392
  }
117
393
 
118
394
  function useListStateReady() {
119
395
  const value = useAtomValue(isReadyAtom)
120
- return value.state === 'hasData' && value.data !== EMPTY
396
+ return value.state === 'hasData'
121
397
  }
122
398
 
123
399
  return {
124
- listsAtom: defaultStateAtom,
400
+ listsAtom: listStateAtom,
401
+ updateListStateAtom,
125
402
  tokenAtom,
126
403
  tokenBySymbolAtom,
404
+ geoBlockListAtom,
405
+ fetchGeoBlockListAtom,
127
406
  fetchListAtom,
407
+ fetchListBatchAtom,
128
408
  useListStateReady,
129
409
  useListState,
130
410
  }
package/react/reducer.ts CHANGED
@@ -8,8 +8,12 @@ import {
8
8
  enableList,
9
9
  disableList,
10
10
  updateListVersion,
11
+ batchFetchTokenListPending,
12
+ batchFetchTokenListFulfilled,
13
+ batchFetchTokenListRejected,
11
14
  } from './actions'
12
- import { getVersionUpgrade, VersionUpgrade, TokenList } from '../src'
15
+ import { TokenList } from '../src/types'
16
+ import { setPendingTokenList, setFulfilledTokenList, setRejectedTokenList, batchActivateUrls } from './reducerHelpers'
13
17
 
14
18
  export interface ListsState {
15
19
  readonly byUrl: {
@@ -44,62 +48,13 @@ export const createTokenListReducer = (
44
48
  createReducer(initialState, (builder) =>
45
49
  builder
46
50
  .addCase(fetchTokenList.pending, (state, { payload: { requestId, url } }) => {
47
- const current = state.byUrl[url]?.current ?? null
48
- const pendingUpdate = state.byUrl[url]?.pendingUpdate ?? null
49
-
50
- state.byUrl[url] = {
51
- current,
52
- pendingUpdate,
53
- loadingRequestId: requestId,
54
- error: null,
55
- }
51
+ setPendingTokenList(state, url, requestId)
56
52
  })
57
53
  .addCase(fetchTokenList.fulfilled, (state, { payload: { requestId, tokenList, url } }) => {
58
- const current = state.byUrl[url]?.current
59
- const loadingRequestId = state.byUrl[url]?.loadingRequestId
60
-
61
- // no-op if update does nothing
62
- if (current) {
63
- const upgradeType = getVersionUpgrade(current.version, tokenList.version)
64
-
65
- if (upgradeType === VersionUpgrade.NONE) return
66
- if (loadingRequestId === null || loadingRequestId === requestId) {
67
- state.byUrl[url] = {
68
- ...state.byUrl[url],
69
- loadingRequestId: null,
70
- error: null,
71
- current,
72
- pendingUpdate: tokenList,
73
- }
74
- }
75
- } else {
76
- // activate if on default active
77
- if (DEFAULT_ACTIVE_LIST_URLS.includes(url) && state.activeListUrls && !state.activeListUrls.includes(url)) {
78
- state.activeListUrls.push(url)
79
- }
80
-
81
- state.byUrl[url] = {
82
- ...state.byUrl[url],
83
- loadingRequestId: null,
84
- error: null,
85
- current: tokenList,
86
- pendingUpdate: null,
87
- }
88
- }
54
+ setFulfilledTokenList(state, url, tokenList, requestId, DEFAULT_ACTIVE_LIST_URLS)
89
55
  })
90
56
  .addCase(fetchTokenList.rejected, (state, { payload: { url, requestId, errorMessage } }) => {
91
- if (state.byUrl[url]?.loadingRequestId !== requestId) {
92
- // no-op since it's not the latest request
93
- return
94
- }
95
-
96
- state.byUrl[url] = {
97
- ...state.byUrl[url],
98
- loadingRequestId: null,
99
- error: errorMessage,
100
- current: null,
101
- pendingUpdate: null,
102
- }
57
+ setRejectedTokenList(state, url, requestId, errorMessage)
103
58
  })
104
59
  .addCase(addList, (state, { payload: url }) => {
105
60
  if (!state.byUrl[url]) {
@@ -182,5 +137,31 @@ export const createTokenListReducer = (
182
137
  return true
183
138
  })
184
139
  }
140
+ })
141
+ .addCase(batchFetchTokenListPending, (state, { payload: { urls, requestId } }) => {
142
+ // Batch update multiple lists to pending state in a single update cycle
143
+ urls.forEach((url) => {
144
+ setPendingTokenList(state, url, requestId)
145
+ })
146
+ })
147
+ .addCase(batchFetchTokenListFulfilled, (state, { payload: { results } }) => {
148
+ // Batch update multiple lists to fulfilled state in a single update cycle
149
+ const urlsToActivate: string[] = []
150
+
151
+ results.forEach(({ url, tokenList, requestId }) => {
152
+ const isNewList = setFulfilledTokenList(state, url, tokenList, requestId, DEFAULT_ACTIVE_LIST_URLS)
153
+ if (isNewList && DEFAULT_ACTIVE_LIST_URLS.includes(url)) {
154
+ urlsToActivate.push(url)
155
+ }
156
+ })
157
+
158
+ // Batch activate URLs if needed
159
+ batchActivateUrls(state, urlsToActivate)
160
+ })
161
+ .addCase(batchFetchTokenListRejected, (state, { payload: { errors } }) => {
162
+ // Batch update multiple lists to rejected state in a single update cycle
163
+ errors.forEach(({ url, requestId, errorMessage }) => {
164
+ setRejectedTokenList(state, url, requestId, errorMessage)
165
+ })
185
166
  }),
186
167
  )