@exodus/assets-feature 9.2.6 → 9.3.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.3.0](https://github.com/ExodusMovement/exodus/compare/@exodus/assets-feature@9.2.6...@exodus/assets-feature@9.3.0) (2026-08-25)
7
+
8
+ ### Features
9
+
10
+ - **assets:** add dynamic combined assets generation ([#44197](https://github.com/ExodusMovement/exodus/issues/44197)) ([f107abe](https://github.com/ExodusMovement/exodus/commit/f107abece571af407df3b7154518aa689b8c1a04))
11
+
6
12
  ## [9.2.6](https://github.com/ExodusMovement/exodus/compare/@exodus/assets-feature@9.2.5...@exodus/assets-feature@9.2.6) (2026-07-30)
7
13
 
8
14
  ### Bug Fixes
package/constants.js CHANGED
@@ -6,7 +6,12 @@ export const disabledPurposesDefaultConfig = {
6
6
  defaults: Object.create(null),
7
7
  }
8
8
 
9
+ export const assetsModuleDefaultConfig = {
10
+ useDynamicCombinedAssets: false,
11
+ }
12
+
9
13
  export const defaultConfig = {
10
14
  multiAddressMode: multiAddressModeDefaultConfig,
11
15
  disabledPurposes: disabledPurposesDefaultConfig,
16
+ assetsModule: assetsModuleDefaultConfig,
12
17
  }
package/index.js CHANGED
@@ -19,6 +19,7 @@ const assets = (config = Object.create(null)) => {
19
19
  compatibilityModeGapLimits,
20
20
  compatibilityModeMultiAddressMode,
21
21
  searchBasedAssetLoaderPlugin,
22
+ assetsModule,
22
23
  } = { ...defaultConfig, ...config }
23
24
 
24
25
  return {
@@ -52,6 +53,7 @@ const assets = (config = Object.create(null)) => {
52
53
  definition: assetModuleDefinition,
53
54
  // storage: { namespace: 'customTokens' },
54
55
  aliases: [{ implementationId: 'customTokensStorage', interfaceId: 'storage' }],
56
+ config: assetsModule,
55
57
  },
56
58
  { definition: assetPreferencesDefinition },
57
59
  { if: { registered: ['customTokensStorage'] }, definition: customTokensMonitorDefinition },
@@ -26,6 +26,11 @@ import createFetchival from '@exodus/fetch/create-fetchival'
26
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
+ import {
30
+ createDynamicCombinedAssetDefinitions,
31
+ shouldTokenJoinCombinedAsset,
32
+ findDynamicCombinedAssetForToken,
33
+ } from './dynamic-combined-assets.js'
29
34
 
30
35
  // oxlint-disable-next-line @exodus/oxlint-plugin-restricted-imports/prefer-basic-utils
31
36
  const { get, isEmpty, once, uniq, chunk, pick } = lodash
@@ -79,6 +84,7 @@ export class AssetsModule {
79
84
  #fetchival
80
85
  #logger
81
86
  #invalidTokenNames
87
+ #useDynamicCombinedAssets
82
88
 
83
89
  constructor({
84
90
  storage,
@@ -118,6 +124,7 @@ export class AssetsModule {
118
124
  ),
119
125
  ...(config.invalidTokenNames || []),
120
126
  ])
127
+ this.#useDynamicCombinedAssets = config.useDynamicCombinedAssets ?? false
121
128
  }
122
129
 
123
130
  #setRegistry = (registry) => {
@@ -161,11 +168,22 @@ export class AssetsModule {
161
168
  console.warn(`Incorrectly referenced supported asset ${name}. Expected ${asset.name}.`)
162
169
  })
163
170
  .filter(Boolean)
171
+
172
+ const combinedAssets = this.#useDynamicCombinedAssets ? [] : this.#combinedAssetsList
173
+
164
174
  this.#setRegistry(
165
175
  createAssetRegistry({
166
- supportedAssetsList: [...assetsList, ...this.#combinedAssetsList],
176
+ supportedAssetsList: [...assetsList, ...combinedAssets],
167
177
  })
168
178
  )
179
+
180
+ // Only flush static combined assets - dynamic ones are flushed in load()
181
+ if (!this.#useDynamicCombinedAssets) {
182
+ const allCombined = Object.values(this.#registry.getAssets()).filter((a) => a.isCombined)
183
+ if (allCombined.length > 0) {
184
+ this.#flushChanges({ added: allCombined })
185
+ }
186
+ }
169
187
  })
170
188
 
171
189
  getAssets = () => this.#registry.getAssets()
@@ -188,6 +206,7 @@ export class AssetsModule {
188
206
  load = once(async () => {
189
207
  try {
190
208
  const storedTokens = await this.#readCustomTokens()
209
+
191
210
  const tokens = pickBy(storedTokens, ({ baseAssetName }) => {
192
211
  const baseAsset = this.getAsset(baseAssetName)
193
212
  return baseAsset?.api?.features?.customTokens
@@ -202,9 +221,34 @@ export class AssetsModule {
202
221
  this.#flushChanges({ added, updated })
203
222
  } catch (e) {
204
223
  this.#logger.error('error reading custom tokens from storage: ' + e.message, e)
224
+ } finally {
225
+ if (this.#useDynamicCombinedAssets) {
226
+ this.#createDynamicCombinedAssets()
227
+ }
205
228
  }
206
229
  })
207
230
 
231
+ #createDynamicCombinedAssets = () => {
232
+ const allAssets = this.getAssets()
233
+ const definitions = createDynamicCombinedAssetDefinitions(allAssets)
234
+
235
+ const newlyCreatedNames = []
236
+ for (const definition of definitions) {
237
+ const existingCombined = this.getAsset(definition.name)
238
+ if (existingCombined) continue
239
+
240
+ this.#registry.addCombinedAsset(definition)
241
+ newlyCreatedNames.push(definition.name)
242
+ }
243
+
244
+ if (newlyCreatedNames.length > 0) {
245
+ const newCombinedAssets = newlyCreatedNames.map((name) => this.getAsset(name)).filter(Boolean)
246
+ const childAssetNames = new Set(newCombinedAssets.flatMap((c) => c.combinedAssetNames))
247
+ const childAssets = [...childAssetNames].map((name) => this.getAsset(name)).filter(Boolean)
248
+ this.#flushChanges({ added: newCombinedAssets, updated: childAssets })
249
+ }
250
+ }
251
+
208
252
  // Custom tokens API
209
253
 
210
254
  fetchToken = async (assetId, baseAssetName) => {
@@ -316,16 +360,16 @@ export class AssetsModule {
316
360
 
317
361
  try {
318
362
  const token = await this.fetchToken(assetId, baseAssetName)
319
- const { asset, isAdded, updates } = this.#handleFetchedToken(token)
320
- const updated = updates.map(this.getAsset)
363
+ const { asset, isAdded, combinedUpdates, combinedCreated } = this.#handleFetchedToken(token)
364
+ const updated = combinedUpdates.map(this.getAsset)
365
+ const added = isAdded ? [asset, ...combinedCreated] : combinedCreated
321
366
 
322
367
  if (isAdded) {
323
368
  await this.#storeCustomTokens([token])
324
- this.#flushChanges({ added: [asset], updated })
325
- } else {
326
- this.#flushChanges({ updated: [asset, ...updated] })
327
369
  }
328
370
 
371
+ this.#flushChanges({ added, updated: isAdded ? updated : [asset, ...updated] })
372
+
329
373
  return asset
330
374
  } catch (err) {
331
375
  this.#logger.warn(`Add custom token ${assetId} error on ${baseAssetName}`, err.message)
@@ -577,6 +621,10 @@ export class AssetsModule {
577
621
  }
578
622
 
579
623
  #handleCombinedParents = ({ name, parents }) => {
624
+ if (this.#useDynamicCombinedAssets) {
625
+ return []
626
+ }
627
+
580
628
  if (!parents) return []
581
629
  assert(Array.isArray(parents), 'Array expected')
582
630
 
@@ -603,59 +651,121 @@ export class AssetsModule {
603
651
  return uniq(updatedCombinedAssetNames)
604
652
  }
605
653
 
654
+ #handleDynamicCombinedAssetUpdate = (token) => {
655
+ if (!this.#useDynamicCombinedAssets) return { updated: [], created: [], members: [] }
656
+ if (!shouldTokenJoinCombinedAsset(token)) return { updated: [], created: [], members: [] }
657
+
658
+ const allAssets = this.getAssets()
659
+ const combinedAssets = Object.values(allAssets).filter((a) => a.isCombined)
660
+ const matchingCombined = findDynamicCombinedAssetForToken(token, combinedAssets)
661
+
662
+ if (matchingCombined) {
663
+ if (!matchingCombined.combinedAssetNames.includes(token.name)) {
664
+ this.#registry.updateCombinedAsset(matchingCombined.name, { addMembers: [token.name] })
665
+ return { updated: [matchingCombined.name], created: [], members: [] }
666
+ }
667
+ } else {
668
+ const matchingAssets = Object.values(allAssets).filter(
669
+ (a) =>
670
+ !a.isCombined &&
671
+ a.displayTicker?.toLowerCase() === token.displayTicker?.toLowerCase() &&
672
+ a.displayName?.toLowerCase() === token.displayName?.toLowerCase() &&
673
+ shouldTokenJoinCombinedAsset(a)
674
+ )
675
+
676
+ if (matchingAssets.length >= 2) {
677
+ const definitions = createDynamicCombinedAssetDefinitions(matchingAssets)
678
+ if (definitions.length > 0) {
679
+ const newCombined = this.#registry.addCombinedAsset(definitions[0])
680
+ const memberNames = matchingAssets.map((a) => a.name).filter((n) => n !== token.name)
681
+ return { updated: [], created: [newCombined], members: memberNames }
682
+ }
683
+ }
684
+ }
685
+
686
+ return { updated: [], created: [], members: [] }
687
+ }
688
+
606
689
  #handleFetchedToken = (token) => {
607
690
  const asset = this.getAsset(token.name)
608
- if (asset) return { asset, isAdded: false, updates: [] }
691
+ if (asset) {
692
+ const dynamicResult = this.#handleDynamicCombinedAssetUpdate(asset)
693
+ return {
694
+ asset,
695
+ isAdded: false,
696
+ combinedUpdates: dynamicResult.updated,
697
+ combinedCreated: dynamicResult.created,
698
+ combinedMembers: dynamicResult.members,
699
+ }
700
+ }
609
701
 
610
- const { name } = this.#registry.addCustomToken(token) // add to registry
611
- const updates = this.#handleCombinedParents(token)
702
+ const { name } = this.#registry.addCustomToken(token)
703
+ const staticUpdates = this.#handleCombinedParents(token)
704
+ const dynamicResult = this.#handleDynamicCombinedAssetUpdate(this.getAsset(name))
705
+ const combinedUpdates = [...staticUpdates, ...dynamicResult.updated]
612
706
 
613
707
  this.#logger.log('Custom token added:', name)
614
- return { asset: this.getAsset(name), isAdded: true, updates }
708
+ return {
709
+ asset: this.getAsset(name),
710
+ isAdded: true,
711
+ combinedUpdates,
712
+ combinedCreated: dynamicResult.created,
713
+ combinedMembers: dynamicResult.members,
714
+ }
615
715
  }
616
716
 
617
717
  #handleFetchedTokens = (tokens) => {
618
718
  const fetchedAndHandledTokens = []
619
719
  const added = []
620
720
  const updated = []
621
- const parentNames = []
721
+ const combinedUpdateNames = []
722
+ const combinedCreated = []
723
+ const combinedMemberNames = []
724
+
622
725
  for (const token of tokens) {
623
726
  try {
624
- const { asset, isAdded, updates } = this.#handleFetchedToken(token)
727
+ const result = this.#handleFetchedToken(token)
625
728
 
626
- if (isAdded) {
627
- added.push(asset)
729
+ if (result.isAdded) {
730
+ added.push(result.asset)
628
731
  fetchedAndHandledTokens.push(token)
629
732
  } else {
630
- updated.push(asset)
733
+ updated.push(result.asset)
631
734
  }
632
735
 
633
- if (updates.length > 0) {
634
- parentNames.push(...updates)
736
+ if (result.combinedUpdates.length > 0) {
737
+ combinedUpdateNames.push(...result.combinedUpdates)
738
+ }
739
+
740
+ if (result.combinedCreated.length > 0) {
741
+ combinedCreated.push(...result.combinedCreated)
742
+ }
743
+
744
+ if (result.combinedMembers.length > 0) {
745
+ combinedMemberNames.push(...result.combinedMembers)
635
746
  }
636
747
  } catch (err) {
637
748
  this.#logger.error('Handle fetched custom tokens error: ' + err.message, err)
638
749
  }
639
750
  }
640
751
 
641
- updated.push(...uniq(parentNames).map(this.getAsset))
752
+ const createdNames = new Set(combinedCreated.map((a) => a.name))
753
+ const dedupedUpdateNames = uniq(combinedUpdateNames).filter((name) => !createdNames.has(name))
754
+ const dedupedMemberNames = uniq(combinedMemberNames).filter((name) => !createdNames.has(name))
755
+ updated.push(...dedupedUpdateNames.map(this.getAsset).filter(Boolean))
756
+ updated.push(...dedupedMemberNames.map(this.getAsset).filter(Boolean))
757
+ added.push(...combinedCreated.map((a) => this.getAsset(a.name)).filter(Boolean))
642
758
 
643
759
  return { fetchedAndHandledTokens, added, updated }
644
760
  }
645
761
 
646
- #fetchIcon = memoizeLruCache(
762
+ #fetchIconToken = memoizeLruCache(
647
763
  async (assetName) => {
648
- const tokenNames = [assetName]
649
-
650
- const tokens = await this.#fetch(
651
- 'tokens',
652
- { tokenNames, lifecycleStatus: ['c', 'v', 'u'] },
653
- 'tokens'
654
- )
655
- const token = tokens[0]
656
- if (token?.icon) {
657
- return this.#iconsStorage.unzipIcon(token.icon)
658
- }
764
+ const tokens = await this.#fetchTokensByNames({
765
+ tokenNames: [assetName],
766
+ allowedStatusList: ['c', 'v', 'u'],
767
+ })
768
+ return tokens[0]
659
769
  },
660
770
  (assetName) => assetName,
661
771
  { max: 100 }
@@ -666,11 +776,21 @@ export class AssetsModule {
666
776
 
667
777
  const asset = await this.getAsset(assetName)
668
778
  if (asset) {
669
- return this.#iconsStorage.getIcon(assetName)
779
+ try {
780
+ const storedIcon = await this.#iconsStorage.getIcon(assetName)
781
+ if (storedIcon) return storedIcon
782
+ } catch (e) {
783
+ this.#logger.warn(`Failed to read stored icon for ${assetName}. ${e.message}`, e)
784
+ }
785
+
786
+ if (asset.isBuiltIn) return
670
787
  }
671
788
 
672
789
  try {
673
- return await this.#fetchIcon(assetName)
790
+ const token = await this.#fetchIconToken(assetName)
791
+ if (!token?.icon) return
792
+ if (asset) await this.#storeIcons([token])
793
+ return this.#iconsStorage.unzipIcon(token.icon)
674
794
  } catch (e) {
675
795
  this.#logger.error(`Cannot load icon for ${assetName} from CTR. ${e.message}`, e)
676
796
  }
@@ -0,0 +1,106 @@
1
+ const TRUSTED_LIFECYCLE = new Set(['c'])
2
+ const MIN_ASSETS_TO_COMBINE = 2
3
+
4
+ const normalizeForKey = (str) => str.toLowerCase().trim()
5
+
6
+ const normalizeForName = (str) => str.toLowerCase().replace(/[^a-z0-9]/g, '')
7
+
8
+ const createGroupKey = (displayTicker, displayName) =>
9
+ `${normalizeForKey(displayTicker)}::${normalizeForKey(displayName)}`
10
+
11
+ const groupAssetsByTickerAndName = (assets) => {
12
+ const groups = Object.create(null)
13
+
14
+ for (const asset of assets) {
15
+ if (asset.isCombined || !asset.displayTicker || !asset.displayName) continue
16
+
17
+ const key = createGroupKey(asset.displayTicker, asset.displayName)
18
+ if (!groups[key]) groups[key] = []
19
+ groups[key].push(asset)
20
+ }
21
+
22
+ return groups
23
+ }
24
+
25
+ const selectPrimaryAsset = (assets) => {
26
+ const sorted = [...assets].sort((a, b) => a.name.localeCompare(b.name))
27
+ return sorted[0]
28
+ }
29
+
30
+ const resolveDecimals = (assets) => {
31
+ const decimals = assets.map((a) => a.currency?.defaultUnit?.power ?? 8)
32
+ return Math.max(...decimals)
33
+ }
34
+
35
+ const createDynamicCombinedAssetDefinition = (assetGroup) => {
36
+ const primary = selectPrimaryAsset(assetGroup)
37
+ const sortedAssets = [...assetGroup].sort((a, b) => a.name.localeCompare(b.name))
38
+
39
+ const normalizedTicker = normalizeForName(primary.displayTicker)
40
+ const normalizedDisplayName = normalizeForName(primary.displayName)
41
+ const decimals = resolveDecimals(assetGroup)
42
+
43
+ const name = `_combined_${normalizedTicker}_${normalizedDisplayName}`
44
+ const ticker = name.toUpperCase()
45
+
46
+ return {
47
+ name,
48
+ ticker,
49
+ baseAssetName: name,
50
+ displayName: primary.displayName,
51
+ displayTicker: primary.displayTicker,
52
+ assetType: 'MULTI_NETWORK_ASSET',
53
+ units: {
54
+ [`${name}_base`]: 0,
55
+ [ticker]: decimals,
56
+ },
57
+ combinedAssetNames: sortedAssets.map((a) => a.name),
58
+ info: primary.info || { description: '' },
59
+ primaryColor: primary.primaryColor || '#888888',
60
+ gradientColors: primary.gradientColors || ['#888888', '#666666'],
61
+ chainBadgeColors: primary.chainBadgeColors || primary.gradientColors || ['#888888', '#666666'],
62
+ }
63
+ }
64
+
65
+ export const isTrustedAsset = (asset) => {
66
+ // Built-in assets (bundled with app, not fetched from CTR) are trusted
67
+ if (asset.isBuiltIn === true && asset.isCustomToken !== true) return true
68
+
69
+ // Curated custom tokens from CTR are trusted (Exodus controls curation)
70
+ if (TRUSTED_LIFECYCLE.has(asset.lifecycleStatus)) return true
71
+
72
+ return false
73
+ }
74
+
75
+ export const createDynamicCombinedAssetDefinitions = (assets) => {
76
+ const assetList = Array.isArray(assets) ? assets : Object.values(assets)
77
+ const trustedAssets = assetList.filter(isTrustedAsset)
78
+ const groups = groupAssetsByTickerAndName(trustedAssets)
79
+
80
+ const definitions = []
81
+ for (const assetGroup of Object.values(groups)) {
82
+ if (assetGroup.length >= MIN_ASSETS_TO_COMBINE) {
83
+ definitions.push(createDynamicCombinedAssetDefinition(assetGroup))
84
+ }
85
+ }
86
+
87
+ return definitions
88
+ }
89
+
90
+ export const findDynamicCombinedAssetForToken = (token, existingCombinedAssets) => {
91
+ if (!token.displayTicker || !token.displayName) return null
92
+
93
+ const tokenKey = createGroupKey(token.displayTicker, token.displayName)
94
+
95
+ for (const combined of existingCombinedAssets) {
96
+ if (!combined.isCombined) continue
97
+ if (!combined.displayTicker || !combined.displayName) continue
98
+
99
+ const combinedKey = createGroupKey(combined.displayTicker, combined.displayName)
100
+ if (combinedKey === tokenKey) return combined
101
+ }
102
+
103
+ return null
104
+ }
105
+
106
+ export const shouldTokenJoinCombinedAsset = (token) => isTrustedAsset(token)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/assets-feature",
3
- "version": "9.2.6",
3
+ "version": "9.3.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",
@@ -40,11 +40,11 @@
40
40
  "@exodus/asset-schema-validation": "^1.2.0",
41
41
  "@exodus/asset-types": "^0.5.0",
42
42
  "@exodus/assets": "^11.3.0",
43
- "@exodus/atoms": "^10.3.3",
44
- "@exodus/basic-utils": "^5.0.0",
43
+ "@exodus/atoms": "^10.3.4",
44
+ "@exodus/basic-utils": "^5.2.0",
45
45
  "@exodus/fetch": "^1.3.0",
46
46
  "@exodus/fusion-atoms": "^1.4.0",
47
- "@exodus/timer": "^1.1.2",
47
+ "@exodus/timer": "^1.2.2",
48
48
  "lodash": "^4.17.21",
49
49
  "make-concurrent": "^5.4.0",
50
50
  "minimalistic-assert": "^1.0.1",
@@ -52,17 +52,16 @@
52
52
  "reselect": "^3.0.1"
53
53
  },
54
54
  "devDependencies": {
55
- "@exodus/available-assets": "^8.11.0",
55
+ "@exodus/available-assets": "^8.11.1",
56
56
  "@exodus/bip32": "^4.3.0",
57
57
  "@exodus/bip39": "^1.1.0",
58
- "@exodus/bip44-constants": "^195.0.0",
58
+ "@exodus/bip44-constants": "^195.0.2",
59
59
  "@exodus/bitcoin-meta": "^2.0.0",
60
60
  "@exodus/bitcoin-plugin": "^2.0.0",
61
61
  "@exodus/bitcoinregtest-plugin": "^2.0.0",
62
62
  "@exodus/bitcointestnet-plugin": "^2.0.0",
63
63
  "@exodus/blockchain-metadata": "^17.2.0",
64
64
  "@exodus/cardano-lib": "^4.0.0",
65
- "@exodus/combined-assets-meta": "^3.7.0",
66
65
  "@exodus/cosmos-plugin": "^1.3.3",
67
66
  "@exodus/ethereum-lib": "^5.0.0",
68
67
  "@exodus/ethereum-meta": "^2.0.0",
@@ -75,8 +74,8 @@
75
74
  "@exodus/public-key-provider": "^4.3.1",
76
75
  "@exodus/redux-dependency-injection": "^5.0.0",
77
76
  "@exodus/storage-memory": "^2.4.0",
78
- "@exodus/wallet-accounts": "^20.4.10",
79
- "@exodus/wild-emitter": "^1.0.0",
77
+ "@exodus/wallet-accounts": "^20.4.11",
78
+ "@exodus/wild-emitter": "^1.1.2",
80
79
  "events": "^3.3.0",
81
80
  "msw": "^2.0.0",
82
81
  "redux": "^4.0.0"
@@ -85,5 +84,5 @@
85
84
  "access": "public",
86
85
  "provenance": false
87
86
  },
88
- "gitHead": "07897763e7cbfa9cf251bd7c436537e059c9fbab"
87
+ "gitHead": "d98bbb7e2796db99847671fe1559b916ca23073d"
89
88
  }