@metamask-previews/assets-controller 16.1.0-preview-b1e33e9f1 → 16.1.0-preview-d2ea3cc

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 (81) hide show
  1. package/CHANGELOG.md +0 -28
  2. package/dist/AssetsController-method-action-types.d.ts +1 -5
  3. package/dist/AssetsController-method-action-types.d.ts.map +1 -1
  4. package/dist/AssetsController-method-action-types.js.map +1 -1
  5. package/dist/AssetsController.d.ts +40 -8
  6. package/dist/AssetsController.d.ts.map +1 -1
  7. package/dist/AssetsController.js +235 -570
  8. package/dist/AssetsController.js.map +1 -1
  9. package/dist/data-sources/AbstractDataSource.d.ts +6 -1
  10. package/dist/data-sources/AbstractDataSource.d.ts.map +1 -1
  11. package/dist/data-sources/AbstractDataSource.js.map +1 -1
  12. package/dist/data-sources/AccountsApiDataSource.d.ts +3 -17
  13. package/dist/data-sources/AccountsApiDataSource.d.ts.map +1 -1
  14. package/dist/data-sources/AccountsApiDataSource.js +125 -275
  15. package/dist/data-sources/AccountsApiDataSource.js.map +1 -1
  16. package/dist/data-sources/PriceDataSource.d.ts +6 -7
  17. package/dist/data-sources/PriceDataSource.d.ts.map +1 -1
  18. package/dist/data-sources/PriceDataSource.js +20 -15
  19. package/dist/data-sources/PriceDataSource.js.map +1 -1
  20. package/dist/data-sources/RpcDataSource.d.ts +1 -15
  21. package/dist/data-sources/RpcDataSource.d.ts.map +1 -1
  22. package/dist/data-sources/RpcDataSource.js +107 -398
  23. package/dist/data-sources/RpcDataSource.js.map +1 -1
  24. package/dist/data-sources/SnapDataSource.d.ts +0 -5
  25. package/dist/data-sources/SnapDataSource.d.ts.map +1 -1
  26. package/dist/data-sources/SnapDataSource.js +0 -114
  27. package/dist/data-sources/SnapDataSource.js.map +1 -1
  28. package/dist/data-sources/TokenDataSource.d.ts +1 -3
  29. package/dist/data-sources/TokenDataSource.d.ts.map +1 -1
  30. package/dist/data-sources/TokenDataSource.js +2 -4
  31. package/dist/data-sources/TokenDataSource.js.map +1 -1
  32. package/dist/data-sources/evm-rpc-services/services/BalanceFetcher.d.ts +0 -8
  33. package/dist/data-sources/evm-rpc-services/services/BalanceFetcher.d.ts.map +1 -1
  34. package/dist/data-sources/evm-rpc-services/services/BalanceFetcher.js +2 -47
  35. package/dist/data-sources/evm-rpc-services/services/BalanceFetcher.js.map +1 -1
  36. package/dist/index.d.ts +1 -1
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js.map +1 -1
  39. package/dist/middlewares/CustomAssetGraduationMiddleware.d.ts +1 -3
  40. package/dist/middlewares/CustomAssetGraduationMiddleware.d.ts.map +1 -1
  41. package/dist/middlewares/CustomAssetGraduationMiddleware.js +1 -3
  42. package/dist/middlewares/CustomAssetGraduationMiddleware.js.map +1 -1
  43. package/dist/middlewares/DetectionMiddleware.d.ts +2 -10
  44. package/dist/middlewares/DetectionMiddleware.d.ts.map +1 -1
  45. package/dist/middlewares/DetectionMiddleware.js +23 -5
  46. package/dist/middlewares/DetectionMiddleware.js.map +1 -1
  47. package/dist/middlewares/ParallelMiddleware.d.ts.map +1 -1
  48. package/dist/middlewares/ParallelMiddleware.js +3 -0
  49. package/dist/middlewares/ParallelMiddleware.js.map +1 -1
  50. package/dist/middlewares/RpcFallbackMiddleware.d.ts +15 -16
  51. package/dist/middlewares/RpcFallbackMiddleware.d.ts.map +1 -1
  52. package/dist/middlewares/RpcFallbackMiddleware.js +107 -125
  53. package/dist/middlewares/RpcFallbackMiddleware.js.map +1 -1
  54. package/dist/middlewares/index.d.ts +0 -1
  55. package/dist/middlewares/index.d.ts.map +1 -1
  56. package/dist/middlewares/index.js.map +1 -1
  57. package/dist/migrations/healAssetsInfoMetadata.d.ts +5 -5
  58. package/dist/migrations/healAssetsInfoMetadata.d.ts.map +1 -1
  59. package/dist/migrations/healAssetsInfoMetadata.js.map +1 -1
  60. package/dist/pipeline/buildFastFetchSources.d.ts +0 -5
  61. package/dist/pipeline/buildFastFetchSources.d.ts.map +1 -1
  62. package/dist/pipeline/buildFastFetchSources.js +1 -7
  63. package/dist/pipeline/buildFastFetchSources.js.map +1 -1
  64. package/dist/pipeline/executeAssetsPipeline.d.ts +3 -1
  65. package/dist/pipeline/executeAssetsPipeline.d.ts.map +1 -1
  66. package/dist/pipeline/executeAssetsPipeline.js +2 -1
  67. package/dist/pipeline/executeAssetsPipeline.js.map +1 -1
  68. package/dist/selectors/balance.js +1 -1
  69. package/dist/selectors/balance.js.map +1 -1
  70. package/dist/types.d.ts +23 -29
  71. package/dist/types.d.ts.map +1 -1
  72. package/dist/types.js.map +1 -1
  73. package/package.json +1 -1
  74. package/dist/utils/assetVisibility.d.ts +0 -29
  75. package/dist/utils/assetVisibility.d.ts.map +0 -1
  76. package/dist/utils/assetVisibility.js +0 -80
  77. package/dist/utils/assetVisibility.js.map +0 -1
  78. package/dist/utils/filterFailedChainBalances.d.ts +0 -11
  79. package/dist/utils/filterFailedChainBalances.d.ts.map +0 -1
  80. package/dist/utils/filterFailedChainBalances.js +0 -22
  81. package/dist/utils/filterFailedChainBalances.js.map +0 -1
@@ -21,7 +21,6 @@ import { createParallelBalanceMiddleware, createParallelMiddleware, } from './mi
21
21
  import { RpcFallbackMiddleware } from './middlewares/RpcFallbackMiddleware.js';
22
22
  import { cleanSpamAssets, isUnlockCleanupEnabled, tempHealAssetsInfoMetadata, } from './migrations/healAssetsInfoMetadata.js';
