@pancakeswap/token-lists 0.1.0 → 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.
@@ -1,5 +1,5 @@
1
1
 
2
- > @pancakeswap/token-lists@0.1.0 build /home/runner/work/pancake-frontend/pancake-frontend/packages/token-lists
2
+ > @pancakeswap/token-lists@0.1.1 build /home/runner/work/pancake-frontend/pancake-frontend/packages/token-lists
3
3
  > tsup
4
4
 
5
5
  CLI Building entry: {"index":"./src/index.ts","react":"./react/index.ts"}
@@ -10,15 +10,15 @@ CLI Target: es6
10
10
  ESM Build start
11
11
  CJS Build start
12
12
  CJS dist/chunk-Y2NTTLTJ.js 2.30 KB
13
- CJS dist/index.js 2.32 KB
14
- CJS dist/react.js 29.55 KB
15
- CJS ⚡️ Build success in 1506ms
13
+ CJS dist/index.js 2.68 KB
14
+ CJS dist/react.js 33.13 KB
15
+ CJS ⚡️ Build success in 3052ms
16
16
  ESM dist/chunk-UUVLERWJ.mjs 2.18 KB
17
- ESM dist/react.mjs 28.31 KB
18
- ESM dist/index.mjs 1.95 KB
19
- ESM ⚡️ Build success in 1506ms
17
+ ESM dist/index.mjs 2.28 KB
18
+ ESM dist/react.mjs 31.72 KB
19
+ ESM ⚡️ Build success in 3052ms
20
20
  DTS Build start
21
- DTS ⚡️ Build success in 14735ms
22
- DTS dist/index.d.ts 1.68 KB
23
- DTS dist/react.d.ts 4.21 KB
24
- DTS dist/types-92a8b029.d.ts 1.06 KB
21
+ DTS ⚡️ Build success in 32179ms
22
+ DTS dist/index.d.ts 3.23 KB
23
+ DTS dist/react.d.ts 5.01 KB
24
+ DTS dist/types-dcc7ec4a.d.ts 1.11 KB
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # @pancakeswap/token-lists
2
2
 
3
+ ## 0.1.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [851a494]
8
+ - @pancakeswap/swap-sdk-core@1.6.1
9
+
3
10
  ## 0.1.0
4
11
 
5
12
  ### Minor Changes
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { SerializedToken, Token } from '@pancakeswap/swap-sdk-core';
2
- import { T as TokenInfo, a as TokenList, V as Version } from './types-92a8b029.js';
3
- export { b as Tags } from './types-92a8b029.js';
2
+ import { T as TokenInfo, a as TokenList, V as Version } from './types-dcc7ec4a.js';
3
+ export { b as Tags } from './types-dcc7ec4a.js';
4
4
 
