@metamask-previews/assets-controller 16.1.0-preview-fb486948f → 16.1.0-preview-aecbce4ff

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.
@@ -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 { projectLogger, createModuleLogger } from './logger.js';
18
18
  import { CustomAssetGraduationMiddleware } from './middlewares/CustomAssetGraduationMiddleware.js';
19
19
  import { DetectionMiddleware } from './middlewares/DetectionMiddleware.js';
@@ -187,10 +187,6 @@ function normalizeResponse(response) {
187
187
  if (response.replaceCoveredChainBalances) {
188
188
  normalized.replaceCoveredChainBalances = true;
189
189
  }
190
- if (response.unprocessedCustomAssets &&
191
- response.unprocessedCustomAssets.length > 0) {
192
- normalized.unprocessedCustomAssets = response.unprocessedCustomAssets.map((assetId) => normalizeAssetId(assetId));
193
- }
194
190
  return normalized;
195
191
  }
196
192
  /**
@@ -198,13 +194,12 @@ function normalizeResponse(response) {
198
194
  *
199
195
  * @param previousBalances - Balances already in state for this account.
200
196
  * @param accountBalances - Balances from the incoming response.
201
- * @param customAssetIds - Custom assets to preserve when replacing covered chains
202
- * on the Accounts API v5 path (`replaceCoveredChainBalances`).
203
- * @param replaceCoveredChains - When true (v5 force refresh), drop prior balances
204
- * on chains present in the response before applying it, then restore custom assets.
197
+ * @param customAssetIds - Custom assets to preserve when replacing covered chains.
198
+ * @param replaceCoveredChains - When true, drop prior balances on chains present
199
+ * in the response before applying it (authoritative chain slice).
205
200
  * @returns The merged balance map for the account.
206
201
  */
207
- function mergeAccountBalancesV5(previousBalances, accountBalances, customAssetIds, replaceCoveredChains) {
202
+ function mergeAccountBalances(previousBalances, accountBalances, customAssetIds, replaceCoveredChains) {
208
203
  if (!replaceCoveredChains) {
209
204
  return { ...previousBalances, ...accountBalances };
210
205
  }
@@ -233,53 +228,6 @@ function mergeAccountBalancesV5(previousBalances, accountBalances, customAssetId
233
228
  }
234
229
  return next;
235
230
  }
236
- /**
237
- * Apply an Accounts API v6 authoritative chain snapshot.
238
- *
239
- * @param previousBalances - Balances already in state for this account.
240
- * @param accountBalances - Balances from the incoming response.
241
- * @param unprocessedCustomAssets - Pins that v6 and RPC could not resolve.
242
- * @returns The merged balance map for the account.
243
- */
244
- function mergeAccountBalancesV6(previousBalances, accountBalances, unprocessedCustomAssets = []) {
245
- const coveredChains = new Set(Object.keys(accountBalances).map((assetId) => assetId.split('/')[0]));
246
- const next = {};
247
- for (const [assetId, balance] of Object.entries(previousBalances)) {
248
- if (!coveredChains.has(assetId.split('/')[0])) {
249
- next[assetId] = balance;
250
- }
251
- }
252
- Object.assign(next, accountBalances);
253
- const unprocessedSet = new Set(unprocessedCustomAssets.map((assetId) => normalizeAssetId(assetId)));
254
- for (const [assetId, balance] of Object.entries(previousBalances)) {
255
- if (unprocessedSet.has(normalizeAssetId(assetId)) &&
256
- !Object.prototype.hasOwnProperty.call(next, assetId)) {
257
- next[assetId] = balance;
258
- }
259
- }
260
- for (const [assetId, balance] of Object.entries(previousBalances)) {
261
- if (isStakingContractAssetId(assetId) &&
262
- !Object.prototype.hasOwnProperty.call(next, assetId)) {
263
- next[assetId] = balance;
264
- }
265
- }
266
- // Default tracked assets (mUSD) are controller-managed and must render at
267
- // zero when the account holds none. The v6 snapshot omits them in that
268
- // case, so re-assert them on covered chains — otherwise a force refresh
269
- // would drop them from the token list.
270
- // TODO: seed defaults as visible pins in `customAssets` instead (skip if
271
- // already present or `assetPreferences[assetId].hidden`), then drop this
272
- // merge special case.
273
- for (const chainId of coveredChains) {
274
- for (const assetId of getDefaultTrackedAssetsForChain(chainId)) {
275
- if (!Object.prototype.hasOwnProperty.call(next, assetId)) {
276
- next[assetId] =
277
- previousBalances[assetId] ?? { amount: '0' };
278
- }
279
- }
280
- }
281
- return next;
282
- }
283
231
  // ============================================================================
284
232
  // CONTROLLER IMPLEMENTATION
285
233
  // ============================================================================
@@ -515,8 +463,6 @@ export class AssetsController extends BaseController {
515
463
  queryApiClient,
516
464
  onActiveChainsUpdated: this.#onActiveChainsUpdated,
517
465
  ...accountsApiDataSourceConfig,
518
- isBalanceV6Enabled: () => this.#isBalanceV6Enabled(),
519
- getAssetsState: () => this.state,
520
466
  });
521
467
  this.#snapDataSource = new SnapDataSource({
522
468
  messenger: this.messenger,
@@ -525,7 +471,6 @@ export class AssetsController extends BaseController {
525
471
  });
526
472
  this.#rpcDataSource = new RpcDataSource({
527
473
  messenger: this.messenger,
528
- getAssetsState: () => this.state,
529
474
  onActiveChainsUpdated: this.#onActiveChainsUpdated,
530
475
  getNativeAssetForChain: (chainId) => this.#getNativeAssetMap()[chainId] ??
531
476
  `${chainId}/erc20:${ZERO_ADDRESS}`,
@@ -569,7 +514,6 @@ export class AssetsController extends BaseController {
569
514
  });
570
515
  this.#rpcFallbackMiddleware = new RpcFallbackMiddleware({
