@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
@@ -1,12 +1,13 @@
1
1
  import { Web3Provider } from '@ethersproject/providers';
2
2
  import { toHex } from '@metamask/controller-utils';
3
- import { isStrictHexString, isCaipChainId, parseCaipAssetType, parseCaipChainId, hexToNumber, toCaipChainId, KnownCaipNamespace, } from '@metamask/utils';
3
+ import { isStrictHexString, isCaipChainId, KnownCaipNamespace, parseCaipAssetType, parseCaipChainId, hexToNumber, toCaipChainId, } from '@metamask/utils';
4
4
  import { BigNumber as BigNumberJS } from 'bignumber.js';
5
5
  import { projectLogger, createModuleLogger } from '../logger.js';
6
6
  import { ZERO_ADDRESS } from '../utils/constants.js';
7
+ import { filterFailedChainBalances } from '../utils/filterFailedChainBalances.js';
7
8
  import { normalizeAssetId } from '../utils/index.js';
8
9
  import { AbstractDataSource } from './AbstractDataSource.js';
9
- import { BalanceFetcher, MulticallClient, TokenDetector, TokensApiClient, } from './evm-rpc-services/index.js';
10
+ import { BalanceFetcher, isStakingContractAssetId, MulticallClient, TokenDetector, TokensApiClient, } from './evm-rpc-services/index.js';
10
11
  import { shouldSkipNativeForCaipChainId } from './evm-rpc-services/utils/assets.js';
11
12
  const CONTROLLER_NAME = 'RpcDataSource';
12
13
  const DEFAULT_BALANCE_INTERVAL = 30_000; // 30 seconds
