@metamask-previews/assets-controller 16.1.2-preview-ff2ab0583 → 17.0.0-preview-c4789fdf4

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 (100) hide show
  1. package/CHANGELOG.md +26 -1
  2. package/dist/AssetsController-method-action-types.d.ts +5 -1
  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 +8 -40
  6. package/dist/AssetsController.d.ts.map +1 -1
  7. package/dist/AssetsController.js +583 -239
  8. package/dist/AssetsController.js.map +1 -1
  9. package/dist/data-sources/AbstractDataSource.d.ts +1 -6
  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 +17 -3
  13. package/dist/data-sources/AccountsApiDataSource.d.ts.map +1 -1
  14. package/dist/data-sources/AccountsApiDataSource.js +275 -125
  15. package/dist/data-sources/AccountsApiDataSource.js.map +1 -1
  16. package/dist/data-sources/PriceDataSource.d.ts +7 -6
  17. package/dist/data-sources/PriceDataSource.d.ts.map +1 -1
  18. package/dist/data-sources/PriceDataSource.js +15 -20
  19. package/dist/data-sources/PriceDataSource.js.map +1 -1
  20. package/dist/data-sources/RpcDataSource.d.ts +18 -4
  21. package/dist/data-sources/RpcDataSource.d.ts.map +1 -1
  22. package/dist/data-sources/RpcDataSource.js +520 -121
  23. package/dist/data-sources/RpcDataSource.js.map +1 -1
  24. package/dist/data-sources/SnapDataSource.d.ts +11 -1
  25. package/dist/data-sources/SnapDataSource.d.ts.map +1 -1
  26. package/dist/data-sources/SnapDataSource.js +155 -0
  27. package/dist/data-sources/SnapDataSource.js.map +1 -1
  28. package/dist/data-sources/TokenDataSource.d.ts +3 -1
  29. package/dist/data-sources/TokenDataSource.d.ts.map +1 -1
  30. package/dist/data-sources/TokenDataSource.js +4 -2
  31. package/dist/data-sources/TokenDataSource.js.map +1 -1
  32. package/dist/data-sources/evm-rpc-services/services/BalanceFetcher.d.ts +11 -7
  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 +51 -6
  35. package/dist/data-sources/evm-rpc-services/services/BalanceFetcher.js.map +1 -1
  36. package/dist/data-sources/evm-rpc-services/services/index.d.ts +1 -1
  37. package/dist/data-sources/evm-rpc-services/services/index.d.ts.map +1 -1
  38. package/dist/data-sources/evm-rpc-services/services/index.js.map +1 -1
  39. package/dist/index.d.ts +1 -1
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js.map +1 -1
  42. package/dist/middlewares/CustomAssetGraduationMiddleware.d.ts +3 -1
  43. package/dist/middlewares/CustomAssetGraduationMiddleware.d.ts.map +1 -1
  44. package/dist/middlewares/CustomAssetGraduationMiddleware.js +3 -1
  45. package/dist/middlewares/CustomAssetGraduationMiddleware.js.map +1 -1
  46. package/dist/middlewares/DetectionMiddleware.d.ts +10 -2
  47. package/dist/middlewares/DetectionMiddleware.d.ts.map +1 -1
  48. package/dist/middlewares/DetectionMiddleware.js +5 -23
  49. package/dist/middlewares/DetectionMiddleware.js.map +1 -1
  50. package/dist/middlewares/ParallelMiddleware.d.ts.map +1 -1
  51. package/dist/middlewares/ParallelMiddleware.js +0 -3
  52. package/dist/middlewares/ParallelMiddleware.js.map +1 -1
  53. package/dist/middlewares/RpcFallbackMiddleware.d.ts +16 -15
  54. package/dist/middlewares/RpcFallbackMiddleware.d.ts.map +1 -1
  55. package/dist/middlewares/RpcFallbackMiddleware.js +125 -107
  56. package/dist/middlewares/RpcFallbackMiddleware.js.map +1 -1
  57. package/dist/middlewares/index.d.ts +1 -0
  58. package/dist/middlewares/index.d.ts.map +1 -1
  59. package/dist/middlewares/index.js.map +1 -1
  60. package/dist/migrations/healAssetsInfoMetadata.d.ts +5 -5
  61. package/dist/migrations/healAssetsInfoMetadata.d.ts.map +1 -1
  62. package/dist/migrations/healAssetsInfoMetadata.js.map +1 -1
  63. package/dist/pipeline/buildFastFetchSources.d.ts +5 -0
  64. package/dist/pipeline/buildFastFetchSources.d.ts.map +1 -1
  65. package/dist/pipeline/buildFastFetchSources.js +7 -1
  66. package/dist/pipeline/buildFastFetchSources.js.map +1 -1
  67. package/dist/pipeline/executeAssetsPipeline.d.ts +1 -3
  68. package/dist/pipeline/executeAssetsPipeline.d.ts.map +1 -1
  69. package/dist/pipeline/executeAssetsPipeline.js +1 -2
  70. package/dist/pipeline/executeAssetsPipeline.js.map +1 -1
  71. package/dist/selectors/balance.js +1 -1
  72. package/dist/selectors/balance.js.map +1 -1
  73. package/dist/types.d.ts +30 -24
  74. package/dist/types.d.ts.map +1 -1
  75. package/dist/types.js.map +1 -1
  76. package/dist/utils/assetVisibility.d.ts +30 -0
  77. package/dist/utils/assetVisibility.d.ts.map +1 -0
  78. package/dist/utils/assetVisibility.js +84 -0
  79. package/dist/utils/assetVisibility.js.map +1 -0
  80. package/dist/utils/constants.d.ts +9 -0
  81. package/dist/utils/constants.d.ts.map +1 -1
  82. package/dist/utils/constants.js +9 -0
  83. package/dist/utils/constants.js.map +1 -1
  84. package/dist/utils/filterFailedChainBalances.d.ts +11 -0
  85. package/dist/utils/filterFailedChainBalances.d.ts.map +1 -0
  86. package/dist/utils/filterFailedChainBalances.js +22 -0
  87. package/dist/utils/filterFailedChainBalances.js.map +1 -0
  88. package/dist/utils/getZeroAssetBalance.d.ts +56 -0
  89. package/dist/utils/getZeroAssetBalance.d.ts.map +1 -0
  90. package/dist/utils/getZeroAssetBalance.js +99 -0
  91. package/dist/utils/getZeroAssetBalance.js.map +1 -0
  92. package/dist/utils/index.d.ts +2 -1
  93. package/dist/utils/index.d.ts.map +1 -1
  94. package/dist/utils/index.js +2 -1
  95. package/dist/utils/index.js.map +1 -1
  96. package/dist/utils/native-assets.d.ts +6 -19
  97. package/dist/utils/native-assets.d.ts.map +1 -1
  98. package/dist/utils/native-assets.js +8 -29
  99. package/dist/utils/native-assets.js.map +1 -1
  100. package/package.json +3 -3
@@ -21,9 +21,10 @@ 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 { ZERO_ADDRESS } from './utils/constants.js';
24
+ import { getAssetVisibility } from './utils/assetVisibility.js';
25
+ import { UNKNOWN_EVM_NATIVE_ASSET_REFERENCE, ZERO_ADDRESS, } from './utils/constants.js';
25
26
  import { pickRpcCustomAssetsSupplement } from './utils/customAssetsRpcSupplement.js';
26
- import { normalizeAmountString, normalizeAssetId, formatExchangeRatesForBridge, formatStateForTransactionPay, buildNativeAssetsFromConstant, buildNativeAssetsFromApi, getDefaultNativeAssetBalance, } from './utils/index.js';
27
+ import { normalizeAmountString, normalizeAssetId, formatExchangeRatesForBridge, formatStateForTransactionPay, buildNativeAssetsFromConstant, buildNativeAssetsFromApi, getDefaultNativeAssetBalance, getZeroAssetBalance, } from './utils/index.js';
27
28
  import { emitTrace, withTrace } from './utils/trace.js';
28
29
  const NATIVE_ASSETS_QUERY_KEY = ['nativeAssets'];
