@exodus/assets-feature 9.1.0 → 9.2.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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,16 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [9.2.1](https://github.com/ExodusMovement/exodus-hydra/compare/@exodus/assets-feature@9.2.0...@exodus/assets-feature@9.2.1) (2026-04-16)
7
+
8
+ **Note:** Version bump only for package @exodus/assets-feature
9
+
10
+ ## [9.2.0](https://github.com/ExodusMovement/exodus-hydra/compare/@exodus/assets-feature@9.1.0...@exodus/assets-feature@9.2.0) (2026-04-15)
11
+
12
+ ### Features
13
+
14
+ - feat(assets-feature): add searchBasedAssetLoaderPlugin (#16016)
15
+
6
16
  ## [9.1.0](https://github.com/ExodusMovement/exodus-hydra/compare/@exodus/assets-feature@9.0.1...@exodus/assets-feature@9.1.0) (2026-04-14)
7
17
 
8
18
  ### Features
package/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import assetsClientInterfaceDefinition from './client/index.js'
2
2
  import assetsPluginDefinition from './plugin/index.js'
3
+ import searchBasedAssetLoaderPluginDefinition from './plugin/search-based-asset-loader-plugin.js'
3
4
  import multiAddressModeAtomDefinition from './atoms/multi-address-mode.js'
4
5
  import legacyAddressModeAtomDefinition from './atoms/legacy-address-mode.js'
5
6
  import taprootAddressModeAtomDefinition from './atoms/taproot-address-mode.js'
@@ -17,6 +18,7 @@ const assets = (config = Object.create(null)) => {
17
18
  disabledPurposes,
18
19
  compatibilityModeGapLimits,
19
20
  compatibilityModeMultiAddressMode,
21
+ searchBasedAssetLoaderPlugin,
20
22
  } = { ...defaultConfig, ...config }
21
23
 
22
24
  return {
@@ -53,10 +55,16 @@ const assets = (config = Object.create(null)) => {
53
55
  },
54
56
  { definition: assetPreferencesDefinition },
55
57
  { if: { registered: ['customTokensStorage'] }, definition: customTokensMonitorDefinition },
58
+ searchBasedAssetLoaderPlugin && {
59
+ if: { registered: ['customTokensStorage'] },
60
+ definition: searchBasedAssetLoaderPluginDefinition,
61
+ storage: { namespace: 'assetPreferences' },
62
+ config: searchBasedAssetLoaderPlugin,
63
+ },
56
64
  // This report was intentionally omitted as it did not provide useful value in practice.
57
65
  // We prefer less data over more when it's not meaningful, keeping them commented for reference.
58
66
  // { definition: assetsReportDefinition },
59
- ],
67
+ ].filter(Boolean),
60
68
  }
61
69
  }
62
70
 
@@ -23,7 +23,7 @@ import {
23
23
  isDisabledCustomToken,
24
24
  } from './utils.js'
25
25
  import createFetchival from '@exodus/fetch/create-fetchival'
26
- import { validateCustomToken, isValidCustomToken } from '@exodus/asset-schema-validation'
26
+ import { validateCustomToken } from '@exodus/asset-schema-validation'
27
27
  import makeConcurrent from 'make-concurrent'
28
28
  import oldToNewStyleTokenNames from '@exodus/asset-legacy-token-name-mapping'
29
29
 
@@ -234,17 +234,10 @@ export class AssetsModule {
234
234
  }
235
235
 
236
236
  const _token = await fetchTokenAndCacheError()
237
- if (this.#shouldValidateCustomToken) {
238
- try {
239
- validateCustomToken(_token)
240
- } catch (e) {
241
- this.#logger.warn(
242
- `Token did not pass validation ${baseAssetName} ${assetId}. Error: ${e.message}`
243
- )
244
- const err = new Error('Token did not pass validation')
245
- this.#setCache(key, { cachedError: err })
246
- throw err
247
- }
237
+ if (!this.#isValidCustomToken(_token)) {
238
+ const err = new Error('Token did not pass validation')
239
+ this.#setCache(key, { cachedError: err })
240
+ throw err
248
241
  }
249
242
 
250
243
  const token = normalizeToken(_token)
@@ -277,21 +270,7 @@ export class AssetsModule {
277
270
  'tokens'
278
271
  )
279
272
 
280
- let validTokens = []
281
- if (this.#shouldValidateCustomToken) {
282
- for (const token of _tokens) {
283
- try {
284
- validateCustomToken(token)
285
- validTokens.push(token)
286
- } catch (e) {
287
- this.#logger.warn(
288
- `Token did not pass validation ${token.baseAssetName} ${token.assetId}. Error: ${e.message}`
289
- )
290
- }
291
- }
292
- } else {
293
- validTokens = _tokens
294
- }
273
+ const validTokens = _tokens.filter(this.#isValidCustomToken)
295
274
 
296
275
  const tokens = validTokens.map((token) => normalizeToken(token))
297
276
 
@@ -775,7 +754,15 @@ export class AssetsModule {
775
754
  return true
776
755
  }
777
756
 
778
- return isValidCustomToken(token)
757
+ try {
758
+ validateCustomToken(token)
759
+ return true
760
+ } catch (e) {
761
+ this.#logger.warn(
762
+ `Token ${token.name ?? token.assetName} did not pass validation: ${e.message}`
763
+ )
764
+ return false
765
+ }
779
766
  }