23
23
  import { buildFastFetchSources, executeAssetsPipeline, } from './pipeline/index.js';
24
- import { getAssetVisibility } from './utils/assetVisibility.js';
25
24
  import { ZERO_ADDRESS } from './utils/constants.js';
26
25
  import { pickRpcCustomAssetsSupplement } from './utils/customAssetsRpcSupplement.js';
27
26
  import { normalizeAmountString, normalizeAssetId, formatExchangeRatesForBridge, formatStateForTransactionPay, buildNativeAssetsFromConstant, buildNativeAssetsFromApi, getDefaultNativeAssetBalance, } from './utils/index.js';
@@ -191,17 +190,16 @@ function normalizeResponse(response) {
191
190
  return normalized;
192
191
  }
193
192
  /**
194
- * Compute the effective account balances from a data-source response and prior state.
193
+ * Merge account balances from a data-source response into prior state.
195
194
  *
196
195
  * @param previousBalances - Balances already in state for this account.
197
196
  * @param accountBalances - Balances from the incoming response.
198
- * @param customAssetIds - Custom assets to preserve when replacing covered chains
199
- * on the Accounts API v5 path (`replaceCoveredChainBalances`).
200
- * @param replaceCoveredChains - When true (v5 force refresh), drop prior balances
201
- * on chains present in the response before applying it, then restore custom assets.
202
- * @returns The effective balance map for the account.
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).
200
+ * @returns The merged balance map for the account.
203
201
  */
204
- function effectiveAccountBalancesV5(previousBalances, accountBalances, customAssetIds, replaceCoveredChains) {
202
+ function mergeAccountBalances(previousBalances, accountBalances, customAssetIds, replaceCoveredChains) {
205
203
  if (!replaceCoveredChains) {
206
204
  return { ...previousBalances, ...accountBalances };
207
205
  }
@@ -230,42 +228,6 @@ function effectiveAccountBalancesV5(previousBalances, accountBalances, customAss
230
228
  }
231
229
  return next;
232
230
  }
233
- /**
234
- * Compute the effective account balances for an Accounts API v6 update.
235
- *
236
- * `merge` overlays the incoming balances on prior state (Account Activity,
237
- * Snap events, staking). `full` replaces each covered chain slice. v6 asks
238
- * for natives, pins, and default tracked assets through `includeAssetIds`
239
- * and either returns them (at zero when the account holds none) or names
240
- * them in `unprocessedIncludeAssetIds`. Assets the client tracks on purpose
241
- * survive a `full` replace even when the response omits them, so an upstream
242
- * gap cannot wipe them — see `skipDelete`.
243
- *
244
- * @param previousBalances - Balances already in state for this account.
245
- * @param accountBalances - Balances from the incoming response.
246
- * @param skipDelete - Whether a prior asset must be kept even though the
247
- * response omits it, because the client asked for it as a visible asset
248
- * (native, pin, or default tracked). Hidden assets are excluded from
249
- * snapshots and dropped here; unhide is expected to fetch a new snapshot.
250
- * @param updateMode - `'merge'` overlays incoming balances; `'full'` replaces
251
- * each covered chain slice except assets kept by `skipDelete`.
252
- * @returns The effective balance map for the account.
253
- */
254
- function effectiveAccountBalancesV6(previousBalances, accountBalances, skipDelete, updateMode) {
255
- if (updateMode === 'merge') {
256
- return { ...previousBalances, ...accountBalances };
257
- }
258
- const coveredChains = new Set(Object.keys(accountBalances).map((assetId) => assetId.split('/')[0]));
259
- const next = {};
260
- for (const [assetId, balance] of Object.entries(previousBalances)) {
261
- if (!coveredChains.has(assetId.split('/')[0]) ||
262
- skipDelete(assetId)) {
263
- next[assetId] = balance;
264
- }
265
- }
266
- Object.assign(next, accountBalances);
267
- return next;
268
- }
269
231
  // ============================================================================
270
232
  // CONTROLLER IMPLEMENTATION
271
233
  // ============================================================================
@@ -501,24 +463,17 @@ export class AssetsController extends BaseController {
501
463
  queryApiClient,
502
464
  onActiveChainsUpdated: this.#onActiveChainsUpdated,
503
465
  ...accountsApiDataSourceConfig,
504
- isBalanceV6Enabled: () => this.#isBalanceV6Enabled(),
505
- getAssetsState: () => this.state,
506
- getAssetVisibility: this.#getAssetVisibility.bind(this),
507
466
  });
508
467
  this.#snapDataSource = new SnapDataSource({
509
468
  messenger: this.messenger,
510
469
  onActiveChainsUpdated: this.#onActiveChainsUpdated,
511
470
  onAssetsUpdate: (response) => this.handleAssetsUpdate(response, 'SnapDataSource'),
512
- isBalanceV6Enabled: () => this.#isBalanceV6Enabled(),
513
- getAssetVisibility: this.#getAssetVisibility.bind(this),
514
471
  });
515
472
  this.#rpcDataSource = new RpcDataSource({
516
473
  messenger: this.messenger,
517
- getAssetsState: () => this.state,
518
- isBalanceV6Enabled: () => this.#isBalanceV6Enabled(),
519
- getAssetVisibility: this.#getAssetVisibility.bind(this),
520
474
  onActiveChainsUpdated: this.#onActiveChainsUpdated,
521
- getNativeAssetForChain: (chainId) => this.#getNativeAssetForChain(chainId),
475
+ getNativeAssetForChain: (chainId) => this.#getNativeAssetMap()[chainId] ??
476
+ `${chainId}/erc20:${ZERO_ADDRESS}`,
522
477
  // Share the API platform's TanStack Query client so the RPC token
523
478
  // detector caches/dedupes its top-token-list fetches alongside the rest
524
479
  // of the package's API calls. Caller-provided rpcConfig.queryClient
@@ -539,17 +494,13 @@ export class AssetsController extends BaseController {
539
494
  return this.#getNativeAssetIdsForEnabledChains();
540
495
  },
541
496
  getAssetType: (assetId) => this.#getAssetType(assetId),
542
- getAssetsState: () => this.state,
543
497
  });
544
498
  this.#priceDataSource = new PriceDataSource({
545
499
  queryApiClient,
546
500
  getSelectedCurrency: () => this.state.selectedCurrency,
547
- getAssetsState: () => this.state,
548
501
  ...priceDataSourceConfig,
549
502
  });