29
30
  /** Stable empty map so memoized formatters are not busted by `?? {}` identity churn. */
@@ -190,16 +191,17 @@ function normalizeResponse(response) {
190
191
  return normalized;
191
192
  }
192
193
  /**
193
- * Merge account balances from a data-source response into prior state.
194
+ * Compute the effective account balances from a data-source response and prior state.
194
195
  *
195
196
  * @param previousBalances - Balances already in state for this account.
196
197
  * @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).
200
- * @returns The merged balance map for the account.
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.
201
203
  */
202
- function mergeAccountBalances(previousBalances, accountBalances, customAssetIds, replaceCoveredChains) {
204
+ function effectiveAccountBalancesV5(previousBalances, accountBalances, customAssetIds, replaceCoveredChains) {
203
205
  if (!replaceCoveredChains) {
204
206
  return { ...previousBalances, ...accountBalances };
205
207
  }
@@ -214,7 +216,7 @@ function mergeAccountBalances(previousBalances, accountBalances, customAssetIds,
214
216
  for (const customId of customAssetIds) {
215
217
  if (!Object.prototype.hasOwnProperty.call(next, customId)) {
216
218
  const prev = previousBalances[customId];
217
- next[customId] = prev ?? { amount: '0' };
219
+ next[customId] = prev ?? getZeroAssetBalance(customId);
218
220
  }
219
221
  }
220
222
  // Staked vault balances are owned by StakedBalanceDataSource. When an
@@ -228,6 +230,43 @@ function mergeAccountBalances(previousBalances, accountBalances, customAssetIds,
228
230
  }
229
231
  return next;
230
232
  }
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 `shouldKeepAsset`.
243
+ *
244
+ * @param previousBalances - Balances already in state for this account.
245
+ * @param updatedBalances - Balances from the incoming response.
246
+ * @param undeletableAssetIds - Visible assets the client asked for (native,
247
+ * pin, or default tracked). They survive a `full` replace if the response
248
+ * omits them. Hidden assets are excluded from snapshots and dropped here;
249
+ * unhide is expected to fetch a new snapshot.
250
+ * @param updateMode - `'merge'` overlays incoming balances; `'full'` replaces
251
+ * each updated chain slice except undeletable and staking assets.
252
+ * @returns The effective balance map for the account.
253
+ */
254
+ function effectiveAccountBalancesV6(previousBalances, updatedBalances, undeletableAssetIds, updateMode) {
255
+ if (updateMode === 'merge') {
256
+ return { ...previousBalances, ...updatedBalances };
257
+ }
258
+ const updatedChains = new Set(Object.keys(updatedBalances).map((assetId) => extractChainId(assetId)));
259
+ const shouldKeepAsset = (assetId) => !updatedChains.has(extractChainId(assetId)) ||
260
+ undeletableAssetIds.has(assetId.toLowerCase()) ||
261
+ isStakingContractAssetId(assetId);
262
+ const keptBalances = {};
263
+ for (const [assetId, balance] of Object.entries(previousBalances)) {
264
+ if (shouldKeepAsset(assetId)) {
265
+ keptBalances[assetId] = balance;
266
+ }
267
+ }
268
+ return { ...keptBalances, ...updatedBalances };
269
+ }
231
270
  // ============================================================================
232
271
  // CONTROLLER IMPLEMENTATION
233
272
  // ============================================================================
@@ -462,17 +501,25 @@ export class AssetsController extends BaseController {
462
501
  queryApiClient,
463
502
  onActiveChainsUpdated: this.#onActiveChainsUpdated,
464
503
  ...accountsApiDataSourceConfig,
504
+ isBalanceV6Enabled: () => this.#isBalanceV6Enabled(),
505
+ getAssetsState: () => this.state,
506
+ getAssetVisibility: this.#getAssetVisibility.bind(this),
465
507
  });
466
508
  this.#snapDataSource = new SnapDataSource({
467
509
  messenger: this.messenger,
468
510
  onActiveChainsUpdated: this.#onActiveChainsUpdated,
469
511
  onAssetsUpdate: (response) => this.handleAssetsUpdate(response, 'SnapDataSource'),
512
+ isBalanceV6Enabled: () => this.#isBalanceV6Enabled(),
513
+ getAssetVisibility: this.#getAssetVisibility.bind(this),
514
+ getAssetsState: () => this.state,
470
515
  });
471
516
  this.#rpcDataSource = new RpcDataSource({
472
517
  messenger: this.messenger,
518
+ getAssetsState: () => this.state,
519
+ isBalanceV6Enabled: () => this.#isBalanceV6Enabled(),
520
+ getAssetVisibility: this.#getAssetVisibility.bind(this),
473
521
  onActiveChainsUpdated: this.#onActiveChainsUpdated,
474
- getNativeAssetForChain: (chainId) => this.#getNativeAssetMap()[chainId] ??
475
- `${chainId}/erc20:${ZERO_ADDRESS}`,
522
+ getNativeAssetForChain: (chainId) => this.#getNativeAssetForChain(chainId),
476
523
  // Share the API platform's TanStack Query client so the RPC token
477
524
  // detector caches/dedupes its top-token-list fetches alongside the rest
478
525
  // of the package's API calls. Caller-provided rpcConfig.queryClient
@@ -493,13 +540,17 @@ export class AssetsController extends BaseController {
493
540
  return this.#getNativeAssetIdsForEnabledChains();
494
541
  },
495
542
  getAssetType: (assetId) => this.#getAssetType(assetId),
543
+ getAssetsState: () => this.state,
496
544
  });
497
545
  this.#priceDataSource = new PriceDataSource({
498
546
  queryApiClient,
499
547
  getSelectedCurrency: () => this.state.selectedCurrency,
548
+ getAssetsState: () => this.state,
500
549
  ...priceDataSourceConfig,
501
550
  });
502
- this.#detectionMiddleware = new DetectionMiddleware();
551
+ this.#detectionMiddleware = new DetectionMiddleware({
552
+ getAssetsState: () => this.state,
553
+ });
503
554
  this.#customAssetGraduationMiddleware = new CustomAssetGraduationMiddleware({
504
555
  getSelectedAccountId: () => {
505
556
  try {
@@ -510,9 +561,12 @@ export class AssetsController extends BaseController {
510
561
  }
511
562
  },
512
563
  removeCustomAsset: (accountId, assetId) => this.removeCustomAsset(accountId, assetId),
564
+ getAssetsState: () => this.state,
513
565
  });
514
566
  this.#rpcFallbackMiddleware = new RpcFallbackMiddleware({
515
567
  rpcDataSource: this.#rpcDataSource,
568
+ isBalanceV6Enabled: () => this.#isBalanceV6Enabled(),
569
+ getAssetsState: () => this.state,
516
570
  });
