@metamask-previews/assets-controller 16.1.0-preview-c6cdff1dd → 16.1.0-preview-9d2a9bdf5

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, } from './defaults.js';
16
+ import { CHAINS_WITH_DEFAULT_TRACKED_ASSETS, DEFAULT_TRACKED_ASSETS_BY_CHAIN, buildDefaultAssetsInfo, getDefaultAssetMetadata, getDefaultTrackedAssetsForChain, } 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,6 +187,10 @@ 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
+ }
190
194
  return normalized;
191
195
  }
192
196
  /**
@@ -194,12 +198,13 @@ function normalizeResponse(response) {
194
198
  *
195
199
  * @param previousBalances - Balances already in state for this account.
196
200
  * @param accountBalances - Balances from the incoming response.
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).
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.
200
205
  * @returns The merged balance map for the account.
201
206
  */
202
- function mergeAccountBalances(previousBalances, accountBalances, customAssetIds, replaceCoveredChains) {
207
+ function mergeAccountBalancesV5(previousBalances, accountBalances, customAssetIds, replaceCoveredChains) {
203
208
  if (!replaceCoveredChains) {
204
209
  return { ...previousBalances, ...accountBalances };
205
210
  }
@@ -228,6 +233,53 @@ function mergeAccountBalances(previousBalances, accountBalances, customAssetIds,
228
233
  }
229
234
  return next;
230
235
  }
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
+ }
231
283
  // ============================================================================
232
284
  // CONTROLLER IMPLEMENTATION
233
285
  // ============================================================================
@@ -463,6 +515,8 @@ export class AssetsController extends BaseController {
463
515
  queryApiClient,
464
516
  onActiveChainsUpdated: this.#onActiveChainsUpdated,
465
517
  ...accountsApiDataSourceConfig,
518
+ isBalanceV6Enabled: () => this.#isBalanceV6Enabled(),
519
+ getAssetsState: () => this.state,
466
520
  });
467
521
  this.#snapDataSource = new SnapDataSource({
468
522
  messenger: this.messenger,
@@ -514,6 +568,7 @@ export class AssetsController extends BaseController {
514
568
  });
515
569
  this.#rpcFallbackMiddleware = new RpcFallbackMiddleware({
516
570
  rpcDataSource: this.#rpcDataSource,
571
+ isBalanceV6Enabled: () => this.#isBalanceV6Enabled(),
517
572
  });
518
573
  log('Initializing AssetsController', {
519
574
  defaultUpdateInterval: this.#defaultUpdateInterval,
@@ -868,143 +923,298 @@ export class AssetsController extends BaseController {
868
923
  if (accounts.length === 0 || chainIds.length === 0) {
869
924
  return this.#getAssetsFromState(accounts, chainIds, assetTypes);
870
925
  }
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
- }
877
926
  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.
880
927
  const pipelineTrace = this.#firstInitFetchReported
881
928
  ? undefined
882
929
  : this.#trace;
883
- const request = this.#buildDataRequest(accounts, chainIds, {
930
+ const requestOptions = {
884
931
  assetTypes,
885
932
  dataTypes,
886
- customAssets: customAssets.length > 0 ? customAssets : undefined,
887
933
  forceUpdate: true,
888
934
  bypassServerCache: options?.bypassServerCache,
889
935
  assetsForPriceUpdate: options?.assetsForPriceUpdate,
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 }));
936
+ };
937
+ if (this.#isBalanceV6Enabled()) {
938
+ await this.#forceUpdateAssetsV6({
939
+ accounts,
940
+ chainIds,
941
+ request: this.#buildForceUpdateRequestV6(accounts, chainIds, {
942
+ ...requestOptions,
943
+ customAssetsOverride: options?.customAssets,
944
+ }),
945
+ pipelineTrace,
946
+ });
947
+ }
948
+ else {
949
+ await this.#forceUpdateAssetsV5({
950
+ accounts,
951
+ chainIds,
952
+ request: this.#buildForceUpdateRequestV5(accounts, chainIds, requestOptions),
953
+ pipelineTrace,
954
+ });
1003
955
  }