5
5
  interface SerializedWrappedToken extends SerializedToken {
6
6
  chainId: number;
@@ -16,6 +16,12 @@ interface SerializedWrappedToken extends SerializedToken {
16
16
  */
17
17
  declare class WrappedTokenInfo extends Token {
18
18
  readonly logoURI: string | undefined;
19
+ /**
20
+ * Preserves the token-list `extensions` bag (curator-set metadata like
21
+ * `bridgeInfo`, `scaledUIAmount.enabled`, etc.). Stored as a readonly reference;
22
+ * not serialised back out of the class (kept in-memory only).
23
+ */
24
+ readonly extensions: TokenInfo['extensions'];
19
25
  constructor(tokenInfo: TokenInfo);
20
26
  get serialize(): SerializedWrappedToken;
21
27
  }
@@ -49,4 +55,30 @@ declare function getVersionUpgrade(base: Version, update: Version): VersionUpgra
49
55
 
50
56
  declare function createFilterToken<T extends TokenInfo | Token>(search: string, isAddress: (address: string) => boolean): (token: T) => boolean;
51
57
 
52
- export { SerializedWrappedToken, TokenAddressMap, TokenInfo, TokenList, Version, VersionUpgrade, WrappedTokenInfo, createFilterToken, deserializeToken, getVersionUpgrade };
58
+ /**
59
+ * Static metadata identifying an ERC-8056 (Scaled UI Amount) token in a token list.
60
+ * `enabled: true` is the only field FE consumes for routing decisions; everything
61
+ * else is hint/snapshot data refreshed live from chain.
62
+ */
63
+ interface ScaledUIAmountExtension {
64
+ /** Set to `true` for tokens that implement ERC-8056. The load-bearing FE check. */
65
+ enabled: boolean;
66
+ /** Optional snapshot of the live multiplier at the time of indexing (18-decimal fixed). */
67
+ multiplier?: string;
68
+ /** Optional human-readable multiplier (e.g. `"2.0"`). Display hint only. */
69
+ multiplierFormatted?: string;
70
+ /** ERC-165 interface IDs the contract claims to support. */
71
+ interfaceIds?: string[];
72
+ }
73
+ /**
74
+ * Returns true if a token-list entry is flagged as ERC-8056 enabled by the indexer.
75
+ * Tokens lacking the extension or with `enabled: false` are treated as standard ERC-20s
76
+ * (identity multiplier `1.0×`); the FE never falls into ScaledUI math for these.
77
+ *
78
+ * Note: the load-bearing decision lives here, not on the `Token` class. Token
79
+ * instances stay pure identity objects per the architectural decision in the
80
+ * implementation doc.
81
+ */
82
+ declare function isScaledUIAmountToken(token: Pick<TokenInfo, 'extensions'>): boolean;
83
+
84
+ export { ScaledUIAmountExtension, SerializedWrappedToken, TokenAddressMap, TokenInfo, TokenList, Version, VersionUpgrade, WrappedTokenInfo, createFilterToken, deserializeToken, getVersionUpgrade, isScaledUIAmountToken };
package/dist/index.js CHANGED
@@ -7,6 +7,7 @@ var WrappedTokenInfo = class extends swapSdkCore.Token {
7
7
  constructor(tokenInfo) {
8
8
  super(tokenInfo.chainId, tokenInfo.address, tokenInfo.decimals, tokenInfo.symbol, tokenInfo.name);
9
9
  this.logoURI = tokenInfo.logoURI;
10
+ this.extensions = tokenInfo.extensions;
10
11
  }
11
12
  get serialize() {
12
13
  return {
@@ -61,6 +62,15 @@ function createFilterToken(search, isAddress) {
61
62
  };
62
63
  }
63
64
 
65
+ // src/scaledUIAmount.ts
66
+ function isScaledUIAmountToken(token) {
67
+ var _a;
68
+ const ext = (_a = token.extensions) == null ? void 0 : _a.scaledUIAmount;
69
+ if (!ext || typeof ext !== "object" || Array.isArray(ext))
70
+ return false;
71
+ return ext.enabled === true;
72
+ }
73
+
64
74
  Object.defineProperty(exports, 'VersionUpgrade', {
65
75
  enumerable: true,
66
76
  get: function () { return chunkY2NTTLTJ_js.VersionUpgrade; }
@@ -72,3 +82,4 @@ Object.defineProperty(exports, 'getVersionUpgrade', {
72
82
  exports.WrappedTokenInfo = WrappedTokenInfo;
73
83
  exports.createFilterToken = createFilterToken;
74
84
  exports.deserializeToken = deserializeToken;
85
+ exports.isScaledUIAmountToken = isScaledUIAmountToken;
package/dist/index.mjs CHANGED
@@ -5,6 +5,7 @@ var WrappedTokenInfo = class extends Token {
5
5
  constructor(tokenInfo) {
6
6
  super(tokenInfo.chainId, tokenInfo.address, tokenInfo.decimals, tokenInfo.symbol, tokenInfo.name);
7
7
  this.logoURI = tokenInfo.logoURI;
8
+ this.extensions = tokenInfo.extensions;
8
9
  }
9
10
  get serialize() {
10
11
  return {
@@ -59,4 +60,13 @@ function createFilterToken(search, isAddress) {
59
60
  };
60
61
  }
61
62
 
62
- export { WrappedTokenInfo, createFilterToken, deserializeToken };
63
+ // src/scaledUIAmount.ts
64
+ function isScaledUIAmountToken(token) {
65
+ var _a;
66
+ const ext = (_a = token.extensions) == null ? void 0 : _a.scaledUIAmount;
67
+ if (!ext || typeof ext !== "object" || Array.isArray(ext))
68
+ return false;
69
+ return ext.enabled === true;
70
+ }
71
+
72
+ export { WrappedTokenInfo, createFilterToken, deserializeToken, isScaledUIAmountToken };
package/dist/react.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import * as _reduxjs_toolkit from '@reduxjs/toolkit';
2
2
  import { ActionCreatorWithPayload } from '@reduxjs/toolkit';
3
- import { a as TokenList, V as Version, T as TokenInfo } from './types-92a8b029.js';
3
+ import { a as TokenList, V as Version, T as TokenInfo } from './types-dcc7ec4a.js';
4
4
  import * as jotai_vanilla_utils_atomFamily from 'jotai/vanilla/utils/atomFamily';
5
5
  import * as jotai from 'jotai';
6
6
  import * as _reduxjs_toolkit_dist_createReducer from '@reduxjs/toolkit/dist/createReducer';
@@ -69,9 +69,23 @@ type ListByUrlState = ListsState['byUrl'][string];
69
69
  declare const NEW_LIST_STATE: ListByUrlState;
70
70
  declare const createTokenListReducer: (initialState: ListsState, DEFAULT_LIST_OF_LISTS: string[], DEFAULT_ACTIVE_LIST_URLS: string[]) => _reduxjs_toolkit_dist_createReducer.ReducerWithInitialState<ListsState>;
71
71
 
72
+ type GeoBlockList = {
73
+ rules?: Record<string, string[]>;
74
+ tokens?: Record<string, string[]>;
75
+ };
76
+ type CreateListsAtomOptions = {
77
+ geoBlockListUrl?: string;
78
+ };
79
+ type TokenKeyLike = {
80
+ chainId: number;
81
+ address: string;
82
+ };
83
+ declare function getGeoBlockTokenKey(token: TokenKeyLike): string;
84
+ declare function getGeoBlockedTokenKeys(geoBlockList?: GeoBlockList, countryCode?: string): Set<string>;
85
+ declare function isGeoBlockedToken(token: TokenKeyLike, geoBlockList?: GeoBlockList, countryCode?: string): boolean;
72
86
  declare function findTokenByAddress(state: ListsState, chainId: number, address: string): TokenInfo | undefined;
73
87
  declare function findTokenBySymbol(state: ListsState, chainId: number, symbol: string): TokenInfo | undefined;
74
- declare const createListsAtom: (storeName: string, reducer: any, initialState: any) => {
88
+ declare const createListsAtom: (storeName: string, reducer: any, initialState: any, options?: CreateListsAtomOptions) => {
75
89
  listsAtom: jotai.Atom<ListsState>;
76
90
  updateListStateAtom: jotai.WritableAtom<null, any, void> & {
77
91
  init: null;
@@ -84,6 +98,10 @@ declare const createListsAtom: (storeName: string, reducer: any, initialState: a
84
98
  chainId: number;
85
99
  symbol: string;
86
100
  }, jotai.Atom<TokenInfo | undefined>>;
101
+ geoBlockListAtom: jotai.Atom<GeoBlockList | undefined>;
102
+ fetchGeoBlockListAtom: jotai.WritableAtom<null, [], Promise<void>> & {
103
+ init: null;
104
+ };
87
105
  fetchListAtom: jotai.WritableAtom<null, [string], Promise<void>> & {
88
106
  init: null;
89
107
  };
@@ -96,4 +114,4 @@ declare const createListsAtom: (storeName: string, reducer: any, initialState: a
96
114
 
97
115
  declare function useFetchListCallback(dispatch: (action?: unknown) => void): (listUrl: string, sendDispatch?: boolean) => Promise<TokenList>;
98
116
 
99
- export { ListByUrlState, ListsState, NEW_LIST_STATE, acceptListUpdate, addList, batchFetchTokenListFulfilled, batchFetchTokenListPending, batchFetchTokenListRejected, createListsAtom, createTokenListReducer, disableList, enableList, fetchTokenList, findTokenByAddress, findTokenBySymbol, getTokenList, rejectVersionUpdate, removeList, updateListVersion, useFetchListCallback };
117
+ export { CreateListsAtomOptions, GeoBlockList, ListByUrlState, ListsState, NEW_LIST_STATE, acceptListUpdate, addList, batchFetchTokenListFulfilled, batchFetchTokenListPending, batchFetchTokenListRejected, createListsAtom, createTokenListReducer, disableList, enableList, fetchTokenList, findTokenByAddress, findTokenBySymbol, getGeoBlockTokenKey, getGeoBlockedTokenKeys, getTokenList, isGeoBlockedToken, rejectVersionUpdate, removeList, updateListVersion, useFetchListCallback };
package/dist/react.js CHANGED
@@ -410,6 +410,15 @@ var pancakeswap_default = {
410
410
  description: "A URI for the logo of the token list; prefer SVG or PNG of size 256x256",
411
411
  format: "uri",
412
412
  examples: ["ipfs://QmXfzKRvjZz3u5JRgC4v5mGVbm9ahrUiB4DgzHBsnWbTMM"]
413
+ },
414
+ extensions: {
415
+ type: "object",
416
+ description: "An object containing arbitrary or vendor-specific token list metadata",
417
+ propertyNames: {
418
+ $ref: "#/definitions/ExtensionIdentifier"
419
+ },
420
+ additionalProperties: true,
421
+ maxProperties: 10
413
422
  }
414
423
  },
415
424
  if: {
@@ -455,17 +464,15 @@ function getTokenList(listUrl) {
455
464
  try {
456
465
  const json = yield fetchJson(url);
457
466
  if (!validator(json)) {
458
- const preFilterErrors = validator.errors;
459
467
  json.tokens = json.tokens.filter((token) => validator(chunkY2NTTLTJ_js.__spreadProps(chunkY2NTTLTJ_js.__spreadValues({}, json), { tokens: [token] })));
460
468
  if (!validator(json)) {
461
469
  const { errors } = validator;
462
470
  throw new Error(`Validation failed after filtering: ${JSON.stringify(errors)}`);
463
471
  }
464
- console.warn(`Pre-filter validation errors: ${JSON.stringify(preFilterErrors)}`);
465
472
  }
466
473
  return json;
467
474
  } catch (error) {
468
- console.error(`Failed to download or validate list from ${url}:`, error);
475
+ console.warn(`Failed to download or validate list from ${url}:`, error);
469
476
  return void 0;
470
477
  }
471
478
  }
@@ -480,6 +487,47 @@ function fetchJson(url) {
480
487
  return res.json();
481
488
  });
482
489
  }
490
+ function normalizeTokenAddress(address) {
491
+ return address.startsWith("0x") ? address.toLowerCase() : address;
492
+ }
493
+ function getGeoBlockTokenKey(token) {
494
+ return `${token.chainId}:${normalizeTokenAddress(token.address)}`;
495
+ }
496
+ function normalizeTokenKey(key) {
497
+ const separatorIndex = key.indexOf(":");
498
+ if (separatorIndex < 0)
499
+ return key;
500
+ const chainId = key.slice(0, separatorIndex);
501
+ const address = key.slice(separatorIndex + 1);
502
+ return `${chainId}:${normalizeTokenAddress(address)}`;
503
+ }
504
+ function getGeoBlockedTokenKeys(geoBlockList, countryCode) {
505
+ const blockedTokenKeys = /* @__PURE__ */ new Set();
506
+ if (!countryCode || !(geoBlockList == null ? void 0 : geoBlockList.rules) || !geoBlockList.tokens) {
507
+ return blockedTokenKeys;
508
+ }
509
+ const country = countryCode.toUpperCase();
510
+ const blockedRuleIds = new Set(
511
+ Object.entries(geoBlockList.rules).filter(([, countries]) => countries.includes(country)).map(([ruleId]) => ruleId)
512
+ );
513
+ Object.entries(geoBlockList.tokens).forEach(([tokenKey, ruleIds]) => {
514
+ if (ruleIds.some((ruleId) => blockedRuleIds.has(ruleId))) {
515
+ blockedTokenKeys.add(normalizeTokenKey(tokenKey));
516
+ }
517
+ });
518
+ return blockedTokenKeys;
519
+ }
520
+ function isGeoBlockedToken(token, geoBlockList, countryCode) {
521
+ return getGeoBlockedTokenKeys(geoBlockList, countryCode).has(getGeoBlockTokenKey(token));
522
+ }
523
+ function fetchJson2(url) {
524
+ return chunkY2NTTLTJ_js.__async(this, null, function* () {
525
+ const res = yield fetch(url);
526
+ if (!res.ok)
527
+ throw new Error(`Failed to fetch: ${url}`);
528
+ return res.json();
529
+ });
530
+ }
483
531
  function noop() {
484
532
  }
485
533
  var noopStorage = {
@@ -510,7 +558,7 @@ function findTokenBySymbol(state, chainId, symbol) {
510
558
  }
511
559
  return void 0;
512
560
  }
513
- var createListsAtom = (storeName, reducer, initialState) => {
561
+ var createListsAtom = (storeName, reducer, initialState, options = {}) => {
514
562
  function IndexedDBStorage(dbName) {
515
563
  if (typeof window !== "undefined") {
516
564
  const db = localForage__default.default.createInstance({
@@ -546,32 +594,77 @@ var createListsAtom = (storeName, reducer, initialState) => {
546
594
  const tokenListsStorageAtom = utils.atomWithStorage(
547
595
  "tokenLists",
548
596
  EMPTY,
549
- IndexedDBStorage("tokenLists")
597
+ IndexedDBStorage("tokenLists"),
598
+ { getOnInit: true }
550
599
  );
600
+ const geoBlockListStorageAtom = utils.atomWithStorage(
601
+ "geoBlockList",
602
+ EMPTY,
603
+ IndexedDBStorage("geoBlockList")
604
+ );
605
+ const geoBlockListAtom = jotai.atom((get) => {
606
+ const value = get(utils.loadable(geoBlockListStorageAtom));
607
+ if (value.state === "hasData" && value.data && value.data !== EMPTY) {
608
+ return value.data;
609
+ }
610
+ return void 0;
611
+ });
612
+ const fetchGeoBlockListAtom = jotai.atom(null, (_get, set) => chunkY2NTTLTJ_js.__async(void 0, null, function* () {
613
+ if (!options.geoBlockListUrl) {
614
+ return;
615
+ }
616
+ try {
617
+ set(geoBlockListStorageAtom, yield fetchJson2(options.geoBlockListUrl));
618
+ } catch (error) {
619
+ console.warn(`Failed to download geo block list from ${options.geoBlockListUrl}:`, error);
620
+ }
621
+ }));
551
622
  const memoryStateAtom = jotai.atom(initialState);
623
+ const mergeStoredData = (baseState, storedData) => {
624
+ var _a;
625
+ const storedTokenLists = (_a = storedData.byUrl) != null ? _a : storedData;
626
+ const storedActiveListUrls = Array.isArray(storedData.activeListUrls) ? storedData.activeListUrls : void 0;
627
+ const mergedState = chunkY2NTTLTJ_js.__spreadValues({}, baseState);
628
+ const updatedByUrl = chunkY2NTTLTJ_js.__spreadValues({}, mergedState.byUrl);
629
+ Object.keys(storedTokenLists).forEach((url) => {
630
+ if (storedTokenLists[url]) {
631
+ updatedByUrl[url] = chunkY2NTTLTJ_js.__spreadProps(chunkY2NTTLTJ_js.__spreadValues({}, updatedByUrl[url]), {
632
+ current: storedTokenLists[url]
633
+ // Keep existing memory state for loading, error, pendingUpdate
634
+ });
635
+ }
636
+ });
637
+ mergedState.byUrl = updatedByUrl;
638
+ if (storedActiveListUrls) {
639
+ mergedState.activeListUrls = storedActiveListUrls;
640
+ }
641
+ return mergedState;
642
+ };
552
643
  const listStateAtom = jotai.atom((get) => {
553
644
  const memoryState = get(memoryStateAtom);
554
645
  const value = get(utils.loadable(tokenListsStorageAtom));
555
646
  if (value.state === "hasData" && value.data && value.data !== EMPTY) {
556
- const storedTokenLists = value.data;
557
- const reconstructedState = chunkY2NTTLTJ_js.__spreadValues({}, memoryState);
558
- const updatedByUrl = chunkY2NTTLTJ_js.__spreadValues({}, reconstructedState.byUrl);
559
- Object.keys(storedTokenLists).forEach((url) => {
560
- if (storedTokenLists[url]) {
561
- updatedByUrl[url] = chunkY2NTTLTJ_js.__spreadProps(chunkY2NTTLTJ_js.__spreadValues({}, updatedByUrl[url]), {
562
- current: storedTokenLists[url]
563
- // Keep existing memory state for loading, error, pendingUpdate
564
- });
565
- }
566
- });
567
- reconstructedState.byUrl = updatedByUrl;
568
- return reconstructedState;
647
+ return mergeStoredData(memoryState, value.data);
569
648
  }
570
649
  return memoryState;
571
650
  });
651
+ let hasHydratedActiveListUrls = false;
572
652
  const updateListStateAtom = jotai.atom(null, (get, set, action) => chunkY2NTTLTJ_js.__async(void 0, null, function* () {
573
- const currentMemoryState = get(memoryStateAtom);
574
- const newState = reducer(currentMemoryState, action);
653
+ const baseState = get(memoryStateAtom);
654
+ if (!hasHydratedActiveListUrls) {
655
+ const storageValue = get(utils.loadable(tokenListsStorageAtom));
656
+ if (storageValue.state !== "hasData") {
657
+ const optimisticState = reducer(baseState, action);
658
+ set(memoryStateAtom, chunkY2NTTLTJ_js.__spreadValues({}, optimisticState));
659
+ return;
660
+ }
661
+ if (storageValue.data && storageValue.data !== EMPTY) {
662
+ set(memoryStateAtom, mergeStoredData(baseState, storageValue.data));
663
+ }
664
+ hasHydratedActiveListUrls = true;
665
+ }
666
+ const currentState = get(memoryStateAtom);
667
+ const newState = reducer(currentState, action);
575
668
  set(memoryStateAtom, chunkY2NTTLTJ_js.__spreadValues({}, newState));
576
669
  const tokenListsToStore = {};
577
670
  Object.keys(newState.byUrl).forEach((url) => {
@@ -580,7 +673,7 @@ var createListsAtom = (storeName, reducer, initialState) => {
580
673
  tokenListsToStore[url] = newState.byUrl[url].current;
581
674
  }
582
675
  });
583
- set(tokenListsStorageAtom, tokenListsToStore);
676
+ set(tokenListsStorageAtom, { byUrl: tokenListsToStore, activeListUrls: newState.activeListUrls });
584
677
  }));
585
678
  const isReadyAtom = utils.loadable(tokenListsStorageAtom);
586
679
  const tokenAtom = utils.atomFamily(
@@ -664,6 +757,8 @@ var createListsAtom = (storeName, reducer, initialState) => {
664
757
  updateListStateAtom,
665
758
  tokenAtom,
666
759
  tokenBySymbolAtom,
760
+ geoBlockListAtom,
761
+ fetchGeoBlockListAtom,
667
762
  fetchListAtom,
668
763
  fetchListBatchAtom,
669
764
  useListStateReady,
@@ -849,7 +944,7 @@ function useFetchListCallback(dispatch) {
849
944
  }
850
945
  return tokenList;
851
946
  }).catch((error) => {
852
- console.error(`Failed to get list at url ${listUrl}`, error);
947
+ console.warn(`Failed to get list at url ${listUrl}`, error);
853
948
  if (sendDispatch) {
854
949
  dispatch(fetchTokenList.rejected({ url: listUrl, requestId, errorMessage: error.message }));
855
950
  }
@@ -874,7 +969,10 @@ exports.enableList = enableList;
874
969
  exports.fetchTokenList = fetchTokenList;
875
970
  exports.findTokenByAddress = findTokenByAddress;
876
971
  exports.findTokenBySymbol = findTokenBySymbol;
972
+ exports.getGeoBlockTokenKey = getGeoBlockTokenKey;
973
+ exports.getGeoBlockedTokenKeys = getGeoBlockedTokenKeys;
877
974
  exports.getTokenList = getTokenList;
975
+ exports.isGeoBlockedToken = isGeoBlockedToken;
878
976
  exports.rejectVersionUpdate = rejectVersionUpdate;
879
977
  exports.removeList = removeList;
880
978
  exports.updateListVersion = updateListVersion;
package/dist/react.mjs CHANGED
@@ -403,6 +403,15 @@ var pancakeswap_default = {
403
403
  description: "A URI for the logo of the token list; prefer SVG or PNG of size 256x256",
404
404
  format: "uri",
405
405
  examples: ["ipfs://QmXfzKRvjZz3u5JRgC4v5mGVbm9ahrUiB4DgzHBsnWbTMM"]
406
+ },
407
+ extensions: {
408
+ type: "object",
409
+ description: "An object containing arbitrary or vendor-specific token list metadata",
410
+ propertyNames: {
411
+ $ref: "#/definitions/ExtensionIdentifier"
412
+ },
413
+ additionalProperties: true,
414
+ maxProperties: 10
406
415
  }
407
416
  },
408
417
  if: {
@@ -448,17 +457,15 @@ function getTokenList(listUrl) {
448
457
  try {
449
458
  const json = yield fetchJson(url);
450
459
  if (!validator(json)) {
451
- const preFilterErrors = validator.errors;
452
460
  json.tokens = json.tokens.filter((token) => validator(__spreadProps(__spreadValues({}, json), { tokens: [token] })));
453
461
  if (!validator(json)) {
454
462
  const { errors } = validator;
455
463
  throw new Error(`Validation failed after filtering: ${JSON.stringify(errors)}`);
456
464
  }
457
- console.warn(`Pre-filter validation errors: ${JSON.stringify(preFilterErrors)}`);
458
465
  }
459
466
  return json;
460
467
  } catch (error) {
461
- console.error(`Failed to download or validate list from ${url}:`, error);
468
+ console.warn(`Failed to download or validate list from ${url}:`, error);
462
469
  return void 0;
463
470
  }
464
471
  }
@@ -473,6 +480,47 @@ function fetchJson(url) {
473
480
  return res.json();
474
481
  });
475
482
  }
483
+ function normalizeTokenAddress(address) {
484
+ return address.startsWith("0x") ? address.toLowerCase() : address;
485
+ }
486
+ function getGeoBlockTokenKey(token) {
487
+ return `${token.chainId}:${normalizeTokenAddress(token.address)}`;
488
+ }
489
+ function normalizeTokenKey(key) {
490
+ const separatorIndex = key.indexOf(":");
491
+ if (separatorIndex < 0)
492
+ return key;
493
+ const chainId = key.slice(0, separatorIndex);
494
+ const address = key.slice(separatorIndex + 1);
495
+ return `${chainId}:${normalizeTokenAddress(address)}`;
496
+ }
497
+ function getGeoBlockedTokenKeys(geoBlockList, countryCode) {
498
+ const blockedTokenKeys = /* @__PURE__ */ new Set();
499
+ if (!countryCode || !(geoBlockList == null ? void 0 : geoBlockList.rules) || !geoBlockList.tokens) {
500
+ return blockedTokenKeys;
501
+ }
502
+ const country = countryCode.toUpperCase();
503
+ const blockedRuleIds = new Set(
504
+ Object.entries(geoBlockList.rules).filter(([, countries]) => countries.includes(country)).map(([ruleId]) => ruleId)
505
+ );
506
+ Object.entries(geoBlockList.tokens).forEach(([tokenKey, ruleIds]) => {
507
+ if (ruleIds.some((ruleId) => blockedRuleIds.has(ruleId))) {
508
+ blockedTokenKeys.add(normalizeTokenKey(tokenKey));
509
+ }
510
+ });
511
+ return blockedTokenKeys;
512
+ }
513
+ function isGeoBlockedToken(token, geoBlockList, countryCode) {
514
+ return getGeoBlockedTokenKeys(geoBlockList, countryCode).has(getGeoBlockTokenKey(token));
515
+ }
516
+ function fetchJson2(url) {
517
+ return __async(this, null, function* () {
518
+ const res = yield fetch(url);
519
+ if (!res.ok)
520
+ throw new Error(`Failed to fetch: ${url}`);
521
+ return res.json();
522
+ });
523
+ }
476
524
  function noop() {
477
525
  }
478
526
  var noopStorage = {
@@ -503,7 +551,7 @@ function findTokenBySymbol(state, chainId, symbol) {
503
551
  }
504
552
  return void 0;
505
553
  }
506
- var createListsAtom = (storeName, reducer, initialState) => {
554
+ var createListsAtom = (storeName, reducer, initialState, options = {}) => {
507
555
  function IndexedDBStorage(dbName) {
508
556
  if (typeof window !== "undefined") {
509
557
  const db = localForage.createInstance({
@@ -539,32 +587,77 @@ var createListsAtom = (storeName, reducer, initialState) => {
539
587
  const tokenListsStorageAtom = atomWithStorage(
540
588
  "tokenLists",
541
589
  EMPTY,
542
- IndexedDBStorage("tokenLists")
590
+ IndexedDBStorage("tokenLists"),
591
+ { getOnInit: true }
543
592
  );
593
+ const geoBlockListStorageAtom = atomWithStorage(
594
+ "geoBlockList",
595
+ EMPTY,
596
+ IndexedDBStorage("geoBlockList")
597
+ );
598
+ const geoBlockListAtom = atom((get) => {
599
+ const value = get(loadable(geoBlockListStorageAtom));
600
+ if (value.state === "hasData" && value.data && value.data !== EMPTY) {
601
+ return value.data;
602
+ }
603
+ return void 0;
604
+ });
605
+ const fetchGeoBlockListAtom = atom(null, (_get, set) => __async(void 0, null, function* () {
606
+ if (!options.geoBlockListUrl) {
607
+ return;
608
+ }
609
+ try {
610
+ set(geoBlockListStorageAtom, yield fetchJson2(options.geoBlockListUrl));
611
+ } catch (error) {
612
+ console.warn(`Failed to download geo block list from ${options.geoBlockListUrl}:`, error);
613
+ }
614
+ }));
544
615
  const memoryStateAtom = atom(initialState);
616
+ const mergeStoredData = (baseState, storedData) => {
617
+ var _a;
618
+ const storedTokenLists = (_a = storedData.byUrl) != null ? _a : storedData;
619
+ const storedActiveListUrls = Array.isArray(storedData.activeListUrls) ? storedData.activeListUrls : void 0;
620
+ const mergedState = __spreadValues({}, baseState);
621
+ const updatedByUrl = __spreadValues({}, mergedState.byUrl);
622
+ Object.keys(storedTokenLists).forEach((url) => {
623
+ if (storedTokenLists[url]) {
624
+ updatedByUrl[url] = __spreadProps(__spreadValues({}, updatedByUrl[url]), {
625
+ current: storedTokenLists[url]
626
+ // Keep existing memory state for loading, error, pendingUpdate
627
+ });
628
+ }
629
+ });
630
+ mergedState.byUrl = updatedByUrl;
631
+ if (storedActiveListUrls) {
632
+ mergedState.activeListUrls = storedActiveListUrls;
633
+ }
634
+ return mergedState;
635
+ };
545
636
  const listStateAtom = atom((get) => {
546
637
  const memoryState = get(memoryStateAtom);
547
638
  const value = get(loadable(tokenListsStorageAtom));
548
639
  if (value.state === "hasData" && value.data && value.data !== EMPTY) {
549
- const storedTokenLists = value.data;
550
- const reconstructedState = __spreadValues({}, memoryState);
551
- const updatedByUrl = __spreadValues({}, reconstructedState.byUrl);
552
- Object.keys(storedTokenLists).forEach((url) => {
553
- if (storedTokenLists[url]) {
554
- updatedByUrl[url] = __spreadProps(__spreadValues({}, updatedByUrl[url]), {
555
- current: storedTokenLists[url]
556
- // Keep existing memory state for loading, error, pendingUpdate
557
- });
558
- }
559
- });
560
- reconstructedState.byUrl = updatedByUrl;
561
- return reconstructedState;
640
+ return mergeStoredData(memoryState, value.data);
562
641
  }
563
642
  return memoryState;
564
643
  });
644
+ let hasHydratedActiveListUrls = false;
565
645
  const updateListStateAtom = atom(null, (get, set, action) => __async(void 0, null, function* () {
566
- const currentMemoryState = get(memoryStateAtom);
567
- const newState = reducer(currentMemoryState, action);
646
+ const baseState = get(memoryStateAtom);
647
+ if (!hasHydratedActiveListUrls) {
648
+ const storageValue = get(loadable(tokenListsStorageAtom));
649
+ if (storageValue.state !== "hasData") {
650
+ const optimisticState = reducer(baseState, action);
651
+ set(memoryStateAtom, __spreadValues({}, optimisticState));
652
+ return;
653
+ }
654
+ if (storageValue.data && storageValue.data !== EMPTY) {
655
+ set(memoryStateAtom, mergeStoredData(baseState, storageValue.data));
656
+ }
657
+ hasHydratedActiveListUrls = true;
658
+ }
659
+ const currentState = get(memoryStateAtom);
660
+ const newState = reducer(currentState, action);
568
661
  set(memoryStateAtom, __spreadValues({}, newState));
569
662
  const tokenListsToStore = {};
570
663
  Object.keys(newState.byUrl).forEach((url) => {
@@ -573,7 +666,7 @@ var createListsAtom = (storeName, reducer, initialState) => {
573
666
  tokenListsToStore[url] = newState.byUrl[url].current;
574
667
  }
575
668
  });
576
- set(tokenListsStorageAtom, tokenListsToStore);
669
+ set(tokenListsStorageAtom, { byUrl: tokenListsToStore, activeListUrls: newState.activeListUrls });
577
670
  }));
578
671
  const isReadyAtom = loadable(tokenListsStorageAtom);
579
672
  const tokenAtom = atomFamily(
@@ -657,6 +750,8 @@ var createListsAtom = (storeName, reducer, initialState) => {
657
750
  updateListStateAtom,
658
751
  tokenAtom,
659
752
  tokenBySymbolAtom,
753
+ geoBlockListAtom,
754
+ fetchGeoBlockListAtom,
660
755
  fetchListAtom,
661
756
  fetchListBatchAtom,
662
757
  useListStateReady,
@@ -842,7 +937,7 @@ function useFetchListCallback(dispatch) {
842
937
  }
843
938
  return tokenList;
844
939
  }).catch((error) => {
845
- console.error(`Failed to get list at url ${listUrl}`, error);
940
+ console.warn(`Failed to get list at url ${listUrl}`, error);
846
941
  if (sendDispatch) {
847
942
  dispatch(fetchTokenList.rejected({ url: listUrl, requestId, errorMessage: error.message }));
848
943
  }
@@ -854,4 +949,4 @@ function useFetchListCallback(dispatch) {
854
949
  }
855
950
  var useFetchListCallback_default = useFetchListCallback;
856
951
 
857
- export { NEW_LIST_STATE, acceptListUpdate, addList, batchFetchTokenListFulfilled, batchFetchTokenListPending, batchFetchTokenListRejected, createListsAtom, createTokenListReducer, disableList, enableList, fetchTokenList, findTokenByAddress, findTokenBySymbol, getTokenList, rejectVersionUpdate, removeList, updateListVersion, useFetchListCallback_default as useFetchListCallback };
952
+ export { NEW_LIST_STATE, acceptListUpdate, addList, batchFetchTokenListFulfilled, batchFetchTokenListPending, batchFetchTokenListRejected, createListsAtom, createTokenListReducer, disableList, enableList, fetchTokenList, findTokenByAddress, findTokenBySymbol, getGeoBlockTokenKey, getGeoBlockedTokenKeys, getTokenList, isGeoBlockedToken, rejectVersionUpdate, removeList, updateListVersion, useFetchListCallback_default as useFetchListCallback };
@@ -35,6 +35,7 @@ interface TokenList {
35
35
  readonly keywords?: string[];
36
36
  readonly tags?: Tags;
37
37
  readonly logoURI?: string;
38
+ readonly extensions?: Record<string, unknown>;
38
39
  }
39
40
 
40
41
  export { TokenInfo as T, Version as V, TokenList as a, Tags as b };
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@pancakeswap/token-lists",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "main": "./dist/index.js",
5
5
  "module": "./dist/index.mjs",
6
6
  "types": "./dist/index.d.ts",
7
7
  "dependencies": {
8
8
  "ajv": "^6.12.3",
9
9
  "lodash": "^4.17.21",
10
- "@pancakeswap/swap-sdk-core": "1.6.0"
10
+ "@pancakeswap/swap-sdk-core": "1.6.1"
11
11
  },
12
12
  "peerDependencies": {
13
13
  "@reduxjs/toolkit": "^1.9.1",
@@ -23,7 +23,7 @@
23
23
  "react": "^18.2.0",
24
24
  "tsup": "^6.7.0",
25
25
  "@types/lodash": "^4.14.182",
26
- "@pancakeswap/utils": "8.0.2"
26
+ "@pancakeswap/utils": "8.0.3"
27
27
  },
28
28
  "exports": {
29
29
  ".": {
@@ -0,0 +1,68 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { getGeoBlockedTokenKeys, getGeoBlockTokenKey, isGeoBlockedToken } from './lists'
3
+
4
+ const GEO_BLOCK_LIST = {
5
+ rules: {
6
+ A: ['US'],
7
+ B: ['SG'],
8
+ },
9
+ tokens: {
10
+ '56:0xa0Fe4e0aEca5479705ce996615B2EACB6b6a10Fb': ['A'],
11
+ '8000001001:Xsc9qvGR1efVDFGLrVsmkzv3qi45LTBjeUKSPmx9qEh': ['A'],
12
+ '56:0x0000000000000000000000000000000000000001': ['B'],
13
+ },
14
+ }
15
+
16
+ describe('geo block helpers', () => {
17
+ it('normalizes EVM addresses and preserves non-EVM addresses', () => {
18
+ expect(
19
+ getGeoBlockTokenKey({
20
+ chainId: 56,
21
+ address: '0xa0Fe4e0aEca5479705ce996615B2EACB6b6a10Fb',
22
+ }),
23
+ ).toBe('56:0xa0fe4e0aeca5479705ce996615b2eacb6b6a10fb')
24
+
25
+ expect(
26
+ getGeoBlockTokenKey({
27
+ chainId: 8000001001,
28
+ address: 'Xsc9qvGR1efVDFGLrVsmkzv3qi45LTBjeUKSPmx9qEh',
29
+ }),
30
+ ).toBe('8000001001:Xsc9qvGR1efVDFGLrVsmkzv3qi45LTBjeUKSPmx9qEh')
31
+ })
32
+
33
+ it('returns blocked token keys for the active country', () => {
34
+ expect([...getGeoBlockedTokenKeys(GEO_BLOCK_LIST, 'US')].sort()).toEqual([
35
+ '56:0xa0fe4e0aeca5479705ce996615b2eacb6b6a10fb',
36
+ '8000001001:Xsc9qvGR1efVDFGLrVsmkzv3qi45LTBjeUKSPmx9qEh',
37
+ ])
38
+ })
39
+
40
+ it('fails open without country or geo block list', () => {
41
+ expect(getGeoBlockedTokenKeys(GEO_BLOCK_LIST).size).toBe(0)
42
+ expect(getGeoBlockedTokenKeys(undefined, 'US').size).toBe(0)
43
+ })
44
+
45
+ it('checks whether a token is geo blocked', () => {
46
+ expect(
47
+ isGeoBlockedToken(
48
+ {
49
+ chainId: 56,
50
+ address: '0xa0Fe4e0aEca5479705ce996615B2EACB6b6a10Fb',
51
+ },
52
+ GEO_BLOCK_LIST,
53
+ 'US',
54
+ ),
55
+ ).toBe(true)
56
+
57
+ expect(
58
+ isGeoBlockedToken(
59
+ {
60
+ chainId: 56,
61
+ address: '0xa0Fe4e0aEca5479705ce996615B2EACB6b6a10Fb',
62
+ },
63
+ GEO_BLOCK_LIST,
64
+ 'SG',
65
+ ),
66
+ ).toBe(false)
67
+ })
68
+ })
@@ -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,20 +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
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
  // }
33
- console.error(`Failed to download or validate list from ${url}:`, error)
31
+ console.warn(`Failed to download or validate list from ${url}:`, error)
34
32
 
35
33
  return undefined
36
34
  }
@@ -16,6 +16,9 @@ test('exports', () => {
16
16
  "batchFetchTokenListFulfilled",
17
17
  "batchFetchTokenListRejected",
18
18
  "getTokenList",
19
+ "getGeoBlockTokenKey",
20
+ "getGeoBlockedTokenKeys",
21
+ "isGeoBlockedToken",
19
22
  "findTokenByAddress",
20
23
  "findTokenBySymbol",
21
24
  "createListsAtom",
package/react/lists.ts CHANGED
@@ -12,6 +12,76 @@ import {
12
12
  } from './actions'
13
13
  import { getTokenList } from './getTokenList'
14
14
  import { ListsState } from './reducer'
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
+ }
15
85
  // eslint-disable-next-line @typescript-eslint/no-empty-function
16
86
  function noop() {}
17
87
 
@@ -44,7 +114,12 @@ export function findTokenBySymbol(state: ListsState, chainId: number, symbol: st
44
114
  return undefined
45
115
  }
46
116
 
47
- 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
+ ) => {
48
123
  /**
49
124
  * Persist only token lists using IndexedDB - optimized storage format
50
125
  * @param {string} dbName - IndexedDB database name
@@ -83,51 +158,137 @@ export const createListsAtom = (storeName: string, reducer: any, initialState: a
83
158
  return noopStorage
84
159
  }
85
160
 
86
- // Storage for token lists only - optimized format: { [url]: TokenList }
87
- const tokenListsStorageAtom = atomWithStorage<Record<string, any> | typeof EMPTY>(
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>(
88
167
  'tokenLists',
89
168
  EMPTY,
90
169
  IndexedDBStorage('tokenLists'),
170
+ { getOnInit: true },
171
+ )
172
+
173
+ const geoBlockListStorageAtom = atomWithStorage<GeoBlockList | typeof EMPTY>(
174
+ 'geoBlockList',
175
+ EMPTY,
176
+ IndexedDBStorage('geoBlockList'),
91
177
  )
92
178
 
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
+
93
199
  // Memory state atom that holds the full ListsState
94
200
  const memoryStateAtom = atom<ListsState>(initialState as ListsState)
95
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
+
96
236
  const listStateAtom = atom<ListsState>((get) => {
97
237
  // Separate this(mem/storage) is a prepare for refactor of the list part
98
238
  const memoryState = get(memoryStateAtom)
99
239
  const value = get(loadable(tokenListsStorageAtom))
100
240
 
101
241
  if (value.state === 'hasData' && value.data && value.data !== EMPTY) {
102
- const storedTokenLists = value.data as Record<string, any>
103
- const reconstructedState = { ...memoryState }
104
-
105
- const updatedByUrl = { ...reconstructedState.byUrl }
106
- Object.keys(storedTokenLists).forEach((url) => {
107
- if (storedTokenLists[url]) {
108
- updatedByUrl[url] = {
109
- ...updatedByUrl[url],
110
- current: storedTokenLists[url],
111
- // Keep existing memory state for loading, error, pendingUpdate
112
- }
113
- }
114
- })
115
- reconstructedState.byUrl = updatedByUrl
116
-
117
- return reconstructedState
242
+ return mergeStoredData(memoryState, value.data as StoredListsPayload | Record<string, any>)
118
243
  }
119
244
 
120
245
  return memoryState
121
246
  })
122
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
+
123
265
  const updateListStateAtom = atom<null, any, void>(null, async (get, set, action) => {
124
- const currentMemoryState = get(memoryStateAtom)
125
- const newState = reducer(currentMemoryState, 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)
126
287
 
127
288
  // Update memory state
128
289
  set(memoryStateAtom, { ...newState })
129
290
 
130
- // Extract only current token lists for storage
291
+ // Extract current token lists and the active list selection for storage
131
292
  const tokenListsToStore: Record<string, any> = {}
132
293
  Object.keys(newState.byUrl).forEach((url) => {
133
294
  if (newState.byUrl[url]?.current) {
@@ -135,8 +296,7 @@ export const createListsAtom = (storeName: string, reducer: any, initialState: a
135
296
  }
136
297
  })
137
298
 
138
- // Store only the token lists, not the full state
139
- set(tokenListsStorageAtom, tokenListsToStore)
299
+ set(tokenListsStorageAtom, { byUrl: tokenListsToStore, activeListUrls: newState.activeListUrls })
140
300
  })
141
301
 
142
302
  const isReadyAtom = loadable(tokenListsStorageAtom)
@@ -241,6 +401,8 @@ export const createListsAtom = (storeName: string, reducer: any, initialState: a
241
401
  updateListStateAtom,
242
402
  tokenAtom,
243
403
  tokenBySymbolAtom,
404
+ geoBlockListAtom,
405
+ fetchGeoBlockListAtom,
244
406
  fetchListAtom,
245
407
  fetchListBatchAtom,
246
408
  useListStateReady,
@@ -26,7 +26,7 @@ function useFetchListCallback(
26
26
  return tokenList
27
27
  })
28
28
  .catch((error) => {
29
- console.error(`Failed to get list at url ${listUrl}`, error)
29
+ console.warn(`Failed to get list at url ${listUrl}`, error)
30
30
  if (sendDispatch) {
31
31
  dispatch(fetchTokenList.rejected({ url: listUrl, requestId, errorMessage: error.message }))
32
32
  }
@@ -356,6 +356,15 @@
356
356
  "description": "A URI for the logo of the token list; prefer SVG or PNG of size 256x256",
357
357
  "format": "uri",
358
358
  "examples": ["ipfs://QmXfzKRvjZz3u5JRgC4v5mGVbm9ahrUiB4DgzHBsnWbTMM"]
359
+ },
360
+ "extensions": {
361
+ "type": "object",
362
+ "description": "An object containing arbitrary or vendor-specific token list metadata",
363
+ "propertyNames": {
364
+ "$ref": "#/definitions/ExtensionIdentifier"
365
+ },
366
+ "additionalProperties": true,
367
+ "maxProperties": 10
359
368
  }
360
369
  },
361
370
  "if": {
package/src/index.test.ts CHANGED
@@ -9,6 +9,7 @@ test('exports', () => {
9
9
  "VersionUpgrade",
10
10
  "getVersionUpgrade",
11
11
  "createFilterToken",
12
+ "isScaledUIAmountToken",
12
13
  ]
13
14
  `)
14
15
  })
package/src/index.ts CHANGED
@@ -2,3 +2,4 @@ export * from './wrappedTokenInfo'
2
2
  export * from './types'
3
3
  export * from './getVersionUpgrade'
4
4
  export * from './filtering'
5
+ export * from './scaledUIAmount'
@@ -0,0 +1,41 @@
1
+ // Token-list `extensions.scaledUIAmount` metadata for ERC-8056 (Scaled UI Amount) tokens.
2
+ //
3
+ // Curated by the indexer/CMS at ingestion time. The boolean `enabled` flag is the
4
+ // load-bearing FE-side check; other fields are snapshots / hints (the live multiplier
5
+ // is always re-read from chain via the `useScaledUIAmountMetadata` hook).
6
+ //
7
+ // See BNB Chain partner integration doc v1.0 (8 Apr 2026) §8.2.4 for the upstream
8
+ // shape this is modeled after.
9
+
10
+ import type { TokenInfo } from './types'
11
+
12
+ /**
13
+ * Static metadata identifying an ERC-8056 (Scaled UI Amount) token in a token list.
14
+ * `enabled: true` is the only field FE consumes for routing decisions; everything
15
+ * else is hint/snapshot data refreshed live from chain.
16
+ */
17
+ export interface ScaledUIAmountExtension {
18
+ /** Set to `true` for tokens that implement ERC-8056. The load-bearing FE check. */
19
+ enabled: boolean
20
+ /** Optional snapshot of the live multiplier at the time of indexing (18-decimal fixed). */
21
+ multiplier?: string
22
+ /** Optional human-readable multiplier (e.g. `"2.0"`). Display hint only. */
23
+ multiplierFormatted?: string
24
+ /** ERC-165 interface IDs the contract claims to support. */
25
+ interfaceIds?: string[]
26
+ }
27
+
28
+ /**
29
+ * Returns true if a token-list entry is flagged as ERC-8056 enabled by the indexer.
30
+ * Tokens lacking the extension or with `enabled: false` are treated as standard ERC-20s
31
+ * (identity multiplier `1.0×`); the FE never falls into ScaledUI math for these.
32
+ *
33
+ * Note: the load-bearing decision lives here, not on the `Token` class. Token
34
+ * instances stay pure identity objects per the architectural decision in the
35
+ * implementation doc.
36
+ */
37
+ export function isScaledUIAmountToken(token: Pick<TokenInfo, 'extensions'>): boolean {
38
+ const ext = token.extensions?.scaledUIAmount
39
+ if (!ext || typeof ext !== 'object' || Array.isArray(ext)) return false
40
+ return (ext as { enabled?: unknown }).enabled === true
41
+ }
package/src/types.ts CHANGED
@@ -43,4 +43,5 @@ export interface TokenList {
43
43
  readonly keywords?: string[]
44
44
  readonly tags?: Tags
45
45
  readonly logoURI?: string
46
+ readonly extensions?: Record<string, unknown>
46
47
  }
@@ -17,9 +17,17 @@ export interface SerializedWrappedToken extends SerializedToken {
17
17
  export class WrappedTokenInfo extends Token {
18
18
  public readonly logoURI: string | undefined
19
19
 
20
+ /**
21
+ * Preserves the token-list `extensions` bag (curator-set metadata like
22
+ * `bridgeInfo`, `scaledUIAmount.enabled`, etc.). Stored as a readonly reference;
23
+ * not serialised back out of the class (kept in-memory only).
24
+ */
25
+ public readonly extensions: TokenInfo['extensions']
26
+
20
27
  constructor(tokenInfo: TokenInfo) {
21
28
  super(tokenInfo.chainId, tokenInfo.address, tokenInfo.decimals, tokenInfo.symbol, tokenInfo.name)
22
29
  this.logoURI = tokenInfo.logoURI
30
+ this.extensions = tokenInfo.extensions
23
31
  }
24
32
 
25
33
  public get serialize(): SerializedWrappedToken {