517
571
  log('Initializing AssetsController', {
518
572
  defaultUpdateInterval: this.#defaultUpdateInterval,
@@ -844,7 +898,7 @@ export class AssetsController extends BaseController {
844
898
  * Returns response and exclusive duration per source (sum ≈ wall time).
845
899
  *
846
900
  * Thin wrapper over {@link executeAssetsPipeline} that supplies the
847
- * controller-owned state accessor and exception reporter.
901
+ * controller-owned exception reporter.
848
902
  *
849
903
  * @param params - Middleware execution options.
850
904
  * @param params.sources - Data sources or middlewares with getName() and assetsMiddleware.
@@ -857,7 +911,6 @@ export class AssetsController extends BaseController {
857
911
  async #executeMiddlewares(params) {
858
912
  return executeAssetsPipeline({
859
913
  ...params,
860
- getAssetsState: () => this.state,
861
914
  captureException: this.#captureException,
862
915
  });
863
916
  }
@@ -871,143 +924,255 @@ export class AssetsController extends BaseController {
871
924
  if (accounts.length === 0 || chainIds.length === 0) {
872
925
  return this.#getAssetsFromState(accounts, chainIds, assetTypes);
873
926
  }
874
- // Collect custom assets for all requested accounts
875
- const customAssets = [];
876
- for (const account of accounts) {
877
- const accountCustomAssets = this.getCustomAssets(account.id);
878
- customAssets.push(...accountCustomAssets);
879
- }
880
927
  if (options?.forceUpdate) {
881
- // Pipeline spans only on unlock/first-init fetch; later forceUpdates pass
882
- // `undefined` so `emitTrace` / `withTrace` no-op without call-site if/else.
883
928
  const pipelineTrace = this.#firstInitFetchReported
884
929
  ? undefined
885
930
  : this.#trace;
886
- const request = this.#buildDataRequest(accounts, chainIds, {
931
+ const requestOptions = {
887
932
  assetTypes,
888
933
  dataTypes,
889
- customAssets: customAssets.length > 0 ? customAssets : undefined,
890
934
  forceUpdate: true,
891
935
  bypassServerCache: options?.bypassServerCache,
892
936
  assetsForPriceUpdate: options?.assetsForPriceUpdate,
893
- });
894
- // Fast pipeline: accountsApi + stakedBalance → detection → token + price.
895
- // Snap and RPC are excluded here due to their latency (snap triggers account
896
- // creation, RPC is slow on many chains). Results are committed to state
897
- // immediately so the UI can display balances without waiting for them.
898
- //
899
- // Fast/slow pipelines use merge so partial API snapshots cannot wipe
900
- // tokens missing from the response (e.g. USDC when only native balance
901
- // is returned). Balances present in the response are still refreshed.
902
- const fastSources = buildFastFetchSources({
903
- accountsApiDataSource: this.#accountsApiDataSource,
904
- stakedBalanceDataSource: this.#stakedBalanceDataSource,
905
- customAssetGraduationMiddleware: this.#customAssetGraduationMiddleware,
906
- rpcFallbackMiddleware: this.#rpcFallbackMiddleware,
907
- detectionMiddleware: this.#detectionMiddleware,
908
- tokenDataSource: this.#tokenDataSource,
909
- priceDataSource: this.#priceDataSource,
910
- }, { isBasicFunctionality: this.#isBasicFunctionality() });
911
- const { response } = await withTrace({
912
- name: TRACE_FETCH_PIPELINE,
913
- trace: pipelineTrace,
914
- data: {
915
- chain_count: chainIds.length,
916
- account_count: accounts.length,
917
- basic_functionality: this.#isBasicFunctionality(),
918
- },
919
- fn: async (parentContext) => {
920
- const startTime = performance.now();
921
- const result = await this.#executeMiddlewares({
922
- sources: fastSources,
923
- request,
924
- parentContext,
925
- trace: pipelineTrace,
926
- });
927
- await this.#updateState({
928
- ...result.response,
929
- updateMode: 'merge',
930
- replaceCoveredChainBalances: true,
931
- });
932
- const durationMs = performance.now() - startTime;
933
- // Summary fields for Assets Health (nested under the parent span).
934
- emitTrace({
935
- name: TRACE_FULL_FETCH,
936
- trace: pipelineTrace,
937
- data: {
938
- duration_ms: durationMs,
939
- chain_count: chainIds.length,
940
- account_count: accounts.length,
941
- basic_functionality: this.#isBasicFunctionality(),
942
- asset_count: result.response.assetsBalance
943
- ? Object.values(result.response.assetsBalance).reduce((sum, acct) => sum + Object.keys(acct).length, 0)
944
- : 0,
945
- price_count: result.response.assetsPrice
946
- ? Object.keys(result.response.assetsPrice).length
947
- : 0,
948
- ...result.durationByDataSource,
949
- },
950
- parentContext,
951
- });
952
- emitTrace({
953
- name: TRACE_FIRST_INIT_FETCH,
954
- trace: pipelineTrace,
955
- data: {
956
- duration_ms: durationMs,
957
- chain_ids: JSON.stringify(chainIds),
958
- ...result.durationByDataSource,
959
- },
960
- parentContext,
961
- });
962
- return result;
963
- },
964
- });
965
- // Mark after the unlock/first forceUpdate so later polls skip pipeline spans.
966
- this.#firstInitFetchReported = true;
967
- // Background (slow) lane — flattened sibling of the fast lane (not nested
968
- // inside its withTrace callback). Still fire-and-forget so UI is not blocked.
969
- const slowPipelineChainIds = this.#getSlowPipelineChainIds(chainIds, response);
970
- if (slowPipelineChainIds.length > 0) {
971
- const slowSources = this.#isBasicFunctionality()
972
- ? [this.#snapDataSource, this.#rpcDataSource]
973
- : [this.#rpcDataSource];
974
- const slowRequest = { ...request, chainIds: slowPipelineChainIds };
975
- withTrace({
976
- name: TRACE_BACKGROUND_FETCH,
977
- trace: pipelineTrace,
978
- data: {
979
- chain_count: slowPipelineChainIds.length,
980
- account_count: accounts.length,
981
- },
982
- fn: async (slowParentContext) => {
983
- const { response: slowResponse } = await this.#executeMiddlewares({
984
- sources: [
985
- createParallelBalanceMiddleware(slowSources),
986
- this.#detectionMiddleware,
987
- ...(this.#isBasicFunctionality()
988
- ? [
989
- createParallelMiddleware([
990
- this.#tokenDataSource,
991
- this.#priceDataSource,
992
- ]),
993
- ]
994
- : []),
995
- ],
996
- request: slowRequest,
997
- parentContext: slowParentContext,
998
- trace: pipelineTrace,
999
- });
1000
- await this.#updateState({
1001
- ...slowResponse,
1002
- updateMode: 'merge',
1003
- });
1004
- },
1005
- }).catch((error) => log('Background pipeline failed', { error }));
937
+ };
938
+ if (this.#isBalanceV6Enabled()) {
939
+ await this.#forceUpdateAssetsV6({
940
+ accounts,
941
+ chainIds,
942
+ request: this.#buildDataRequest(accounts, chainIds, requestOptions),
943
+ pipelineTrace,
944
+ });
945
+ }
946
+ else {
947
+ await this.#forceUpdateAssetsV5({
948
+ accounts,
949
+ chainIds,
950
+ request: this.#buildForceUpdateRequestV5(accounts, chainIds, requestOptions),
951
+ pipelineTrace,
952
+ });
1006
953
  }
1007
954
  }
1008
955
  const result = this.#getAssetsFromState(accounts, chainIds, assetTypes);
1009
956
  return result;
1010
957
  }