@@ -47,6 +48,7 @@ export const caipChainIdToHex = (chainId) => {
47
48
  */
48
49
  export class RpcDataSource extends AbstractDataSource {
49
50
  #messenger;
51
+ #getAssetsState;
50
52
  #onActiveChainsUpdated;
51
53
  #getNativeAssetForChain;
52
54
  #timeout;
@@ -67,9 +69,12 @@ export class RpcDataSource extends AbstractDataSource {
67
69
  #balanceFetcher;
68
70
  #tokenDetector;
69
71
  #getAssetType;
72
+ #isBalanceV6Enabled;
73
+ #getAssetVisibility;
70
74
  constructor(options) {
71
75
  super(CONTROLLER_NAME, { activeChains: [] });
72
76
  this.#messenger = options.messenger;
77
+ this.#getAssetsState = options.getAssetsState;
73
78
  this.#onActiveChainsUpdated = options.onActiveChainsUpdated;
74
79
  this.#getNativeAssetForChain = options.getNativeAssetForChain;
75
80
  this.#getAssetType = options.getAssetType;
@@ -79,6 +84,9 @@ export class RpcDataSource extends AbstractDataSource {
79
84
  this.#useExternalService =
80
85
  options.useExternalService ?? (() => true);
81
86
  this.#isOnboarded = options.isOnboarded ?? (() => true);
87
+ this.#isBalanceV6Enabled =
88
+ options.isBalanceV6Enabled ?? (() => false);
89
+ this.#getAssetVisibility = options.getAssetVisibility;
82
90
  const balanceInterval = options.balanceInterval ?? DEFAULT_BALANCE_INTERVAL;
83
91
  const detectionInterval = options.detectionInterval ?? DEFAULT_DETECTION_INTERVAL;
84
92
  log('Initializing RpcDataSource', {
@@ -98,26 +106,17 @@ export class RpcDataSource extends AbstractDataSource {
98
106
  // eslint-disable-next-line no-restricted-syntax
99
107
  this.#messenger.call('ConfigRegistryController:getNetworkConfigByCaip2ChainId', toCaipChainId(KnownCaipNamespace.Eip155, hexToNumber(hexChainId).toString()))?.contracts?.multicall3,
100
108
  });
101
- // Create messenger adapters for BalanceFetcher and TokenDetector
102
- const balanceFetcherMessenger = {
103
- call: (_action) => {
104
- // The messenger is not being called from a constructor, so this is safe.
105
- // eslint-disable-next-line no-restricted-syntax
106
- const state = this.#messenger.call('AssetsController:getState');
107
- return {
108
- assetsBalance: state.assetsBalance ?? {},
109
- customAssets: state.customAssets ?? {},
110
- };
111
- },
112
- };
113
109
  // Initialize BalanceFetcher with polling interval
114
- this.#balanceFetcher = new BalanceFetcher(this.#multicallClient, balanceFetcherMessenger, {
110
+ this.#balanceFetcher = new BalanceFetcher(this.#multicallClient, {
115
111
  pollingInterval: balanceInterval,
112
+ getAssetsState: this.#getAssetsState,
116
113
  isNativeAsset: (assetId) => {
117
114
  const { chainId } = parseCaipAssetType(assetId);
118
115
  const nativeId = this.#getNativeAssetForChain(chainId);
119
116
  return nativeId?.toLowerCase() === assetId.toLowerCase();
120
117
  },
118
+ isBalanceV6Enabled: () => this.#isBalanceV6Enabled(),
119
+ getAssetVisibility: this.#getAssetVisibility,
121
120
  });
122
121
  // Polling controller awaits this callback; rejections must not become unhandled.
123
122
  this.#balanceFetcher.setOnBalanceUpdate(async (result) => {
@@ -263,6 +262,19 @@ export class RpcDataSource extends AbstractDataSource {
263
262
  * @param result - The balance fetch result.
264
263
  */
265
264
  async #handleBalanceUpdate(result) {
265
+ if (this.#isBalanceV6Enabled()) {
266
+ await this.#handleBalanceUpdateV6(result);
267
+ return;
268
+ }
269
+ await this.#handleBalanceUpdateV5(result);
270
+ }
271
+ /**
272
+ * v5 poll path. Unchanged from the pre-v6 handler; delete the v6 sibling
273
+ * first if `assetsAccountsApiV6` is rolled back.
274
+ *
275
+ * @param result - The balance fetch result.
276
+ */
277
+ async #handleBalanceUpdateV5(result) {
266
278
  const newBalances = {};
267
279
  // Convert hex chain ID to CAIP-2 format
268
280
  const chainIdDecimal = parseInt(result.chainId, 16);
@@ -308,7 +320,88 @@ export class RpcDataSource extends AbstractDataSource {
308
320
  newBalanceCount: Object.keys(newBalances).length,
309
321
  });
310
322
  for (const subscription of this.#activeSubscriptions.values()) {
311
- subscription.onAssetsUpdate(response, request)?.catch((error) => {
323
+ subscription
324
+ .onAssetsUpdate(response, request)
325
+ ?.catch((error) => {
326
+ log('Failed to update assets', { error });
327
+ });
328
+ }
329
+ }
330
+ /**
331
+ * v6 poll path. Always stamps `full`: assets that resolve get their fresh
332
+ * amount, and a failed `balanceOf` or missing metadata keeps the amount
333
+ * already in state instead of dropping the asset. Delete with the rest of
334
+ * the v6 path if `assetsAccountsApiV6` is rolled back.
335
+ *
336
+ * @param result - The balance fetch result.
337
+ */
338
+ async #handleBalanceUpdateV6(result) {
339
+ const newBalances = {};
340
+ // Convert hex chain ID to CAIP-2 format
341
+ const chainIdDecimal = parseInt(result.chainId, 16);
342
+ const caipChainId = `eip155:${chainIdDecimal}`;
343
+ // Normalize asset IDs from BalanceFetcher (lowercase) to checksummed form
344
+ const normalizedBalances = result.balances.map((b) => ({
345
+ ...b,
346
+ assetId: normalizeAssetId(b.assetId),
347
+ }));
348
+ // Collect metadata for all balances
349
+ const assetsInfo = this.#collectMetadataForBalances(normalizedBalances, caipChainId);
350
+ // Convert balances that have decimals. Skip the rest so a missing
351
+ // metadata lookup cannot drop the tokens that did resolve.
352
+ const visibleAssetKeys = this.#getVisibleAssetKeys(result.accountId, caipChainId);
353
+ const handledAssetKeys = new Set();
354
+ const existingMetadata = this.#getExistingAssetsMetadata();
355
+ for (const balance of normalizedBalances) {
356
+ if (this.#isUnheldInvisibleAsset(balance.assetId, balance.balance, visibleAssetKeys)) {
357
+ handledAssetKeys.add(balance.assetId.toLowerCase());
358
+ continue;
359
+ }
360
+ const stateMetadata = existingMetadata[balance.assetId];
361
+ const pipelineMetadata = assetsInfo[balance.assetId];
362
+ const decimals = this.#pickValidDecimals(stateMetadata, pipelineMetadata);
363
+ if (decimals === undefined) {
364
+ continue;
365
+ }
366
+ const humanReadableAmount = this.#convertToHumanReadable(balance.balance, decimals);
367
+ newBalances[balance.assetId] = {
368
+ amount: humanReadableAmount,
369
+ };
370
+ handledAssetKeys.add(balance.assetId.toLowerCase());
371
+ }
372
+ if (handledAssetKeys.size === 0) {
373
+ return;
374
+ }
375
+ // Everything requested that did not resolve keeps its previous amount, so
376
+ // the snapshot stays complete for the chain and can be applied as `full`.
377
+ const unresolved = this.#getAssetsToFetchV6(result.accountId, caipChainId).filter((entry) => !handledAssetKeys.has(normalizeAssetId(entry.assetId).toLowerCase()));
378
+ this.#carryOverUnresolvedBalances({
379
+ unresolved,
380
+ accountId: result.accountId,
381
+ chainId: caipChainId,
382
+ balances: newBalances,
383
+ visibleAssetKeys,
384
+ });
385
+ const response = {
386
+ assetsBalance: {
387
+ [result.accountId]: newBalances,
388
+ },
389
+ assetsInfo,
390
+ updateMode: 'full',
391
+ };
392
+ const request = {
393
+ accountsWithSupportedChains: [],
394
+ chainIds: [caipChainId],
395
+ dataTypes: ['balance'],
396
+ };
397
+ log('Balance update response', {
398
+ accountId: result.accountId,
399
+ newBalanceCount: Object.keys(newBalances).length,
400
+ });
401
+ for (const subscription of this.#activeSubscriptions.values()) {
402
+ subscription
403
+ .onAssetsUpdate(response, request)
404
+ ?.catch((error) => {
312
405
  log('Failed to update assets', { error });
313
406
  });
314
407
  }
@@ -373,7 +466,9 @@ export class RpcDataSource extends AbstractDataSource {
373
466
  dataTypes: ['balance', 'metadata', 'price'],
374
467
  };
375
468
  for (const subscription of this.#activeSubscriptions.values()) {
376
- subscription.onAssetsUpdate(response, request)?.catch((error) => {
469
+ subscription
470
+ .onAssetsUpdate(response, request)
471
+ ?.catch((error) => {
377
472
  log('Failed to update detected assets', { error });
378
473
  });
379
474
  }
@@ -661,21 +756,27 @@ export class RpcDataSource extends AbstractDataSource {
661
756
  log('Skipping fetch - onboarding not complete');
662
757
  return {};
663
758
  }
664
- const response = {};
665
- const chainsToFetch = request.chainIds.filter((chainId) => this.#activeChains.includes(chainId));
666
- log('Fetch requested', {
667
- accounts: request.accountsWithSupportedChains.map((a) => a.account.id),
668
- requestedChains: request.chainIds,
669
- chainsToFetch,
670
- });
759
+ if (this.#isBalanceV6Enabled()) {
760
+ return this.#fetchV6(request);
761
+ }
762
+ return this.#fetchV5(request);
763
+ }
764
+ /**
765
+ * v5 fetch. Overlays tokens that succeed, stamps `merge`. Delete with the
766
+ * rest of the v5 path if `assetsAccountsApiV6` is rolled back.
767
+ *
768
+ * @param request - The data request.
769
+ * @returns Balance and metadata for the requested chains.
770
+ */
771
+ async #fetchV5(request) {
772
+ const chainsToFetch = this.#getChainsToFetch(request);
671
773
  if (chainsToFetch.length === 0) {
672
774
  log('No active chains to fetch');
673
- return response;
775
+ return {};
674
776
  }
675
777
  const assetsBalance = {};
676
778
  const assetsInfo = {};
677
779
  const failedChains = [];
678
- // Fetch balances for each account and its supported chains (pre-computed in request)
679
780
  for (const { account, supportedChains, } of request.accountsWithSupportedChains) {
680
781
  const chainsForAccount = chainsToFetch.filter((chain) => supportedChains.includes(chain));
681
782
  if (chainsForAccount.length === 0) {
@@ -686,100 +787,290 @@ export class RpcDataSource extends AbstractDataSource {
686
787
  const hexChainId = caipChainIdToHex(chainId);
687
788
  const nativeAssetId = this.#getNativeAssetForChain(chainId);
688
789
  const shouldSkipNative = shouldSkipNativeForCaipChainId(chainId);
689
- const assetsToFetch = [];
690
- if (!shouldSkipNative) {
691
- // Build a single AssetFetchEntry[] for native + custom ERC-20s
692
- assetsToFetch.push({ assetId: nativeAssetId, address: ZERO_ADDRESS });
790
+ const assetsToFetch = this.#getAssetsToFetchV5(request, chainId, shouldSkipNative);
791
+ try {
792
+ const result = await this.#balanceFetcher.fetchBalancesForAssets(hexChainId, accountId, address, assetsToFetch);
793
+ await this.#ingestFetchedBalances(result, accountId, chainId, assetsBalance, assetsInfo);
693
794
  }
694
- if (request.customAssets) {
695
- const existingMetadata = this.#getExistingAssetsMetadata();
696
- for (const assetId of request.customAssets) {
697
- try {
698
- const parsed = parseCaipAssetType(assetId);
699
- const assetChainId = `${parsed.chain.namespace}:${parsed.chain.reference}`;
700
- if (assetChainId === chainId &&
701
- this.#getAssetType(assetId) === 'erc20') {
702
- const tokenAddress = parsed.assetReference.toLowerCase();
703
- const normalizedId = normalizeAssetId(assetId);
704
- const decimals = existingMetadata[normalizedId]?.decimals;
705
- assetsToFetch.push({
706
- assetId,
707
- address: tokenAddress,
708
- decimals,
709
- });
710
- }
711
- }
712
- catch {
713
- // Skip unparseable asset IDs
714
- }
715
- }
795
+ catch (error) {
796
+ this.#recordFetchChainFailure({
797
+ address,
798
+ chainId,
799
+ error,
800
+ accountId,
801
+ nativeAssetId,
802
+ shouldSkipNative,
803
+ assetsBalance,
804
+ assetsInfo,
805
+ failedChains,
806
+ });
716
807
  }
808
+ }
809
+ }
810
+ return this.#completeFetchResponse({
811
+ assetsBalance,
812
+ assetsInfo,
813
+ failedChains,
814
+ chainsToFetch,
815
+ updateMode: 'merge',
816
+ });
817
+ }
818
+ /**
819
+ * v6 fetch. Visibility-scoped snapshot, always stamped `full`.
820
+ *
821
+ * A single failed `balanceOf` or unknown decimals no longer holds back the
822
+ * rest of the chain: the assets that resolved are written, and the ones that
823
+ * did not keep the amount already in state (see
824
+ * `#carryOverUnresolvedBalances`). A chain that resolved nothing at all is
825
+ * reported in `errors` and contributes no balances.
826
+ *
827
+ * @param request - The data request.
828
+ * @returns An authoritative snapshot for the chains that resolved at least
829
+ * one balance. Fully failed chains are omitted.
830
+ */
831
+ async #fetchV6(request) {
832
+ const chainsToFetch = this.#getChainsToFetch(request);
833
+ if (chainsToFetch.length === 0) {
834
+ log('No active chains to fetch');
835
+ return {};
836
+ }
837
+ const assetsBalance = {};
838
+ const assetsInfo = {};
839
+ const failedChains = [];
840
+ for (const { account, supportedChains, } of request.accountsWithSupportedChains) {
841
+ const chainsForAccount = chainsToFetch.filter((chain) => supportedChains.includes(chain));
842
+ if (chainsForAccount.length === 0) {
843
+ continue;
844
+ }
845
+ const { address, id: accountId } = account;
846
+ for (const chainId of chainsForAccount) {
847
+ const hexChainId = caipChainIdToHex(chainId);
848
+ const nativeAssetId = this.#getNativeAssetForChain(chainId);
849
+ const shouldSkipNative = shouldSkipNativeForCaipChainId(chainId);
717
850
  try {
851
+ const assetsToFetch = this.#getAssetsToFetchV6(accountId, chainId);
852
+ const visibleAssetKeys = this.#getVisibleAssetKeys(accountId, chainId);
718
853
  const result = await this.#balanceFetcher.fetchBalancesForAssets(hexChainId, accountId, address, assetsToFetch);
719
- if (!assetsBalance[accountId]) {
720
- assetsBalance[accountId] = {};
854
+ const handledAssetKeys = await this.#ingestFetchedBalances(result, accountId, chainId, assetsBalance, assetsInfo, visibleAssetKeys);
855
+ const unresolved = assetsToFetch.filter((entry) => !handledAssetKeys.has(normalizeAssetId(entry.assetId).toLowerCase()));
856
+ if (unresolved.length === 0) {
857
+ continue;
721
858
  }
722
- // Normalize asset IDs from BalanceFetcher (which uses lowercase
723
- // addresses) to checksummed form so they match assetsInfo state keys.
724
- const normalizedBalances = result.balances.map((b) => ({
725
- ...b,
726
- assetId: normalizeAssetId(b.assetId),
727
- }));
728
- // Collect metadata for all balances
729
- const balanceMetadata = this.#collectMetadataForBalances(normalizedBalances, chainId);
730
- Object.assign(assetsInfo, balanceMetadata);
731
- // Convert balances to human-readable format using decimals from
732
- // assetsInfo state (which includes pendingMetadata from addCustomAsset).
733
- // Resolution: state → pipeline metadata → RPC `decimals()`; omit balance if still unknown.
734
- const existingMetadata = this.#getExistingAssetsMetadata();
735
- for (const balance of normalizedBalances) {
736
- const stateMetadata = existingMetadata[balance.assetId];
737
- const pipelineMetadata = assetsInfo[balance.assetId];
738
- let decimals = this.#pickValidDecimals(stateMetadata, pipelineMetadata);
739
- if (decimals === undefined) {
740
- const parsed = parseCaipAssetType(balance.assetId);
741
- if (this.#getAssetType(balance.assetId) === 'erc20') {
742
- decimals = await this.#fetchDecimalsViaRpc(chainId, parsed.assetReference);
743
- }
744
- }
745
- if (decimals === undefined) {
746
- continue;
859
+ if (handledAssetKeys.size === 0) {
860
+ // Nothing at all resolved, so there is no trustworthy slice to
861
+ // write. Leave the chain to the RPC fallback.
862
+ if (!failedChains.includes(chainId)) {
863
+ failedChains.push(chainId);
747
864
  }
748
- const humanReadableAmount = this.#convertToHumanReadable(balance.balance, decimals);
749
- assetsBalance[accountId][balance.assetId] = {
750
- amount: humanReadableAmount,
751
- };
865
+ continue;
752
866
  }
867
+ log('Partial v6 RPC fetch', {
868
+ accountId,
869
+ chainId,
870
+ failedAddresses: result.failedAddresses,
871
+ unresolved: unresolved.map((entry) => entry.assetId),
872
+ });
873
+ this.#carryOverUnresolvedBalances({
874
+ unresolved,
875
+ accountId,
876
+ chainId,
877
+ balances: assetsBalance[accountId],
878
+ visibleAssetKeys,
879
+ });
753
880
  }
754
881
  catch (error) {
755
- log('Failed to fetch balance', { address, chainId, error });
756
- if (!assetsBalance[accountId]) {
757
- assetsBalance[accountId] = {};
758
- }
759
- if (!shouldSkipNative) {
760
- assetsBalance[accountId][nativeAssetId] = { amount: '0' };
761
- }
762
- // On error, emit a stub only when no valid metadata exists in state
763
- // yet. Re-emitting existing metadata would overwrite richer entries
764
- // (e.g. image/description added by AccountsAPI) with a simpler stub.
765
- const existingNativeMeta = this.#getExistingAssetsMetadata()[nativeAssetId];
766
- if (!this.#hasValidDecimals(existingNativeMeta)) {
767
- const chainStatus = this.#chainStatuses[chainId];
768
- if (chainStatus) {
769
- assetsInfo[nativeAssetId] = {
770
- type: 'native',
771
- symbol: chainStatus.nativeCurrency,
772
- name: chainStatus.nativeCurrency,
773
- decimals: 18,
774
- };
775
- }
776
- }
777
- if (!failedChains.includes(chainId)) {
778
- failedChains.push(chainId);
779
- }
882
+ this.#recordFetchChainFailure({
883
+ address,
884
+ chainId,
885
+ error,
886
+ accountId,
887
+ nativeAssetId,
888
+ shouldSkipNative,
889
+ assetsBalance,
890
+ assetsInfo,
891
+ failedChains,
892
+ });
780
893
  }
781
894
  }
782
895
  }
896
+ return this.#completeFetchResponse({
897
+ // A fully failed chain contributes nothing, so its balances stay as they are.
898
+ assetsBalance: filterFailedChainBalances(assetsBalance, new Set(failedChains)),
899
+ assetsInfo,
900
+ failedChains,
901
+ chainsToFetch,
902
+ updateMode: 'full',
903
+ });
904
+ }
905
+ #getChainsToFetch(request) {
906
+ const chainsToFetch = request.chainIds.filter((chainId) => this.#activeChains.includes(chainId));
907
+ log('Fetch requested', {
908
+ accounts: request.accountsWithSupportedChains.map((a) => a.account.id),
909
+ requestedChains: request.chainIds,
910
+ chainsToFetch,
911
+ });
912
+ return chainsToFetch;
913
+ }
914
+ /**
915
+ * Lowercased visible asset IDs (native, pins, default tracked) for one
916
+ * account-chain scope, for membership checks against normalized IDs.
917
+ *
918
+ * @param accountId - Account being fetched.
919
+ * @param chainId - Chain being fetched.
920
+ * @returns Lookup keys for the visible set.
921
+ */
922
+ #getVisibleAssetKeys(accountId, chainId) {
923
+ const { visibleAssetIds } = this.#getAssetVisibility([accountId], [chainId]);
924
+ return new Set(visibleAssetIds.map((assetId) => assetId.toLowerCase()));
925
+ }
926
+ /**
927
+ * Whether a v6 snapshot should leave a fetched asset out.
928
+ *
929
+ * RPC also reads tokens that are only tracked because they were detected
930
+ * earlier. A zero balance for one of those is not worth a row: writing it
931
+ * keeps an empty token in state indefinitely, and on a `full` snapshot the
932
+ * omission drops it instead. Visible assets (native, pins, default tracked)
933
+ * are always written, at zero when the account holds none.
934
+ *
935
+ * @param assetId - Normalized CAIP-19 asset ID.
936
+ * @param rawBalance - Raw on-chain amount as returned by the fetcher.
937
+ * @param visibleAssetKeys - Lowercased visible IDs for this account-chain.
938
+ * @returns True when the asset should be omitted from the snapshot.
939
+ */
940
+ #isUnheldInvisibleAsset(assetId, rawBalance, visibleAssetKeys) {
941
+ return rawBalance === '0' && !visibleAssetKeys.has(assetId.toLowerCase());
942
+ }
943
+ /**
944
+ * Convert fetched raw balances into human-readable amounts.
945
+ *
946
+ * Tokens that resolve are overlaid; tokens whose decimals cannot be resolved
947
+ * are skipped so callers can preserve their previous amount.
948
+ *
949
+ * @param result - The balance fetch result.
950
+ * @param accountId - The account the balances belong to.
951
+ * @param chainId - The CAIP-2 chain ID.
952
+ * @param assetsBalance - Accumulator for converted balances.
953
+ * @param assetsInfo - Accumulator for metadata collected from this fetch.
954
+ * @param visibleAssetKeys - Lowercased visible IDs (v6 only). When given,
955
+ * zero balances for assets outside the set are left out of the snapshot.
956
+ * @returns Lowercased IDs this pass settled: written, or deliberately left
957
+ * out as an unheld invisible asset. Anything requested but missing from the
958
+ * set failed and still needs its previous amount.
959
+ */
960
+ async #ingestFetchedBalances(result, accountId, chainId, assetsBalance, assetsInfo, visibleAssetKeys) {
961
+ assetsBalance[accountId] ??= {};
962
+ const normalizedBalances = result.balances.map((balance) => ({
963
+ ...balance,
964
+ assetId: normalizeAssetId(balance.assetId),
965
+ }));
966
+ Object.assign(assetsInfo, this.#collectMetadataForBalances(normalizedBalances, chainId));
967
+ const handledAssetKeys = new Set();
968
+ const existingMetadata = this.#getExistingAssetsMetadata();
969
+ for (const balance of normalizedBalances) {
970
+ if (visibleAssetKeys &&
971
+ this.#isUnheldInvisibleAsset(balance.assetId, balance.balance, visibleAssetKeys)) {
972
+ log('Skipping unheld asset outside the visible set', {
973
+ accountId,
974
+ chainId,
975
+ assetId: balance.assetId,
976
+ });
977
+ handledAssetKeys.add(balance.assetId.toLowerCase());
978
+ continue;
979
+ }
980
+ const stateMetadata = existingMetadata[balance.assetId];
981
+ const pipelineMetadata = assetsInfo[balance.assetId];
982
+ let decimals = this.#pickValidDecimals(stateMetadata, pipelineMetadata);
983
+ if (decimals === undefined) {
984
+ const parsed = parseCaipAssetType(balance.assetId);
985
+ if (this.#getAssetType(balance.assetId) === 'erc20') {
986
+ decimals = await this.#fetchDecimalsViaRpc(chainId, parsed.assetReference);
987
+ }
988
+ }
989
+ if (decimals === undefined) {
990
+ log('Skipping asset with unresolved decimals on RPC fetch', {
991
+ accountId,
992
+ chainId,
993
+ assetId: balance.assetId,
994
+ });
995
+ continue;
996
+ }
997
+ assetsBalance[accountId][balance.assetId] = {
998
+ amount: this.#convertToHumanReadable(balance.balance, decimals),
999
+ };
1000
+ handledAssetKeys.add(balance.assetId.toLowerCase());
1001
+ }
1002
+ return handledAssetKeys;
1003
+ }
1004
+ /**
1005
+ * Preserve the amount already in state for assets this pass could not read.
1006
+ *
1007
+ * A v6 snapshot replaces the chain slice it covers, so an asset left out
1008
+ * would be dropped. Writing its previous amount back means one failed
1009
+ * `balanceOf` or unknown decimals no longer blocks the chain from updating:
1010
+ * every other asset still gets its fresh amount, and the unreadable one is
1011
+ * retried on the next poll. An unheld asset outside the visible set is not
1012
+ * preserved — the same rule as a fresh zero read applies.
1013
+ *
1014
+ * @param options - Carry-over inputs.
1015
+ * @param options.unresolved - Requested entries the fetch did not settle.
1016
+ * @param options.accountId - Account being fetched.
1017
+ * @param options.chainId - Chain being fetched.
1018
+ * @param options.balances - Balance map for this account, mutated in place.
1019
+ * @param options.visibleAssetKeys - Lowercased visible IDs for this scope.
1020
+ */
1021
+ #carryOverUnresolvedBalances({ unresolved, accountId, chainId, balances, visibleAssetKeys, }) {
1022
+ const previousBalances = this.#getAssetsState().assetsBalance[accountId] ?? {};
1023
+ const previousByKey = new Map(Object.entries(previousBalances).map(([assetId, balance]) => [
1024
+ assetId.toLowerCase(),
1025
+ balance,
1026
+ ]));
1027
+ for (const entry of unresolved) {
1028
+ const assetId = normalizeAssetId(entry.assetId);
1029
+ const key = assetId.toLowerCase();
1030
+ if (balances[assetId] !== undefined) {
1031
+ continue;
1032
+ }
1033
+ const previous = previousByKey.get(key);
1034
+ if (previous === undefined) {
1035
+ continue;
1036
+ }
1037
+ if (!visibleAssetKeys.has(key) && Number(previous.amount) === 0) {
1038
+ continue;
1039
+ }
1040
+ log('Keeping previous balance for unreadable asset', {
1041
+ accountId,
1042
+ chainId,
1043
+ assetId,
1044
+ });
1045
+ balances[assetId] = { ...previous };
1046
+ }
1047
+ }
1048
+ #recordFetchChainFailure({ address, chainId, error, accountId, nativeAssetId, shouldSkipNative, assetsBalance, assetsInfo, failedChains, }) {
1049
+ log('Failed to fetch balance', { address, chainId, error });
1050
+ assetsBalance[accountId] ??= {};
1051
+ if (nativeAssetId) {
1052
+ if (!shouldSkipNative) {
1053
+ assetsBalance[accountId][nativeAssetId] = { amount: '0' };
1054
+ }
1055
+ const existingNativeMeta = this.#getExistingAssetsMetadata()[nativeAssetId];
1056
+ if (!this.#hasValidDecimals(existingNativeMeta)) {
1057
+ const chainStatus = this.#chainStatuses[chainId];
1058
+ if (chainStatus) {
1059
+ assetsInfo[nativeAssetId] = {
1060
+ type: 'native',
1061
+ symbol: chainStatus.nativeCurrency,
1062
+ name: chainStatus.nativeCurrency,
1063
+ decimals: 18,
1064
+ };
1065
+ }
1066
+ }
1067
+ }
1068
+ if (!failedChains.includes(chainId)) {
1069
+ failedChains.push(chainId);
1070
+ }
1071
+ }
1072
+ #completeFetchResponse({ assetsBalance, assetsInfo, failedChains, chainsToFetch, updateMode, }) {
1073
+ const response = { updateMode };
783
1074
  if (failedChains.length > 0) {
784
1075
  log('Fetch PARTIAL - some chains failed', {
785
1076
  successChains: chainsToFetch.filter((chain) => !failedChains.includes(chain)),
@@ -793,15 +1084,13 @@ export class RpcDataSource extends AbstractDataSource {
793
1084
  else {
794
1085
  log('Fetch SUCCESS', {
795
1086
  chains: chainsToFetch,
796
- accountCount: Object.keys(assetsBalance).length,
1087
+ accountCount: Object.keys(assetsBalance ?? {}).length,
797
1088
  });
798
1089
  }
799
1090
  response.assetsBalance = assetsBalance;
800
- // Include metadata for native tokens if we have any
801
1091
  if (Object.keys(assetsInfo).length > 0) {
802
1092
  response.assetsInfo = assetsInfo;
803
1093
  }
804
- response.updateMode = 'merge';
805
1094
  return response;
806
1095
  }
807
1096
  /**
@@ -919,6 +1208,12 @@ export class RpcDataSource extends AbstractDataSource {
919
1208
  ...response.errors,
920
1209
  };
921
1210
  }
1211
+ if (response.updateMode) {
1212
+ context.response = {
1213
+ ...context.response,
1214
+ updateMode: response.updateMode,
1215
+ };
1216
+ }
922
1217
  const failedChains = new Set(Object.keys(response.errors ?? {}));
923
1218
  successfullyHandledChains = supportedChains.filter((chainId) => !failedChains.has(chainId));
924
1219
  if (successfullyHandledChains.length > 0) {
@@ -998,8 +1293,6 @@ export class RpcDataSource extends AbstractDataSource {
998
1293
  };
999
1294
  const balanceToken = this.#balanceFetcher.startPolling(balanceInput);
1000
1295
  balancePollingTokens.push(balanceToken);
1001
- // Token detection is only relevant for "regular" subscriptions —
1002
- // a customAssetsOnly subscription should never run detection.
1003
1296
  if (request.customAssetsOnly !== true &&
1004
1297
  this.#tokenDetectionEnabled() &&
1005
1298
  this.#useExternalService()) {
@@ -1049,6 +1342,119 @@ export class RpcDataSource extends AbstractDataSource {
1049
1342
  log('Unsubscribed and stopped polling', { subscriptionId });
1050
1343
  }
1051
1344
  }
1345
+ /**
1346
+ * v5 entries for one account-chain fetch: native plus the ERC-20s the
1347
+ * caller scoped via `request.customAssets`. Unchanged from the pre-v6
1348
+ * behavior; delete with the rest of the v5 path.
1349
+ *
1350
+ * @param request - The data request being fetched.
1351
+ * @param chainId - Chain being fetched.
1352
+ * @param shouldSkipNative - When true, omit the native entry.
1353
+ * @returns Entries to hand to the balance fetcher.
1354
+ */
1355
+ #getAssetsToFetchV5(request, chainId, shouldSkipNative) {
1356
+ const assetsToFetch = [];
1357
+ const nativeAssetId = this.#getNativeAssetForChain(chainId);
1358
+ if (!shouldSkipNative && nativeAssetId) {
1359
+ // Build a single AssetFetchEntry[] for native + custom ERC-20s
1360
+ assetsToFetch.push({
1361
+ assetId: nativeAssetId,
1362
+ address: ZERO_ADDRESS,
1363
+ });
1364
+ }
1365
+ if (request.customAssets) {
1366
+ const existingMetadata = this.#getExistingAssetsMetadata();
1367
+ for (const assetId of request.customAssets) {
1368
+ try {
1369
+ const parsed = parseCaipAssetType(assetId);
1370
+ const assetChainId = `${parsed.chain.namespace}:${parsed.chain.reference}`;
1371
+ if (assetChainId === chainId &&
1372
+ this.#getAssetType(assetId) === 'erc20') {
1373
+ const tokenAddress = parsed.assetReference.toLowerCase();
1374
+ const normalizedId = normalizeAssetId(assetId);
1375
+ const decimals = existingMetadata[normalizedId]?.decimals;
1376
+ assetsToFetch.push({
1377
+ assetId,
1378
+ address: tokenAddress,
1379
+ decimals,
1380
+ });
1381
+ }
1382
+ }
1383
+ catch {
1384
+ // Skip unparseable asset IDs
1385
+ }
1386
+ }
1387
+ }
1388
+ return assetsToFetch;
1389
+ }
1390
+ /**
1391
+ * v6 entries for one account-chain fetch: the shared visible set (native,
1392
+ * pins, default tracked) plus already-tracked ERC-20s that are not hidden.
1393
+ * Chains with no native token omit native from `visibleAssetIds`. Hide is
1394
+ * only applied to `assetsBalance` rows. The request is never scoped via
1395
+ * `customAssets` on v6.
1396
+ *
1397
+ * @param accountId - Account being fetched.
1398
+ * @param chainId - Chain being fetched.
1399
+ * @returns Entries to hand to the balance fetcher.
1400
+ */
1401
+ #getAssetsToFetchV6(accountId, chainId) {
1402
+ const { assetsInfo = {}, assetsBalance = {} } = this.#getAssetsState();
1403
+ const { visibleAssetIds, hiddenAssetIds } = this.#getAssetVisibility([accountId], [chainId]);
1404
+ const hidden = new Set(hiddenAssetIds);
1405
+ const assetsToFetch = [];
1406
+ const seen = new Set();
1407
+ // Fetch visible assets
1408
+ for (const assetId of visibleAssetIds) {
1409
+ try {
1410
+ if (this.#getAssetType(assetId) === 'native') {
1411
+ assetsToFetch.push({ assetId, address: ZERO_ADDRESS });
1412
+ continue;
1413
+ }
1414
+ const parsed = parseCaipAssetType(assetId);
1415
+ const assetChainId = `${parsed.chain.namespace}:${parsed.chain.reference}`;
1416
+ if (assetChainId !== chainId ||
1417
+ this.#getAssetType(assetId) !== 'erc20' ||
1418
+ seen.has(assetId)) {
1419
+ continue;
1420
+ }
1421
+ seen.add(assetId);
1422
+ assetsToFetch.push({
1423
+ assetId,
1424
+ address: parsed.assetReference.toLowerCase(),
1425
+ decimals: assetsInfo[assetId]?.decimals,
1426
+ });
1427
+ }
1428
+ catch {
1429
+ // Skip unparseable asset IDs
1430
+ }
1431
+ }
1432
+ // Fetch already-tracked ERC-20s that are not hidden
1433
+ for (const assetId of Object.keys(assetsBalance[accountId] ?? {})) {
1434
+ try {
1435
+ const parsed = parseCaipAssetType(assetId);
1436
+ const assetChainId = `${parsed.chain.namespace}:${parsed.chain.reference}`;
1437
+ const normalizedId = normalizeAssetId(assetId);
1438
+ if (hidden.has(normalizedId) ||
1439
+ isStakingContractAssetId(assetId) ||
1440
+ assetChainId !== chainId ||
1441
+ this.#getAssetType(assetId) !== 'erc20' ||
1442
+ seen.has(normalizedId)) {
1443
+ continue;
1444
+ }
1445
+ seen.add(normalizedId);
1446
+ assetsToFetch.push({
1447
+ assetId,
1448
+ address: parsed.assetReference.toLowerCase(),
1449
+ decimals: assetsInfo[normalizedId]?.decimals,
1450
+ });
1451
+ }
1452
+ catch {
1453
+ // Skip unparseable asset IDs
1454
+ }
1455
+ }
1456
+ return assetsToFetch;
1457
+ }
1052
1458
  /**
1053
1459
  * Get existing assets metadata from AssetsController state.
1054
1460
  * Used to include metadata for ERC20 tokens when returning balance updates.
@@ -1056,14 +1462,7 @@ export class RpcDataSource extends AbstractDataSource {
1056
1462
  * @returns Record of asset IDs to their metadata.
1057
1463
  */
1058
1464
  #getExistingAssetsMetadata() {
1059
- try {
1060
- const state = this.#messenger.call('AssetsController:getState');
1061
- return state.assetsInfo ?? {};
1062
- }
1063
- catch (error) {
1064
- log('Failed to get existing assets metadata', { error });
1065
- return {};
1066
- }
1465
+ return this.#getAssetsState().assetsInfo ?? {};
1067
1466
  }
1068
1467
  /**
1069
1468
  * Destroy the data source and clean up resources.