780
767
 
781
768
  clear = async () =>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/assets-feature",
3
- "version": "9.1.0",
3
+ "version": "9.2.1",
4
4
  "license": "MIT",
5
5
  "description": "This Exodus SDK feature provides access to instances of all blockchain asset adapters supported by the wallet, and enables you to search for and add custom tokens at runtime.",
6
6
  "type": "module",
@@ -84,5 +84,5 @@
84
84
  "access": "public",
85
85
  "provenance": false
86
86
  },
87
- "gitHead": "b58f4d5c33731861e8613ff3321f55a54463267b"
87
+ "gitHead": "9819576371d9361b2710dd8eae2fc6f4ef05d0b9"
88
88
  }
@@ -0,0 +1,107 @@
1
+ import ms from 'ms'
2
+
3
+ const toArray = (value) => (Array.isArray(value) ? value : [value])
4
+
5
+ const STORAGE_KEY = 'loadedSearches'
6
+ const DEFAULT_REFRESH_INTERVAL = '8h'
7
+
8
+ const isValidSearchConfig = ({ tags, baseAssetName, lifecycleStatus }) =>
9
+ Array.isArray(tags) && tags.length > 0 && baseAssetName && lifecycleStatus
10
+
11
+ const getSearchHash = ({ tags, baseAssetName, lifecycleStatus }) => {
12
+ const sortedTags = [...tags].sort().join(',')
13
+ const sortedStatus = [...toArray(lifecycleStatus)].sort().join(',')
14
+ return `${sortedTags}:${baseAssetName}:${sortedStatus}`
15
+ }
16
+
17
+ const createSearchBasedAssetLoaderPlugin = ({
18
+ assetsModule,
19
+ logger,
20
+ storage,
21
+ config: { searches = [], refreshInterval = DEFAULT_REFRESH_INTERVAL, pageSize } = {},
22
+ }) => {
23
+ const refreshIntervalMs =
24
+ typeof refreshInterval === 'string' ? ms(refreshInterval) : refreshInterval
25
+
26
+ const getLoadedSearches = async () => {
27
+ return (await storage.get(STORAGE_KEY)) || {}
28
+ }
29
+
30
+ const markSearchAsLoaded = async (hash) => {
31
+ const loadedSearches = await getLoadedSearches()
32
+ await storage.set(STORAGE_KEY, { ...loadedSearches, [hash]: Date.now() })
33
+ }
34
+
35
+ const isSearchStale = (loadedSearches, hash) => {
36
+ const lastLoaded = loadedSearches[hash]
37
+ return !lastLoaded || Date.now() - lastLoaded > refreshIntervalMs
38
+ }
39
+
40
+ const loadTokensForSearch = async (searchConfig, loadedSearches) => {
41
+ if (!isValidSearchConfig(searchConfig)) {
42
+ logger.error('Invalid search config, skipping:', JSON.stringify(searchConfig))
43
+ return
44
+ }
45
+
46
+ const { tags, baseAssetName, lifecycleStatus } = searchConfig
47
+ const lifecycleStatusList = toArray(lifecycleStatus)
48
+ const hash = getSearchHash(searchConfig)
49
+
50
+ if (!isSearchStale(loadedSearches, hash)) {
51
+ logger.info(`Search still fresh, skipping: ${hash}`)
52
+ return
53
+ }
54
+
55
+ try {
56
+ logger.info(
57
+ `Fetching tokens: tags=${tags.join(',')}, baseAsset=${baseAssetName}, status=${lifecycleStatusList.join(',')}`
58
+ )
59
+
60
+ const tokens = await assetsModule.searchTokens({
61
+ tags,
62
+ baseAssetName,
63
+ lifecycleStatus: lifecycleStatusList,
64
+ pageSize,
65
+ })
66
+
67
+ if (tokens.length === 0) {
68
+ logger.info('No tokens found for search criteria')
69
+ await markSearchAsLoaded(hash)
70
+ return
71
+ }
72
+
73
+ const tokenNames = tokens.map((t) => t.name)
74
+ await assetsModule.addRemoteTokens({
75
+ tokenNames,
76
+ allowedStatusList: lifecycleStatusList,
77
+ })
78
+
79
+ await markSearchAsLoaded(hash)
80
+ logger.info(`Loaded ${tokens.length} tokens`)
81
+ } catch (error) {
82
+ logger.error('Failed to load tokens:', error.message, error)
83
+ }
84
+ }
85
+
86
+ const onLoad = async () => {
87
+ const loadedSearches = await getLoadedSearches()
88
+ for (const searchConfig of searches) {
89
+ await loadTokensForSearch(searchConfig, loadedSearches)
90
+ }
91
+ }
92
+
93
+ const onClear = async () => {
94
+ await storage.delete(STORAGE_KEY)
95
+ }
96
+
97
+ return { onLoad, onClear }
98
+ }
99
+
100
+ const searchBasedAssetLoaderPluginDefinition = {
101
+ id: 'searchBasedAssetLoaderPlugin',
102
+ type: 'plugin',
103
+ factory: createSearchBasedAssetLoaderPlugin,
104
+ dependencies: ['assetsModule', 'logger', 'storage', 'config?'],
105
+ }
106
+
107
+ export default searchBasedAssetLoaderPluginDefinition