958
+ /**
959
+ * v5 force-update request: every pin of the requested accounts, unscoped.
960
+ * Delete with the rest of the v5 path when `assetsAccountsApiV6` is the default.
961
+ *
962
+ * @param accounts - Accounts in this fetch.
963
+ * @param chainIds - Chains in this fetch.
964
+ * @param requestOptions - Shared force-update request fields.
965
+ * @param requestOptions.assetTypes - Asset types to fetch.
966
+ * @param requestOptions.dataTypes - Data types to fetch.
967
+ * @param requestOptions.forceUpdate - Always `true` to bypass caches.
968
+ * @param requestOptions.bypassServerCache - Also bypass server-side HTTP caches.
969
+ * @param requestOptions.assetsForPriceUpdate - Assets to refresh prices for.
970
+ * @returns The v5 data request.
971
+ */
972
+ #buildForceUpdateRequestV5(accounts, chainIds, requestOptions) {
973
+ const customAssets = [];
974
+ for (const account of accounts) {
975
+ customAssets.push(...this.getCustomAssets(account.id));
976
+ }
977
+ return this.#buildDataRequest(accounts, chainIds, {
978
+ ...requestOptions,
979
+ customAssets: customAssets.length > 0 ? customAssets : undefined,
980
+ });
981
+ }
982
+ async #forceUpdateAssetsV5({ accounts, chainIds, request, pipelineTrace, }) {
983
+ const isBasicFunctionality = this.#isBasicFunctionality();
984
+ const fastSources = buildFastFetchSources({
985
+ accountsApiDataSource: this.#accountsApiDataSource,
986
+ stakedBalanceDataSource: this.#stakedBalanceDataSource,
987
+ customAssetGraduationMiddleware: this.#customAssetGraduationMiddleware,
988
+ rpcFallbackMiddleware: this.#rpcFallbackMiddleware,
989
+ detectionMiddleware: this.#detectionMiddleware,
990
+ tokenDataSource: this.#tokenDataSource,
991
+ priceDataSource: this.#priceDataSource,
992
+ }, { isBasicFunctionality, includeCustomAssetGraduation: true });
993
+ const fastResponse = await this.#runFastFetchV5({
994
+ accounts,
995
+ chainIds,
996
+ request,
997
+ pipelineTrace,
998
+ fastSources,
999
+ isBasicFunctionality,
1000
+ });
1001
+ this.#firstInitFetchReported = true;
1002
+ this.#runBackgroundFetch({
1003
+ accounts,
1004
+ request,
1005
+ pipelineTrace,
1006
+ chainIds: this.#getSlowPipelineChainIds(chainIds, fastResponse),
1007
+ isBasicFunctionality,
1008
+ });
1009
+ }
1010
+ async #forceUpdateAssetsV6({ accounts, chainIds, request, pipelineTrace, }) {
1011
+ const isBasicFunctionality = this.#isBasicFunctionality();
1012
+ const fastSources = buildFastFetchSources({
1013
+ accountsApiDataSource: this.#accountsApiDataSource,
1014
+ stakedBalanceDataSource: this.#stakedBalanceDataSource,
1015
+ customAssetGraduationMiddleware: this.#customAssetGraduationMiddleware,
1016
+ rpcFallbackMiddleware: this.#rpcFallbackMiddleware,
1017
+ detectionMiddleware: this.#detectionMiddleware,
1018
+ tokenDataSource: this.#tokenDataSource,
1019
+ priceDataSource: this.#priceDataSource,
1020
+ }, {
1021
+ isBasicFunctionality,
1022
+ includeCustomAssetGraduation: false,
1023
+ });
1024
+ const fastResponse = await this.#runFastFetchV6({
1025
+ accounts,
1026
+ chainIds,
1027
+ request,
1028
+ pipelineTrace,
1029
+ fastSources,
1030
+ isBasicFunctionality,
1031
+ });
1032
+ this.#firstInitFetchReported = true;
1033
+ this.#runBackgroundFetch({
1034
+ accounts,
1035
+ request,
1036
+ pipelineTrace,
1037
+ chainIds: this.#getSlowPipelineChainIds(chainIds, fastResponse),
1038
+ isBasicFunctionality,
1039
+ });
1040
+ }
1041
+ async #runFastFetchV5({ accounts, chainIds, request, pipelineTrace, fastSources, isBasicFunctionality, }) {
1042
+ const { response } = await withTrace({
1043
+ name: TRACE_FETCH_PIPELINE,
1044
+ trace: pipelineTrace,
1045
+ data: {
1046
+ chain_count: chainIds.length,
1047
+ account_count: accounts.length,
1048
+ basic_functionality: isBasicFunctionality,
1049
+ },
1050
+ fn: async (parentContext) => {
1051
+ const startTime = performance.now();
1052
+ const result = await this.#executeMiddlewares({
1053
+ sources: fastSources,
1054
+ request,
1055
+ parentContext,
1056
+ trace: pipelineTrace,
1057
+ });
1058
+ await this.#updateState({
1059
+ ...result.response,
1060
+ updateMode: 'merge',
1061
+ replaceCoveredChainBalances: true,
1062
+ });
1063
+ this.#emitFastFetchTraces({
1064
+ accounts,
1065
+ chainIds,
1066
+ result,
1067
+ durationMs: performance.now() - startTime,
1068
+ isBasicFunctionality,
1069
+ parentContext,
1070
+ pipelineTrace,
1071
+ });
1072
+ return result;
1073
+ },
1074
+ });
1075
+ return response;
1076
+ }
1077
+ async #runFastFetchV6({ accounts, chainIds, request, pipelineTrace, fastSources, isBasicFunctionality, }) {
1078
+ const { response } = await withTrace({
1079
+ name: TRACE_FETCH_PIPELINE,
1080
+ trace: pipelineTrace,
1081
+ data: {
1082
+ chain_count: chainIds.length,
1083
+ account_count: accounts.length,
1084
+ basic_functionality: isBasicFunctionality,
1085
+ },
1086
+ fn: async (parentContext) => {
1087
+ const startTime = performance.now();
1088
+ const result = await this.#executeMiddlewares({
1089
+ sources: fastSources,
1090
+ request,
1091
+ parentContext,
1092
+ trace: pipelineTrace,
1093
+ });
1094
+ await this.#updateState(result.response);
1095
+ this.#emitFastFetchTraces({
1096
+ accounts,
1097
+ chainIds,
1098
+ result,
1099
+ durationMs: performance.now() - startTime,
1100
+ isBasicFunctionality,
1101
+ parentContext,
1102
+ pipelineTrace,
1103
+ });
1104
+ return result;
1105
+ },
1106
+ });
1107
+ return response;
1108
+ }
1109
+ #runBackgroundFetch({ accounts, chainIds, request, pipelineTrace, isBasicFunctionality, }) {
1110
+ if (chainIds.length === 0) {
1111
+ return;
1112
+ }
1113
+ const slowSources = isBasicFunctionality
1114
+ ? [this.#snapDataSource, this.#rpcDataSource]
1115
+ : [this.#rpcDataSource];
1116
+ const slowRequest = { ...request, chainIds };
1117
+ withTrace({
1118
+ name: TRACE_BACKGROUND_FETCH,
1119
+ trace: pipelineTrace,
1120
+ data: {
1121
+ chain_count: chainIds.length,
1122
+ account_count: accounts.length,
1123
+ },
1124
+ fn: async (parentContext) => {
1125
+ const { response } = await this.#executeMiddlewares({
1126
+ sources: [
1127
+ createParallelBalanceMiddleware(slowSources),
1128
+ this.#detectionMiddleware,
1129
+ ...(isBasicFunctionality
1130
+ ? [
1131
+ createParallelMiddleware([
1132
+ this.#tokenDataSource,
1133
+ this.#priceDataSource,
1134
+ ]),
1135
+ ]
1136
+ : []),
1137
+ ],
1138
+ request: slowRequest,
1139
+ parentContext,
1140
+ trace: pipelineTrace,
1141
+ });
1142
+ await this.#updateState(response);
1143
+ },
1144
+ }).catch((error) => log('Background pipeline failed', { error }));
1145
+ }
1146
+ #emitFastFetchTraces({ accounts, chainIds, result, durationMs, isBasicFunctionality, parentContext, pipelineTrace, }) {
1147
+ emitTrace({
1148
+ name: TRACE_FULL_FETCH,
1149
+ trace: pipelineTrace,
1150
+ data: {
1151
+ duration_ms: durationMs,
1152
+ chain_count: chainIds.length,
1153
+ account_count: accounts.length,
1154
+ basic_functionality: isBasicFunctionality,
1155
+ asset_count: result.response.assetsBalance
1156
+ ? Object.values(result.response.assetsBalance).reduce((sum, account) => sum + Object.keys(account).length, 0)
1157
+ : 0,
1158
+ price_count: result.response.assetsPrice
1159
+ ? Object.keys(result.response.assetsPrice).length
1160
+ : 0,
1161
+ ...result.durationByDataSource,
1162
+ },
1163
+ parentContext,
1164
+ });
1165
+ emitTrace({
1166
+ name: TRACE_FIRST_INIT_FETCH,
1167
+ trace: pipelineTrace,
1168
+ data: {
1169
+ duration_ms: durationMs,
1170
+ chain_ids: JSON.stringify(chainIds),
1171
+ ...result.durationByDataSource,
1172
+ },
1173
+ parentContext,
1174
+ });
1175
+ }
1011
1176
  async getAssetsBalance(accounts, options) {
1012
1177
  // Reuse getAssets with dataTypes: ['balance'] only
1013
1178
  const assets = await this.getAssets(accounts, {
@@ -1216,7 +1381,8 @@ export class AssetsController extends BaseController {
1216
1381
  /**
1217
1382
  * Add a custom asset for an account.
1218
1383
  * Custom assets are included in subscription and fetch operations.
1219
- * Adding a custom asset also unhides it if it was previously hidden.
1384
+ * Adding a custom asset also unhides it if it was previously hidden,
1385
+ * and force-fetches that asset's chain (including every visible pin).
1220
1386
  *
1221
1387
  * When `pendingMetadata` is provided (e.g. from the extension's pending-tokens
1222
1388
  * flow), the token metadata is persisted immediately into `assetsInfo` so the
@@ -1273,22 +1439,19 @@ export class AssetsController extends BaseController {
1273
1439
  // before the next pipeline fetch returns the real amount.
1274
1440
  const balances = state.assetsBalance;
1275
1441
  balances[accountId] ??= {};
1276
- balances[accountId][normalizedAssetId] ??= { amount: '0' };
1442
+ balances[accountId][normalizedAssetId] ??=
1443
+ getZeroAssetBalance(normalizedAssetId);
1277
1444
  });
1278
- // Fetch data for the newly added custom asset (merge to preserve other chains)
1279
1445
  const account = this.#getSelectedAccounts().find((a) => a.id === accountId);
1280
1446
  if (account) {
1281
- const chainId = extractChainId(normalizedAssetId);
1282
1447
  await this.getAssets([account], {
1283
- chainIds: [chainId],
1448
+ chainIds: [extractChainId(normalizedAssetId)],
1284
1449
  dataTypes: ['balance', 'metadata', 'price'],
1285
1450
  assetTypes: ['fungible'],
1286
1451
  forceUpdate: true,
1287
- updateMode: 'merge',
1288
1452
  });
1289
1453
  }
1290
- // Re-evaluate subscriptions so the supplemental RPC poll picks up the
1291
- // new customAsset on chains another data source already owns.
1454
+ // Re-evaluate subscriptions so polls pick up the new pin.
1292
1455
  this.#subscribeAssets();
1293
1456
  }
1294
1457
  /**
@@ -1309,8 +1472,7 @@ export class AssetsController extends BaseController {
1309
1472
  }
1310
1473
  }
1311
1474
  });
1312
- // Re-evaluate subscriptions so the supplemental RPC poll for that chain
1313
- // is torn down when no more customAssets remain there.
1475
+ // Re-evaluate subscriptions so polls drop the removed pin.
1314
1476
  this.#subscribeAssets();
1315
1477
  }
1316
1478
  /**
@@ -1341,13 +1503,18 @@ export class AssetsController extends BaseController {
1341
1503
  }
1342
1504
  state.assetPreferences[normalizedAssetId].hidden = true;
1343
1505
  });
1506
+ // Re-evaluate subscriptions so polls exclude the newly hidden asset.
1507
+ this.#subscribeAssets();
1344
1508
  }
1345
1509
  /**
1346
1510
  * Unhide an asset globally.
1511
+ * Force-fetches that asset's chain (including every visible pin) so a
1512
+ * `full` snapshot can restore the balance immediately, then re-evaluates
1513
+ * subscriptions so later polls stop excluding it.
1347
1514
  *
1348
1515
  * @param assetId - The CAIP-19 asset ID to unhide.
1349
1516
  */
1350
- unhideAsset(assetId) {
1517
+ async unhideAsset(assetId) {
1351
1518
  const normalizedAssetId = normalizeAssetId(assetId);
1352
1519
  log('Unhiding asset', { assetId: normalizedAssetId });
1353
1520
  this.update((state) => {
@@ -1359,6 +1526,35 @@ export class AssetsController extends BaseController {
1359
1526
  }
1360
1527
  }
1361
1528
  });
1529
+ const chainId = extractChainId(normalizedAssetId);
1530
+ const account = this.#getSelectedAccounts().find((selectedAccount) => this.#getEnabledChainsForAccount(selectedAccount).includes(chainId));
1531
+ if (account) {
1532
+ await this.getAssets([account], {
1533
+ chainIds: [chainId],
1534
+ dataTypes: ['balance', 'metadata', 'price'],
1535
+ assetTypes: ['fungible'],
1536
+ forceUpdate: true,
1537
+ });
1538
+ }
1539
+ this.#subscribeAssets();
1540
+ }
1541
+ /**
1542
+ * Whether Accounts API v6 (and the v6 custom-asset path) is enabled.
1543
+ * Injected into AccountsApiDataSource and RpcFallbackMiddleware.
1544
+ *
1545
+ * `RemoteFeatureFlagController` resolves a threshold-scoped flag to the
1546
+ * selected group's `value`, so processed state is a boolean.
1547
+ *
1548
+ * @returns `true` when the v6 remote flag is on.
1549
+ */
1550
+ #isBalanceV6Enabled() {
1551
+ try {
1552
+ const { remoteFeatureFlags } = this.messenger.call('RemoteFeatureFlagController:getState');
1553
+ return remoteFeatureFlags?.assetsAccountsApiV6 === true;
1554
+ }
1555
+ catch {
1556
+ return false;
1557
+ }
1362
1558
  }
1363
1559
  // ============================================================================
1364
1560
  // CURRENT CURRENCY MANAGEMENT
@@ -1464,7 +1660,6 @@ export class AssetsController extends BaseController {
1464
1660
  subscriptionId: subscriptionKey,
1465
1661
  isUpdate,
1466
1662
  onAssetsUpdate: (response) => this.handleAssetsUpdate(response, 'PriceDataSource'),
1467
- getAssetsState: () => this.state,
1468
1663
  };
1469
1664
  this.#priceDataSource.subscribe(subscribeReq).catch(console.error);
1470
1665
  // Track subscription
@@ -1504,6 +1699,63 @@ export class AssetsController extends BaseController {
1504
1699
  #getNativeAssetMap() {
1505
1700
  return (this.#queryApiClient.getCachedData(NATIVE_ASSETS_QUERY_KEY) ?? EMPTY_NATIVE_ASSET_MAP);
1506
1701
  }
1702
+ /**
1703
+ * Canonical native CAIP-19 ID for a chain. Uses the native asset map when
1704
+ * the chain is registered. An unregistered EVM chain falls back to the
1705
+ * zero-address ERC-20 encoding used for EVM natives without a SLIP-44 id;
1706
+ * an unregistered non-EVM chain resolves no native at all — fabricating an
1707
+ * `erc20:` asset on a non-EVM namespace would produce an ID no data source
1708
+ * ever writes.
1709
+ *
1710
+ * @param chainId - CAIP-2 chain ID.
1711
+ * @returns The native asset ID for the chain, or `undefined` when the
1712
+ * chain is unregistered and non-EVM.
1713
+ */
1714
+ #getNativeAssetForChain(chainId) {
1715
+ const registered = this.#getNativeAssetMap()[chainId];
1716
+ if (registered) {
1717
+ return registered;
1718
+ }
1719
+ if (parseCaipChainId(chainId).namespace === KnownCaipNamespace.Eip155) {
1720
+ return `${chainId}/${UNKNOWN_EVM_NATIVE_ASSET_REFERENCE}`;
1721
+ }
1722
+ return undefined;
1723
+ }
1724
+ #getAssetVisibility(accountIds, chainIds) {
1725
+ return getAssetVisibility({
1726
+ state: this.state,
1727
+ accountIds,
1728
+ chainIds,
1729
+ getNativeAssetForChain: (chainId) => this.#getNativeAssetForChain(chainId),
1730
+ });
1731
+ }
1732
+ /**
1733
+ * Lowercased asset IDs a v6 `full` snapshot must keep for an account, even
1734
+ * when the response omits them: natives, pins, and default tracked assets
1735
+ * the client asked for. Hidden assets are sent as `excludeAssetIds` and are
1736
+ * dropped here; unhide is expected to fetch a new snapshot. Everything else
1737
+ * on a covered chain was discovered by the source, so its absence means the
1738
+ * balance is gone.
1739
+ *
1740
+ * @param state - State being updated (the in-flight draft, not `this.state`).
1741
+ * @param accountId - Account whose pins apply.
1742
+ * @param accountBalances - Incoming balances, which define the covered chains.
1743
+ * @returns Lowercased CAIP-19 IDs that must survive the replace.
1744
+ */
1745
+ #getUndeletableAssetIds(state, accountId, accountBalances) {
1746
+ const coveredChainIds = [
1747
+ ...new Set(Object.keys(accountBalances).map((assetId) => assetId.split('/')[0])),
1748
+ ];
1749
+ const { visibleAssetIds } = getAssetVisibility({
1750
+ state,
1751
+ accountIds: [accountId],
1752
+ chainIds: coveredChainIds,
1753
+ // Registered natives only: the unknown-native fallback is a fabricated
1754
+ // ID, so it must never shield a row from a `full` replace.
1755
+ getNativeAssetForChain: (chainId) => this.#getNativeAssetMap()[chainId],
1756
+ });
1757
+ return new Set(visibleAssetIds.map((assetId) => assetId.toLowerCase()));
1758
+ }
1507
1759
  /**
1508
1760
  * Checks whether the given CAIP-19 asset ID represents a native asset
1509
1761
  * according to the cached native asset map, the asset namespace, and the asset reference.
@@ -1696,6 +1948,7 @@ export class AssetsController extends BaseController {
1696
1948
  async #updateState(response) {
1697
1949
  const normalizedResponse = normalizeResponse(response);
1698
1950
  const mode = normalizedResponse.updateMode ?? 'merge';
1951
+ const isBalanceV6Enabled = this.#isBalanceV6Enabled();
1699
1952
  const releaseLock = await this.#controllerMutex.acquire();
1700
1953
  try {
1701
1954
  const previousState = this.state;
@@ -1755,24 +2008,23 @@ export class AssetsController extends BaseController {
1755
2008
  }
1756
2009
  }
1757
2010
  if (normalizedResponse.assetsBalance) {
1758
- for (const [accountId, accountBalances] of Object.entries(normalizedResponse.assetsBalance)) {
1759
- const previousBalances = previousState.assetsBalance[accountId] ?? {};
2011
+ const applyV5AccountBalanceUpdate = (accountId, accountBalances, previousBalances) => {
1760
2012
  const customAssetIds = state.customAssets[accountId] ?? [];
1761
2013
  const replaceCoveredChains = mode === 'full' ||
1762
2014
  normalizedResponse.replaceCoveredChainBalances === true;
1763
- const effective = mergeAccountBalances(previousBalances, accountBalances, customAssetIds, replaceCoveredChains);
2015
+ const effectiveAccountBalances = effectiveAccountBalancesV5(previousBalances, accountBalances, customAssetIds, replaceCoveredChains);
1764
2016
  // Ensure native tokens have an entry (0 if missing) for chains this account supports
1765
2017
  const account = this.#getSelectedAccounts().find((a) => a.id === accountId);
1766
2018
  const nativeAssetIdsForAccount = account
1767
2019
  ? this.#getNativeAssetIdsForAccount(account)
1768
2020
  : this.#getNativeAssetIdsForEnabledChains();
1769
2021
  for (const nativeAssetId of nativeAssetIdsForAccount) {
1770
- if (!Object.prototype.hasOwnProperty.call(effective, nativeAssetId)) {
1771
- effective[nativeAssetId] =
2022
+ if (!Object.prototype.hasOwnProperty.call(effectiveAccountBalances, nativeAssetId)) {
2023
+ effectiveAccountBalances[nativeAssetId] =
1772
2024
  getDefaultNativeAssetBalance(nativeAssetId);
1773
2025
  }
1774
2026
  }
1775
- for (const [assetId, balance] of Object.entries(effective)) {
2027
+ for (const [assetId, balance] of Object.entries(effectiveAccountBalances)) {
1776
2028
  const previousBalance = previousBalances[assetId];
1777
2029
  // Coerce amounts (e.g. "1e-18" from a data source stringifying
1778
2030
  // a JS Number) into a plain decimal so downstream BigInt()
@@ -1785,15 +2037,17 @@ export class AssetsController extends BaseController {
1785
2037
  const newAmount = normalizeAmountString(balance.amount, assetDecimals);
1786
2038
  const newMetadata = balance.metadata ??
1787
2039
  previousBalance?.metadata;
1788
- effective[assetId] = {
2040
+ effectiveAccountBalances[assetId] = {
1789
2041
  amount: newAmount,
1790
2042
  ...(newMetadata === undefined ? {} : { metadata: newMetadata }),
1791
2043
  };
1792
2044
  const oldAmount = previousBalance?.amount;
1793
- const isNewDefaultNativeZero = oldAmount === undefined &&
2045
+ const isNewSeededZero = oldAmount === undefined &&
1794
2046
  newAmount === '0' &&
1795
- nativeAssetIdsForAccount.includes(assetId);
1796
- if (oldAmount !== newAmount && !isNewDefaultNativeZero) {
2047
+ (nativeAssetIdsForAccount.includes(assetId) ||
2048
+ getDefaultAssetMetadata(assetId) !==
2049
+ undefined);
2050
+ if (oldAmount !== newAmount && !isNewSeededZero) {
1797
2051
  changedBalances.push({
1798
2052
  accountId,
1799
2053
  assetId,
@@ -1802,8 +2056,49 @@ export class AssetsController extends BaseController {
1802
2056
  });
1803
2057
  }
1804
2058
  }
1805
- if (!isEqual(previousBalances, effective)) {
1806
- balances[accountId] = effective;
2059
+ if (!isEqual(previousBalances, effectiveAccountBalances)) {
2060
+ balances[accountId] = effectiveAccountBalances;
2061
+ }
2062
+ };
2063
+ // Delete with the rest of the v6 path if `assetsAccountsApiV6` is
2064
+ // rolled back; v5 keeps using `applyV5AccountBalanceUpdate`.
2065
+ const applyV6AccountBalanceUpdate = (accountId, accountBalances, previousBalances) => {
2066
+ const effectiveAccountBalances = effectiveAccountBalancesV6(previousBalances, accountBalances, this.#getUndeletableAssetIds(state, accountId, accountBalances), mode);
2067
+ for (const [assetId, balance] of Object.entries(effectiveAccountBalances)) {
2068
+ const previousBalance = previousBalances[assetId];
2069
+ const assetDecimals = metadata[assetId]?.decimals;
2070
+ const newAmount = normalizeAmountString(balance.amount, assetDecimals);
2071
+ const newMetadata = balance.metadata ??
2072
+ previousBalance?.metadata;
2073
+ effectiveAccountBalances[assetId] = {
2074
+ amount: newAmount,
2075
+ ...(newMetadata === undefined ? {} : { metadata: newMetadata }),
2076
+ };
2077
+ const oldAmount = previousBalance?.amount;
2078
+ // A v6 snapshot reports assets the account holds none of (its
2079
+ // `includeAssetIds`) at zero. Publishing those as a change would
2080
+ // emit `0 -> 0`, since a missing previous amount reads as '0'.
2081
+ const isNewZero = oldAmount === undefined && newAmount === '0';
2082
+ if (oldAmount !== newAmount && !isNewZero) {
2083
+ changedBalances.push({
2084
+ accountId,
2085
+ assetId,
2086
+ oldAmount,
2087
+ newAmount,
2088
+ });
2089
+ }
2090
+ }
2091
+ if (!isEqual(previousBalances, effectiveAccountBalances)) {
2092
+ balances[accountId] = effectiveAccountBalances;
2093
+ }
2094
+ };
2095
+ for (const [accountId, accountBalances] of Object.entries(normalizedResponse.assetsBalance)) {
2096
+ const previousBalances = previousState.assetsBalance[accountId] ?? {};
2097
+ if (isBalanceV6Enabled) {
2098
+ applyV6AccountBalanceUpdate(accountId, accountBalances, previousBalances);
2099
+ }
2100
+ else {
2101
+ applyV5AccountBalanceUpdate(accountId, accountBalances, previousBalances);
1807
2102
  }
1808
2103
  }
1809
2104
  }
@@ -1827,10 +2122,8 @@ export class AssetsController extends BaseController {
1827
2122
  changedPriceAssets.length > 0) {
1828
2123
  log('State updated', {
1829
2124
  changedBalances: changedBalances.length > 0 ? changedBalances : undefined,
1830
- changedMetadataCount: changedMetadata.length > 0 ? changedMetadata.length : undefined,
1831
- changedPricesCount: changedPriceAssets.length > 0
1832
- ? changedPriceAssets.length
1833
- : undefined,
2125
+ changedMetadata: changedMetadata.length > 0 ? changedMetadata : undefined,
2126
+ changedPrices: changedPriceAssets.length > 0 ? changedPriceAssets : undefined,
1834
2127
  newAssets: Object.keys(detectedAssets).length > 0
1835
2128
  ? Object.entries(detectedAssets).map(([accountId, assets]) => ({
1836
2129
  accountId,
@@ -2052,16 +2345,10 @@ export class AssetsController extends BaseController {
2052
2345
  if (!subscriptionKey.startsWith('ds:')) {
2053
2346
  continue;
2054
2347
  }
2055
- // Subscription keys take the form `ds:<SourceName>` for the regular
2056
- // subscription or `ds:<SourceName>:<suffix>` for supplemental
2057
- // subscriptions (e.g. `ds:RpcDataSource:custom`). Split on `:` and
2058
- // pick the source-name segment so both shapes resolve correctly.
2348
+ // Subscription keys take the form `ds:<SourceName>`.
2059
2349
  const [, sourceId] = subscriptionKey.split(':');
2060
2350
  const source = allSources.find((ds) => ds.getName() === sourceId);
2061
2351
  if (source) {
2062
- // Unsubscribe by the actual key — `#unsubscribeDataSource` only
2063
- // knows the regular `ds:<SourceName>` shape and would miss
2064
- // supplemental subscriptions, leaking their polling timers.
2065
2352
  this.#unsubscribeBySubscriptionKey(source, subscriptionKey);
2066
2353
  }
2067
2354
  }
@@ -2105,10 +2392,12 @@ export class AssetsController extends BaseController {
2105
2392
  * Strategy to minimize data source calls:
2106
2393
  * 1. Collect all chains to subscribe based on enabled networks
2107
2394
  * 2. Map chains to accounts based on their scopes
2108
- * 3. Split by data source (ordered by priority) - each data source gets ONE subscription
2109
- *
2110
- * This ensures we make minimal subscriptions to each data source while covering
2111
- * all accounts and chains.
2395
+ * 3. Split by data source (priority order) — each source gets one
2396
+ * subscription for the chains it was assigned (accounts + chains only).
2397
+ * v6 sources read pins/hides/defaults from state themselves (Accounts API
2398
+ * sends `includeAssetIds` / `excludeAssetIds`; RPC polls native, balances,
2399
+ * pins, and default tracked assets). v5 uses a separate RPC
2400
+ * `customAssetsOnly` supplement for pins on chains another source owns.
2112
2401
  *
2113
2402
  * @param accounts - Accounts to subscribe balance updates for.
2114
2403
  * @param chainIds - Chain IDs to subscribe for.
@@ -2118,10 +2407,7 @@ export class AssetsController extends BaseController {
2118
2407
  #subscribeAssetsBalance(accounts, chainIds, options) {
2119
2408
  const chainToAccounts = this.#buildChainToAccountsMap(accounts, new Set(chainIds));
2120
2409
  const remainingChains = new Set(chainToAccounts.keys());
2121
- // When basic functionality is on, use all balance data sources; when off,
2122
- // RPC only.
2123
- const isBasicFunctionality = this.#isBasicFunctionality();
2124
- const balanceDataSources = isBasicFunctionality
2410
+ const balanceDataSources = this.#isBasicFunctionality()
2125
2411
  ? this.#allBalanceDataSources
2126
2412
  : [this.#rpcDataSource];
2127
2413
  let rpcAssignedChains = new Set();
@@ -2160,22 +2446,15 @@ export class AssetsController extends BaseController {
2160
2446
  });
2161
2447
  }
2162
2448
  }
2163
- // Supplemental RPC subscription for customAssets on chains another data
2164
- // source claimed during regular handoff. RPC is the sole balance fetcher
2165
- // for customAssets, so we must always poll them — even when (e.g.)
2166
- // AccountsApi is already covering the chain for normal balances. The
2167
- // supplemental subscription runs in `customAssetsOnly` mode so it does
2168
- // NOT double-poll the regular tracked balances.
2169
- this.#subscribeRpcCustomAssetsSupplement(accounts, chainToAccounts, rpcAssignedChains);
2449
+ if (!this.#isBalanceV6Enabled()) {
2450
+ this.#subscribeRpcCustomAssetsSupplement(accounts, chainToAccounts, rpcAssignedChains);
2451
+ }
2170
2452
  }
2171
2453
  /**
2172
2454
  * Guarantee that customAssets are **always** polled by RPC, even when
2173
2455
  * AccountsApi or another data source has claimed the chain in the
2174
2456
  * regular handoff. RPC is the sole balance fetcher for user-imported
2175
- * tokens (see `pickRpcCustomAssetsSupplement` for the full rationale),
2176
- * so we run a dedicated subscription in `customAssetsOnly` mode under a
2177
- * distinct subscription key (`ds:RpcDataSource:custom`) that does not
2178
- * interfere with the regular RPC subscription.
2457
+ * tokens on the Accounts API v5 path (see `pickRpcCustomAssetsSupplement`).
2179
2458
  *
2180
2459
  * @param accounts - Accounts to consider for customAssets.
2181
2460
  * @param chainToAccounts - Map of chain → accounts (built by caller).
@@ -2273,7 +2552,8 @@ export class AssetsController extends BaseController {
2273
2552
  * @param chains - Array of chain IDs to subscribe for.
2274
2553
  * @param options - Optional subscription overrides.
2275
2554
  * @param options.subscriptionKey - Custom subscription key (default: `ds:<sourceId>`).
2276
- * @param options.customAssetsOnly - When true, only poll customAssets for these chains.
2555
+ * @param options.customAssetsOnly - When true, only poll customAssets for these
2556
+ * chains (Accounts API v5 supplemental RPC subscription).
2277
2557
  * @param options.skipInitialFetch - When true, skip the data source's subscribe-time fetch.
2278
2558
  */
2279
2559
  #subscribeDataSource(source, accounts, chains, options = {}) {
@@ -2302,7 +2582,6 @@ export class AssetsController extends BaseController {
2302
2582
  subscriptionId: subscriptionKey,
2303
2583
  isUpdate,
2304
2584
  onAssetsUpdate: (response, request) => this.handleAssetsUpdate(response, sourceId, request),
2305
- getAssetsState: () => this.state,
2306
2585
  ...(options.skipInitialFetch === true ? { skipInitialFetch: true } : {}),
2307
2586
  };
2308
2587
  source.subscribe(subscribeReq).catch((error) => {
@@ -2486,13 +2765,13 @@ export class AssetsController extends BaseController {
2486
2765
  // The data will simply not be updated until the network is re-enabled.
2487
2766
  // Refresh subscriptions for new chain set
2488
2767
  this.#subscribeAssets();
2489
- // Do one-time fetch for newly enabled chains; merge so we keep existing chain balances
2768
+ // One-time fetch for newly enabled chains. v6 `full` replace is scoped to
2769
+ // those chains; existing balances on other chains are left untouched.
2490
2770
  const accounts = this.#getSelectedAccounts();
2491
2771
  if (addedChains.length > 0 && accounts.length > 0) {
2492
2772
  await this.getAssets(accounts, {
2493
2773
  chainIds: addedChains,
2494
2774
  forceUpdate: true,
2495
- updateMode: 'merge',
2496
2775
  });
2497
2776
  }
2498
2777
  this.#ensureNativeBalancesDefaultZero();
@@ -2652,48 +2931,28 @@ export class AssetsController extends BaseController {
2652
2931
  async handleAssetsUpdate(response, sourceId, request) {
2653
2932
  log('Assets updated from data source', {
2654
2933
  sourceId,
2655
- hasBalance: Boolean(response.assetsBalance),
2656
- hasPrice: Boolean(response.assetsPrice),
2934
+ assetsBalance: response.assetsBalance,
2935
+ assetsPrice: response.assetsPrice,
2657
2936
  });
2658
- // Enrichment spans only before unlock/first-init fetch completes.
2937
+ if (this.#isBalanceV6Enabled()) {
2938
+ await this.#handleAssetsUpdateV6(response, sourceId, request);
2939
+ return;
2940
+ }
2941
+ await this.#handleAssetsUpdateV5(response, sourceId, request);
2942
+ }
2943
+ async #handleAssetsUpdateV5(response, sourceId, request) {
2659
2944
  const pipelineTrace = this.#firstInitFetchReported
2660
2945
  ? undefined
2661
2946
  : this.#trace;
2662
2947
  await withTrace({
2663
2948
  name: TRACE_UPDATE_PARENT,
2664
2949
  trace: pipelineTrace,
2665
- data: {
2666
- source: sourceId,
2667
- has_balance: Boolean(response.assetsBalance),
2668
- has_price: Boolean(response.assetsPrice),
2669
- balance_account_count: response.assetsBalance
2670
- ? Object.keys(response.assetsBalance).length
2671
- : 0,
2672
- },
2950
+ data: this.#getUpdateTraceData(response, sourceId),
2673
2951
  fn: async (parentContext) => {
2674
2952
  const updateStart = performance.now();
2675
- const resolvedRequest = request ?? {
2676
- accountsWithSupportedChains: [],
2677
- chainIds: [],
2678
- dataTypes: ['balance', 'metadata', 'price'],
2679
- };
2680
- // RPC-only mode (basic functionality off): never run token/price APIs. Strip
2681
- // those data types so downstream middleware cannot treat them as requested.
2682
- const pipelineRequest = this.#isBasicFunctionality()
2683
- ? resolvedRequest
2684
- : {
2685
- ...resolvedRequest,
2686
- dataTypes: resolvedRequest.dataTypes.filter((dt) => dt !== 'metadata' && dt !== 'price'),
2687
- };
2688
- // Graduate custom assets only when AccountsAPI / AccountActivity reports
2689
- // them. RPC already fetches custom assets on purpose, and Snap handles
2690
- // non-EVM chains the rule does not apply to, so skip the middleware for
2691
- // those.
2953
+ const pipelineRequest = this.#getUpdatePipelineRequest(request);
2692
2954
  const shouldGraduateCustomAssets = sourceId === 'AccountsApiDataSource' ||
2693
2955
  sourceId === 'AccountActivityDataSource';
2694
- // Websocket updates can carry brand-new spam airdrops: enrich them
2695
- // with Token API occurrences and drop below-floor tokens BEFORE
2696
- // detection, so spam is never detected, enriched, priced or persisted.
2697
2956
  const shouldFilterOccurrences = sourceId === 'AccountActivityDataSource' &&
2698
2957
  this.#isBasicFunctionality();
2699
2958
  const shouldRunRpcFallback = sourceId === 'AccountsApiDataSource';
@@ -2729,25 +2988,110 @@ export class AssetsController extends BaseController {
2729
2988
  ...enrichedResponse,
2730
2989
  replaceCoveredChainBalances: response.replaceCoveredChainBalances,
2731
2990
  });
2732
- // Summary fields for Assets Health (nested under the parent span).
2733
- emitTrace({
2734
- name: TRACE_UPDATE_PIPELINE,
2991
+ this.#emitUpdateTrace({
2992
+ response,
2993
+ sourceId,
2994
+ enrichedResponse,
2995
+ updateStart,
2996
+ pipelineTrace,
2997
+ parentContext,
2998
+ });
2999
+ },
3000
+ });
3001
+ }
3002
+ async #handleAssetsUpdateV6(response, sourceId, request) {
3003
+ const pipelineTrace = this.#firstInitFetchReported
3004
+ ? undefined
3005
+ : this.#trace;
3006
+ await withTrace({
3007
+ name: TRACE_UPDATE_PARENT,
3008
+ trace: pipelineTrace,
3009
+ data: this.#getUpdateTraceData(response, sourceId),
3010
+ fn: async (parentContext) => {
3011
+ const updateStart = performance.now();
3012
+ const pipelineRequest = this.#getUpdatePipelineRequest(request);
3013
+ const shouldFilterOccurrences = sourceId === 'AccountActivityDataSource' &&
3014
+ this.#isBasicFunctionality();
3015
+ const enrichmentSources = [
3016
+ ...(shouldFilterOccurrences
3017
+ ? [
3018
+ {
3019
+ getName: () => 'OccurrenceFloorFilter',
3020
+ assetsMiddleware: this.#tokenDataSource.occurrenceFilterMiddleware,
3021
+ },
3022
+ ]
3023
+ : []),
3024
+ ];
3025
+ if (this.#isBasicFunctionality()) {
3026
+ enrichmentSources.push(this.#rpcFallbackMiddleware);
3027
+ }
3028
+ enrichmentSources.push(this.#detectionMiddleware);
3029
+ if (this.#isBasicFunctionality()) {
3030
+ enrichmentSources.push(createParallelMiddleware([
3031
+ this.#tokenDataSource,
3032
+ this.#priceDataSource,
3033
+ ]));
3034
+ }
3035
+ const { response: enrichedResponse } = await this.#executeMiddlewares({
3036
+ sources: enrichmentSources,
3037
+ request: pipelineRequest,
3038
+ initialResponse: response,
3039
+ parentContext,
2735
3040
  trace: pipelineTrace,
2736
- data: {
2737
- source: sourceId,
2738
- duration_ms: performance.now() - updateStart,
2739
- has_balance: Boolean(response.assetsBalance),
2740
- has_price: Boolean(response.assetsPrice),
2741
- has_metadata: Boolean(enrichedResponse.assetsInfo),
2742
- balance_account_count: response.assetsBalance
2743
- ? Object.keys(response.assetsBalance).length
2744
- : 0,
2745
- },
3041
+ });
3042
+ await this.#updateState(enrichedResponse);
3043
+ this.#emitUpdateTrace({
3044
+ response,
3045
+ sourceId,
3046
+ enrichedResponse,
3047
+ updateStart,
3048
+ pipelineTrace,
2746
3049
  parentContext,
2747
3050
  });
2748
3051
  },
2749
3052
  });
2750
3053
  }