571
516
  rpcDataSource: this.#rpcDataSource,
572
- isBalanceV6Enabled: () => this.#isBalanceV6Enabled(),
573
517
  });
574
518
  log('Initializing AssetsController', {
575
519
  defaultUpdateInterval: this.#defaultUpdateInterval,
@@ -924,298 +868,143 @@ export class AssetsController extends BaseController {
924
868
  if (accounts.length === 0 || chainIds.length === 0) {
925
869
  return this.#getAssetsFromState(accounts, chainIds, assetTypes);
926
870
  }
871
+ // Collect custom assets for all requested accounts
872
+ const customAssets = [];
873
+ for (const account of accounts) {
874
+ const accountCustomAssets = this.getCustomAssets(account.id);
875
+ customAssets.push(...accountCustomAssets);
876
+ }
927
877
  if (options?.forceUpdate) {
878
+ // Pipeline spans only on unlock/first-init fetch; later forceUpdates pass
879
+ // `undefined` so `emitTrace` / `withTrace` no-op without call-site if/else.
928
880
  const pipelineTrace = this.#firstInitFetchReported
929
881
  ? undefined
930
882
  : this.#trace;
931
- const requestOptions = {
883
+ const request = this.#buildDataRequest(accounts, chainIds, {
932
884
  assetTypes,
933
885
  dataTypes,
886
+ customAssets: customAssets.length > 0 ? customAssets : undefined,
934
887
  forceUpdate: true,
935
888
  bypassServerCache: options?.bypassServerCache,
936
889
  assetsForPriceUpdate: options?.assetsForPriceUpdate,
937
- };
938
- if (this.#isBalanceV6Enabled()) {
939
- await this.#forceUpdateAssetsV6({
940
- accounts,
941
- chainIds,
942
- request: this.#buildForceUpdateRequestV6(accounts, chainIds, {
943
- ...requestOptions,
944
- customAssetsOverride: options?.customAssets,
945
- }),
946
- pipelineTrace,
947
- });
948
- }
949
- else {
950
- await this.#forceUpdateAssetsV5({
951
- accounts,
952
- chainIds,
953
- request: this.#buildForceUpdateRequestV5(accounts, chainIds, requestOptions),
954
- pipelineTrace,
955
- });
890
+ });
891
+ // Fast pipeline: accountsApi + stakedBalance → detection → token + price.
892
+ // Snap and RPC are excluded here due to their latency (snap triggers account
893
+ // creation, RPC is slow on many chains). Results are committed to state
894
+ // immediately so the UI can display balances without waiting for them.
895
+ //
896
+ // Fast/slow pipelines use merge so partial API snapshots cannot wipe
897
+ // tokens missing from the response (e.g. USDC when only native balance
898
+ // is returned). Balances present in the response are still refreshed.
899
+ const fastSources = buildFastFetchSources({
900
+ accountsApiDataSource: this.#accountsApiDataSource,
901
+ stakedBalanceDataSource: this.#stakedBalanceDataSource,
902
+ customAssetGraduationMiddleware: this.#customAssetGraduationMiddleware,
903
+ rpcFallbackMiddleware: this.#rpcFallbackMiddleware,
904
+ detectionMiddleware: this.#detectionMiddleware,
905
+ tokenDataSource: this.#tokenDataSource,
906
+ priceDataSource: this.#priceDataSource,
907
+ }, { isBasicFunctionality: this.#isBasicFunctionality() });
908
+ const { response } = await withTrace({
909
+ name: TRACE_FETCH_PIPELINE,
910
+ trace: pipelineTrace,
911
+ data: {
912
+ chain_count: chainIds.length,
913
+ account_count: accounts.length,
914
+ basic_functionality: this.#isBasicFunctionality(),
915
+ },
916
+ fn: async (parentContext) => {
917
+ const startTime = performance.now();
918
+ const result = await this.#executeMiddlewares({
919
+ sources: fastSources,
920
+ request,
921
+ parentContext,
922
+ trace: pipelineTrace,
923
+ });
924
+ await this.#updateState({
925
+ ...result.response,
926
+ updateMode: 'merge',
927
+ replaceCoveredChainBalances: true,
928
+ });
929
+ const durationMs = performance.now() - startTime;
930
+ // Summary fields for Assets Health (nested under the parent span).
931
+ emitTrace({
932
+ name: TRACE_FULL_FETCH,
933
+ trace: pipelineTrace,
934
+ data: {
935
+ duration_ms: durationMs,
936
+ chain_count: chainIds.length,
937
+ account_count: accounts.length,
938
+ basic_functionality: this.#isBasicFunctionality(),
939
+ asset_count: result.response.assetsBalance
940
+ ? Object.values(result.response.assetsBalance).reduce((sum, acct) => sum + Object.keys(acct).length, 0)
941
+ : 0,
942
+ price_count: result.response.assetsPrice
943
+ ? Object.keys(result.response.assetsPrice).length
944
+ : 0,
945
+ ...result.durationByDataSource,
946
+ },
947
+ parentContext,
948
+ });
949
+ emitTrace({
950
+ name: TRACE_FIRST_INIT_FETCH,
951
+ trace: pipelineTrace,
952
+ data: {
953
+ duration_ms: durationMs,
954
+ chain_ids: JSON.stringify(chainIds),
955
+ ...result.durationByDataSource,
956
+ },
957
+ parentContext,
958
+ });
959
+ return result;
960
+ },
961
+ });
962
+ // Mark after the unlock/first forceUpdate so later polls skip pipeline spans.
963
+ this.#firstInitFetchReported = true;
964
+ // Background (slow) lane — flattened sibling of the fast lane (not nested
965
+ // inside its withTrace callback). Still fire-and-forget so UI is not blocked.
966
+ const slowPipelineChainIds = this.#getSlowPipelineChainIds(chainIds, response);
967
+ if (slowPipelineChainIds.length > 0) {
968
+ const slowSources = this.#isBasicFunctionality()
969
+ ? [this.#snapDataSource, this.#rpcDataSource]
970
+ : [this.#rpcDataSource];
971
+ const slowRequest = { ...request, chainIds: slowPipelineChainIds };
972
+ withTrace({
973
+ name: TRACE_BACKGROUND_FETCH,
974
+ trace: pipelineTrace,
975
+ data: {
976
+ chain_count: slowPipelineChainIds.length,
977
+ account_count: accounts.length,
978
+ },
979
+ fn: async (slowParentContext) => {
980
+ const { response: slowResponse } = await this.#executeMiddlewares({
981
+ sources: [
982
+ createParallelBalanceMiddleware(slowSources),
983
+ this.#detectionMiddleware,
984
+ ...(this.#isBasicFunctionality()
985
+ ? [
986
+ createParallelMiddleware([
987
+ this.#tokenDataSource,
988
+ this.#priceDataSource,
989
+ ]),
990
+ ]
991
+ : []),
992
+ ],
993
+ request: slowRequest,
994
+ parentContext: slowParentContext,
995
+ trace: pipelineTrace,
996
+ });
997
+ await this.#updateState({
998
+ ...slowResponse,
999
+ updateMode: 'merge',
1000
+ });
1001
+ },
1002
+ }).catch((error) => log('Background pipeline failed', { error }));
956
1003
  }
