@metamask-previews/assets-controller 16.0.0-preview-ff6e6f34a → 16.0.0-preview-95104ffdb

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.
Files changed (38) hide show
  1. package/CHANGELOG.md +4 -10
  2. package/dist/AssetsController.d.ts +3 -7
  3. package/dist/AssetsController.d.ts.map +1 -1
  4. package/dist/AssetsController.js +240 -671
  5. package/dist/AssetsController.js.map +1 -1
  6. package/dist/data-sources/AbstractDataSource.d.ts +1 -13
  7. package/dist/data-sources/AbstractDataSource.d.ts.map +1 -1
  8. package/dist/data-sources/AbstractDataSource.js +0 -23
  9. package/dist/data-sources/AbstractDataSource.js.map +1 -1
  10. package/dist/data-sources/AccountActivityDataSource.d.ts.map +1 -1
  11. package/dist/data-sources/AccountActivityDataSource.js +1 -2
  12. package/dist/data-sources/AccountActivityDataSource.js.map +1 -1
  13. package/dist/data-sources/AccountsApiDataSource.d.ts +2 -18
  14. package/dist/data-sources/AccountsApiDataSource.d.ts.map +1 -1
  15. package/dist/data-sources/AccountsApiDataSource.js +41 -165
  16. package/dist/data-sources/AccountsApiDataSource.js.map +1 -1
  17. package/dist/data-sources/RpcDataSource.d.ts +0 -17
  18. package/dist/data-sources/RpcDataSource.d.ts.map +1 -1
  19. package/dist/data-sources/RpcDataSource.js +23 -200
  20. package/dist/data-sources/RpcDataSource.js.map +1 -1
  21. package/dist/data-sources/evm-rpc-services/services/BalanceFetcher.d.ts +2 -7
  22. package/dist/data-sources/evm-rpc-services/services/BalanceFetcher.d.ts.map +1 -1
  23. package/dist/data-sources/evm-rpc-services/services/BalanceFetcher.js +12 -23
  24. package/dist/data-sources/evm-rpc-services/services/BalanceFetcher.js.map +1 -1
  25. package/dist/data-sources/evm-rpc-services/types/state.d.ts +2 -3
  26. package/dist/data-sources/evm-rpc-services/types/state.d.ts.map +1 -1
  27. package/dist/data-sources/evm-rpc-services/types/state.js.map +1 -1
  28. package/dist/middlewares/ParallelMiddleware.d.ts.map +1 -1
  29. package/dist/middlewares/ParallelMiddleware.js +0 -9
  30. package/dist/middlewares/ParallelMiddleware.js.map +1 -1
  31. package/dist/middlewares/RpcFallbackMiddleware.d.ts +14 -10
  32. package/dist/middlewares/RpcFallbackMiddleware.d.ts.map +1 -1
  33. package/dist/middlewares/RpcFallbackMiddleware.js +123 -157
  34. package/dist/middlewares/RpcFallbackMiddleware.js.map +1 -1
  35. package/dist/types.d.ts +24 -28
  36. package/dist/types.d.ts.map +1 -1
  37. package/dist/types.js.map +1 -1
  38. package/package.json +4 -4
@@ -13,7 +13,7 @@ import { RpcDataSource } from './data-sources/RpcDataSource.js';
13
13
  import { SnapDataSource } from './data-sources/SnapDataSource.js';
14
14
  import { StakedBalanceDataSource } from './data-sources/StakedBalanceDataSource.js';
15
15
  import { CaipAssetNamespace, TokenDataSource, } from './data-sources/TokenDataSource.js';
16
- import { CHAINS_WITH_DEFAULT_TRACKED_ASSETS, DEFAULT_TRACKED_ASSETS_BY_CHAIN, buildDefaultAssetsInfo, getDefaultAssetMetadata, getDefaultTrackedAssetsForChain, } from './defaults.js';
16
+ import { CHAINS_WITH_DEFAULT_TRACKED_ASSETS, DEFAULT_TRACKED_ASSETS_BY_CHAIN, buildDefaultAssetsInfo, getDefaultAssetMetadata, } from './defaults.js';
17
17
  import { AssetsDataSourceError } from './errors.js';
18
18
  import { projectLogger, createModuleLogger } from './logger.js';
19
19
  import { CustomAssetGraduationMiddleware } from './middlewares/CustomAssetGraduationMiddleware.js';
@@ -189,10 +189,6 @@ function normalizeResponse(response) {
189
189
  if (response.replaceCoveredChainBalances) {
190
190
  normalized.replaceCoveredChainBalances = true;
191
191
  }
192
- if (response.unprocessedCustomAssets &&
193
- response.unprocessedCustomAssets.length > 0) {
194
- normalized.unprocessedCustomAssets = response.unprocessedCustomAssets.map((assetId) => normalizeAssetId(assetId));
195
- }
196
192
  return normalized;
197
193
  }
198
194
  /**
@@ -200,13 +196,12 @@ function normalizeResponse(response) {
200
196
  *
201
197
  * @param previousBalances - Balances already in state for this account.
202
198
  * @param accountBalances - Balances from the incoming response.
203
- * @param customAssetIds - Custom assets to preserve when replacing covered chains
204
- * on the Accounts API v5 path (`replaceCoveredChainBalances`).
205
- * @param replaceCoveredChains - When true (v5 force refresh), drop prior balances
206
- * on chains present in the response before applying it, then restore custom assets.
199
+ * @param customAssetIds - Custom assets to preserve when replacing covered chains.
200
+ * @param replaceCoveredChains - When true, drop prior balances on chains present
201
+ * in the response before applying it (authoritative chain slice).
207
202
  * @returns The merged balance map for the account.
208
203
  */
209
- function mergeAccountBalancesV5(previousBalances, accountBalances, customAssetIds = [], replaceCoveredChains = false) {
204
+ function mergeAccountBalances(previousBalances, accountBalances, customAssetIds, replaceCoveredChains) {
210
205
  if (!replaceCoveredChains) {
211
206
  return { ...previousBalances, ...accountBalances };
212
207
  }
@@ -220,8 +215,8 @@ function mergeAccountBalancesV5(previousBalances, accountBalances, customAssetId
220
215
  Object.assign(next, accountBalances);
221
216
  for (const customId of customAssetIds) {
222
217
  if (!Object.prototype.hasOwnProperty.call(next, customId)) {
223
- const previous = previousBalances[customId];
224
- next[customId] = previous ?? { amount: '0' };
218
+ const prev = previousBalances[customId];
219
+ next[customId] = prev ?? { amount: '0' };
225
220
  }
226
221
  }
227
222
  // Staked vault balances are owned by StakedBalanceDataSource. When an
@@ -235,38 +230,6 @@ function mergeAccountBalancesV5(previousBalances, accountBalances, customAssetId
235
230
  }
236
231
  return next;
237
232
  }
238
- /**
239
- * Apply an Accounts API v6 authoritative chain snapshot.
240
- *
241
- * @param previousBalances - Balances already in state for this account.
242
- * @param accountBalances - Balances from the incoming response.
243
- * @param unprocessedCustomAssets - Pins that v6 and RPC could not resolve.
244
- * @returns The merged balance map for the account.
245
- */
246
- function mergeAccountBalancesV6(previousBalances, accountBalances, unprocessedCustomAssets = []) {
247
- const coveredChains = new Set(Object.keys(accountBalances).map((assetId) => assetId.split('/')[0]));
248
- const next = {};
249
- for (const [assetId, balance] of Object.entries(previousBalances)) {
250
- if (!coveredChains.has(assetId.split('/')[0])) {
251
- next[assetId] = balance;
252
- }
253
- }
254
- Object.assign(next, accountBalances);
255
- const unprocessedSet = new Set(unprocessedCustomAssets.map((assetId) => normalizeAssetId(assetId)));
256
- for (const [assetId, balance] of Object.entries(previousBalances)) {
257
- if (unprocessedSet.has(normalizeAssetId(assetId)) &&
258
- !Object.prototype.hasOwnProperty.call(next, assetId)) {
259
- next[assetId] = balance;
260
- }
261
- }
262
- for (const [assetId, balance] of Object.entries(previousBalances)) {
263
- if (isStakingContractAssetId(assetId) &&
264
- !Object.prototype.hasOwnProperty.call(next, assetId)) {
265
- next[assetId] = balance;
266
- }
267
- }
268
- return next;
269
- }
270
233
  // ============================================================================
271
234
  // CONTROLLER IMPLEMENTATION
272
235
  // ============================================================================
@@ -502,7 +465,6 @@ export class AssetsController extends BaseController {
502
465
  queryApiClient,
503
466
  onActiveChainsUpdated: this.#onActiveChainsUpdated,
504
467
  ...accountsApiDataSourceConfig,
505
- isBalanceV6Enabled: () => this.#isBalanceV6Enabled(),
506
468
  });
507
469
  this.#snapDataSource = new SnapDataSource({
508
470
  messenger: this.messenger,
@@ -522,7 +484,6 @@ export class AssetsController extends BaseController {
522
484
  ...rpcConfig,
523
485
  isOnboarded: rpcConfig.isOnboarded ?? isOnboarded,
524
486
  getAssetType: (assetId) => this.#getAssetType(assetId),
525
- isBalanceV6Enabled: () => this.#isBalanceV6Enabled(),
526
487
  });