1004
956
  }
1005
957
  const result = this.#getAssetsFromState(accounts, chainIds, assetTypes);
1006
958
  return result;
1007
959
  }
960
+ /**
961
+ * v5 force-update request: every pin of the requested accounts, unscoped.
962
+ * Delete with the rest of the v5 path when `assetsAccountsApiV6` is the default.
963
+ *
964
+ * @param accounts - Accounts in this fetch.
965
+ * @param chainIds - Chains in this fetch.
966
+ * @param requestOptions - Shared force-update request fields.
967
+ * @param requestOptions.assetTypes - Asset types to fetch.
968
+ * @param requestOptions.dataTypes - Data types to fetch.
969
+ * @param requestOptions.forceUpdate - Always `true` to bypass caches.
970
+ * @param requestOptions.bypassServerCache - Also bypass server-side HTTP caches.
971
+ * @param requestOptions.assetsForPriceUpdate - Assets to refresh prices for.
972
+ * @returns The v5 data request.
973
+ */
974
+ #buildForceUpdateRequestV5(accounts, chainIds, requestOptions) {
975
+ const customAssets = [];
976
+ for (const account of accounts) {
977
+ customAssets.push(...this.getCustomAssets(account.id));
978
+ }
979
+ return this.#buildDataRequest(accounts, chainIds, {
980
+ ...requestOptions,
981
+ customAssets: customAssets.length > 0 ? customAssets : undefined,
982
+ });
983
+ }
984
+ /**
985
+ * v6 force-update request. Pins and hides are read from state by the
986
+ * Accounts API unless `customAssetsOverride` scopes this fetch.
987
+ *
988
+ * @param accounts - Accounts in this fetch.
989
+ * @param chainIds - Chains in this fetch.
990
+ * @param requestOptions - Shared force-update request fields and optional pin scope.
991
+ * @param requestOptions.assetTypes - Asset types to fetch.
992
+ * @param requestOptions.dataTypes - Data types to fetch.
993
+ * @param requestOptions.forceUpdate - Always `true` to bypass caches.
994
+ * @param requestOptions.bypassServerCache - Also bypass server-side HTTP caches.
995
+ * @param requestOptions.assetsForPriceUpdate - Assets to refresh prices for.
996
+ * @param requestOptions.customAssetsOverride - When set, fetch only these pins.
997
+ * @returns The v6 data request.
998
+ */
999
+ #buildForceUpdateRequestV6(accounts, chainIds, requestOptions) {
1000
+ const requestedChains = new Set(chainIds);
1001
+ const customAssetsSet = new Set();
1002
+ for (const assetId of requestOptions.customAssetsOverride ?? []) {
1003
+ try {
1004
+ const normalizedAssetId = normalizeAssetId(assetId);
1005
+ if (requestedChains.has(parseCaipAssetType(normalizedAssetId).chainId) &&
1006
+ !this.state.assetPreferences[normalizedAssetId]?.hidden) {
1007
+ customAssetsSet.add(normalizedAssetId);
1008
+ }
1009
+ }
1010
+ catch {
1011
+ // Skip unparseable asset IDs
1012
+ }
1013
+ }
1014
+ const customAssets = [...customAssetsSet];
1015
+ return this.#buildDataRequest(accounts, chainIds, {
1016
+ assetTypes: requestOptions.assetTypes,
1017
+ dataTypes: requestOptions.dataTypes,
1018
+ forceUpdate: requestOptions.forceUpdate,
1019
+ bypassServerCache: requestOptions.bypassServerCache,
1020
+ assetsForPriceUpdate: requestOptions.assetsForPriceUpdate,
1021
+ customAssets: customAssets.length > 0 ? customAssets : undefined,
1022
+ });
1023
+ }
1024
+ async #forceUpdateAssetsV5({ accounts, chainIds, request, pipelineTrace, }) {
1025
+ const isBasicFunctionality = this.#isBasicFunctionality();
1026
+ const fastSources = buildFastFetchSources({
1027
+ accountsApiDataSource: this.#accountsApiDataSource,
1028
+ stakedBalanceDataSource: this.#stakedBalanceDataSource,
1029
+ customAssetGraduationMiddleware: this.#customAssetGraduationMiddleware,
1030
+ rpcFallbackMiddleware: this.#rpcFallbackMiddleware,
1031
+ detectionMiddleware: this.#detectionMiddleware,
1032
+ tokenDataSource: this.#tokenDataSource,
1033
+ priceDataSource: this.#priceDataSource,
1034
+ }, { isBasicFunctionality, includeCustomAssetGraduation: true });
1035
+ const fastResponse = await this.#runFastFetchV5({
1036
+ accounts,
1037
+ chainIds,
1038
+ request,
1039
+ pipelineTrace,
1040
+ fastSources,
1041
+ isBasicFunctionality,
1042
+ });
1043
+ this.#firstInitFetchReported = true;
1044
+ this.#runBackgroundFetch({
1045
+ accounts,
1046
+ request,
1047
+ pipelineTrace,
1048
+ chainIds: this.#getSlowPipelineChainIds(chainIds, fastResponse),
1049
+ isBasicFunctionality,
1050
+ });
1051
+ }
1052
+ async #forceUpdateAssetsV6({ accounts, chainIds, request, pipelineTrace, }) {
1053
+ const isBasicFunctionality = this.#isBasicFunctionality();
1054
+ const fastSources = buildFastFetchSources({
1055
+ accountsApiDataSource: this.#accountsApiDataSource,
1056
+ stakedBalanceDataSource: this.#stakedBalanceDataSource,
1057
+ customAssetGraduationMiddleware: this.#customAssetGraduationMiddleware,
1058
+ rpcFallbackMiddleware: this.#rpcFallbackMiddleware,
1059
+ detectionMiddleware: this.#detectionMiddleware,
1060
+ tokenDataSource: this.#tokenDataSource,
1061
+ priceDataSource: this.#priceDataSource,
1062
+ }, {
1063
+ isBasicFunctionality,
1064
+ includeCustomAssetGraduation: false,
1065
+ });
1066
+ const fastResponse = await this.#runFastFetchV6({
1067
+ accounts,
1068
+ chainIds,
1069
+ request,
1070
+ pipelineTrace,
1071
+ fastSources,
1072
+ isBasicFunctionality,
1073
+ });
1074
+ this.#firstInitFetchReported = true;
1075
+ this.#runBackgroundFetch({
1076
+ accounts,
1077
+ request,
1078
+ pipelineTrace,
1079
+ chainIds: this.#getSlowPipelineChainIds(chainIds, fastResponse),
1080
+ isBasicFunctionality,
1081
+ });
1082
+ }
1083
+ async #runFastFetchV5({ accounts, chainIds, request, pipelineTrace, fastSources, isBasicFunctionality, }) {
1084
+ const { response } = await withTrace({
1085
+ name: TRACE_FETCH_PIPELINE,
1086
+ trace: pipelineTrace,
1087
+ data: {
1088
+ chain_count: chainIds.length,
1089
+ account_count: accounts.length,
1090
+ basic_functionality: isBasicFunctionality,
1091
+ },
1092
+ fn: async (parentContext) => {
1093
+ const startTime = performance.now();
1094
+ const result = await this.#executeMiddlewares({
1095
+ sources: fastSources,
1096
+ request,
1097
+ parentContext,
1098
+ trace: pipelineTrace,
1099
+ });
1100
+ await this.#updateState({
1101
+ ...result.response,
1102
+ updateMode: 'merge',
1103
+ replaceCoveredChainBalances: true,
1104
+ });
1105
+ this.#emitFastFetchTraces({
1106
+ accounts,
1107
+ chainIds,
1108
+ result,
1109
+ durationMs: performance.now() - startTime,
1110
+ isBasicFunctionality,
1111
+ parentContext,
1112
+ pipelineTrace,
1113
+ });
1114
+ return result;
1115
+ },
1116
+ });
1117
+ return response;
1118
+ }
1119
+ async #runFastFetchV6({ accounts, chainIds, request, pipelineTrace, fastSources, isBasicFunctionality, }) {
1120
+ const { response } = await withTrace({
1121
+ name: TRACE_FETCH_PIPELINE,
1122
+ trace: pipelineTrace,
1123
+ data: {
1124
+ chain_count: chainIds.length,
1125
+ account_count: accounts.length,
1126
+ basic_functionality: isBasicFunctionality,
1127
+ },
1128
+ fn: async (parentContext) => {
1129
+ const startTime = performance.now();
1130
+ const result = await this.#executeMiddlewares({
1131
+ sources: fastSources,
1132
+ request,
1133
+ parentContext,
1134
+ trace: pipelineTrace,
1135
+ });
1136
+ await this.#updateState(result.response);
1137
+ this.#emitFastFetchTraces({
1138
+ accounts,
1139
+ chainIds,
1140
+ result,
1141
+ durationMs: performance.now() - startTime,
1142
+ isBasicFunctionality,
1143
+ parentContext,
1144
+ pipelineTrace,
1145
+ });
1146
+ return result;
1147
+ },
1148
+ });
1149
+ return response;
1150
+ }
1151
+ #runBackgroundFetch({ accounts, chainIds, request, pipelineTrace, isBasicFunctionality, }) {
1152
+ if (chainIds.length === 0) {
1153
+ return;
1154
+ }
1155
+ const slowSources = isBasicFunctionality
1156
+ ? [this.#snapDataSource, this.#rpcDataSource]
1157
+ : [this.#rpcDataSource];
1158
+ const slowRequest = { ...request, chainIds };
1159
+ withTrace({
1160
+ name: TRACE_BACKGROUND_FETCH,
1161
+ trace: pipelineTrace,
1162
+ data: {
1163
+ chain_count: chainIds.length,
1164
+ account_count: accounts.length,
1165
+ },
1166
+ fn: async (parentContext) => {
1167
+ const { response } = await this.#executeMiddlewares({
1168
+ sources: [
1169
+ createParallelBalanceMiddleware(slowSources),
1170
+ this.#detectionMiddleware,
1171
+ ...(isBasicFunctionality
1172
+ ? [
1173
+ createParallelMiddleware([
1174
+ this.#tokenDataSource,
1175
+ this.#priceDataSource,
1176
+ ]),
1177
+ ]
1178
+ : []),
1179
+ ],
1180
+ request: slowRequest,
1181
+ parentContext,
1182
+ trace: pipelineTrace,
1183
+ });
1184
+ await this.#updateState({ ...response, updateMode: 'merge' });
1185
+ },
1186
+ }).catch((error) => log('Background pipeline failed', { error }));
1187
+ }
1188
+ #emitFastFetchTraces({ accounts, chainIds, result, durationMs, isBasicFunctionality, parentContext, pipelineTrace, }) {
1189
+ emitTrace({
1190
+ name: TRACE_FULL_FETCH,
1191
+ trace: pipelineTrace,
1192
+ data: {
1193
+ duration_ms: durationMs,
1194
+ chain_count: chainIds.length,
1195
+ account_count: accounts.length,
1196
+ basic_functionality: isBasicFunctionality,
1197
+ asset_count: result.response.assetsBalance
1198
+ ? Object.values(result.response.assetsBalance).reduce((sum, account) => sum + Object.keys(account).length, 0)
1199
+ : 0,
1200
+ price_count: result.response.assetsPrice
1201
+ ? Object.keys(result.response.assetsPrice).length
1202
+ : 0,
1203
+ ...result.durationByDataSource,
1204
+ },
1205
+ parentContext,
1206
+ });
1207
+ emitTrace({
1208
+ name: TRACE_FIRST_INIT_FETCH,
1209
+ trace: pipelineTrace,
1210
+ data: {
1211
+ duration_ms: durationMs,
1212
+ chain_ids: JSON.stringify(chainIds),
1213
+ ...result.durationByDataSource,
1214
+ },
1215
+ parentContext,
1216
+ });
1217
+ }
1008
1218
  async getAssetsBalance(accounts, options) {
1009
1219
  // Reuse getAssets with dataTypes: ['balance'] only
1010
1220
  const assets = await this.getAssets(accounts, {
@@ -1272,20 +1482,28 @@ export class AssetsController extends BaseController {
1272
1482
  balances[accountId] ??= {};
1273
1483
  balances[accountId][normalizedAssetId] ??= { amount: '0' };
1274
1484
  });
1275
- // Fetch data for the newly added custom asset (merge to preserve other chains)
1276
1485
  const account = this.#getSelectedAccounts().find((a) => a.id === accountId);
1277
1486
  if (account) {
1278
1487
  const chainId = extractChainId(normalizedAssetId);
1279
- await this.getAssets([account], {
1280
- chainIds: [chainId],
1281
- dataTypes: ['balance', 'metadata', 'price'],
1282
- assetTypes: ['fungible'],
1283
- forceUpdate: true,
1284
- updateMode: 'merge',
1285
- });
1488
+ if (this.#isBalanceV6Enabled()) {
1489
+ await this.getAssets([account], {
1490
+ chainIds: [chainId],
1491
+ dataTypes: ['balance', 'metadata', 'price'],
1492
+ assetTypes: ['fungible'],
1493
+ forceUpdate: true,
1494
+ customAssets: [normalizedAssetId],
1495
+ });
1496
+ }
1497
+ else {
1498
+ await this.getAssets([account], {
1499
+ chainIds: [chainId],
1500
+ dataTypes: ['balance', 'metadata', 'price'],
1501
+ assetTypes: ['fungible'],
1502
+ forceUpdate: true,
1503
+ });
1504
+ }
1286
1505
  }
1287
- // Re-evaluate subscriptions so the supplemental RPC poll picks up the
1288
- // new customAsset on chains another data source already owns.
1506
+ // Re-evaluate subscriptions so polls pick up the new pin.
1289
1507
  this.#subscribeAssets();
1290
1508
  }
1291
1509
  /**
@@ -1306,8 +1524,7 @@ export class AssetsController extends BaseController {
1306
1524
  }
1307
1525
  }
1308
1526
  });
1309
- // Re-evaluate subscriptions so the supplemental RPC poll for that chain
1310
- // is torn down when no more customAssets remain there.
1527
+ // Re-evaluate subscriptions so polls drop the removed pin.
1311
1528
  this.#subscribeAssets();
1312
1529
  }
1313
1530
  /**
@@ -1338,6 +1555,8 @@ export class AssetsController extends BaseController {
1338
1555
  }
1339
1556
  state.assetPreferences[normalizedAssetId].hidden = true;
1340
1557
  });
1558
+ // Re-evaluate subscriptions so polls exclude the newly hidden asset.
1559
+ this.#subscribeAssets();
1341
1560
  }
1342
1561
  /**
1343
1562
  * Unhide an asset globally.
@@ -1356,6 +1575,26 @@ export class AssetsController extends BaseController {
1356
1575
  }
1357
1576
  }
1358
1577
  });
1578
+ // Re-evaluate subscriptions so polls stop excluding the asset.
1579
+ this.#subscribeAssets();
1580
+ }
1581
+ /**
1582
+ * Whether Accounts API v6 (and the v6 custom-asset path) is enabled.
1583
+ * Injected into AccountsApiDataSource and RpcFallbackMiddleware.
1584
+ *
1585
+ * `RemoteFeatureFlagController` resolves a threshold-scoped flag to the
1586
+ * selected group's `value`, so processed state is a boolean.
1587
+ *
1588
+ * @returns `true` when the v6 remote flag is on.
1589
+ */
1590
+ #isBalanceV6Enabled() {
1591
+ try {
1592
+ const { remoteFeatureFlags } = this.messenger.call('RemoteFeatureFlagController:getState');
1593
+ return remoteFeatureFlags?.assetsAccountsApiV6 === true;
1594
+ }
1595
+ catch {
1596
+ return false;
1597
+ }
1359
1598
  }