957
1004
  }
958
1005
  const result = this.#getAssetsFromState(accounts, chainIds, assetTypes);
959
1006
  return result;
960
1007
  }
961
- /**
962
- * v5 force-update request: every pin of the requested accounts, unscoped.
963
- * Delete with the rest of the v5 path when `assetsAccountsApiV6` is the default.
964
- *
965
- * @param accounts - Accounts in this fetch.
966
- * @param chainIds - Chains in this fetch.
967
- * @param requestOptions - Shared force-update request fields.
968
- * @param requestOptions.assetTypes - Asset types to fetch.
969
- * @param requestOptions.dataTypes - Data types to fetch.
970
- * @param requestOptions.forceUpdate - Always `true` to bypass caches.
971
- * @param requestOptions.bypassServerCache - Also bypass server-side HTTP caches.
972
- * @param requestOptions.assetsForPriceUpdate - Assets to refresh prices for.
973
- * @returns The v5 data request.
974
- */
975
- #buildForceUpdateRequestV5(accounts, chainIds, requestOptions) {
976
- const customAssets = [];
977
- for (const account of accounts) {
978
- customAssets.push(...this.getCustomAssets(account.id));
979
- }
980
- return this.#buildDataRequest(accounts, chainIds, {
981
- ...requestOptions,
982
- customAssets: customAssets.length > 0 ? customAssets : undefined,
983
- });
984
- }
985
- /**
986
- * v6 force-update request. Pins and hides are read from state by the
987
- * Accounts API unless `customAssetsOverride` scopes this fetch.
988
- *
989
- * @param accounts - Accounts in this fetch.
990
- * @param chainIds - Chains in this fetch.
991
- * @param requestOptions - Shared force-update request fields and optional pin scope.
992
- * @param requestOptions.assetTypes - Asset types to fetch.
993
- * @param requestOptions.dataTypes - Data types to fetch.
994
- * @param requestOptions.forceUpdate - Always `true` to bypass caches.
995
- * @param requestOptions.bypassServerCache - Also bypass server-side HTTP caches.
996
- * @param requestOptions.assetsForPriceUpdate - Assets to refresh prices for.
997
- * @param requestOptions.customAssetsOverride - When set, fetch only these pins.
998
- * @returns The v6 data request.
999
- */
1000
- #buildForceUpdateRequestV6(accounts, chainIds, requestOptions) {
1001
- const requestedChains = new Set(chainIds);
1002
- const customAssetsSet = new Set();
1003
- for (const assetId of requestOptions.customAssetsOverride ?? []) {
1004
- try {
1005
- const normalizedAssetId = normalizeAssetId(assetId);
1006
- if (requestedChains.has(parseCaipAssetType(normalizedAssetId).chainId) &&
1007
- !this.state.assetPreferences[normalizedAssetId]?.hidden) {
1008
- customAssetsSet.add(normalizedAssetId);
1009
- }
1010
- }
1011
- catch {
1012
- // Skip unparseable asset IDs
1013
- }
1014
- }
1015
- const customAssets = [...customAssetsSet];
1016
- return this.#buildDataRequest(accounts, chainIds, {
1017
- assetTypes: requestOptions.assetTypes,
1018
- dataTypes: requestOptions.dataTypes,
1019
- forceUpdate: requestOptions.forceUpdate,
1020
- bypassServerCache: requestOptions.bypassServerCache,
1021
- assetsForPriceUpdate: requestOptions.assetsForPriceUpdate,
1022
- customAssets: customAssets.length > 0 ? customAssets : undefined,
1023
- });
1024
- }
1025
- async #forceUpdateAssetsV5({ accounts, chainIds, request, pipelineTrace, }) {
1026
- const isBasicFunctionality = this.#isBasicFunctionality();
1027
- const fastSources = buildFastFetchSources({
1028
- accountsApiDataSource: this.#accountsApiDataSource,
1029
- stakedBalanceDataSource: this.#stakedBalanceDataSource,
1030
- customAssetGraduationMiddleware: this.#customAssetGraduationMiddleware,
1031
- rpcFallbackMiddleware: this.#rpcFallbackMiddleware,
1032
- detectionMiddleware: this.#detectionMiddleware,
1033
- tokenDataSource: this.#tokenDataSource,
1034
- priceDataSource: this.#priceDataSource,
1035
- }, { isBasicFunctionality, includeCustomAssetGraduation: true });
1036
- const fastResponse = await this.#runFastFetchV5({
1037
- accounts,
1038
- chainIds,
1039
- request,
1040
- pipelineTrace,
1041
- fastSources,
1042
- isBasicFunctionality,
1043
- });
1044
- this.#firstInitFetchReported = true;
1045
- this.#runBackgroundFetch({
1046
- accounts,
1047
- request,
1048
- pipelineTrace,
1049
- chainIds: this.#getSlowPipelineChainIds(chainIds, fastResponse),
1050
- isBasicFunctionality,
1051
- });
1052
- }
1053
- async #forceUpdateAssetsV6({ accounts, chainIds, request, pipelineTrace, }) {
1054
- const isBasicFunctionality = this.#isBasicFunctionality();
1055
- const fastSources = buildFastFetchSources({
1056
- accountsApiDataSource: this.#accountsApiDataSource,
1057
- stakedBalanceDataSource: this.#stakedBalanceDataSource,
1058
- customAssetGraduationMiddleware: this.#customAssetGraduationMiddleware,
1059
- rpcFallbackMiddleware: this.#rpcFallbackMiddleware,
1060
- detectionMiddleware: this.#detectionMiddleware,
1061
- tokenDataSource: this.#tokenDataSource,
1062
- priceDataSource: this.#priceDataSource,
1063
- }, {
1064
- isBasicFunctionality,
1065
- includeCustomAssetGraduation: false,
1066
- });
1067
- const fastResponse = await this.#runFastFetchV6({
1068
- accounts,
1069
- chainIds,
1070
- request,
1071
- pipelineTrace,
1072
- fastSources,
1073
- isBasicFunctionality,
1074
- });
1075
- this.#firstInitFetchReported = true;
1076
- this.#runBackgroundFetch({
1077
- accounts,
1078
- request,
1079
- pipelineTrace,
1080
- chainIds: this.#getSlowPipelineChainIds(chainIds, fastResponse),
1081
- isBasicFunctionality,
1082
- });
1083
- }
1084
- async #runFastFetchV5({ accounts, chainIds, request, pipelineTrace, fastSources, isBasicFunctionality, }) {
1085
- const { response } = await withTrace({
1086
- name: TRACE_FETCH_PIPELINE,
1087
- trace: pipelineTrace,
1088
- data: {
1089
- chain_count: chainIds.length,
1090
- account_count: accounts.length,
1091
- basic_functionality: isBasicFunctionality,
1092
- },
1093
- fn: async (parentContext) => {
1094
- const startTime = performance.now();
1095
- const result = await this.#executeMiddlewares({
1096
- sources: fastSources,
1097
- request,
1098
- parentContext,
1099
- trace: pipelineTrace,
1100
- });
1101
- await this.#updateState({
1102
- ...result.response,
1103
- updateMode: 'merge',
1104
- replaceCoveredChainBalances: true,
1105
- });
1106
- this.#emitFastFetchTraces({
1107
- accounts,
1108
- chainIds,
1109
- result,
1110
- durationMs: performance.now() - startTime,
1111
- isBasicFunctionality,
1112
- parentContext,
1113
- pipelineTrace,
1114
- });
1115
- return result;
1116
- },
1117
- });
1118
- return response;
1119
- }
1120
- async #runFastFetchV6({ accounts, chainIds, request, pipelineTrace, fastSources, isBasicFunctionality, }) {
1121
- const { response } = await withTrace({
1122
- name: TRACE_FETCH_PIPELINE,
1123
- trace: pipelineTrace,
1124
- data: {
1125
- chain_count: chainIds.length,
1126
- account_count: accounts.length,
1127
- basic_functionality: isBasicFunctionality,
1128
- },
1129
- fn: async (parentContext) => {
1130
- const startTime = performance.now();
1131
- const result = await this.#executeMiddlewares({
1132
- sources: fastSources,
1133
- request,
1134
- parentContext,
1135
- trace: pipelineTrace,
1136
- });
1137
- await this.#updateState(result.response);
1138
- this.#emitFastFetchTraces({
1139
- accounts,
1140
- chainIds,
1141
- result,
1142
- durationMs: performance.now() - startTime,
1143
- isBasicFunctionality,
1144
- parentContext,
1145
- pipelineTrace,
1146
- });
1147
- return result;
1148
- },
1149
- });
1150
- return response;
1151
- }
1152
- #runBackgroundFetch({ accounts, chainIds, request, pipelineTrace, isBasicFunctionality, }) {
1153
- if (chainIds.length === 0) {
1154
- return;
1155
- }
1156
- const slowSources = isBasicFunctionality
1157
- ? [this.#snapDataSource, this.#rpcDataSource]
1158
- : [this.#rpcDataSource];
1159
- const slowRequest = { ...request, chainIds };
1160
- withTrace({
1161
- name: TRACE_BACKGROUND_FETCH,
1162
- trace: pipelineTrace,
1163
- data: {
1164
- chain_count: chainIds.length,
1165
- account_count: accounts.length,
1166
- },
1167
- fn: async (parentContext) => {
1168
- const { response } = await this.#executeMiddlewares({
1169
- sources: [
1170
- createParallelBalanceMiddleware(slowSources),
1171
- this.#detectionMiddleware,
1172
- ...(isBasicFunctionality
1173
- ? [
1174
- createParallelMiddleware([
1175
- this.#tokenDataSource,
1176
- this.#priceDataSource,
1177
- ]),
1178
- ]
1179
- : []),
1180
- ],
1181
- request: slowRequest,
1182
- parentContext,
1183
- trace: pipelineTrace,
1184
- });
1185
- await this.#updateState({ ...response, updateMode: 'merge' });
1186
- },
1187
- }).catch((error) => log('Background pipeline failed', { error }));
1188
- }
1189
- #emitFastFetchTraces({ accounts, chainIds, result, durationMs, isBasicFunctionality, parentContext, pipelineTrace, }) {
1190
- emitTrace({
1191
- name: TRACE_FULL_FETCH,
1192
- trace: pipelineTrace,
1193
- data: {
1194
- duration_ms: durationMs,
1195
- chain_count: chainIds.length,
1196
- account_count: accounts.length,
1197
- basic_functionality: isBasicFunctionality,
1198
- asset_count: result.response.assetsBalance
1199
- ? Object.values(result.response.assetsBalance).reduce((sum, account) => sum + Object.keys(account).length, 0)
1200
- : 0,
1201
- price_count: result.response.assetsPrice
1202
- ? Object.keys(result.response.assetsPrice).length
1203
- : 0,
1204
- ...result.durationByDataSource,
1205
- },
1206
- parentContext,
1207
- });
1208
- emitTrace({
1209
- name: TRACE_FIRST_INIT_FETCH,
1210
- trace: pipelineTrace,
1211
- data: {
1212
- duration_ms: durationMs,
1213
- chain_ids: JSON.stringify(chainIds),
1214
- ...result.durationByDataSource,
1215
- },
1216
- parentContext,
1217
- });
1218
- }
1219
1008
  async getAssetsBalance(accounts, options) {
1220
1009
  // Reuse getAssets with dataTypes: ['balance'] only
1221
1010
  const assets = await this.getAssets(accounts, {
@@ -1483,28 +1272,20 @@ export class AssetsController extends BaseController {
1483
1272
  balances[accountId] ??= {};
1484
1273
  balances[accountId][normalizedAssetId] ??= { amount: '0' };
1485
1274
  });
1275
+ // Fetch data for the newly added custom asset (merge to preserve other chains)
1486
1276
  const account = this.#getSelectedAccounts().find((a) => a.id === accountId);
1487
1277
  if (account) {
1488
1278
  const chainId = extractChainId(normalizedAssetId);
1489
- if (this.#isBalanceV6Enabled()) {
1490
- await this.getAssets([account], {
1491
- chainIds: [chainId],
1492
- dataTypes: ['balance', 'metadata', 'price'],
1493
- assetTypes: ['fungible'],
1494
- forceUpdate: true,
1495
- customAssets: [normalizedAssetId],
1496
- });
1497
- }
1498
- else {
1499
- await this.getAssets([account], {
1500
- chainIds: [chainId],
1501
- dataTypes: ['balance', 'metadata', 'price'],
1502
- assetTypes: ['fungible'],
1503
- forceUpdate: true,
1504
- });
1505
- }
1279
+ await this.getAssets([account], {
1280
+ chainIds: [chainId],
1281
+ dataTypes: ['balance', 'metadata', 'price'],
1282
+ assetTypes: ['fungible'],
1283
+ forceUpdate: true,
1284
+ updateMode: 'merge',
1285
+ });
1506
1286
  }
1507
- // Re-evaluate subscriptions so polls pick up the new pin.
1287
+ // Re-evaluate subscriptions so the supplemental RPC poll picks up the
1288
+ // new customAsset on chains another data source already owns.
1508
1289
  this.#subscribeAssets();
1509
1290
  }
1510
1291
  /**
@@ -1525,7 +1306,8 @@ export class AssetsController extends BaseController {
1525
1306
  }
1526
1307
  }
1527
1308
  });
1528
- // Re-evaluate subscriptions so polls drop the removed pin.
1309
+ // Re-evaluate subscriptions so the supplemental RPC poll for that chain
1310
+ // is torn down when no more customAssets remain there.
1529
1311
  this.#subscribeAssets();
1530
1312
  }
1531
1313
  /**
@@ -1556,8 +1338,6 @@ export class AssetsController extends BaseController {
1556
1338
  }
1557
1339
  state.assetPreferences[normalizedAssetId].hidden = true;
1558
1340
  });
1559
- // Re-evaluate subscriptions so polls exclude the newly hidden asset.
1560
- this.#subscribeAssets();
1561
1341
  }
1562
1342
  /**
1563
1343
  * Unhide an asset globally.
@@ -1576,26 +1356,6 @@ export class AssetsController extends BaseController {
1576
1356
  }
1577
1357
  }
1578
1358
  });
1579
- // Re-evaluate subscriptions so polls stop excluding the asset.
1580
- this.#subscribeAssets();
1581
- }
1582
- /**
1583
- * Whether Accounts API v6 (and the v6 custom-asset path) is enabled.
1584
- * Injected into AccountsApiDataSource and RpcFallbackMiddleware.
1585
- *
1586
- * `RemoteFeatureFlagController` resolves a threshold-scoped flag to the
1587
- * selected group's `value`, so processed state is a boolean.
1588
- *
1589
- * @returns `true` when the v6 remote flag is on.
1590
- */
1591
- #isBalanceV6Enabled() {
1592
- try {
1593
- const { remoteFeatureFlags } = this.messenger.call('RemoteFeatureFlagController:getState');
1594
- return remoteFeatureFlags?.assetsAccountsApiV6 === true;
1595
- }
1596
- catch {
1597
- return false;
1598
- }
1599
1359
  }