527
488
  this.#stakedBalanceDataSource = new StakedBalanceDataSource({
528
489
  messenger: this.messenger,
@@ -555,7 +516,6 @@ export class AssetsController extends BaseController {
555
516
  });
556
517
  this.#rpcFallbackMiddleware = new RpcFallbackMiddleware({
557
518
  rpcDataSource: this.#rpcDataSource,
558
- isBalanceV6Enabled: () => this.#isBalanceV6Enabled(),
559
519
  });
560
520
  log('Initializing AssetsController', {
561
521
  defaultUpdateInterval: this.#defaultUpdateInterval,
@@ -997,310 +957,152 @@ export class AssetsController extends BaseController {
997
957
  if (accounts.length === 0 || chainIds.length === 0) {
998
958
  return this.#getAssetsFromState(accounts, chainIds, assetTypes);
999
959
  }
960
+ // Collect custom assets for all requested accounts
961
+ const customAssets = [];
962
+ for (const account of accounts) {
963
+ const accountCustomAssets = this.getCustomAssets(account.id);
964
+ customAssets.push(...accountCustomAssets);
965
+ }
1000
966
  if (options?.forceUpdate) {
967
+ // Pipeline spans only on unlock/first-init fetch; later forceUpdates pass
968
+ // `undefined` so `emitTrace` / `withTrace` no-op without call-site if/else.
1001
969
  const pipelineTrace = this.#firstInitFetchReported
1002
970
  ? undefined
1003
971
  : this.#trace;
1004
- const requestOptions = {
972
+ const request = this.#buildDataRequest(accounts, chainIds, {
1005
973
  assetTypes,
1006
974
  dataTypes,
975
+ customAssets: customAssets.length > 0 ? customAssets : undefined,
1007
976
  forceUpdate: true,
1008
977
  bypassServerCache: options?.bypassServerCache,
1009
978
  assetsForPriceUpdate: options?.assetsForPriceUpdate,
1010
- };
1011
- if (this.#isBalanceV6Enabled()) {
1012
- await this.#forceUpdateAssetsV6({
1013
- accounts,
1014
- chainIds,
1015
- request: this.#buildForceUpdateRequestV6(accounts, chainIds, {
1016
- ...requestOptions,
1017
- customAssetsOverride: options?.customAssets,
1018
- }),
1019
- pipelineTrace,
1020
- });
1021
- }
1022
- else {
1023
- await this.#forceUpdateAssetsV5({
1024
- accounts,
1025
- chainIds,
1026
- request: this.#buildForceUpdateRequestV5(accounts, chainIds, requestOptions),
1027
- pipelineTrace,
1028
- });
979
+ });
980
+ // Fast pipeline: accountsApi + stakedBalance → detection → token + price.
981
+ // Snap and RPC are excluded here due to their latency (snap triggers account
982
+ // creation, RPC is slow on many chains). Results are committed to state
983
+ // immediately so the UI can display balances without waiting for them.
984
+ //
985
+ // Fast/slow pipelines use merge so partial API snapshots cannot wipe
986
+ // tokens missing from the response (e.g. USDC when only native balance
987
+ // is returned). Balances present in the response are still refreshed.
988
+ const fastSources = this.#isBasicFunctionality()
989
+ ? [
990
+ createParallelBalanceMiddleware([
991
+ this.#accountsApiDataSource,
992
+ this.#stakedBalanceDataSource,
993
+ ]),
994
+ // Graduation must run BEFORE the RPC fallback so it only sees
995
+ // AccountsApi/Websocket balances. RPC intentionally carries
996
+ // custom assets and must never trigger graduation.
997
+ this.#customAssetGraduationMiddleware,
998
+ this.#rpcFallbackMiddleware,
999
+ this.#detectionMiddleware,
1000
+ createParallelMiddleware([
1001
+ this.#tokenDataSource,
1002
+ this.#priceDataSource,
1003
+ ]),
1004
+ ]
1005
+ : [this.#stakedBalanceDataSource, this.#detectionMiddleware];
1006
+ const { response } = await withTrace({
1007
+ name: TRACE_FETCH_PIPELINE,
1008
+ trace: pipelineTrace,
1009
+ data: {
1010
+ chain_count: chainIds.length,
1011
+ account_count: accounts.length,
1012
+ basic_functionality: this.#isBasicFunctionality(),
1013
+ },
1014
+ fn: async (parentContext) => {
1015
+ const startTime = performance.now();
1016
+ const result = await this.#executeMiddlewares({
1017
+ sources: fastSources,
1018
+ request,
1019
+ parentContext,
1020
+ trace: pipelineTrace,
1021
+ });
1022
+ await this.#updateState({
1023
+ ...result.response,
1024
+ updateMode: 'merge',
1025
+ replaceCoveredChainBalances: true,
1026
+ });
1027
+ const durationMs = performance.now() - startTime;
1028
+ // Summary fields for Assets Health (nested under the parent span).
1029
+ emitTrace({
1030
+ name: TRACE_FULL_FETCH,
1031
+ trace: pipelineTrace,
1032
+ data: {
1033
+ duration_ms: durationMs,
1034
+ chain_count: chainIds.length,
1035
+ account_count: accounts.length,
1036
+ basic_functionality: this.#isBasicFunctionality(),
1037
+ asset_count: result.response.assetsBalance
1038
+ ? Object.values(result.response.assetsBalance).reduce((sum, acct) => sum + Object.keys(acct).length, 0)
1039
+ : 0,
1040
+ price_count: result.response.assetsPrice
1041
+ ? Object.keys(result.response.assetsPrice).length
1042
+ : 0,
1043
+ ...result.durationByDataSource,
1044
+ },
1045
+ parentContext,
1046
+ });
1047
+ emitTrace({
1048
+ name: TRACE_FIRST_INIT_FETCH,
1049
+ trace: pipelineTrace,
1050
+ data: {
1051
+ duration_ms: durationMs,
1052
+ chain_ids: JSON.stringify(chainIds),
1053
+ ...result.durationByDataSource,
1054
+ },
1055
+ parentContext,
1056
+ });
1057
+ return result;
1058
+ },
1059
+ });
1060
+ // Mark after the unlock/first forceUpdate so later polls skip pipeline spans.
1061
+ this.#firstInitFetchReported = true;
1062
+ // Background (slow) lane — flattened sibling of the fast lane (not nested
1063
+ // inside its withTrace callback). Still fire-and-forget so UI is not blocked.
1064
+ const slowPipelineChainIds = this.#getSlowPipelineChainIds(chainIds, response);
1065
+ if (slowPipelineChainIds.length > 0) {
1066
+ const slowSources = this.#isBasicFunctionality()
1067
+ ? [this.#snapDataSource, this.#rpcDataSource]
1068
+ : [this.#rpcDataSource];
1069
+ const slowRequest = { ...request, chainIds: slowPipelineChainIds };
1070
+ withTrace({
1071
+ name: TRACE_BACKGROUND_FETCH,
1072
+ trace: pipelineTrace,
1073
+ data: {
1074
+ chain_count: slowPipelineChainIds.length,
1075
+ account_count: accounts.length,
1076
+ },
1077
+ fn: async (slowParentContext) => {
1078
+ const { response: slowResponse } = await this.#executeMiddlewares({
1079
+ sources: [
1080
+ createParallelBalanceMiddleware(slowSources),
1081
+ this.#detectionMiddleware,
1082
+ ...(this.#isBasicFunctionality()
1083
+ ? [
1084
+ createParallelMiddleware([
1085
+ this.#tokenDataSource,
1086
+ this.#priceDataSource,
1087
+ ]),
1088
+ ]
1089
+ : []),
1090
+ ],
1091
+ request: slowRequest,
1092
+ parentContext: slowParentContext,
1093
+ trace: pipelineTrace,
1094
+ });
1095
+ await this.#updateState({
1096
+ ...slowResponse,
1097
+ updateMode: 'merge',
1098
+ });
1099
+ },
1100
+ }).catch((error) => log('Background pipeline failed', { error }));
1029
1101
  }