550
- this.#detectionMiddleware = new DetectionMiddleware({
551
- getAssetsState: () => this.state,
552
- });
503
+ this.#detectionMiddleware = new DetectionMiddleware();
553
504
  this.#customAssetGraduationMiddleware = new CustomAssetGraduationMiddleware({
554
505
  getSelectedAccountId: () => {
555
506
  try {
@@ -560,12 +511,9 @@ export class AssetsController extends BaseController {
560
511
  }
561
512
  },
562
513
  removeCustomAsset: (accountId, assetId) => this.removeCustomAsset(accountId, assetId),
563
- getAssetsState: () => this.state,
564
514
  });
565
515
  this.#rpcFallbackMiddleware = new RpcFallbackMiddleware({
566
516
  rpcDataSource: this.#rpcDataSource,
567
- isBalanceV6Enabled: () => this.#isBalanceV6Enabled(),
568
- getAssetsState: () => this.state,
569
517
  });
570
518
  log('Initializing AssetsController', {
571
519
  defaultUpdateInterval: this.#defaultUpdateInterval,
@@ -893,7 +841,7 @@ export class AssetsController extends BaseController {
893
841
  * Returns response and exclusive duration per source (sum ≈ wall time).
894
842
  *
895
843
  * Thin wrapper over {@link executeAssetsPipeline} that supplies the
896
- * controller-owned exception reporter.
844
+ * controller-owned state accessor and exception reporter.
897
845
  *
898
846
  * @param params - Middleware execution options.
899
847
  * @param params.sources - Data sources or middlewares with getName() and assetsMiddleware.
@@ -906,6 +854,7 @@ export class AssetsController extends BaseController {
906
854
  async #executeMiddlewares(params) {
907
855
  return executeAssetsPipeline({
908
856
  ...params,
857
+ getAssetsState: () => this.state,
909
858
  captureException: this.#captureException,
910
859
  });
911
860
  }
@@ -919,255 +868,143 @@ export class AssetsController extends BaseController {
919
868
  if (accounts.length === 0 || chainIds.length === 0) {
920
869
  return this.#getAssetsFromState(accounts, chainIds, assetTypes);
921
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
+ }
922
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.
923
880
  const pipelineTrace = this.#firstInitFetchReported
924
881
  ? undefined
925
882
  : this.#trace;
926
- const requestOptions = {
883
+ const request = this.#buildDataRequest(accounts, chainIds, {
927
884
  assetTypes,
928
885
  dataTypes,
886
+ customAssets: customAssets.length > 0 ? customAssets : undefined,
929
887
  forceUpdate: true,
930
888
  bypassServerCache: options?.bypassServerCache,
931
889
  assetsForPriceUpdate: options?.assetsForPriceUpdate,
932
- };
933
- if (this.#isBalanceV6Enabled()) {
934
- await this.#forceUpdateAssetsV6({
935
- accounts,
936
- chainIds,
937
- request: this.#buildDataRequest(accounts, chainIds, requestOptions),
938
- pipelineTrace,
939
- });
940
- }
941
- else {
942
- await this.#forceUpdateAssetsV5({
943
- accounts,
944
- chainIds,
945
- request: this.#buildForceUpdateRequestV5(accounts, chainIds, requestOptions),
946
- pipelineTrace,
947
- });
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 }));
948
1003
  }
949
1004
  }
950
1005
  const result = this.#getAssetsFromState(accounts, chainIds, assetTypes);
951
1006
  return result;
952
1007
  }