3054
+ #getUpdatePipelineRequest(request) {
3055
+ const resolvedRequest = request ?? {
3056
+ accountsWithSupportedChains: [],
3057
+ chainIds: [],
3058
+ dataTypes: ['balance', 'metadata', 'price'],
3059
+ };
3060
+ if (this.#isBasicFunctionality()) {
3061
+ return resolvedRequest;
3062
+ }
3063
+ return {
3064
+ ...resolvedRequest,
3065
+ dataTypes: resolvedRequest.dataTypes.filter((dataType) => dataType !== 'metadata' && dataType !== 'price'),
3066
+ };
3067
+ }
3068
+ #getUpdateTraceData(response, sourceId) {
3069
+ return {
3070
+ source: sourceId,
3071
+ has_balance: Boolean(response.assetsBalance),
3072
+ has_price: Boolean(response.assetsPrice),
3073
+ balance_account_count: response.assetsBalance
3074
+ ? Object.keys(response.assetsBalance).length
3075
+ : 0,
3076
+ };
3077
+ }
3078
+ #emitUpdateTrace({ response, sourceId, enrichedResponse, updateStart, pipelineTrace, parentContext, }) {
3079
+ emitTrace({
3080
+ name: TRACE_UPDATE_PIPELINE,
3081
+ trace: pipelineTrace,
3082
+ data: {
3083
+ source: sourceId,
3084
+ duration_ms: performance.now() - updateStart,
3085
+ has_balance: Boolean(response.assetsBalance),
3086
+ has_price: Boolean(response.assetsPrice),
3087
+ has_metadata: Boolean(enrichedResponse.assetsInfo),
3088
+ balance_account_count: response.assetsBalance
3089
+ ? Object.keys(response.assetsBalance).length
3090
+ : 0,
3091
+ },
3092
+ parentContext,
3093
+ });
3094
+ }
2751
3095
  // ============================================================================
2752
3096
  // CLEANUP
2753
3097
  // ============================================================================