1030
1102
  }
1031
1103
  const result = this.#getAssetsFromState(accounts, chainIds, assetTypes);
1032
1104
  return result;
1033
1105
  }
1034
- /**
1035
- * v5 force-update request: every pin of the requested accounts, unscoped.
1036
- * Delete with the rest of the v5 path when `assetsAccountsApiV6` is the default.
1037
- *
1038
- * @param accounts - Accounts in this fetch.
1039
- * @param chainIds - Chains in this fetch.
1040
- * @param requestOptions - Shared force-update request fields.
1041
- * @param requestOptions.assetTypes - Asset types to fetch.
1042
- * @param requestOptions.dataTypes - Data types to fetch.
1043
- * @param requestOptions.forceUpdate - Always `true` to bypass caches.
1044
- * @param requestOptions.bypassServerCache - Also bypass server-side HTTP caches.
1045
- * @param requestOptions.assetsForPriceUpdate - Assets to refresh prices for.
1046
- * @returns The v5 data request.
1047
- */
1048
- #buildForceUpdateRequestV5(accounts, chainIds, requestOptions) {
1049
- const customAssets = [];
1050
- for (const account of accounts) {
1051
- customAssets.push(...this.#getVisibleCustomAssets(account.id));
1052
- }
1053
- return this.#buildDataRequest(accounts, chainIds, {
1054
- ...requestOptions,
1055
- customAssets: customAssets.length > 0 ? customAssets : undefined,
1056
- });
1057
- }
1058
- /**
1059
- * v6 force-update request: chain-scoped pins (or `customAssets` override)
1060
- * plus hidden assets as `excludeAssetIds`.
1061
- *
1062
- * @param accounts - Accounts in this fetch.
1063
- * @param chainIds - Chains in this fetch.
1064
- * @param requestOptions - Shared force-update request fields and optional pin override.
1065
- * @param requestOptions.assetTypes - Asset types to fetch.
1066
- * @param requestOptions.dataTypes - Data types to fetch.
1067
- * @param requestOptions.forceUpdate - Always `true` to bypass caches.
1068
- * @param requestOptions.bypassServerCache - Also bypass server-side HTTP caches.
1069
- * @param requestOptions.assetsForPriceUpdate - Assets to refresh prices for.
1070
- * @param requestOptions.customAssetsOverride - Pinned assets to use instead of the stored custom assets.
1071
- * @returns The v6 data request.
1072
- */
1073
- #buildForceUpdateRequestV6(accounts, chainIds, requestOptions) {
1074
- const requestedChains = new Set(chainIds);
1075
- const candidateCustomAssets = requestOptions.customAssetsOverride ??
1076
- accounts.flatMap((account) => this.getCustomAssets(account.id));
1077
- const customAssetsSet = new Set();
1078
- for (const assetId of candidateCustomAssets) {
1079
- try {
1080
- const normalizedAssetId = normalizeAssetId(assetId);
1081
- if (requestedChains.has(parseCaipAssetType(normalizedAssetId).chainId) &&
1082
- !this.state.assetPreferences[normalizedAssetId]?.hidden) {
1083
- customAssetsSet.add(normalizedAssetId);
1084
- }
1085
- }
1086
- catch {
1087
- // Skip unparseable asset IDs
1088
- }
1089
- }
1090
- const customAssets = [...customAssetsSet];
1091
- const hiddenAssets = this.#getHiddenAssetIds();
1092
- return this.#buildDataRequest(accounts, chainIds, {
1093
- assetTypes: requestOptions.assetTypes,
1094
- dataTypes: requestOptions.dataTypes,
1095
- forceUpdate: requestOptions.forceUpdate,
1096
- bypassServerCache: requestOptions.bypassServerCache,
1097
- assetsForPriceUpdate: requestOptions.assetsForPriceUpdate,
1098
- customAssets: customAssets.length > 0 ? customAssets : undefined,
1099
- excludeAssetIds: hiddenAssets.length > 0 ? hiddenAssets : undefined,
1100
- });
1101
- }
1102
- async #forceUpdateAssetsV5({ accounts, chainIds, request, pipelineTrace, }) {
1103
- const isBasicFunctionality = this.#isBasicFunctionality();
1104
- const fastSources = isBasicFunctionality
1105
- ? [
1106
- createParallelBalanceMiddleware([
1107
- this.#accountsApiDataSource,
1108
- this.#stakedBalanceDataSource,
1109
- ]),
1110
- this.#customAssetGraduationMiddleware,
1111
- this.#rpcFallbackMiddleware,
1112
- this.#detectionMiddleware,
1113
- createParallelMiddleware([
1114
- this.#tokenDataSource,
1115
- this.#priceDataSource,
1116
- ]),
1117
- ]
1118
- : [this.#stakedBalanceDataSource, this.#detectionMiddleware];
1119
- const fastResponse = await this.#runFastFetchV5({
1120
- accounts,
1121
- chainIds,
1122
- request,
1123
- pipelineTrace,
1124
- fastSources,
1125
- isBasicFunctionality,
1126
- });
1127
- this.#firstInitFetchReported = true;
1128
- this.#runBackgroundFetch({
1129
- accounts,
1130
- request,
1131
- pipelineTrace,
1132
- chainIds: this.#getSlowPipelineChainIds(chainIds, fastResponse),
1133
- isBasicFunctionality,
1134
- });
1135
- }
1136
- async #forceUpdateAssetsV6({ accounts, chainIds, request, pipelineTrace, }) {
1137
- const isBasicFunctionality = this.#isBasicFunctionality();
1138
- const fastSources = isBasicFunctionality
1139
- ? [
1140
- createParallelBalanceMiddleware([
1141
- this.#accountsApiDataSource,
1142
- this.#stakedBalanceDataSource,
1143
- ]),
1144
- this.#rpcFallbackMiddleware,
1145
- this.#detectionMiddleware,
1146
- createParallelMiddleware([
1147
- this.#tokenDataSource,
1148
- this.#priceDataSource,
1149
- ]),
1150
- ]
1151
- : [this.#stakedBalanceDataSource, this.#detectionMiddleware];
1152
- const fastResponse = await this.#runFastFetchV6({
1153
- accounts,
1154
- chainIds,
1155
- request,
1156
- pipelineTrace,
1157
- fastSources,
1158
- isBasicFunctionality,
1159
- });
1160
- this.#firstInitFetchReported = true;
1161
- this.#runBackgroundFetch({
1162
- accounts,
1163
- request,
1164
- pipelineTrace,
1165
- chainIds: this.#getSlowPipelineChainIds(chainIds, fastResponse),
1166
- isBasicFunctionality,
1167
- });
1168
- }
1169
- async #runFastFetchV5({ accounts, chainIds, request, pipelineTrace, fastSources, isBasicFunctionality, }) {
1170
- const { response } = await withTrace({
1171
- name: TRACE_FETCH_PIPELINE,
1172
- trace: pipelineTrace,
1173
- data: {
1174
- chain_count: chainIds.length,
1175
- account_count: accounts.length,
1176
- basic_functionality: isBasicFunctionality,
1177
- },
1178
- fn: async (parentContext) => {
1179
- const startTime = performance.now();
1180
- const result = await this.#executeMiddlewares({
1181
- sources: fastSources,
1182
- request,
1183
- parentContext,
1184
- trace: pipelineTrace,
1185
- });
1186
- await this.#updateState({
1187
- ...result.response,
1188
- updateMode: 'merge',
1189
- replaceCoveredChainBalances: true,
1190
- });
1191
- this.#emitFastFetchTraces({
1192
- accounts,
1193
- chainIds,
1194
- result,
1195
- durationMs: performance.now() - startTime,
1196
- isBasicFunctionality,
1197
- parentContext,
1198
- pipelineTrace,
1199
- });
1200
- return result;
1201
- },
1202
- });
1203
- return response;
1204
- }
1205
- async #runFastFetchV6({ accounts, chainIds, request, pipelineTrace, fastSources, isBasicFunctionality, }) {
1206
- const { response } = await withTrace({
1207
- name: TRACE_FETCH_PIPELINE,
1208
- trace: pipelineTrace,
1209
- data: {
1210
- chain_count: chainIds.length,
1211
- account_count: accounts.length,
1212
- basic_functionality: isBasicFunctionality,
1213
- },
1214
- fn: async (parentContext) => {
1215
- const startTime = performance.now();
1216
- const result = await this.#executeMiddlewares({
1217
- sources: fastSources,
1218
- request,
1219
- parentContext,
1220
- trace: pipelineTrace,
1221
- });
1222
- await this.#updateState(result.response);
1223
- this.#emitFastFetchTraces({
1224
- accounts,
1225
- chainIds,
1226
- result,
1227
- durationMs: performance.now() - startTime,
1228
- isBasicFunctionality,
1229
- parentContext,
1230
- pipelineTrace,
1231
- });
1232
- return result;
1233
- },
1234
- });
1235
- return response;
1236
- }
1237
- #runBackgroundFetch({ accounts, chainIds, request, pipelineTrace, isBasicFunctionality, }) {
1238
- if (chainIds.length === 0) {
1239
- return;
1240
- }
1241
- const slowSources = isBasicFunctionality
1242
- ? [this.#snapDataSource, this.#rpcDataSource]
1243
- : [this.#rpcDataSource];
1244
- const slowRequest = { ...request, chainIds };
1245
- withTrace({
1246
- name: TRACE_BACKGROUND_FETCH,
1247
- trace: pipelineTrace,
1248
- data: {
1249
- chain_count: chainIds.length,
1250
- account_count: accounts.length,
1251
- },
1252
- fn: async (parentContext) => {
1253
- const { response } = await this.#executeMiddlewares({
1254
- sources: [
1255
- createParallelBalanceMiddleware(slowSources),
1256
- this.#detectionMiddleware,
1257
- ...(isBasicFunctionality
1258
- ? [
1259
- createParallelMiddleware([
1260
- this.#tokenDataSource,
1261
- this.#priceDataSource,
1262
- ]),
1263
- ]
1264
- : []),
1265
- ],
1266
- request: slowRequest,
1267
- parentContext,
1268
- trace: pipelineTrace,
1269
- });
1270
- await this.#updateState({ ...response, updateMode: 'merge' });
1271
- },
1272
- }).catch((error) => log('Background pipeline failed', { error }));
1273
- }
1274
- #emitFastFetchTraces({ accounts, chainIds, result, durationMs, isBasicFunctionality, parentContext, pipelineTrace, }) {
1275
- emitTrace({
1276
- name: TRACE_FULL_FETCH,
1277
- trace: pipelineTrace,
1278
- data: {
1279
- duration_ms: durationMs,
1280
- chain_count: chainIds.length,
1281
- account_count: accounts.length,
1282
- basic_functionality: isBasicFunctionality,
1283
- asset_count: result.response.assetsBalance
1284
- ? Object.values(result.response.assetsBalance).reduce((sum, account) => sum + Object.keys(account).length, 0)
1285
- : 0,
1286
- price_count: result.response.assetsPrice
1287
- ? Object.keys(result.response.assetsPrice).length
1288
- : 0,
1289
- ...result.durationByDataSource,
1290
- },
1291
- parentContext,
1292
- });
1293
- emitTrace({
1294
- name: TRACE_FIRST_INIT_FETCH,
1295
- trace: pipelineTrace,
1296
- data: {
1297
- duration_ms: durationMs,
1298
- chain_ids: JSON.stringify(chainIds),
1299
- ...result.durationByDataSource,
1300
- },
1301
- parentContext,
1302
- });
1303
- }
1304
1106
  async getAssetsBalance(accounts, options) {
1305
1107
  // Reuse getAssets with dataTypes: ['balance'] only
1306
1108
  const assets = await this.getAssets(accounts, {
@@ -1568,28 +1370,20 @@ export class AssetsController extends BaseController {
1568
1370
  balances[accountId] ??= {};
1569
1371
  balances[accountId][normalizedAssetId] ??= { amount: '0' };
1570
1372
  });
1373
+ // Fetch data for the newly added custom asset (merge to preserve other chains)
1571
1374
  const account = this.#getSelectedAccounts().find((a) => a.id === accountId);
1572
1375
  if (account) {
1573
1376
  const chainId = extractChainId(normalizedAssetId);
1574
- if (this.#isBalanceV6Enabled()) {
1575
- await this.getAssets([account], {
1576
- chainIds: [chainId],
1577
- dataTypes: ['balance', 'metadata', 'price'],
1578
- assetTypes: ['fungible'],
1579
- forceUpdate: true,
1580
- customAssets: [normalizedAssetId],
1581
- });
1582
- }
1583
- else {
1584
- await this.getAssets([account], {
1585
- chainIds: [chainId],
1586
- dataTypes: ['balance', 'metadata', 'price'],
1587
- assetTypes: ['fungible'],
1588
- forceUpdate: true,
1589
- });
1590
- }
1377
+ await this.getAssets([account], {
1378
+ chainIds: [chainId],
1379
+ dataTypes: ['balance', 'metadata', 'price'],
1380
+ assetTypes: ['fungible'],
1381
+ forceUpdate: true,
1382
+ updateMode: 'merge',
1383
+ });
1591
1384
  }
1592
- // Re-evaluate subscriptions so polls pick up the new pin.
1385
+ // Re-evaluate subscriptions so the supplemental RPC poll picks up the
1386
+ // new customAsset on chains another data source already owns.
1593
1387
  this.#subscribeAssets();
1594
1388
  }
1595
1389
  /**
@@ -1610,7 +1404,8 @@ export class AssetsController extends BaseController {
1610
1404
  }
1611
1405
  }
1612
1406
  });
1613
- // Re-evaluate subscriptions so polls drop the removed pin.
1407
+ // Re-evaluate subscriptions so the supplemental RPC poll for that chain
1408
+ // is torn down when no more customAssets remain there.
1614
1409
  this.#subscribeAssets();
1615
1410
  }
1616
1411
  /**
@@ -1641,8 +1436,6 @@ export class AssetsController extends BaseController {
1641
1436
  }
1642
1437
  state.assetPreferences[normalizedAssetId].hidden = true;
1643
1438
  });
1644
- // Re-evaluate subscriptions so polls exclude the newly hidden asset.
1645
- this.#subscribeAssets();
1646
1439
  }
1647
1440
  /**
1648
1441
  * Unhide an asset globally.
@@ -1661,53 +1454,6 @@ export class AssetsController extends BaseController {
1661
1454
  }
1662
1455
  }
1663
1456
  });
1664
- // Re-evaluate subscriptions so polls stop excluding the asset.
1665
- this.#subscribeAssets();
1666
- }
1667
- /**
1668
- * Collect globally hidden asset IDs (from `assetPreferences`), forwarded on
1669
- * data requests as `excludeAssetIds`.
1670
- *
1671
- * @returns The CAIP-19 asset IDs the user has hidden.
1672
- */
1673
- #getHiddenAssetIds() {
1674
- const hidden = [];
1675
- for (const [assetId, prefs] of Object.entries(this.state.assetPreferences)) {
1676
- if (prefs.hidden) {
1677
- hidden.push(assetId);
1678
- }
1679
- }
1680
- return hidden;
1681
- }
1682
- /**
1683
- * An account's pins minus the ones the user has hidden. A hide wins over a
1684
- * pin on requests, so a hidden asset is never fetched; the pin stays in
1685
- * `customAssets` to record that the token was imported, and unhiding it
1686
- * restores the pin on the next subscription.
1687
- *
1688
- * @param accountId - The account whose pins should be collected.
1689
- * @returns The account's pinned asset IDs that are not hidden.
1690
- */
1691
- #getVisibleCustomAssets(accountId) {
1692
- return this.getCustomAssets(accountId).filter((assetId) => !this.state.assetPreferences[assetId]?.hidden);
1693
- }
1694
- /**
1695
- * Whether Accounts API v6 (and the v6 custom-asset path) is enabled.
1696
- * Injected into AccountsApiDataSource and RpcFallbackMiddleware.
1697
- *
1698
- * `RemoteFeatureFlagController` resolves a threshold-scoped flag to the
1699
- * selected group's `value`, so processed state is a boolean.
1700
- *
1701
- * @returns `true` when the v6 remote flag is on.
1702
- */
1703
- #isBalanceV6Enabled() {
1704
- try {
1705
- const { remoteFeatureFlags } = this.messenger.call('RemoteFeatureFlagController:getState');
1706
- return remoteFeatureFlags?.assetsAccountsApiV6 === true;
1707
- }
1708
- catch {
1709
- return false;
1710
- }
1711
1457
  }
