@exodus/assets-feature 9.1.0 → 9.2.0

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,12 @@
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.0](https://github.com/ExodusMovement/exodus-hydra/compare/@exodus/assets-feature@9.1.0...@exodus/assets-feature@9.2.0) (2026-04-15)
7
+
8
+ ### Features
9
+
10
+ - feat(assets-feature): add searchBasedAssetLoaderPlugin (#16016)
11
+
6
12
  ## [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
13
 
8
14
  ### 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
 
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.0",
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": "3e347c272d0c2c679ee9eec9e53a2b4831ef1faf"
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