1360
1599
  // ============================================================================
1361
1600
  // CURRENT CURRENCY MANAGEMENT
@@ -1589,10 +1828,16 @@ export class AssetsController extends BaseController {
1589
1828
  */
1590
1829
  #getSlowPipelineChainIds(chainIds, fastResponse) {
1591
1830
  const accountsApiChains = new Set(this.#accountsApiDataSource.getActiveChainsSync());
1831
+ // Chains whose pins went unresolved (`unprocessedCustomAssets`): route
1832
+ // them to the slow pipeline so RPC fetches the pins.
1833
+ const unprocessedCustomAssetChains = new Set((fastResponse.unprocessedCustomAssets ?? []).map((assetId) => assetId.split('/')[0]));
1592
1834
  return chainIds.filter((chainId) => {
1593
1835
  if (fastResponse.errors?.[chainId]) {
1594
1836
  return true;
1595
1837
  }
1838
+ if (unprocessedCustomAssetChains.has(chainId)) {
1839
+ return true;
1840
+ }
1596
1841
  if (!accountsApiChains.has(chainId)) {
1597
1842
  return true;
1598
1843
  }
@@ -1755,9 +2000,9 @@ export class AssetsController extends BaseController {
1755
2000
  for (const [accountId, accountBalances] of Object.entries(normalizedResponse.assetsBalance)) {
1756
2001
  const previousBalances = previousState.assetsBalance[accountId] ?? {};
1757
2002
  const customAssetIds = state.customAssets[accountId] ?? [];
1758
- const replaceCoveredChains = mode === 'full' ||
1759
- normalizedResponse.replaceCoveredChainBalances === true;
1760
- const effective = mergeAccountBalances(previousBalances, accountBalances, customAssetIds, replaceCoveredChains);
2003
+ const effective = mode === 'full'
2004
+ ? mergeAccountBalancesV6(previousBalances, accountBalances, normalizedResponse.unprocessedCustomAssets ?? [])
2005
+ : mergeAccountBalancesV5(previousBalances, accountBalances, customAssetIds, normalizedResponse.replaceCoveredChainBalances === true);
1761
2006
  // Ensure native tokens have an entry (0 if missing) for chains this account supports
1762
2007
  const account = this.#getSelectedAccounts().find((a) => a.id === accountId);
1763
2008
  const nativeAssetIdsForAccount = account
@@ -1787,10 +2032,12 @@ export class AssetsController extends BaseController {
1787
2032
  ...(newMetadata === undefined ? {} : { metadata: newMetadata }),
1788
2033
  };
1789
2034
  const oldAmount = previousBalance?.amount;
1790
- const isNewDefaultNativeZero = oldAmount === undefined &&
2035
+ const isNewSeededZero = oldAmount === undefined &&
1791
2036
  newAmount === '0' &&
1792
- nativeAssetIdsForAccount.includes(assetId);
1793
- if (oldAmount !== newAmount && !isNewDefaultNativeZero) {
2037
+ (nativeAssetIdsForAccount.includes(assetId) ||
2038
+ getDefaultAssetMetadata(assetId) !==
2039
+ undefined);
2040
+ if (oldAmount !== newAmount && !isNewSeededZero) {
1794
2041
  changedBalances.push({
1795
2042
  accountId,
1796
2043
  assetId,
@@ -1824,10 +2071,8 @@ export class AssetsController extends BaseController {
1824
2071
  changedPriceAssets.length > 0) {
1825
2072
  log('State updated', {
1826
2073
  changedBalances: changedBalances.length > 0 ? changedBalances : undefined,
1827
- changedMetadataCount: changedMetadata.length > 0 ? changedMetadata.length : undefined,
1828
- changedPricesCount: changedPriceAssets.length > 0
1829
- ? changedPriceAssets.length
1830
- : undefined,
2074
+ changedMetadata: changedMetadata.length > 0 ? changedMetadata : undefined,
2075
+ changedPrices: changedPriceAssets.length > 0 ? changedPriceAssets : undefined,
1831
2076
  newAssets: Object.keys(detectedAssets).length > 0
1832
2077
  ? Object.entries(detectedAssets).map(([accountId, assets]) => ({
1833
2078
  accountId,
@@ -2049,16 +2294,10 @@ export class AssetsController extends BaseController {
2049
2294
  if (!subscriptionKey.startsWith('ds:')) {
2050
2295
  continue;
2051
2296
  }
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.
2297
+ // Subscription keys take the form `ds:<SourceName>`.
2056
2298
  const [, sourceId] = subscriptionKey.split(':');
2057
2299
  const source = allSources.find((ds) => ds.getName() === sourceId);
2058
2300
  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.
2062
2301
  this.#unsubscribeBySubscriptionKey(source, subscriptionKey);
2063
2302
  }
2064
2303
  }
@@ -2102,10 +2341,11 @@ export class AssetsController extends BaseController {
2102
2341
  * Strategy to minimize data source calls:
2103
2342
  * 1. Collect all chains to subscribe based on enabled networks
2104
2343
  * 2. Map chains to accounts based on their scopes
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.
2344
+ * 3. Split by data source (priority order) — each source gets one
2345
+ * subscription for the chains it was assigned (accounts + chains only).
2346
+ * v6 sources read pins/hides from state themselves (Accounts API sends
2347
+ * `includeAssetIds` / `excludeAssetIds`). v5 uses a separate RPC
2348
+ * `customAssetsOnly` supplement for pins on chains another source owns.
2109
2349
  *
2110
2350
  * @param accounts - Accounts to subscribe balance updates for.
2111
2351
  * @param chainIds - Chain IDs to subscribe for.
@@ -2115,10 +2355,7 @@ export class AssetsController extends BaseController {
2115
2355
  #subscribeAssetsBalance(accounts, chainIds, options) {
2116
2356
  const chainToAccounts = this.#buildChainToAccountsMap(accounts, new Set(chainIds));
2117
2357
  const remainingChains = new Set(chainToAccounts.keys());
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
2358
+ const balanceDataSources = this.#isBasicFunctionality()
2122
2359
  ? this.#allBalanceDataSources
2123
2360
  : [this.#rpcDataSource];
2124
2361
  let rpcAssignedChains = new Set();
@@ -2157,22 +2394,15 @@ export class AssetsController extends BaseController {
2157
2394
  });
2158
2395
  }
2159
2396
  }
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);
2397
+ if (!this.#isBalanceV6Enabled()) {
2398
+ this.#subscribeRpcCustomAssetsSupplement(accounts, chainToAccounts, rpcAssignedChains);
2399
+ }
2167
2400
  }
2168
2401
  /**
2169
2402
  * Guarantee that customAssets are **always** polled by RPC, even when
2170
2403
  * AccountsApi or another data source has claimed the chain in the
2171
2404
  * regular handoff. RPC is the sole balance fetcher for user-imported
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.
2405
+ * tokens on the Accounts API v5 path (see `pickRpcCustomAssetsSupplement`).
2176
2406
  *
2177
2407
  * @param accounts - Accounts to consider for customAssets.
2178
2408
  * @param chainToAccounts - Map of chain → accounts (built by caller).
@@ -2270,7 +2500,8 @@ export class AssetsController extends BaseController {
2270
2500
  * @param chains - Array of chain IDs to subscribe for.
2271
2501
  * @param options - Optional subscription overrides.
2272
2502
  * @param options.subscriptionKey - Custom subscription key (default: `ds:<sourceId>`).
2273
- * @param options.customAssetsOnly - When true, only poll customAssets for these chains.
2503
+ * @param options.customAssetsOnly - When true, only poll customAssets for these
2504
+ * chains (Accounts API v5 supplemental RPC subscription).
2274
2505
  * @param options.skipInitialFetch - When true, skip the data source's subscribe-time fetch.
2275
2506
  */
2276
2507
  #subscribeDataSource(source, accounts, chains, options = {}) {
@@ -2483,13 +2714,13 @@ export class AssetsController extends BaseController {
2483
2714
  // The data will simply not be updated until the network is re-enabled.
2484
2715
  // Refresh subscriptions for new chain set
2485
2716
  this.#subscribeAssets();
2486
- // Do one-time fetch for newly enabled chains; merge so we keep existing chain balances
2717
+ // One-time fetch for newly enabled chains. v6 `full` replace is scoped to
2718
+ // those chains; existing balances on other chains are left untouched.
2487
2719
  const accounts = this.#getSelectedAccounts();
2488
2720
  if (addedChains.length > 0 && accounts.length > 0) {
2489
2721
  await this.getAssets(accounts, {
2490
2722
  chainIds: addedChains,
2491
2723
  forceUpdate: true,
2492
- updateMode: 'merge',
2493
2724
  });
2494
2725
  }
2495
2726
  this.#ensureNativeBalancesDefaultZero();
@@ -2649,48 +2880,28 @@ export class AssetsController extends BaseController {
2649
2880
  async handleAssetsUpdate(response, sourceId, request) {
2650
2881
  log('Assets updated from data source', {
2651
2882
  sourceId,
2652
- hasBalance: Boolean(response.assetsBalance),
2653
- hasPrice: Boolean(response.assetsPrice),
2883
+ assetsBalance: response.assetsBalance,
2884
+ assetsPrice: response.assetsPrice,
2654
2885
  });
2655
- // Enrichment spans only before unlock/first-init fetch completes.
2886
+ if (this.#isBalanceV6Enabled()) {
2887
+ await this.#handleAssetsUpdateV6(response, sourceId, request);
2888
+ return;
2889
+ }
2890
+ await this.#handleAssetsUpdateV5(response, sourceId, request);
2891
+ }
2892
+ async #handleAssetsUpdateV5(response, sourceId, request) {
2656
2893
  const pipelineTrace = this.#firstInitFetchReported
2657
2894
  ? undefined
2658
2895
  : this.#trace;
2659
2896
  await withTrace({
2660
2897
  name: TRACE_UPDATE_PARENT,
2661
2898
  trace: pipelineTrace,
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
- },
2899
+ data: this.#getUpdateTraceData(response, sourceId),
2670
2900
  fn: async (parentContext) => {
2671
2901
  const updateStart = performance.now();
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.
2902
+ const pipelineRequest = this.#getUpdatePipelineRequest(request);
2689
2903
  const shouldGraduateCustomAssets = sourceId === 'AccountsApiDataSource' ||
2690
2904
  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.
2694
2905
  const shouldFilterOccurrences = sourceId === 'AccountActivityDataSource' &&
2695
2906
  this.#isBasicFunctionality();
2696
2907
  const shouldRunRpcFallback = sourceId === 'AccountsApiDataSource';
@@ -2709,6 +2920,7 @@ export class AssetsController extends BaseController {
2709
2920
  ...(shouldRunRpcFallback ? [this.#rpcFallbackMiddleware] : []),
2710
2921
  this.#detectionMiddleware,
2711
2922
  ];
2923
+ enrichmentSources.push(this.#detectionMiddleware);
2712
2924
  if (this.#isBasicFunctionality()) {
2713
2925
  enrichmentSources.push(createParallelMiddleware([
2714
2926
  this.#tokenDataSource,
@@ -2726,25 +2938,110 @@ export class AssetsController extends BaseController {
2726
2938
  ...enrichedResponse,
2727
2939
  replaceCoveredChainBalances: response.replaceCoveredChainBalances,
2728
2940
  });
2729
- // Summary fields for Assets Health (nested under the parent span).
2730
- emitTrace({
2731
- name: TRACE_UPDATE_PIPELINE,
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,
2732
2990
  trace: 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
- },
2991
+ });
2992
+ await this.#updateState(enrichedResponse);
2993
+ this.#emitUpdateTrace({
2994
+ response,
2995
+ sourceId,
2996
+ enrichedResponse,
2997
+ updateStart,
2998
+ pipelineTrace,
2743
2999
  parentContext,
2744
3000
  });
2745
3001
  },
2746
3002
  });
2747
3003
  }
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
+ }
2748
3045
  // ============================================================================
2749
3046
  // CLEANUP
2750
3047
  // ============================================================================