953
- /**
954
- * v5 force-update request: every pin of the requested accounts, unscoped.
955
- * Delete with the rest of the v5 path when `assetsAccountsApiV6` is the default.
956
- *
957
- * @param accounts - Accounts in this fetch.
958
- * @param chainIds - Chains in this fetch.
959
- * @param requestOptions - Shared force-update request fields.
960
- * @param requestOptions.assetTypes - Asset types to fetch.
961
- * @param requestOptions.dataTypes - Data types to fetch.
962
- * @param requestOptions.forceUpdate - Always `true` to bypass caches.
963
- * @param requestOptions.bypassServerCache - Also bypass server-side HTTP caches.
964
- * @param requestOptions.assetsForPriceUpdate - Assets to refresh prices for.
965
- * @returns The v5 data request.
966
- */
967
- #buildForceUpdateRequestV5(accounts, chainIds, requestOptions) {
968
- const customAssets = [];
969
- for (const account of accounts) {
970
- customAssets.push(...this.getCustomAssets(account.id));
971
- }
972
- return this.#buildDataRequest(accounts, chainIds, {
973
- ...requestOptions,
974
- customAssets: customAssets.length > 0 ? customAssets : undefined,
975
- });
976
- }
977
- async #forceUpdateAssetsV5({ accounts, chainIds, request, pipelineTrace, }) {
978
- const isBasicFunctionality = this.#isBasicFunctionality();
979
- const fastSources = buildFastFetchSources({
980
- accountsApiDataSource: this.#accountsApiDataSource,
981
- stakedBalanceDataSource: this.#stakedBalanceDataSource,
982
- customAssetGraduationMiddleware: this.#customAssetGraduationMiddleware,
983
- rpcFallbackMiddleware: this.#rpcFallbackMiddleware,
984
- detectionMiddleware: this.#detectionMiddleware,
985
- tokenDataSource: this.#tokenDataSource,
986
- priceDataSource: this.#priceDataSource,
987
- }, { isBasicFunctionality, includeCustomAssetGraduation: true });
988
- const fastResponse = await this.#runFastFetchV5({
989
- accounts,
990
- chainIds,
991
- request,
992
- pipelineTrace,
993
- fastSources,
994
- isBasicFunctionality,
995
- });
996
- this.#firstInitFetchReported = true;
997
- this.#runBackgroundFetch({
998
- accounts,
999
- request,
1000
- pipelineTrace,
1001
- chainIds: this.#getSlowPipelineChainIds(chainIds, fastResponse),
1002
- isBasicFunctionality,
1003
- });
1004
- }
1005
- async #forceUpdateAssetsV6({ accounts, chainIds, request, pipelineTrace, }) {
1006
- const isBasicFunctionality = this.#isBasicFunctionality();
1007
- const fastSources = buildFastFetchSources({
1008
- accountsApiDataSource: this.#accountsApiDataSource,
1009
- stakedBalanceDataSource: this.#stakedBalanceDataSource,
1010
- customAssetGraduationMiddleware: this.#customAssetGraduationMiddleware,
1011
- rpcFallbackMiddleware: this.#rpcFallbackMiddleware,
1012
- detectionMiddleware: this.#detectionMiddleware,
1013
- tokenDataSource: this.#tokenDataSource,
1014
- priceDataSource: this.#priceDataSource,
1015
- }, {
1016
- isBasicFunctionality,
1017
- includeCustomAssetGraduation: false,
1018
- });
1019
- const fastResponse = await this.#runFastFetchV6({
1020
- accounts,
1021
- chainIds,
1022
- request,
1023
- pipelineTrace,
1024
- fastSources,
1025
- isBasicFunctionality,
1026
- });
1027
- this.#firstInitFetchReported = true;
1028
- this.#runBackgroundFetch({
1029
- accounts,
1030
- request,
1031
- pipelineTrace,
1032
- chainIds: this.#getSlowPipelineChainIds(chainIds, fastResponse),
1033
- isBasicFunctionality,
1034
- });
1035
- }
1036
- async #runFastFetchV5({ accounts, chainIds, request, pipelineTrace, fastSources, isBasicFunctionality, }) {
1037
- const { response } = await withTrace({
1038
- name: TRACE_FETCH_PIPELINE,
1039
- trace: pipelineTrace,
1040
- data: {
1041
- chain_count: chainIds.length,
1042
- account_count: accounts.length,
1043
- basic_functionality: isBasicFunctionality,
1044
- },
1045
- fn: async (parentContext) => {
1046
- const startTime = performance.now();
1047
- const result = await this.#executeMiddlewares({
1048
- sources: fastSources,
1049
- request,
1050
- parentContext,
1051
- trace: pipelineTrace,
1052
- });
1053
- await this.#updateState({
1054
- ...result.response,
1055
- updateMode: 'merge',
1056
- replaceCoveredChainBalances: true,
1057
- });
1058
- this.#emitFastFetchTraces({
1059
- accounts,
1060
- chainIds,
1061
- result,
1062
- durationMs: performance.now() - startTime,
1063
- isBasicFunctionality,
1064
- parentContext,
1065
- pipelineTrace,
1066
- });
1067
- return result;
1068
- },
1069
- });
1070
- return response;
1071
- }
1072
- async #runFastFetchV6({ accounts, chainIds, request, pipelineTrace, fastSources, isBasicFunctionality, }) {
1073
- const { response } = await withTrace({
1074
- name: TRACE_FETCH_PIPELINE,
1075
- trace: pipelineTrace,
1076
- data: {
1077
- chain_count: chainIds.length,
1078
- account_count: accounts.length,
1079
- basic_functionality: isBasicFunctionality,
1080
- },
1081
- fn: async (parentContext) => {
1082
- const startTime = performance.now();
1083
- const result = await this.#executeMiddlewares({
1084
- sources: fastSources,
1085
- request,
1086
- parentContext,
1087
- trace: pipelineTrace,
1088
- });
1089
- await this.#updateState(result.response);
1090
- this.#emitFastFetchTraces({
1091
- accounts,
1092
- chainIds,
1093
- result,
1094
- durationMs: performance.now() - startTime,
1095
- isBasicFunctionality,
1096
- parentContext,
1097
- pipelineTrace,
1098
- });
1099
- return result;
1100
- },
1101
- });
1102
- return response;
1103
- }
1104
- #runBackgroundFetch({ accounts, chainIds, request, pipelineTrace, isBasicFunctionality, }) {
1105
- if (chainIds.length === 0) {
1106
- return;
1107
- }
1108
- const slowSources = isBasicFunctionality
1109
- ? [this.#snapDataSource, this.#rpcDataSource]
1110
- : [this.#rpcDataSource];
1111
- const slowRequest = { ...request, chainIds };
1112
- withTrace({
1113
- name: TRACE_BACKGROUND_FETCH,
1114
- trace: pipelineTrace,
1115
- data: {
1116
- chain_count: chainIds.length,
1117
- account_count: accounts.length,
1118
- },
1119
- fn: async (parentContext) => {
1120
- const { response } = await this.#executeMiddlewares({
1121
- sources: [
1122
- createParallelBalanceMiddleware(slowSources),
1123
- this.#detectionMiddleware,
1124
- ...(isBasicFunctionality
1125
- ? [
1126
- createParallelMiddleware([
1127
- this.#tokenDataSource,
1128
- this.#priceDataSource,
1129
- ]),
1130
- ]
1131
- : []),
1132
- ],
1133
- request: slowRequest,
1134
- parentContext,
1135
- trace: pipelineTrace,
1136
- });
1137
- await this.#updateState(response);
1138
- },
1139
- }).catch((error) => log('Background pipeline failed', { error }));
1140
- }
1141
- #emitFastFetchTraces({ accounts, chainIds, result, durationMs, isBasicFunctionality, parentContext, pipelineTrace, }) {
1142
- emitTrace({
1143
- name: TRACE_FULL_FETCH,
1144
- trace: pipelineTrace,
1145
- data: {
1146
- duration_ms: durationMs,
1147
- chain_count: chainIds.length,
1148
- account_count: accounts.length,
1149
- basic_functionality: isBasicFunctionality,
1150
- asset_count: result.response.assetsBalance
1151
- ? Object.values(result.response.assetsBalance).reduce((sum, account) => sum + Object.keys(account).length, 0)
1152
- : 0,
1153
- price_count: result.response.assetsPrice
1154
- ? Object.keys(result.response.assetsPrice).length
1155
- : 0,
1156
- ...result.durationByDataSource,
1157
- },
1158
- parentContext,
1159
- });
1160
- emitTrace({
1161
- name: TRACE_FIRST_INIT_FETCH,
1162
- trace: pipelineTrace,
1163
- data: {
1164
- duration_ms: durationMs,
1165
- chain_ids: JSON.stringify(chainIds),
1166
- ...result.durationByDataSource,
1167
- },
1168
- parentContext,
1169
- });
1170
- }
1171
1008
  async getAssetsBalance(accounts, options) {
1172
1009
  // Reuse getAssets with dataTypes: ['balance'] only
1173
1010
  const assets = await this.getAssets(accounts, {
@@ -1376,8 +1213,7 @@ export class AssetsController extends BaseController {
1376
1213
  /**
1377
1214
  * Add a custom asset for an account.
1378
1215
  * Custom assets are included in subscription and fetch operations.
1379
- * Adding a custom asset also unhides it if it was previously hidden,
1380
- * and force-fetches that asset's chain (including every visible pin).
1216
+ * Adding a custom asset also unhides it if it was previously hidden.
1381
1217
  *
1382
1218
  * When `pendingMetadata` is provided (e.g. from the extension's pending-tokens
1383
1219
  * flow), the token metadata is persisted immediately into `assetsInfo` so the
@@ -1436,16 +1272,20 @@ export class AssetsController extends BaseController {
1436
1272
  balances[accountId] ??= {};
1437
1273
  balances[accountId][normalizedAssetId] ??= { amount: '0' };
1438
1274
  });
1275
+ // Fetch data for the newly added custom asset (merge to preserve other chains)
1439
1276
  const account = this.#getSelectedAccounts().find((a) => a.id === accountId);
1440
1277
  if (account) {
1278
+ const chainId = extractChainId(normalizedAssetId);
1441
1279
  await this.getAssets([account], {
1442
- chainIds: [extractChainId(normalizedAssetId)],
1280
+ chainIds: [chainId],
1443
1281
  dataTypes: ['balance', 'metadata', 'price'],
1444
1282
  assetTypes: ['fungible'],
1445
1283
  forceUpdate: true,
1284
+ updateMode: 'merge',
1446
1285
  });
1447
1286
  }
1448
- // 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.
1449
1289
  this.#subscribeAssets();
1450
1290
  }
1451
1291
  /**
@@ -1466,7 +1306,8 @@ export class AssetsController extends BaseController {
1466
1306
  }
1467
1307
  }
1468
1308
  });
1469
- // 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.
1470
1311
  this.#subscribeAssets();
1471
1312
  }
1472
1313
  /**
@@ -1497,18 +1338,13 @@ export class AssetsController extends BaseController {
1497
1338
  }
1498
1339
  state.assetPreferences[normalizedAssetId].hidden = true;
1499
1340
  });
1500
- // Re-evaluate subscriptions so polls exclude the newly hidden asset.
1501
- this.#subscribeAssets();
1502
1341
  }
1503
1342
  /**
1504
1343
  * Unhide an asset globally.
1505
- * Force-fetches that asset's chain (including every visible pin) so a
1506
- * `full` snapshot can restore the balance immediately, then re-evaluates
1507
- * subscriptions so later polls stop excluding it.
1508
1344
  *
1509
1345
  * @param assetId - The CAIP-19 asset ID to unhide.
1510
1346
  */
1511
- async unhideAsset(assetId) {
1347
+ unhideAsset(assetId) {
1512
1348
  const normalizedAssetId = normalizeAssetId(assetId);
1513
1349
  log('Unhiding asset', { assetId: normalizedAssetId });
1514
1350
  this.update((state) => {
@@ -1520,35 +1356,6 @@ export class AssetsController extends BaseController {
1520
1356
  }
1521
1357
  }
1522
1358
  });
1523
- const chainId = extractChainId(normalizedAssetId);
1524
- const account = this.#getSelectedAccounts().find((selectedAccount) => this.#getEnabledChainsForAccount(selectedAccount).includes(chainId));
1525
- if (account) {
1526
- await this.getAssets([account], {
1527
- chainIds: [chainId],
1528
- dataTypes: ['balance', 'metadata', 'price'],
1529
- assetTypes: ['fungible'],
1530
- forceUpdate: true,
1531
- });
1532
- }
1533
- this.#subscribeAssets();
1534
- }
1535
- /**
1536
- * Whether Accounts API v6 (and the v6 custom-asset path) is enabled.
1537
- * Injected into AccountsApiDataSource and RpcFallbackMiddleware.
1538
- *
1539
- * `RemoteFeatureFlagController` resolves a threshold-scoped flag to the
1540
- * selected group's `value`, so processed state is a boolean.
1541
- *
1542
- * @returns `true` when the v6 remote flag is on.
1543
- */
1544
- #isBalanceV6Enabled() {
1545
- try {
1546
- const { remoteFeatureFlags } = this.messenger.call('RemoteFeatureFlagController:getState');
1547
- return remoteFeatureFlags?.assetsAccountsApiV6 === true;
1548
- }
1549
- catch {
1550
- return false;
1551
- }
1552
1359
  }
1553
1360
  // ============================================================================
1554
1361
  // CURRENT CURRENCY MANAGEMENT
@@ -1654,6 +1461,7 @@ export class AssetsController extends BaseController {
1654
1461
  subscriptionId: subscriptionKey,
1655
1462
  isUpdate,
1656
1463
  onAssetsUpdate: (response) => this.handleAssetsUpdate(response, 'PriceDataSource'),
1464
+ getAssetsState: () => this.state,
1657
1465
  };
1658
1466
  this.#priceDataSource.subscribe(subscribeReq).catch(console.error);
1659
1467
  // Track subscription
@@ -1693,51 +1501,6 @@ export class AssetsController extends BaseController {
1693
1501
  #getNativeAssetMap() {
1694
1502
  return (this.#queryApiClient.getCachedData(NATIVE_ASSETS_QUERY_KEY) ?? EMPTY_NATIVE_ASSET_MAP);
1695
1503
  }
1696
- /**
1697
- * Canonical native CAIP-19 ID for a chain. Uses the native asset map when
1698
- * the chain is registered; otherwise falls back to the zero-address ERC-20
1699
- * encoding used for EVM natives without a SLIP-44 id.
1700
- *
1701
- * @param chainId - CAIP-2 chain ID.
1702
- * @returns The native asset ID for the chain.
1703
- */
1704
- #getNativeAssetForChain(chainId) {
1705
- return (this.#getNativeAssetMap()[chainId] ??
1706
- `${chainId}/erc20:${ZERO_ADDRESS}`);
1707
- }
1708
- #getAssetVisibility(accountIds, chainIds) {
1709
- return getAssetVisibility({
1710
- state: this.state,
1711
- accountIds,
1712
- chainIds,
1713
- getNativeAssetForChain: (chainId) => this.#getNativeAssetForChain(chainId),
1714
- });
1715
- }
1716
- /**
1717
- * Lowercased asset IDs a v6 `full` snapshot must keep for an account, even
1718
- * when the response omits them: natives, pins, and default tracked assets
1719
- * the client asked for. Hidden assets are sent as `excludeAssetIds` and are
1720
- * dropped here; unhide is expected to fetch a new snapshot. Everything else
1721
- * on a covered chain was discovered by the source, so its absence means the
1722
- * balance is gone.
1723
- *
1724
- * @param state - State being updated (the in-flight draft, not `this.state`).
1725
- * @param accountId - Account whose pins apply.
1726
- * @param accountBalances - Incoming balances, which define the covered chains.
1727
- * @returns Lowercased CAIP-19 IDs that must survive the replace.
1728
- */
1729
- #getUndeletableAssetIds(state, accountId, accountBalances) {
1730
- const coveredChainIds = [
1731
- ...new Set(Object.keys(accountBalances).map((assetId) => assetId.split('/')[0])),
1732
- ];
1733
- const { visibleAssetIds } = getAssetVisibility({
1734
- state,
1735
- accountIds: [accountId],
1736
- chainIds: coveredChainIds,
1737
- getNativeAssetForChain: (chainId) => this.#getNativeAssetForChain(chainId),
1738
- });
1739
- return new Set(visibleAssetIds.map((assetId) => assetId.toLowerCase()));
1740
- }
1741
1504
  /**
1742
1505
  * Checks whether the given CAIP-19 asset ID represents a native asset
1743
1506
  * according to the cached native asset map, the asset namespace, and the asset reference.
@@ -1930,7 +1693,6 @@ export class AssetsController extends BaseController {
1930
1693
  async #updateState(response) {
1931
1694
  const normalizedResponse = normalizeResponse(response);
1932
1695
  const mode = normalizedResponse.updateMode ?? 'merge';
1933
- const isBalanceV6Enabled = this.#isBalanceV6Enabled();
1934
1696
  const releaseLock = await this.#controllerMutex.acquire();
1935
1697
  try {
1936
1698
  const previousState = this.state;
@@ -1990,23 +1752,24 @@ export class AssetsController extends BaseController {
1990
1752
  }
1991
1753
  }
1992
1754
  if (normalizedResponse.assetsBalance) {
1993
- const applyV5AccountBalanceUpdate = (accountId, accountBalances, previousBalances) => {
1755
+ for (const [accountId, accountBalances] of Object.entries(normalizedResponse.assetsBalance)) {
1756
+ const previousBalances = previousState.assetsBalance[accountId] ?? {};
1994
1757
  const customAssetIds = state.customAssets[accountId] ?? [];
1995
1758
  const replaceCoveredChains = mode === 'full' ||
1996
1759
  normalizedResponse.replaceCoveredChainBalances === true;
1997
- const effectiveAccountBalances = effectiveAccountBalancesV5(previousBalances, accountBalances, customAssetIds, replaceCoveredChains);
1760
+ const effective = mergeAccountBalances(previousBalances, accountBalances, customAssetIds, replaceCoveredChains);
1998
1761
  // Ensure native tokens have an entry (0 if missing) for chains this account supports
1999
1762
  const account = this.#getSelectedAccounts().find((a) => a.id === accountId);
2000
1763
  const nativeAssetIdsForAccount = account
2001
1764
  ? this.#getNativeAssetIdsForAccount(account)
2002
1765
  : this.#getNativeAssetIdsForEnabledChains();
2003
1766
  for (const nativeAssetId of nativeAssetIdsForAccount) {
2004
- if (!Object.prototype.hasOwnProperty.call(effectiveAccountBalances, nativeAssetId)) {
2005
- effectiveAccountBalances[nativeAssetId] =
1767
+ if (!Object.prototype.hasOwnProperty.call(effective, nativeAssetId)) {
1768
+ effective[nativeAssetId] =
2006
1769
  getDefaultNativeAssetBalance(nativeAssetId);
2007
1770
  }
2008
1771
  }
2009
- for (const [assetId, balance] of Object.entries(effectiveAccountBalances)) {
1772
+ for (const [assetId, balance] of Object.entries(effective)) {
2010
1773
  const previousBalance = previousBalances[assetId];
2011
1774
  // Coerce amounts (e.g. "1e-18" from a data source stringifying
2012
1775
  // a JS Number) into a plain decimal so downstream BigInt()
@@ -2019,55 +1782,15 @@ export class AssetsController extends BaseController {
2019
1782
  const newAmount = normalizeAmountString(balance.amount, assetDecimals);
2020
1783
  const newMetadata = balance.metadata ??
2021
1784
  previousBalance?.metadata;
2022
- effectiveAccountBalances[assetId] = {
1785
+ effective[assetId] = {
2023
1786
  amount: newAmount,
2024
1787
  ...(newMetadata === undefined ? {} : { metadata: newMetadata }),
2025
1788
  };
2026
1789
  const oldAmount = previousBalance?.amount;
2027
- const isNewSeededZero = oldAmount === undefined &&
1790
+ const isNewDefaultNativeZero = oldAmount === undefined &&
2028
1791
  newAmount === '0' &&
2029
- (nativeAssetIdsForAccount.includes(assetId) ||
2030
- getDefaultAssetMetadata(assetId) !==
2031
- undefined);
2032
- if (oldAmount !== newAmount && !isNewSeededZero) {
2033
- changedBalances.push({
2034
- accountId,
2035
- assetId,
2036
- oldAmount,
2037
- newAmount,
2038
- });
2039
- }
2040
- }
2041
- if (!isEqual(previousBalances, effectiveAccountBalances)) {
2042
- balances[accountId] = effectiveAccountBalances;
2043
- }
2044
- };
2045
- // Delete with the rest of the v6 path if `assetsAccountsApiV6` is
2046
- // rolled back; v5 keeps using `applyV5AccountBalanceUpdate`.
2047
- const applyV6AccountBalanceUpdate = (accountId, accountBalances, previousBalances) => {
2048
- let undeletableAssetIds;
2049
- const skipDelete = (assetId) => {
2050
- undeletableAssetIds ??= this.#getUndeletableAssetIds(state, accountId, accountBalances);
2051
- return (undeletableAssetIds.has(assetId.toLowerCase()) ||
2052
- isStakingContractAssetId(assetId));
2053
- };
2054
- const effectiveAccountBalances = effectiveAccountBalancesV6(previousBalances, accountBalances, skipDelete, mode);
2055
- for (const [assetId, balance] of Object.entries(effectiveAccountBalances)) {
2056
- const previousBalance = previousBalances[assetId];
2057
- const assetDecimals = metadata[assetId]?.decimals;
2058
- const newAmount = normalizeAmountString(balance.amount, assetDecimals);
2059
- const newMetadata = balance.metadata ??
2060
- previousBalance?.metadata;
2061
- effectiveAccountBalances[assetId] = {
2062
- amount: newAmount,
2063
- ...(newMetadata === undefined ? {} : { metadata: newMetadata }),
2064
- };
2065
- const oldAmount = previousBalance?.amount;
2066
- // A v6 snapshot reports assets the account holds none of (its
2067
- // `includeAssetIds`) at zero. Publishing those as a change would
2068
- // emit `0 -> 0`, since a missing previous amount reads as '0'.
2069
- const isNewZero = oldAmount === undefined && newAmount === '0';
2070
- if (oldAmount !== newAmount && !isNewZero) {
1792
+ nativeAssetIdsForAccount.includes(assetId);
1793
+ if (oldAmount !== newAmount && !isNewDefaultNativeZero) {
2071
1794
  changedBalances.push({
2072
1795
  accountId,
2073
1796
  assetId,
@@ -2076,17 +1799,8 @@ export class AssetsController extends BaseController {
2076
1799
  });
2077
1800
  }
2078
1801
  }
2079
- if (!isEqual(previousBalances, effectiveAccountBalances)) {
2080
- balances[accountId] = effectiveAccountBalances;
2081
- }
2082
- };
2083
- for (const [accountId, accountBalances] of Object.entries(normalizedResponse.assetsBalance)) {
2084
- const previousBalances = previousState.assetsBalance[accountId] ?? {};
2085
- if (isBalanceV6Enabled) {
2086
- applyV6AccountBalanceUpdate(accountId, accountBalances, previousBalances);
2087
- }
2088
- else {
2089
- applyV5AccountBalanceUpdate(accountId, accountBalances, previousBalances);
1802
+ if (!isEqual(previousBalances, effective)) {
1803
+ balances[accountId] = effective;
2090
1804
  }
2091
1805
  }
2092
1806
  }
@@ -2110,8 +1824,10 @@ export class AssetsController extends BaseController {
2110
1824
  changedPriceAssets.length > 0) {
2111
1825
  log('State updated', {
2112
1826
  changedBalances: changedBalances.length > 0 ? changedBalances : undefined,
2113
- changedMetadata: changedMetadata.length > 0 ? changedMetadata : undefined,
2114
- changedPrices: changedPriceAssets.length > 0 ? changedPriceAssets : undefined,
1827
+ changedMetadataCount: changedMetadata.length > 0 ? changedMetadata.length : undefined,
1828
+ changedPricesCount: changedPriceAssets.length > 0
1829
+ ? changedPriceAssets.length
1830
+ : undefined,
2115
1831
  newAssets: Object.keys(detectedAssets).length > 0
2116
1832
  ? Object.entries(detectedAssets).map(([accountId, assets]) => ({
2117
1833
  accountId,
@@ -2333,10 +2049,16 @@ export class AssetsController extends BaseController {
2333
2049
  if (!subscriptionKey.startsWith('ds:')) {
2334
2050
  continue;
2335
2051
  }
2336
- // 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.
2337
2056
  const [, sourceId] = subscriptionKey.split(':');
2338
2057
  const source = allSources.find((ds) => ds.getName() === sourceId);
2339
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.
2340
2062
  this.#unsubscribeBySubscriptionKey(source, subscriptionKey);
2341
2063
  }
2342
2064
  }
@@ -2380,12 +2102,10 @@ export class AssetsController extends BaseController {
2380
2102
  * Strategy to minimize data source calls:
2381
2103
  * 1. Collect all chains to subscribe based on enabled networks
2382
2104
  * 2. Map chains to accounts based on their scopes
2383
- * 3. Split by data source (priority order) — each source gets one
2384
- * subscription for the chains it was assigned (accounts + chains only).
2385
- * v6 sources read pins/hides/defaults from state themselves (Accounts API
2386
- * sends `includeAssetIds` / `excludeAssetIds`; RPC polls native, balances,
2387
- * pins, and default tracked assets). v5 uses a separate RPC
2388
- * `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.
2389
2109
  *
2390
2110
  * @param accounts - Accounts to subscribe balance updates for.
2391
2111
  * @param chainIds - Chain IDs to subscribe for.
@@ -2395,7 +2115,10 @@ export class AssetsController extends BaseController {
2395
2115
  #subscribeAssetsBalance(accounts, chainIds, options) {
2396
2116
  const chainToAccounts = this.#buildChainToAccountsMap(accounts, new Set(chainIds));
2397
2117
  const remainingChains = new Set(chainToAccounts.keys());
2398
- 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
2399
2122
  ? this.#allBalanceDataSources
2400
2123
  : [this.#rpcDataSource];
2401
2124
  let rpcAssignedChains = new Set();
@@ -2434,15 +2157,22 @@ export class AssetsController extends BaseController {
2434
2157
  });
2435
2158
  }
2436
2159
  }
2437
- if (!this.#isBalanceV6Enabled()) {
2438
- this.#subscribeRpcCustomAssetsSupplement(accounts, chainToAccounts, rpcAssignedChains);
2439
- }
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);
2440
2167
  }
2441
2168
  /**
2442
2169
  * Guarantee that customAssets are **always** polled by RPC, even when
2443
2170
  * AccountsApi or another data source has claimed the chain in the
2444
2171
  * regular handoff. RPC is the sole balance fetcher for user-imported
2445
- * 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.
2446
2176
  *
2447
2177
  * @param accounts - Accounts to consider for customAssets.
2448
2178
  * @param chainToAccounts - Map of chain → accounts (built by caller).
@@ -2540,8 +2270,7 @@ export class AssetsController extends BaseController {
2540
2270
  * @param chains - Array of chain IDs to subscribe for.
2541
2271
  * @param options - Optional subscription overrides.
2542
2272
  * @param options.subscriptionKey - Custom subscription key (default: `ds:<sourceId>`).
2543
- * @param options.customAssetsOnly - When true, only poll customAssets for these
2544
- * chains (Accounts API v5 supplemental RPC subscription).
2273
+ * @param options.customAssetsOnly - When true, only poll customAssets for these chains.
2545
2274
  * @param options.skipInitialFetch - When true, skip the data source's subscribe-time fetch.
2546
2275
  */
2547
2276
  #subscribeDataSource(source, accounts, chains, options = {}) {
@@ -2570,6 +2299,7 @@ export class AssetsController extends BaseController {
2570
2299
  subscriptionId: subscriptionKey,
2571
2300
  isUpdate,
2572
2301
  onAssetsUpdate: (response, request) => this.handleAssetsUpdate(response, sourceId, request),
2302
+ getAssetsState: () => this.state,
2573
2303
  ...(options.skipInitialFetch === true ? { skipInitialFetch: true } : {}),
2574
2304
  };
2575
2305
  source.subscribe(subscribeReq).catch((error) => {
@@ -2753,13 +2483,13 @@ export class AssetsController extends BaseController {
2753
2483
  // The data will simply not be updated until the network is re-enabled.
2754
2484
  // Refresh subscriptions for new chain set
2755
2485
  this.#subscribeAssets();
2756
- // One-time fetch for newly enabled chains. v6 `full` replace is scoped to
2757
- // 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
2758
2487
  const accounts = this.#getSelectedAccounts();
2759
2488
  if (addedChains.length > 0 && accounts.length > 0) {
2760
2489
  await this.getAssets(accounts, {
2761
2490
  chainIds: addedChains,
2762
2491
  forceUpdate: true,
2492
+ updateMode: 'merge',
2763
2493
  });
2764
2494
  }
2765
2495
  this.#ensureNativeBalancesDefaultZero();
@@ -2919,28 +2649,48 @@ export class AssetsController extends BaseController {
2919
2649
  async handleAssetsUpdate(response, sourceId, request) {
2920
2650
  log('Assets updated from data source', {
2921
2651
  sourceId,
2922
- assetsBalance: response.assetsBalance,
2923
- assetsPrice: response.assetsPrice,
2652
+ hasBalance: Boolean(response.assetsBalance),
2653
+ hasPrice: Boolean(response.assetsPrice),
2924
2654
  });
2925
- if (this.#isBalanceV6Enabled()) {
2926
- await this.#handleAssetsUpdateV6(response, sourceId, request);
2927
- return;
2928
- }
2929
- await this.#handleAssetsUpdateV5(response, sourceId, request);
2930
- }
2931
- async #handleAssetsUpdateV5(response, sourceId, request) {
2655
+ // Enrichment spans only before unlock/first-init fetch completes.
2932
2656
  const pipelineTrace = this.#firstInitFetchReported
2933
2657
  ? undefined
2934
2658
  : this.#trace;
2935
2659
  await withTrace({
2936
2660
  name: TRACE_UPDATE_PARENT,
2937
2661
  trace: pipelineTrace,
2938
- 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
+ },
2939
2670
  fn: async (parentContext) => {
2940
2671
  const updateStart = performance.now();
2941
- 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.
2942
2689
  const shouldGraduateCustomAssets = sourceId === 'AccountsApiDataSource' ||
2943
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.
2944
2694
  const shouldFilterOccurrences = sourceId === 'AccountActivityDataSource' &&
2945
2695
  this.#isBasicFunctionality();
2946
2696
  const shouldRunRpcFallback = sourceId === 'AccountsApiDataSource';
@@ -2976,110 +2726,25 @@ export class AssetsController extends BaseController {
2976
2726
  ...enrichedResponse,
2977
2727
  replaceCoveredChainBalances: response.replaceCoveredChainBalances,
2978
2728
  });
2979
- this.#emitUpdateTrace({
2980
- response,
2981
- sourceId,
2982
- enrichedResponse,
2983
- updateStart,
2984
- pipelineTrace,
2985
- parentContext,
2986
- });
2987
- },
2988
- });
2989
- }
2990
- async #handleAssetsUpdateV6(response, sourceId, request) {
2991
- const pipelineTrace = this.#firstInitFetchReported
2992
- ? undefined
2993
- : this.#trace;
2994
- await withTrace({
2995
- name: TRACE_UPDATE_PARENT,
2996
- trace: pipelineTrace,
2997
- data: this.#getUpdateTraceData(response, sourceId),
2998
- fn: async (parentContext) => {
2999
- const updateStart = performance.now();
3000
- const pipelineRequest = this.#getUpdatePipelineRequest(request);
3001
- const shouldFilterOccurrences = sourceId === 'AccountActivityDataSource' &&
3002
- this.#isBasicFunctionality();
3003
- const enrichmentSources = [
3004
- ...(shouldFilterOccurrences
3005
- ? [
3006
- {
3007
- getName: () => 'OccurrenceFloorFilter',
3008
- assetsMiddleware: this.#tokenDataSource.occurrenceFilterMiddleware,
3009
- },
3010
- ]
3011
- : []),
3012
- ];
3013
- if (this.#isBasicFunctionality()) {
3014
- enrichmentSources.push(this.#rpcFallbackMiddleware);
3015
- }
3016
- enrichmentSources.push(this.#detectionMiddleware);
3017
- if (this.#isBasicFunctionality()) {
3018
- enrichmentSources.push(createParallelMiddleware([
3019
- this.#tokenDataSource,
3020
- this.#priceDataSource,
3021
- ]));
3022
- }
3023
- const { response: enrichedResponse } = await this.#executeMiddlewares({
3024
- sources: enrichmentSources,
3025
- request: pipelineRequest,
3026
- initialResponse: response,
3027
- parentContext,
2729
+ // Summary fields for Assets Health (nested under the parent span).
2730
+ emitTrace({
2731
+ name: TRACE_UPDATE_PIPELINE,
3028
2732
  trace: pipelineTrace,
3029
- });
3030
- await this.#updateState(enrichedResponse);
3031
- this.#emitUpdateTrace({
3032
- response,
3033
- sourceId,
3034
- enrichedResponse,
3035
- updateStart,
3036
- 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
+ },
3037
2743
  parentContext,
3038
2744
  });
3039
2745
  },
3040
2746
  });