1712
1458
  // ============================================================================
1713
1459
  // CURRENT CURRENCY MANAGEMENT
@@ -1930,22 +1676,6 @@ export class AssetsController extends BaseController {
1930
1676
  #getNativeAssetIdsForAccount(account) {
1931
1677
  return this.#resolveNativeAssetIds(this.#getEnabledChainsForAccount(account));
1932
1678
  }
1933
- /**
1934
- * Returns the controller-managed default tracked asset IDs (e.g. mUSD) for
1935
- * the chains this account supports (account scopes ∩ enabled chains).
1936
- * Non-EVM accounts resolve to an empty list because every chain in the
1937
- * defaults registry is EVM today.
1938
- *
1939
- * @param account - The account (scopes determine which chains apply).
1940
- * @returns Array of default tracked asset IDs across the supported chains.
1941
- */
1942
- #getDefaultTrackedAssetIdsForAccount(account) {
1943
- const ids = [];
1944
- for (const chainId of this.#getEnabledChainsForAccount(account)) {
1945
- ids.push(...getDefaultTrackedAssetsForChain(chainId));
1946
- }
1947
- return ids;
1948
- }
1949
1679
  /**
1950
1680
  * Chains for the post-commit slow pipeline (Snap + RPC). Excludes chains the
1951
1681
  * fast Accounts API path already handled without error so stale RPC data cannot
@@ -1957,16 +1687,10 @@ export class AssetsController extends BaseController {
1957
1687
  */
