@exodus/assets-feature 9.3.3 → 9.5.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,18 @@
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.5.0](https://github.com/ExodusMovement/exodus/compare/@exodus/assets-feature@9.4.0...@exodus/assets-feature@9.5.0) (2026-10-12)
7
+
8
+ ### Features
9
+
10
+ - **assets:** configure dynamic combined asset overrides ([#46749](https://github.com/ExodusMovement/exodus/issues/46749)) ([582f204](https://github.com/ExodusMovement/exodus/commit/582f20416509431e63b2d5a47160e4ff6a5296d1))
11
+
12
+ ## [9.4.0](https://github.com/ExodusMovement/exodus/compare/@exodus/assets-feature@9.3.3...@exodus/assets-feature@9.4.0) (2026-10-07)
13
+
14
+ ### Features
15
+
16
+ - **assets-feature:** add getRuntimeConfig to asset client interface ([#46606](https://github.com/ExodusMovement/exodus/issues/46606)) ([d4e0969](https://github.com/ExodusMovement/exodus/commit/d4e096901d753541928a9e6e8331a25a3765d375))
17
+
6
18
  ## [9.3.3](https://github.com/ExodusMovement/exodus/compare/@exodus/assets-feature@9.3.2...@exodus/assets-feature@9.3.3) (2026-09-11)
7
19
 
8
20
  ### Bug Fixes
package/README.md CHANGED
@@ -40,6 +40,8 @@ See [api/index.d.ts](./api/index.d.ts) for more details on the API and token val
40
40
 
41
41
  ### API Side
42
42
 
43
+ `assetsModule.dynamicCombinedAssetOverrides` maps a token's asset name to the asset whose name and ticker should identify its dynamic combined group. For example, `{ cbbtc_basemainnet_58df8ab4: 'bitcoin' }` combines Base cbBTC with Bitcoin while preserving cbBTC's metadata and transaction identity. The target is the primary member; both assets must be loaded and eligible for dynamic grouping. Until the target is available and eligible, the token remains available individually and does not fall back to name/ticker grouping. Static grouping is unchanged.
44
+
43
45
  See [using the sdk](../../docs/development/using-the-sdk.md#setup-the-api-side) for more details on how features plug into the SDK and the API interface in the [type declaration](./api/index.d.ts).
44
46
 
45
47
  ### UI Side
@@ -1,4 +1,4 @@
1
- import { filterAsync, pickBy } from '@exodus/basic-utils'
1
+ import { filterAsync, pickBy, set } from '@exodus/basic-utils'
2
2
  import assert from 'minimalistic-assert'
3
3
 
4
4
  class AssetClientInterface {
@@ -16,6 +16,7 @@ class AssetClientInterface {
16
16
  #transactionSigner
17
17
  #walletAccountsAtom
18
18
  #multisigAtom
19
+ #featureFlagAtoms
19
20
  #freshlyCreated = false
20
21
 
21
22
  constructor({
@@ -33,6 +34,7 @@ class AssetClientInterface {
33
34
  transactionSigner,
34
35
  walletAccountsAtom,
35
36
  multisigAtom,
37
+ featureFlagAtoms,
36
38
  }) {
37
39
  this.#addressProvider = addressProvider
38
40
  this.#assetsModule = assetsModule
@@ -48,6 +50,7 @@ class AssetClientInterface {
48
50
  this.#transactionSigner = transactionSigner
49
51
  this.#walletAccountsAtom = walletAccountsAtom
50
52
  this.#multisigAtom = multisigAtom
53
+ this.#featureFlagAtoms = featureFlagAtoms
51
54
  }
52
55
 
53
56
  setFreshlyCreated = (value) => {
@@ -210,6 +213,30 @@ class AssetClientInterface {
210
213
  return out
211
214
  }
212
215
 
216
+ getRuntimeConfig = async ({ assetName }) => {
217
+ const asset = this.#assetsModule.getAsset(assetName)
218
+ assert(asset, `assetName ${assetName} is not supported`)
219
+ const runtimeConfigFlags = this.#config?.runtimeConfigFlags ?? Object.create(null)
220
+ const flagsByPath = {
221
+ ...runtimeConfigFlags[asset.baseAsset.name],
222
+ ...runtimeConfigFlags[asset.name],
223
+ }
224
+ if (!this.#featureFlagAtoms || Object.keys(flagsByPath).length === 0) return {}
225
+
226
+ const flagValuesByPath = await Promise.all(
227
+ Object.entries(flagsByPath).map(async ([path, flag]) => {
228
+ const featureFlagAtom = this.#featureFlagAtoms[flag]
229
+ assert(featureFlagAtom, `feature flag ${flag} is not registered`)
230
+ const featureFlag = await featureFlagAtom.get()
231
+ return [path, featureFlag?.isOn === true]
232
+ })
233
+ )
234
+
235
+ const runtimeConfig = {}
236
+ for (const [path, isOn] of flagValuesByPath) set(runtimeConfig, path, isOn)
237
+ return runtimeConfig
238
+ }
239
+
213
240
  getConfirmationsNumber = async ({ assetName }) => {
214
241
  const baseAsset = this.#assetsModule.getAsset(assetName).baseAsset
215
242
  return baseAsset.api?.getConfirmationsNumber ? baseAsset.api.getConfirmationsNumber() : 1
package/client/index.js CHANGED
@@ -20,6 +20,7 @@ const assetsClientInterfaceDefinition = {
20
20
  'wallet',
21
21
  'walletAccountsAtom',
22
22
  'multisigAtom?',
23
+ 'featureFlagAtoms?',
23
24
  ],
24
25
  public: true,
25
26
  }
package/constants.js CHANGED
@@ -8,6 +8,7 @@ export const disabledPurposesDefaultConfig = {
8
8
 
9
9
  export const assetsModuleDefaultConfig = {
10
10
  useDynamicCombinedAssets: false,
11
+ dynamicCombinedAssetOverrides: Object.create(null),
11
12
  }
12
13
 
13
14
  export const defaultConfig = {
package/index.d.ts CHANGED
@@ -3,7 +3,19 @@ import type assetsApiDefinition from './api/index.js'
3
3
  export type { AssetsModule } from './module/assets-module.js'
4
4
  export { TokenStatus } from './api/index.js'
5
5
 
6
- declare const assets: () => {
6
+ export type RuntimeConfigFlags = Record<string, Record<string, string>>
7
+
8
+ export type AssetsConfig = {
9
+ multiAddressMode?: { defaults?: Record<string, boolean> }
10
+ disabledPurposes?: { defaults?: Record<string, number[]> }
11
+ compatibilityModeGapLimits?: Record<string, number>
12
+ compatibilityModeMultiAddressMode?: Record<string, boolean>
13
+ runtimeConfigFlags?: RuntimeConfigFlags
14
+ searchBasedAssetLoaderPlugin?: unknown
15
+ assetsModule?: Record<string, unknown>
16
+ }
17
+
18
+ declare const assets: (config?: AssetsConfig) => {
7
19
  id: 'assets'
8
20
  definitions: [{ definition: typeof assetsApiDefinition }]
9
21
  }
package/index.js CHANGED
@@ -18,6 +18,7 @@ const assets = (config = Object.create(null)) => {
18
18
  disabledPurposes,
19
19
  compatibilityModeGapLimits,
20
20
  compatibilityModeMultiAddressMode,
21
+ runtimeConfigFlags,
21
22
  searchBasedAssetLoaderPlugin,
22
23
  assetsModule,
23
24
  } = { ...defaultConfig, ...config }
@@ -30,6 +31,7 @@ const assets = (config = Object.create(null)) => {
30
31
  config: {
31
32
  compatibilityModeGapLimits,
32
33
  compatibilityModeMultiAddressMode,
34
+ runtimeConfigFlags,
33
35
  },
34
36
  },
35
37
  { definition: assetsPluginDefinition },
@@ -4,7 +4,7 @@ import {
4
4
  CT_STATUS as STATUS,
5
5
  CT_UPDATEABLE_PROPERTIES,
6
6
  } from '@exodus/assets'
7
- import { keyBy, mapValues, partition, pickBy, difference } from '@exodus/basic-utils'
7
+ import { keyBy, mapValues, partition, pickBy, difference, pick } from '@exodus/basic-utils'
8
8
  // eslint-disable-next-line no-restricted-imports -- TODO: Fix this the next time the file is edited.
9
9
  import lodash from 'lodash'
10
10
  import assert from 'minimalistic-assert'
@@ -30,10 +30,10 @@ import {
30
30
  createDynamicCombinedAssetDefinitions,
31
31
  shouldTokenJoinCombinedAsset,
32
32
  findDynamicCombinedAssetForToken,
33
+ getDynamicCombinedAssetKey,
33
34
  } from './dynamic-combined-assets.js'
34
35
 
35
- // oxlint-disable-next-line @exodus/oxlint-plugin-restricted-imports/prefer-basic-utils
36
- const { get, isEmpty, once, uniq, chunk, pick } = lodash
36
+ const { get, isEmpty, once, uniq, chunk } = lodash
37
37
 
38
38
  const getFetchCacheKey = (baseAssetName, assetId) => `${assetId}-${baseAssetName}`
39
39
 
@@ -85,6 +85,7 @@ export class AssetsModule {
85
85
  #logger
86
86
  #invalidTokenNames
87
87
  #useDynamicCombinedAssets
88
+ #dynamicCombinedAssetOverrides
88
89
 
89
90
  constructor({
90
91
  storage,
@@ -125,6 +126,8 @@ export class AssetsModule {
125
126
  ...(config.invalidTokenNames || []),
126
127
  ])
127
128
  this.#useDynamicCombinedAssets = config.useDynamicCombinedAssets ?? false
129
+ this.#dynamicCombinedAssetOverrides =
130
+ config.dynamicCombinedAssetOverrides ?? Object.create(null)
128
131
  }
129
132
 
130
133
  #setRegistry = (registry) => {
@@ -242,7 +245,9 @@ export class AssetsModule {
242
245
 
243
246
  #createDynamicCombinedAssets = () => {
244
247
  const allAssets = this.getAssets()
245
- const definitions = createDynamicCombinedAssetDefinitions(allAssets)
248
+ const definitions = createDynamicCombinedAssetDefinitions(allAssets, {
249
+ overrides: this.#dynamicCombinedAssetOverrides,
250
+ })
246
251
 
247
252
  const newlyCreatedNames = []
248
253
  for (const definition of definitions) {
@@ -671,7 +676,8 @@ export class AssetsModule {
671
676
 
672
677
  const allAssets = this.getAssets()
673
678
  const combinedAssets = Object.values(allAssets).filter((a) => a.isCombined)
674
- const matchingCombined = findDynamicCombinedAssetForToken(token, combinedAssets)
679
+ const options = { assets: allAssets, overrides: this.#dynamicCombinedAssetOverrides }
680
+ const matchingCombined = findDynamicCombinedAssetForToken(token, combinedAssets, options)
675
681
 
676
682
  if (matchingCombined) {
677
683
  if (!matchingCombined.combinedAssetNames.includes(token.name)) {
@@ -679,16 +685,17 @@ export class AssetsModule {
679
685
  return { updated: [matchingCombined.name], created: [], members: [] }
680
686
  }
681
687
  } else {
688
+ const tokenKey = getDynamicCombinedAssetKey(token, options)
689
+ if (!tokenKey) return { updated: [], created: [], members: [] }
682
690
  const matchingAssets = Object.values(allAssets).filter(
683
691
  (a) =>
684
692
  !a.isCombined &&
685
- a.displayTicker?.toLowerCase() === token.displayTicker?.toLowerCase() &&
686
- a.displayName?.toLowerCase() === token.displayName?.toLowerCase() &&
693
+ getDynamicCombinedAssetKey(a, options) === tokenKey &&
687
694
  shouldTokenJoinCombinedAsset(a)
688
695
  )
689
696
 
690
697
  if (matchingAssets.length >= 2) {
691
- const definitions = createDynamicCombinedAssetDefinitions(matchingAssets)
698
+ const definitions = createDynamicCombinedAssetDefinitions(matchingAssets, options)
692
699
  if (definitions.length > 0) {
693
700
  const newCombined = this.#registry.addCombinedAsset(definitions[0])
694
701
  const memberNames = matchingAssets.map((a) => a.name).filter((n) => n !== token.name)
@@ -8,13 +8,29 @@ const normalizeForName = (str) => str.toLowerCase().replace(/[^a-z0-9]/g, '')
8
8
  const createGroupKey = (displayTicker, displayName) =>
9
9
  `${normalizeForKey(displayTicker)}::${normalizeForKey(displayName)}`
10
10
 
11
- const groupAssetsByTickerAndName = (assets) => {
11
+ const getGroupingAsset = (asset, { assets = {}, overrides = Object.create(null) } = {}) => {
12
+ if (!Object.hasOwn(overrides, asset.name)) return asset
13
+
14
+ const target = assets[overrides[asset.name]]
15
+ return target && !target.isCombined && shouldTokenJoinCombinedAsset(target) ? target : null
16
+ }
17
+
18
+ export const getDynamicCombinedAssetKey = (asset, options) => {
19
+ const groupingAsset = getGroupingAsset(asset, options)
20
+ if (!groupingAsset) return null
21
+
22
+ const { displayTicker, displayName } = groupingAsset
23
+ return displayTicker && displayName ? createGroupKey(displayTicker, displayName) : null
24
+ }
25
+
26
+ const groupAssetsByTickerAndName = (assets, options) => {
12
27
  const groups = Object.create(null)
13
28
 
14
29
  for (const asset of assets) {
15
- if (asset.isCombined || !asset.displayTicker || !asset.displayName) continue
30
+ if (asset.isCombined) continue
16
31
 
17
- const key = createGroupKey(asset.displayTicker, asset.displayName)
32
+ const key = getDynamicCombinedAssetKey(asset, options)
33
+ if (!key) continue
18
34
  if (!groups[key]) groups[key] = []
19
35
  groups[key].push(asset)
20
36
  }
@@ -31,9 +47,12 @@ const resolveDecimals = (assets) => {
31
47
  return Math.max(...decimals)
32
48
  }
33
49
 
34
- const createDynamicCombinedAssetDefinition = (assetGroup) => {
50
+ const createDynamicCombinedAssetDefinition = (assetGroup, { overrides }) => {
35
51
  const sortedAssets = [...assetGroup].sort(byName)
36
- const [primary] = sortedAssets
52
+ const targetNames = new Set(Object.values(overrides))
53
+ const overrideTarget = sortedAssets.find((asset) => targetNames.has(asset.name))
54
+ const primary = overrideTarget ?? sortedAssets[0]
55
+ const members = [primary, ...sortedAssets.filter((asset) => asset !== primary)]
37
56
 
38
57
  const normalizedTicker = normalizeForName(primary.displayTicker)
39
58
  const normalizedDisplayName = normalizeForName(primary.displayName)
@@ -53,7 +72,7 @@ const createDynamicCombinedAssetDefinition = (assetGroup) => {
53
72
  [`${name}_base`]: 0,
54
73
  [ticker]: decimals,
55
74
  },
56
- combinedAssetNames: sortedAssets.map((a) => a.name),
75
+ combinedAssetNames: members.map((a) => a.name),
57
76
  info: primary.info || { description: '' },
58
77
  primaryColor: primary.primaryColor || '#888888',
59
78
  gradientColors: primary.gradientColors || ['#888888', '#666666'],
@@ -71,27 +90,33 @@ export const isTrustedAsset = (asset) => {
71
90
  return false
72
91
  }
73
92
 
74
- export const createDynamicCombinedAssetDefinitions = (assets) => {
93
+ export const createDynamicCombinedAssetDefinitions = (
94
+ assets,
95
+ { overrides = Object.create(null) } = {}
96
+ ) => {
75
97
  const assetList = Array.isArray(assets) ? assets : Object.values(assets)
98
+ const options = {
99
+ assets: Object.fromEntries(assetList.map((asset) => [asset.name, asset])),
100
+ overrides,
101
+ }
76
102
  const trustedAssets = assetList.filter(
77
103
  (asset) => isTrustedAsset(asset) && !isTestnetVariant(asset)
78
104
  )
79
- const groups = groupAssetsByTickerAndName(trustedAssets)
105
+ const groups = groupAssetsByTickerAndName(trustedAssets, options)
80
106
 
81
107
  const definitions = []
82
108
  for (const assetGroup of Object.values(groups)) {
83
109
  if (assetGroup.length >= MIN_ASSETS_TO_COMBINE) {
84
- definitions.push(createDynamicCombinedAssetDefinition(assetGroup))
110
+ definitions.push(createDynamicCombinedAssetDefinition(assetGroup, options))
85
111
  }
86
112
  }
87
113
 
88
114
  return definitions
89
115
  }
90
116
 
91
- export const findDynamicCombinedAssetForToken = (token, existingCombinedAssets) => {
92
- if (!token.displayTicker || !token.displayName) return null
93
-
94
- const tokenKey = createGroupKey(token.displayTicker, token.displayName)
117
+ export const findDynamicCombinedAssetForToken = (token, existingCombinedAssets, options) => {
118
+ const tokenKey = getDynamicCombinedAssetKey(token, options)
119
+ if (!tokenKey) return null
95
120
 
96
121
  for (const combined of existingCombinedAssets) {
97
122
  if (!combined.isCombined) continue
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/assets-feature",
3
- "version": "9.3.3",
3
+ "version": "9.5.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,8 +40,8 @@
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.4",
44
- "@exodus/basic-utils": "^5.2.0",
43
+ "@exodus/atoms": "^10.3.5",
44
+ "@exodus/basic-utils": "^5.2.1",
45
45
  "@exodus/fetch": "^1.3.0",
46
46
  "@exodus/fusion-atoms": "^1.4.0",
47
47
  "@exodus/timer": "^1.2.2",
@@ -52,29 +52,27 @@
52
52
  "reselect": "^3.0.1"
53
53
  },
54
54
  "devDependencies": {
55
- "@exodus/available-assets": "^8.11.2",
56
- "@exodus/bip32": "^4.3.0",
57
- "@exodus/bip39": "^1.1.0",
55
+ "@exodus/bip32": "^4.3.1",
56
+ "@exodus/bip39": "^1.1.1",
58
57
  "@exodus/bip44-constants": "^195.0.2",
59
58
  "@exodus/bitcoin-meta": "^2.0.0",
60
59
  "@exodus/bitcoin-plugin": "^2.0.0",
61
60
  "@exodus/bitcoinregtest-plugin": "^2.0.0",
62
61
  "@exodus/bitcointestnet-plugin": "^2.0.0",
63
- "@exodus/blockchain-metadata": "^17.2.0",
64
62
  "@exodus/cardano-lib": "^4.0.0",
65
63
  "@exodus/cosmos-plugin": "^1.3.3",
66
- "@exodus/ethereum-lib": "^5.0.0",
64
+ "@exodus/ethereum-lib": "^5.26.0",
67
65
  "@exodus/ethereum-meta": "^2.0.0",
68
- "@exodus/ethereum-plugin": "^2.7.4",
69
- "@exodus/fusion-local": "^2.1.0",
70
- "@exodus/keychain": "^12.0.0",
66
+ "@exodus/ethereum-plugin": "^3.1.1",
67
+ "@exodus/fusion-local": "^2.1.1",
68
+ "@exodus/keychain": "^12.1.0",
71
69
  "@exodus/logger": "^1.2.3",
72
- "@exodus/models": "^13.3.1",
70
+ "@exodus/models": "^13.3.2",
73
71
  "@exodus/osmosis-plugin": "^1.3.3",
74
- "@exodus/public-key-provider": "^4.3.1",
72
+ "@exodus/public-key-provider": "^4.3.2",
75
73
  "@exodus/redux-dependency-injection": "^5.0.0",
76
- "@exodus/storage-memory": "^2.4.0",
77
- "@exodus/wallet-accounts": "^20.4.11",
74
+ "@exodus/storage-memory": "^2.4.1",
75
+ "@exodus/wallet-accounts": "^20.4.12",
78
76
  "@exodus/wild-emitter": "^1.1.2",
79
77
  "events": "^3.3.0",
80
78
  "msw": "^2.0.0",
@@ -84,5 +82,5 @@
84
82
  "access": "public",
85
83
  "provenance": false
86
84
  },
87
- "gitHead": "3f88b975403ac684cd7c23b7559fecbb797d83ad"
85
+ "gitHead": "e5f91abf792fc2cdeb62bd7d047976f20b5d017b"
88
86
  }