3041
2747
  }
3042
- #getUpdatePipelineRequest(request) {
3043
- const resolvedRequest = request ?? {
3044
- accountsWithSupportedChains: [],
3045
- chainIds: [],
3046
- dataTypes: ['balance', 'metadata', 'price'],
3047
- };
3048
- if (this.#isBasicFunctionality()) {
3049
- return resolvedRequest;
3050
- }
3051
- return {
3052
- ...resolvedRequest,
3053
- dataTypes: resolvedRequest.dataTypes.filter((dataType) => dataType !== 'metadata' && dataType !== 'price'),
3054
- };
3055
- }
3056
- #getUpdateTraceData(response, sourceId) {
3057
- return {
3058
- source: sourceId,
3059
- has_balance: Boolean(response.assetsBalance),
3060
- has_price: Boolean(response.assetsPrice),
3061
- balance_account_count: response.assetsBalance
3062
- ? Object.keys(response.assetsBalance).length
3063
- : 0,
3064
- };
3065
- }
3066
- #emitUpdateTrace({ response, sourceId, enrichedResponse, updateStart, pipelineTrace, parentContext, }) {
3067
- emitTrace({
3068
- name: TRACE_UPDATE_PIPELINE,
3069
- trace: pipelineTrace,
3070
- data: {
3071
- source: sourceId,
3072
- duration_ms: performance.now() - updateStart,
3073
- has_balance: Boolean(response.assetsBalance),
3074
- has_price: Boolean(response.assetsPrice),
3075
- has_metadata: Boolean(enrichedResponse.assetsInfo),
3076
- balance_account_count: response.assetsBalance
3077
- ? Object.keys(response.assetsBalance).length
3078
- : 0,
3079
- },
3080
- parentContext,
3081
- });
3082
- }
3083
2748
  // ============================================================================
3084
2749
  // CLEANUP
3085
2750
  // ============================================================================