1958
1688
  #getSlowPipelineChainIds(chainIds, fastResponse) {
1959
1689
  const accountsApiChains = new Set(this.#accountsApiDataSource.getActiveChainsSync());
1960
- // Chains whose pins went unresolved (`unprocessedCustomAssets`): route
1961
- // them to the slow pipeline so RPC fetches the pins.
1962
- const unprocessedCustomAssetChains = new Set((fastResponse.unprocessedCustomAssets ?? []).map((assetId) => assetId.split('/')[0]));
1963
1690
  return chainIds.filter((chainId) => {
1964
1691
  if (fastResponse.errors?.[chainId]) {
1965
1692
  return true;
1966
1693
  }
1967
- if (unprocessedCustomAssetChains.has(chainId)) {
1968
- return true;
1969
- }
1970
1694
  if (!accountsApiChains.has(chainId)) {
1971
1695
  return true;
1972
1696
  }
@@ -2130,9 +1854,9 @@ export class AssetsController extends BaseController {
2130
1854
  for (const [accountId, accountBalances] of Object.entries(normalizedResponse.assetsBalance)) {
2131
1855
  const previousBalances = previousState.assetsBalance[accountId] ?? {};
2132
1856
  const customAssetIds = state.customAssets[accountId] ?? [];
2133
- const effective = mode === 'full'
2134
- ? mergeAccountBalancesV6(previousBalances, accountBalances, normalizedResponse.unprocessedCustomAssets ?? [])
2135
- : mergeAccountBalancesV5(previousBalances, accountBalances, customAssetIds, normalizedResponse.replaceCoveredChainBalances === true);
1857
+ const replaceCoveredChains = mode === 'full' ||
1858
+ normalizedResponse.replaceCoveredChainBalances === true;
1859
+ const effective = mergeAccountBalances(previousBalances, accountBalances, customAssetIds, replaceCoveredChains);
2136
1860
  // Ensure native tokens have an entry (0 if missing) for chains this account supports
2137
1861
  const account = this.#getSelectedAccounts().find((a) => a.id === accountId);
2138
1862
  const nativeAssetIdsForAccount = account
@@ -2143,19 +1867,6 @@ export class AssetsController extends BaseController {
2143
1867
  effective[nativeAssetId] = { amount: '0' };
2144
1868
  }
2145
1869
  }
2146
- // Default tracked assets (mUSD) are controller-managed and, like
2147
- // natives, must render at zero balance. An authoritative
2148
- // chain-slice replace omits them whenever the account holds none,
2149
- // so re-assert them here — otherwise a force refresh (e.g. the
2150
- // "Refresh list" action) would drop mUSD from the token list.
2151
- const defaultTrackedAssetIdsForAccount = account
2152
- ? this.#getDefaultTrackedAssetIdsForAccount(account)
2153
- : [];
2154
- for (const defaultAssetId of defaultTrackedAssetIdsForAccount) {
2155
- if (!Object.prototype.hasOwnProperty.call(effective, defaultAssetId)) {
2156
- effective[defaultAssetId] = { amount: '0' };
2157
- }
2158
- }
2159
1870
  for (const [assetId, balance] of Object.entries(effective)) {
2160
1871
  const previousBalance = previousBalances[assetId];
2161
1872
  // Coerce amounts (e.g. "1e-18" from a data source stringifying
@@ -2167,13 +1878,19 @@ export class AssetsController extends BaseController {
2167
1878
  // their metadata aren't lossily truncated.
2168
1879
  const assetDecimals = metadata[assetId]?.decimals;
2169
1880
  const newAmount = normalizeAmountString(balance.amount, assetDecimals);
2170
- effective[assetId] = { ...balance, amount: newAmount };
1881
+ // Keep existing metadata when the incoming update is
1882
+ // amount-only (e.g. Account Activity websocket). Incoming
1883
+ // metadata still wins when present.
1884
+ effective[assetId] = {
1885
+ ...previousBalance,
1886
+ ...balance,
1887
+ amount: newAmount,
1888
+ };
2171
1889
  const oldAmount = previousBalance?.amount;
2172
- const isNewSeededZero = oldAmount === undefined &&
1890
+ const isNewDefaultNativeZero = oldAmount === undefined &&
2173
1891
  newAmount === '0' &&
2174
- (nativeAssetIdsForAccount.includes(assetId) ||
2175
- defaultTrackedAssetIdsForAccount.includes(assetId));
2176
- if (oldAmount !== newAmount && !isNewSeededZero) {
1892
+ nativeAssetIdsForAccount.includes(assetId);
1893
+ if (oldAmount !== newAmount && !isNewDefaultNativeZero) {
2177
1894
  changedBalances.push({
2178
1895
  accountId,
2179
1896
  assetId,
@@ -2203,8 +1920,10 @@ export class AssetsController extends BaseController {
2203
1920
  changedPriceAssets.length > 0) {
2204
1921
  log('State updated', {
2205
1922
  changedBalances: changedBalances.length > 0 ? changedBalances : undefined,
2206
- changedMetadata: changedMetadata.length > 0 ? changedMetadata : undefined,
2207
- changedPrices: changedPriceAssets.length > 0 ? changedPriceAssets : undefined,
1923
+ changedMetadataCount: changedMetadata.length > 0 ? changedMetadata.length : undefined,
1924
+ changedPricesCount: changedPriceAssets.length > 0
1925
+ ? changedPriceAssets.length
1926
+ : undefined,
2208
1927
  newAssets: Object.keys(detectedAssets).length > 0
2209
1928
  ? Object.entries(detectedAssets).map(([accountId, assets]) => ({
2210
1929
  accountId,
@@ -2426,10 +2145,16 @@ export class AssetsController extends BaseController {
2426
2145
  if (!subscriptionKey.startsWith('ds:')) {
2427
2146
  continue;
2428
2147
  }
2429
- // Subscription keys take the form `ds:<SourceName>`.
2148
+ // Subscription keys take the form `ds:<SourceName>` for the regular
2149
+ // subscription or `ds:<SourceName>:<suffix>` for supplemental
2150
+ // subscriptions (e.g. `ds:RpcDataSource:custom`). Split on `:` and
2151
+ // pick the source-name segment so both shapes resolve correctly.
2430
2152
  const [, sourceId] = subscriptionKey.split(':');
2431
2153
  const source = allSources.find((ds) => ds.getName() === sourceId);
2432
2154
  if (source) {
2155
+ // Unsubscribe by the actual key — `#unsubscribeDataSource` only
2156
+ // knows the regular `ds:<SourceName>` shape and would miss
2157
+ // supplemental subscriptions, leaking their polling timers.
2433
2158
  this.#unsubscribeBySubscriptionKey(source, subscriptionKey);
2434
2159
  }
2435
2160
  }
@@ -2473,12 +2198,10 @@ export class AssetsController extends BaseController {
2473
2198
  * Strategy to minimize data source calls:
2474
2199
  * 1. Collect all chains to subscribe based on enabled networks
2475
2200
  * 2. Map chains to accounts based on their scopes
2476
- * 3. Split by data source (priority order) - each source gets ONE
2477
- * subscription, claiming chains AND pinned assets (`claimCustomAssets`);
2478
- * unclaimed assets fall through to lower-priority sources.
2201
+ * 3. Split by data source (ordered by priority) - each data source gets ONE subscription
2479
2202
  *
2480
2203
  * This ensures we make minimal subscriptions to each data source while covering
2481
- * all accounts, chains, and pinned assets.
2204
+ * all accounts and chains.
2482
2205
  *
2483
2206
  * @param accounts - Accounts to subscribe balance updates for.
2484
2207
  * @param chainIds - Chain IDs to subscribe for.
@@ -2486,16 +2209,12 @@ export class AssetsController extends BaseController {
2486
2209
  * @param options.skipInitialFetch - Forwarded to AccountsApi subscribe.
2487
2210
  */
2488
2211
  #subscribeAssetsBalance(accounts, chainIds, options) {
2489
- if (this.#isBalanceV6Enabled()) {
2490
- this.#subscribeAssetsBalanceV6(accounts, chainIds, options);
2491
- return;
2492
- }
2493
- this.#subscribeAssetsBalanceV5(accounts, chainIds, options);
2494
- }
2495
- #subscribeAssetsBalanceV5(accounts, chainIds, options) {
2496
2212
  const chainToAccounts = this.#buildChainToAccountsMap(accounts, new Set(chainIds));
2497
2213
  const remainingChains = new Set(chainToAccounts.keys());
2498
- const balanceDataSources = this.#isBasicFunctionality()
2214
+ // When basic functionality is on, use all balance data sources; when off,
2215
+ // RPC only.
2216
+ const isBasicFunctionality = this.#isBasicFunctionality();
2217
+ const balanceDataSources = isBasicFunctionality
2499
2218
  ? this.#allBalanceDataSources
2500
2219
  : [this.#rpcDataSource];
2501
2220
  let rpcAssignedChains = new Set();
@@ -2534,88 +2253,22 @@ export class AssetsController extends BaseController {
2534
2253
  });
2535
2254
  }
2536
2255
  }
2256
+ // Supplemental RPC subscription for customAssets on chains another data
2257
+ // source claimed during regular handoff. RPC is the sole balance fetcher
2258
+ // for customAssets, so we must always poll them — even when (e.g.)
2259
+ // AccountsApi is already covering the chain for normal balances. The
2260
+ // supplemental subscription runs in `customAssetsOnly` mode so it does
2261
+ // NOT double-poll the regular tracked balances.
2537
2262
  this.#subscribeRpcCustomAssetsSupplement(accounts, chainToAccounts, rpcAssignedChains);
2538
2263
  }
2539
- #subscribeAssetsBalanceV6(accounts, chainIds, options) {
2540
- const chainToAccounts = this.#buildChainToAccountsMap(accounts, new Set(chainIds));
2541
- const remainingChains = new Set(chainToAccounts.keys());
2542
- const remainingCustomAssets = new Set();
2543
- for (const account of accounts) {
2544
- for (const assetId of this.#getVisibleCustomAssets(account.id)) {
2545
- try {
2546
- if (remainingChains.has(parseCaipAssetType(assetId).chainId)) {
2547
- remainingCustomAssets.add(assetId);
2548
- }
2549
- }
2550
- catch {
2551
- // Skip unparseable asset IDs
2552
- }
2553
- }
2554
- }
2555
- const balanceDataSources = this.#isBasicFunctionality()
2556
- ? this.#allBalanceDataSources
2557
- : [this.#rpcDataSource];
2558
- for (const source of balanceDataSources) {
2559
- const availableChains = new Set(source.getActiveChainsSync());
2560
- const assignedChains = [];
2561
- for (const chainId of remainingChains) {
2562
- if (availableChains.has(chainId)) {
2563
- assignedChains.push(chainId);
2564
- remainingChains.delete(chainId);
2565
- }
2566
- }
2567
- const claimedAssets = source.claimCustomAssets([...remainingCustomAssets], assignedChains);
2568
- for (const assetId of claimedAssets) {
2569
- remainingCustomAssets.delete(assetId);
2570
- }
2571
- if (assignedChains.length === 0 && claimedAssets.length === 0) {
2572
- this.#unsubscribeDataSource(source);
2573
- continue;
2574
- }
2575
- const claimedAssetsSet = new Set(claimedAssets);
2576
- const seenIds = new Set();
2577
- const accountsForSource = assignedChains
2578
- .flatMap((chainId) => chainToAccounts.get(chainId) ?? [])
2579
- .filter((account) => {
2580
- if (seenIds.has(account.id)) {
2581
- return false;
2582
- }
2583
- seenIds.add(account.id);
2584
- return true;
2585
- });
2586
- for (const account of accounts) {
2587
- if (!seenIds.has(account.id) &&
2588
- this.getCustomAssets(account.id).some((assetId) => claimedAssetsSet.has(assetId))) {
2589
- seenIds.add(account.id);
2590
- accountsForSource.push(account);
2591
- }
2592
- }
2593
- if (accountsForSource.length > 0) {
2594
- const hiddenAssets = this.#getHiddenAssetIds();
2595
- this.#subscribeDataSource(source, accountsForSource, assignedChains, {
2596
- customAssets: claimedAssets,
2597
- excludeAssetIds: hiddenAssets.length > 0 ? hiddenAssets : undefined,
2598
- ...(options?.skipInitialFetch &&
2599
- source === this.#accountsApiDataSource
2600
- ? { skipInitialFetch: true }
2601
- : {}),
2602
- });
2603
- }
2604
- else {
2605
- this.#unsubscribeDataSource(source);
2606
- }
2607
- }
2608
- if (remainingCustomAssets.size > 0) {
2609
- log('Custom assets unclaimed by any data source', {
2610
- assetIds: [...remainingCustomAssets],
2611
- });
2612
- }
2613
- }
2614
2264
  /**
2615
2265
  * Guarantee that customAssets are **always** polled by RPC, even when
2616
2266
  * AccountsApi or another data source has claimed the chain in the
2617
2267
  * regular handoff. RPC is the sole balance fetcher for user-imported
2618
- * tokens on the Accounts API v5 path (see `pickRpcCustomAssetsSupplement`).
2268
+ * tokens (see `pickRpcCustomAssetsSupplement` for the full rationale),
2269
+ * so we run a dedicated subscription in `customAssetsOnly` mode under a
2270
+ * distinct subscription key (`ds:RpcDataSource:custom`) that does not
2271
+ * interfere with the regular RPC subscription.
2619
2272
  *
2620
2273
  * @param accounts - Accounts to consider for customAssets.
2621
2274
  * @param chainToAccounts - Map of chain → accounts (built by caller).
@@ -2624,16 +2277,9 @@ export class AssetsController extends BaseController {
2624
2277
  #subscribeRpcCustomAssetsSupplement(accounts, chainToAccounts, rpcAssignedChains) {
2625
2278
  const rpc = this.#rpcDataSource;
2626
2279
  const supplementalKey = `ds:${rpc.getName()}:custom`;
2627
- const visibleCustomAssetsByAccount = {};
2628
- for (const account of accounts) {
2629
- const visibleCustomAssets = this.#getVisibleCustomAssets(account.id);
2630
- if (visibleCustomAssets.length > 0) {
2631
- visibleCustomAssetsByAccount[account.id] = visibleCustomAssets;
2632
- }
2633
- }
2634
2280
  const decision = pickRpcCustomAssetsSupplement({
2635
2281
  accountIds: accounts.map((account) => account.id),
2636
- customAssetsByAccount: visibleCustomAssetsByAccount,
2282
+ customAssetsByAccount: this.state.customAssets,
2637
2283
  rpcAssignedChains,
2638
2284
  rpcAvailableChains: new Set(rpc.getActiveChainsSync()),
2639
2285
  enabledChains: new Set(chainToAccounts.keys()),
@@ -2720,12 +2366,7 @@ export class AssetsController extends BaseController {
2720
2366
  * @param chains - Array of chain IDs to subscribe for.
2721
2367
  * @param options - Optional subscription overrides.
2722
2368
  * @param options.subscriptionKey - Custom subscription key (default: `ds:<sourceId>`).
2723
- * @param options.customAssets - Pinned assets this source claimed
2724
- * (`claimCustomAssets`), forwarded on the poll request (Accounts API v6).
2725
- * @param options.customAssetsOnly - When true, only poll customAssets for these
2726
- * chains (Accounts API v5 supplemental RPC subscription).
2727
- * @param options.excludeAssetIds - Hidden assets forwarded on the poll request
2728
- * (Accounts API v6).
2369
+ * @param options.customAssetsOnly - When true, only poll customAssets for these chains.
2729
2370
  * @param options.skipInitialFetch - When true, skip the data source's subscribe-time fetch.
2730
2371
  */
2731
2372
  #subscribeDataSource(source, accounts, chains, options = {}) {
@@ -2733,14 +2374,12 @@ export class AssetsController extends BaseController {
2733
2374
  const subscriptionKey = options.subscriptionKey ?? `ds:${sourceId}`;
2734
2375
  const existingSubscription = this.#activeSubscriptions.get(subscriptionKey);
2735
2376
  const isUpdate = existingSubscription !== undefined;
2736
- const customAssets = options.customAssets ?? [];
2737
2377
  log('Subscribe to data source', {
2738
2378
  sourceId,
2739
2379
  subscriptionKey,
2740
2380
  isUpdate,
2741
2381
  accountCount: accounts.length,
2742
2382
  chainCount: chains.length,
2743
- customAssetCount: customAssets.length,
2744
2383
  customAssetsOnly: options.customAssetsOnly === true,
2745
2384
  skipInitialFetch: options.skipInitialFetch === true,
2746
2385
  });
@@ -2749,10 +2388,6 @@ export class AssetsController extends BaseController {
2749
2388
  assetTypes: ['fungible'],
2750
2389
  dataTypes: ['balance'],
2751
2390
  updateInterval: this.#defaultUpdateInterval,
2752
- customAssets: customAssets.length > 0 ? customAssets : undefined,
2753
- excludeAssetIds: options.excludeAssetIds && options.excludeAssetIds.length > 0
2754
- ? options.excludeAssetIds
2755
- : undefined,
2756
2391
  ...(options.customAssetsOnly === true
2757
2392
  ? { customAssetsOnly: true }
2758
2393
  : {}),
@@ -2944,13 +2579,13 @@ export class AssetsController extends BaseController {
2944
2579
  // The data will simply not be updated until the network is re-enabled.
2945
2580
  // Refresh subscriptions for new chain set
2946
2581
  this.#subscribeAssets();
2947
- // One-time fetch for newly enabled chains. v6 `full` replace is scoped to
2948
- // those chains; existing balances on other chains are left untouched.
2582
+ // Do one-time fetch for newly enabled chains; merge so we keep existing chain balances
2949
2583
  const accounts = this.#getSelectedAccounts();
2950
2584
  if (addedChains.length > 0 && accounts.length > 0) {
2951
2585
  await this.getAssets(accounts, {
2952
2586
  chainIds: addedChains,
2953
2587
  forceUpdate: true,
2588
+ updateMode: 'merge',
2954
2589
  });
2955
2590
  }
2956
2591
  this.#ensureNativeBalancesDefaultZero();
@@ -3110,28 +2745,48 @@ export class AssetsController extends BaseController {
3110
2745
  async handleAssetsUpdate(response, sourceId, request) {
3111
2746
  log('Assets updated from data source', {
3112
2747
  sourceId,
3113
- assetsBalance: response.assetsBalance,
3114
- assetsPrice: response.assetsPrice,
2748
+ hasBalance: Boolean(response.assetsBalance),
2749
+ hasPrice: Boolean(response.assetsPrice),
3115
2750
  });
3116
- if (this.#isBalanceV6Enabled()) {
3117
- await this.#handleAssetsUpdateV6(response, sourceId, request);
3118
- return;
3119
- }
3120
- await this.#handleAssetsUpdateV5(response, sourceId, request);
3121
- }
3122
- async #handleAssetsUpdateV5(response, sourceId, request) {
2751
+ // Enrichment spans only before unlock/first-init fetch completes.
3123
2752
  const pipelineTrace = this.#firstInitFetchReported
3124
2753
  ? undefined
3125
2754
  : this.#trace;
3126
2755
  await withTrace({
3127
2756
  name: TRACE_UPDATE_PARENT,
3128
2757
  trace: pipelineTrace,
3129
- data: this.#getUpdateTraceData(response, sourceId),
2758
+ data: {
2759
+ source: sourceId,
2760
+ has_balance: Boolean(response.assetsBalance),
2761
+ has_price: Boolean(response.assetsPrice),
2762
+ balance_account_count: response.assetsBalance
2763
+ ? Object.keys(response.assetsBalance).length
2764
+ : 0,
2765
+ },
3130
2766
  fn: async (parentContext) => {
3131
2767
  const updateStart = performance.now();
3132
- const pipelineRequest = this.#getUpdatePipelineRequest(request);
2768
+ const resolvedRequest = request ?? {
2769
+ accountsWithSupportedChains: [],
2770
+ chainIds: [],
2771
+ dataTypes: ['balance', 'metadata', 'price'],
2772
+ };
2773
+ // RPC-only mode (basic functionality off): never run token/price APIs. Strip
2774
+ // those data types so downstream middleware cannot treat them as requested.
2775
+ const pipelineRequest = this.#isBasicFunctionality()
2776
+ ? resolvedRequest
2777
+ : {
2778
+ ...resolvedRequest,
2779
+ dataTypes: resolvedRequest.dataTypes.filter((dt) => dt !== 'metadata' && dt !== 'price'),
2780
+ };
2781
+ // Graduate custom assets only when AccountsAPI / AccountActivity reports
2782
+ // them. RPC already fetches custom assets on purpose, and Snap handles
2783
+ // non-EVM chains the rule does not apply to, so skip the middleware for
2784
+ // those.
3133
2785
  const shouldGraduateCustomAssets = sourceId === 'AccountsApiDataSource' ||
3134
2786
  sourceId === 'AccountActivityDataSource';
2787
+ // Websocket updates can carry brand-new spam airdrops: enrich them
2788
+ // with Token API occurrences and drop below-floor tokens BEFORE
2789
+ // detection, so spam is never detected, enriched, priced or persisted.
3135
2790
  const shouldFilterOccurrences = sourceId === 'AccountActivityDataSource' &&
3136
2791
  this.#isBasicFunctionality();
3137
2792
  const shouldRunRpcFallback = sourceId === 'AccountsApiDataSource';
@@ -3150,7 +2805,6 @@ export class AssetsController extends BaseController {
3150
2805
  ...(shouldRunRpcFallback ? [this.#rpcFallbackMiddleware] : []),
3151
2806
  this.#detectionMiddleware,
3152
2807
  ];
3153
- enrichmentSources.push(this.#detectionMiddleware);
3154
2808
  if (this.#isBasicFunctionality()) {
3155
2809
  enrichmentSources.push(createParallelMiddleware([
3156
2810
  this.#tokenDataSource,
@@ -3168,110 +2822,25 @@ export class AssetsController extends BaseController {
3168
2822
  ...enrichedResponse,
3169
2823
  replaceCoveredChainBalances: response.replaceCoveredChainBalances,
3170
2824
  });
3171
- this.#emitUpdateTrace({
3172
- response,
3173
- sourceId,
3174
- enrichedResponse,
3175
- updateStart,
3176
- pipelineTrace,
3177
- parentContext,
3178
- });
3179
- },
3180
- });
3181
- }
3182
- async #handleAssetsUpdateV6(response, sourceId, request) {
3183
- const pipelineTrace = this.#firstInitFetchReported
3184
- ? undefined
3185
- : this.#trace;
3186
- await withTrace({
3187
- name: TRACE_UPDATE_PARENT,
3188
- trace: pipelineTrace,
3189
- data: this.#getUpdateTraceData(response, sourceId),
3190
- fn: async (parentContext) => {
3191
- const updateStart = performance.now();
3192
- const pipelineRequest = this.#getUpdatePipelineRequest(request);
3193
- const shouldFilterOccurrences = sourceId === 'AccountActivityDataSource' &&
3194
- this.#isBasicFunctionality();
3195
- const enrichmentSources = [
3196
- ...(shouldFilterOccurrences
3197
- ? [
3198
- {
3199
- getName: () => 'OccurrenceFloorFilter',
3200
- assetsMiddleware: this.#tokenDataSource.occurrenceFilterMiddleware,
3201
- },
3202
- ]
3203
- : []),
3204
- ];
3205
- if (this.#isBasicFunctionality()) {
3206
- enrichmentSources.push(this.#rpcFallbackMiddleware);
3207
- }
3208
- enrichmentSources.push(this.#detectionMiddleware);
3209
- if (this.#isBasicFunctionality()) {
3210
- enrichmentSources.push(createParallelMiddleware([
3211
- this.#tokenDataSource,
3212
- this.#priceDataSource,
3213
- ]));
3214
- }
3215
- const { response: enrichedResponse } = await this.#executeMiddlewares({
3216
- sources: enrichmentSources,
3217
- request: pipelineRequest,
3218
- initialResponse: response,
3219
- parentContext,
2825
+ // Summary fields for Assets Health (nested under the parent span).
2826
+ emitTrace({
2827
+ name: TRACE_UPDATE_PIPELINE,
3220
2828
  trace: pipelineTrace,
3221
- });
3222
- await this.#updateState(enrichedResponse);
3223
- this.#emitUpdateTrace({
3224
- response,
3225
- sourceId,
3226
- enrichedResponse,
3227
- updateStart,
3228
- pipelineTrace,
2829
+ data: {
2830
+ source: sourceId,
2831
+ duration_ms: performance.now() - updateStart,
2832
+ has_balance: Boolean(response.assetsBalance),
2833
+ has_price: Boolean(response.assetsPrice),
2834
+ has_metadata: Boolean(enrichedResponse.assetsInfo),
2835
+ balance_account_count: response.assetsBalance
2836
+ ? Object.keys(response.assetsBalance).length
2837
+ : 0,
2838
+ },
3229
2839
  parentContext,
3230
2840
  });
3231
2841
  },
3232
2842
  });
3233
2843
  }
3234
- #getUpdatePipelineRequest(request) {
3235
- const resolvedRequest = request ?? {
3236
- accountsWithSupportedChains: [],
3237
- chainIds: [],
3238
- dataTypes: ['balance', 'metadata', 'price'],
3239
- };
3240
- if (this.#isBasicFunctionality()) {
3241
- return resolvedRequest;
3242
- }
3243
- return {
3244
- ...resolvedRequest,
3245
- dataTypes: resolvedRequest.dataTypes.filter((dataType) => dataType !== 'metadata' && dataType !== 'price'),
3246
- };
3247
- }
3248
- #getUpdateTraceData(response, sourceId) {
3249
- return {
3250
- source: sourceId,
3251
- has_balance: Boolean(response.assetsBalance),
3252
- has_price: Boolean(response.assetsPrice),
3253
- balance_account_count: response.assetsBalance
3254
- ? Object.keys(response.assetsBalance).length
3255
- : 0,
3256
- };
3257
- }
3258
- #emitUpdateTrace({ response, sourceId, enrichedResponse, updateStart, pipelineTrace, parentContext, }) {
3259
- emitTrace({
3260
- name: TRACE_UPDATE_PIPELINE,
3261
- trace: pipelineTrace,
3262
- data: {
3263
- source: sourceId,
3264
- duration_ms: performance.now() - updateStart,
3265
- has_balance: Boolean(response.assetsBalance),
3266
- has_price: Boolean(response.assetsPrice),
3267
- has_metadata: Boolean(enrichedResponse.assetsInfo),
3268
- balance_account_count: response.assetsBalance
3269
- ? Object.keys(response.assetsBalance).length
3270
- : 0,
3271
- },
3272
- parentContext,
3273
- });
3274
- }
3275
2844
  // ============================================================================
3276
2845
  // CLEANUP
3277
2846
  // ============================================================================