1600
1360
  // ============================================================================
1601
1361
  // CURRENT CURRENCY MANAGEMENT
@@ -1829,16 +1589,10 @@ export class AssetsController extends BaseController {
1829
1589
  */
1830
1590
  #getSlowPipelineChainIds(chainIds, fastResponse) {
1831
1591
  const accountsApiChains = new Set(this.#accountsApiDataSource.getActiveChainsSync());
1832
- // Chains whose pins went unresolved (`unprocessedCustomAssets`): route
1833
- // them to the slow pipeline so RPC fetches the pins.
1834
- const unprocessedCustomAssetChains = new Set((fastResponse.unprocessedCustomAssets ?? []).map((assetId) => assetId.split('/')[0]));
1835
1592
  return chainIds.filter((chainId) => {
1836
1593
  if (fastResponse.errors?.[chainId]) {
1837
1594
  return true;
1838
1595
  }
1839
- if (unprocessedCustomAssetChains.has(chainId)) {
1840
- return true;
1841
- }
1842
1596
  if (!accountsApiChains.has(chainId)) {
1843
1597
  return true;
1844
1598
  }
@@ -2001,9 +1755,9 @@ export class AssetsController extends BaseController {
2001
1755
  for (const [accountId, accountBalances] of Object.entries(normalizedResponse.assetsBalance)) {
2002
1756
  const previousBalances = previousState.assetsBalance[accountId] ?? {};
2003
1757
  const customAssetIds = state.customAssets[accountId] ?? [];
2004
- const effective = mode === 'full'
2005
- ? mergeAccountBalancesV6(previousBalances, accountBalances, normalizedResponse.unprocessedCustomAssets ?? [])
2006
- : mergeAccountBalancesV5(previousBalances, accountBalances, customAssetIds, normalizedResponse.replaceCoveredChainBalances === true);
1758
+ const replaceCoveredChains = mode === 'full' ||
1759
+ normalizedResponse.replaceCoveredChainBalances === true;
1760
+ const effective = mergeAccountBalances(previousBalances, accountBalances, customAssetIds, replaceCoveredChains);
2007
1761
  // Ensure native tokens have an entry (0 if missing) for chains this account supports
2008
1762
  const account = this.#getSelectedAccounts().find((a) => a.id === accountId);
2009
1763
  const nativeAssetIdsForAccount = account
@@ -2033,12 +1787,10 @@ export class AssetsController extends BaseController {
2033
1787
  ...(newMetadata === undefined ? {} : { metadata: newMetadata }),
2034
1788
  };
2035
1789
  const oldAmount = previousBalance?.amount;
2036
- const isNewSeededZero = oldAmount === undefined &&
1790
+ const isNewDefaultNativeZero = oldAmount === undefined &&
2037
1791
  newAmount === '0' &&
2038
- (nativeAssetIdsForAccount.includes(assetId) ||
2039
- getDefaultAssetMetadata(assetId) !==
2040
- undefined);
2041
- if (oldAmount !== newAmount && !isNewSeededZero) {
1792
+ nativeAssetIdsForAccount.includes(assetId);
1793
+ if (oldAmount !== newAmount && !isNewDefaultNativeZero) {
2042
1794
  changedBalances.push({
2043
1795
  accountId,
2044
1796
  assetId,
@@ -2072,8 +1824,10 @@ export class AssetsController extends BaseController {
2072
1824
  changedPriceAssets.length > 0) {
2073
1825
  log('State updated', {
2074
1826
  changedBalances: changedBalances.length > 0 ? changedBalances : undefined,
2075
- changedMetadata: changedMetadata.length > 0 ? changedMetadata : undefined,
2076
- changedPrices: changedPriceAssets.length > 0 ? changedPriceAssets : undefined,
1827
+ changedMetadataCount: changedMetadata.length > 0 ? changedMetadata.length : undefined,
1828
+ changedPricesCount: changedPriceAssets.length > 0
1829
+ ? changedPriceAssets.length
1830
+ : undefined,
2077
1831
  newAssets: Object.keys(detectedAssets).length > 0
2078
1832
  ? Object.entries(detectedAssets).map(([accountId, assets]) => ({
2079
1833
  accountId,
@@ -2295,10 +2049,16 @@ export class AssetsController extends BaseController {
2295
2049
  if (!subscriptionKey.startsWith('ds:')) {
2296
2050
  continue;
2297
2051
  }
2298
- // Subscription keys take the form `ds:<SourceName>`.
2052
+ // Subscription keys take the form `ds:<SourceName>` for the regular
2053
+ // subscription or `ds:<SourceName>:<suffix>` for supplemental
2054
+ // subscriptions (e.g. `ds:RpcDataSource:custom`). Split on `:` and
2055
+ // pick the source-name segment so both shapes resolve correctly.
2299
2056
  const [, sourceId] = subscriptionKey.split(':');
2300
2057
  const source = allSources.find((ds) => ds.getName() === sourceId);
2301
2058
  if (source) {
2059
+ // Unsubscribe by the actual key — `#unsubscribeDataSource` only
2060
+ // knows the regular `ds:<SourceName>` shape and would miss
2061
+ // supplemental subscriptions, leaking their polling timers.
2302
2062
  this.#unsubscribeBySubscriptionKey(source, subscriptionKey);
2303
2063
  }
2304
2064
  }
@@ -2342,11 +2102,10 @@ export class AssetsController extends BaseController {
2342
2102
  * Strategy to minimize data source calls:
2343
2103
  * 1. Collect all chains to subscribe based on enabled networks
2344
2104
  * 2. Map chains to accounts based on their scopes
2345
- * 3. Split by data source (priority order) — each source gets one
2346
- * subscription for the chains it was assigned (accounts + chains only).
2347
- * v6 sources read pins/hides from state themselves (Accounts API sends
2348
- * `includeAssetIds` / `excludeAssetIds`). v5 uses a separate RPC
2349
- * `customAssetsOnly` supplement for pins on chains another source owns.
2105
+ * 3. Split by data source (ordered by priority) - each data source gets ONE subscription
2106
+ *
2107
+ * This ensures we make minimal subscriptions to each data source while covering
2108
+ * all accounts and chains.
2350
2109
  *
2351
2110
  * @param accounts - Accounts to subscribe balance updates for.
2352
2111
  * @param chainIds - Chain IDs to subscribe for.
@@ -2356,7 +2115,10 @@ export class AssetsController extends BaseController {
2356
2115
  #subscribeAssetsBalance(accounts, chainIds, options) {
2357
2116
  const chainToAccounts = this.#buildChainToAccountsMap(accounts, new Set(chainIds));
2358
2117
  const remainingChains = new Set(chainToAccounts.keys());
2359
- const balanceDataSources = this.#isBasicFunctionality()
2118
+ // When basic functionality is on, use all balance data sources; when off,
2119
+ // RPC only.
2120
+ const isBasicFunctionality = this.#isBasicFunctionality();
2121
+ const balanceDataSources = isBasicFunctionality
2360
2122
  ? this.#allBalanceDataSources
2361
2123
  : [this.#rpcDataSource];
2362
2124
  let rpcAssignedChains = new Set();
@@ -2395,15 +2157,22 @@ export class AssetsController extends BaseController {
2395
2157
  });
2396
2158
  }
2397
2159
  }
2398
- if (!this.#isBalanceV6Enabled()) {
2399
- this.#subscribeRpcCustomAssetsSupplement(accounts, chainToAccounts, rpcAssignedChains);
2400
- }
2160
+ // Supplemental RPC subscription for customAssets on chains another data
2161
+ // source claimed during regular handoff. RPC is the sole balance fetcher
2162
+ // for customAssets, so we must always poll them — even when (e.g.)
2163
+ // AccountsApi is already covering the chain for normal balances. The
2164
+ // supplemental subscription runs in `customAssetsOnly` mode so it does
2165
+ // NOT double-poll the regular tracked balances.
2166
+ this.#subscribeRpcCustomAssetsSupplement(accounts, chainToAccounts, rpcAssignedChains);
2401
2167
  }
2402
2168
  /**
2403
2169
  * Guarantee that customAssets are **always** polled by RPC, even when
2404
2170
  * AccountsApi or another data source has claimed the chain in the
2405
2171
  * regular handoff. RPC is the sole balance fetcher for user-imported
2406
- * tokens on the Accounts API v5 path (see `pickRpcCustomAssetsSupplement`).
2172
+ * tokens (see `pickRpcCustomAssetsSupplement` for the full rationale),
2173
+ * so we run a dedicated subscription in `customAssetsOnly` mode under a
2174
+ * distinct subscription key (`ds:RpcDataSource:custom`) that does not
2175
+ * interfere with the regular RPC subscription.
2407
2176
  *
2408
2177
  * @param accounts - Accounts to consider for customAssets.
2409
2178
  * @param chainToAccounts - Map of chain → accounts (built by caller).
@@ -2501,8 +2270,7 @@ export class AssetsController extends BaseController {
2501
2270
  * @param chains - Array of chain IDs to subscribe for.
2502
2271
  * @param options - Optional subscription overrides.
2503
2272
  * @param options.subscriptionKey - Custom subscription key (default: `ds:<sourceId>`).
2504
- * @param options.customAssetsOnly - When true, only poll customAssets for these
2505
- * chains (Accounts API v5 supplemental RPC subscription).
2273
+ * @param options.customAssetsOnly - When true, only poll customAssets for these chains.
2506
2274
  * @param options.skipInitialFetch - When true, skip the data source's subscribe-time fetch.
2507
2275
  */
2508
2276
  #subscribeDataSource(source, accounts, chains, options = {}) {
@@ -2715,13 +2483,13 @@ export class AssetsController extends BaseController {
2715
2483
  // The data will simply not be updated until the network is re-enabled.
2716
2484
  // Refresh subscriptions for new chain set
2717
2485
  this.#subscribeAssets();
2718
- // One-time fetch for newly enabled chains. v6 `full` replace is scoped to
2719
- // those chains; existing balances on other chains are left untouched.
2486
+ // Do one-time fetch for newly enabled chains; merge so we keep existing chain balances
2720
2487
  const accounts = this.#getSelectedAccounts();
2721
2488
  if (addedChains.length > 0 && accounts.length > 0) {
2722
2489
  await this.getAssets(accounts, {
2723
2490
  chainIds: addedChains,
2724
2491
  forceUpdate: true,
2492
+ updateMode: 'merge',
2725
2493
  });
2726
2494
  }
2727
2495
  this.#ensureNativeBalancesDefaultZero();
@@ -2881,28 +2649,48 @@ export class AssetsController extends BaseController {
2881
2649
  async handleAssetsUpdate(response, sourceId, request) {
2882
2650
  log('Assets updated from data source', {
2883
2651
  sourceId,
2884
- assetsBalance: response.assetsBalance,
2885
- assetsPrice: response.assetsPrice,
2652
+ hasBalance: Boolean(response.assetsBalance),
2653
+ hasPrice: Boolean(response.assetsPrice),
2886
2654
  });
2887
- if (this.#isBalanceV6Enabled()) {
2888
- await this.#handleAssetsUpdateV6(response, sourceId, request);
2889
- return;
2890
- }
2891
- await this.#handleAssetsUpdateV5(response, sourceId, request);
2892
- }
2893
- async #handleAssetsUpdateV5(response, sourceId, request) {
2655
+ // Enrichment spans only before unlock/first-init fetch completes.
2894
2656
  const pipelineTrace = this.#firstInitFetchReported
2895
2657
  ? undefined
2896
2658
  : this.#trace;
2897
2659
  await withTrace({
2898
2660
  name: TRACE_UPDATE_PARENT,
2899
2661
  trace: pipelineTrace,
2900
- data: this.#getUpdateTraceData(response, sourceId),
2662
+ data: {
2663
+ source: sourceId,
2664
+ has_balance: Boolean(response.assetsBalance),
2665
+ has_price: Boolean(response.assetsPrice),
2666
+ balance_account_count: response.assetsBalance
2667
+ ? Object.keys(response.assetsBalance).length
2668
+ : 0,
2669
+ },
2901
2670
  fn: async (parentContext) => {
2902
2671
  const updateStart = performance.now();
2903
- const pipelineRequest = this.#getUpdatePipelineRequest(request);
2672
+ const resolvedRequest = request ?? {
2673
+ accountsWithSupportedChains: [],
2674
+ chainIds: [],
2675
+ dataTypes: ['balance', 'metadata', 'price'],
2676
+ };
2677
+ // RPC-only mode (basic functionality off): never run token/price APIs. Strip
2678
+ // those data types so downstream middleware cannot treat them as requested.
2679
+ const pipelineRequest = this.#isBasicFunctionality()
2680
+ ? resolvedRequest
2681
+ : {
2682
+ ...resolvedRequest,
2683
+ dataTypes: resolvedRequest.dataTypes.filter((dt) => dt !== 'metadata' && dt !== 'price'),
2684
+ };
2685
+ // Graduate custom assets only when AccountsAPI / AccountActivity reports
2686
+ // them. RPC already fetches custom assets on purpose, and Snap handles
2687
+ // non-EVM chains the rule does not apply to, so skip the middleware for
2688
+ // those.
2904
2689
  const shouldGraduateCustomAssets = sourceId === 'AccountsApiDataSource' ||
2905
2690
  sourceId === 'AccountActivityDataSource';
2691
+ // Websocket updates can carry brand-new spam airdrops: enrich them
2692
+ // with Token API occurrences and drop below-floor tokens BEFORE
2693
+ // detection, so spam is never detected, enriched, priced or persisted.
2906
2694
  const shouldFilterOccurrences = sourceId === 'AccountActivityDataSource' &&
2907
2695
  this.#isBasicFunctionality();
2908
2696
  const shouldRunRpcFallback = sourceId === 'AccountsApiDataSource';
@@ -2938,110 +2726,25 @@ export class AssetsController extends BaseController {
2938
2726
  ...enrichedResponse,
2939
2727
  replaceCoveredChainBalances: response.replaceCoveredChainBalances,
2940
2728
  });
2941
- this.#emitUpdateTrace({
2942
- response,
2943
- sourceId,
2944
- enrichedResponse,
2945
- updateStart,
2946
- pipelineTrace,
2947
- parentContext,
2948
- });
2949
- },
2950
- });
2951
- }
2952
- async #handleAssetsUpdateV6(response, sourceId, request) {
2953
- const pipelineTrace = this.#firstInitFetchReported
2954
- ? undefined
2955
- : this.#trace;
2956
- await withTrace({
2957
- name: TRACE_UPDATE_PARENT,
2958
- trace: pipelineTrace,
2959
- data: this.#getUpdateTraceData(response, sourceId),
2960
- fn: async (parentContext) => {
2961
- const updateStart = performance.now();
2962
- const pipelineRequest = this.#getUpdatePipelineRequest(request);
2963
- const shouldFilterOccurrences = sourceId === 'AccountActivityDataSource' &&
2964
- this.#isBasicFunctionality();
2965
- const enrichmentSources = [
2966
- ...(shouldFilterOccurrences
2967
- ? [
2968
- {
2969
- getName: () => 'OccurrenceFloorFilter',
2970
- assetsMiddleware: this.#tokenDataSource.occurrenceFilterMiddleware,
2971
- },
2972
- ]
2973
- : []),
2974
- ];
2975
- if (this.#isBasicFunctionality()) {
2976
- enrichmentSources.push(this.#rpcFallbackMiddleware);
2977
- }
2978
- enrichmentSources.push(this.#detectionMiddleware);
2979
- if (this.#isBasicFunctionality()) {
2980
- enrichmentSources.push(createParallelMiddleware([
2981
- this.#tokenDataSource,
2982
- this.#priceDataSource,
2983
- ]));
2984
- }
2985
- const { response: enrichedResponse } = await this.#executeMiddlewares({
2986
- sources: enrichmentSources,
2987
- request: pipelineRequest,
2988
- initialResponse: response,
2989
- parentContext,
2729
+ // Summary fields for Assets Health (nested under the parent span).
2730
+ emitTrace({
2731
+ name: TRACE_UPDATE_PIPELINE,
2990
2732
  trace: pipelineTrace,
2991
- });
2992
- await this.#updateState(enrichedResponse);
2993
- this.#emitUpdateTrace({
2994
- response,
2995
- sourceId,
2996
- enrichedResponse,
2997
- updateStart,
2998
- pipelineTrace,
2733
+ data: {
2734
+ source: sourceId,
2735
+ duration_ms: performance.now() - updateStart,
2736
+ has_balance: Boolean(response.assetsBalance),
2737
+ has_price: Boolean(response.assetsPrice),
2738
+ has_metadata: Boolean(enrichedResponse.assetsInfo),
2739
+ balance_account_count: response.assetsBalance
2740
+ ? Object.keys(response.assetsBalance).length
2741
+ : 0,
2742
+ },
2999
2743
  parentContext,
3000
2744
  });
3001
2745
  },
3002
2746
  });
3003
2747
  }
3004
- #getUpdatePipelineRequest(request) {
3005
- const resolvedRequest = request ?? {
3006
- accountsWithSupportedChains: [],
3007
- chainIds: [],
3008
- dataTypes: ['balance', 'metadata', 'price'],
3009
- };
3010
- if (this.#isBasicFunctionality()) {
3011
- return resolvedRequest;
3012
- }
3013
- return {
3014
- ...resolvedRequest,
3015
- dataTypes: resolvedRequest.dataTypes.filter((dataType) => dataType !== 'metadata' && dataType !== 'price'),
3016
- };
3017
- }
3018
- #getUpdateTraceData(response, sourceId) {
3019
- return {
3020
- source: sourceId,
3021
- has_balance: Boolean(response.assetsBalance),
3022
- has_price: Boolean(response.assetsPrice),
3023
- balance_account_count: response.assetsBalance
3024
- ? Object.keys(response.assetsBalance).length
3025
- : 0,
3026
- };
3027
- }
3028
- #emitUpdateTrace({ response, sourceId, enrichedResponse, updateStart, pipelineTrace, parentContext, }) {
3029
- emitTrace({
3030
- name: TRACE_UPDATE_PIPELINE,
3031
- trace: pipelineTrace,
3032
- data: {
3033
- source: sourceId,
3034
- duration_ms: performance.now() - updateStart,
3035
- has_balance: Boolean(response.assetsBalance),
3036
- has_price: Boolean(response.assetsPrice),
3037
- has_metadata: Boolean(enrichedResponse.assetsInfo),
3038
- balance_account_count: response.assetsBalance
3039
- ? Object.keys(response.assetsBalance).length
3040
- : 0,
3041
- },
3042
- parentContext,
3043
- });
3044
- }
3045
2748
  // ============================================================================
3046
2749
  // CLEANUP
3047
2750
  // ============================================================================