From 32f86e3657605b826891691a27a9bd19c45ca987 Mon Sep 17 00:00:00 2001 From: Bernardo Garces Chapero Date: Tue, 24 Mar 2026 14:20:57 +0000 Subject: [PATCH 01/17] balance fetcher changes --- packages/assets-controller/CHANGELOG.md | 5 + .../src/data-sources/RpcDataSource.test.ts | 38 +- .../src/data-sources/RpcDataSource.ts | 73 ++-- .../data-sources/evm-rpc-services/index.ts | 1 + .../services/BalanceFetcher.test.ts | 343 ++++++++++-------- .../services/BalanceFetcher.ts | 170 +++++---- .../evm-rpc-services/types/index.ts | 2 +- .../evm-rpc-services/types/services.ts | 15 +- 8 files changed, 357 insertions(+), 290 deletions(-) diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index ef770496051..8380d20fb7d 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -7,8 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Fix `BalanceFetcher` producing incorrect native asset IDs for non-Ethereum EVM chains (e.g. Avalanche). Previously all native balances were hardcoded to `slip44:60` (ETH); now `BalanceFetcher` accepts CAIP-19 asset IDs from callers and preserves them in results, so `RpcDataSource` passes the correct chain-specific native identifier from `NetworkEnablementController.nativeAssetIdentifiers`. + ### Changed +- **BREAKING:** `BalanceFetcher.fetchBalancesForTokens` is renamed to `fetchBalancesForAssets` and now accepts `CaipAssetType[]` (CAIP-19 asset IDs) instead of `Address[]` token addresses. `BalanceFetcher.getTokensToFetch` is renamed to `getAssetIdsToFetch` and returns `CaipAssetType[]`. `BalanceFetchOptions.includeNative` and `BalanceFetcherConfig.includeNativeByDefault` are removed; include the native asset ID in the asset IDs array instead. - EVM RPC balance pipeline (`RpcDataSource`, `BalanceFetcher`, `TokenDetector`) no longer falls back to 18 decimals for ERC-20 when decimals are unknown; human-readable balances and `detectedBalances` entries are omitted until decimals are available from state, token list metadata, or on-chain `decimals()` (native token handling unchanged) ([#8267](https://github.com/MetaMask/core/pull/8267)) - Bump `@metamask/keyring-api` from `^21.5.0` to `^21.6.0` ([#8259](https://github.com/MetaMask/core/pull/8259)) diff --git a/packages/assets-controller/src/data-sources/RpcDataSource.test.ts b/packages/assets-controller/src/data-sources/RpcDataSource.test.ts index c6ce6eaf9ce..7be443576ed 100644 --- a/packages/assets-controller/src/data-sources/RpcDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/RpcDataSource.test.ts @@ -476,7 +476,7 @@ describe('RpcDataSource', () => { const nativeAssetId = 'eip155:1/slip44:60' as Caip19AssetId; await withController(async ({ controller }) => { jest - .spyOn(BalanceFetcher.prototype, 'fetchBalancesForTokens') + .spyOn(BalanceFetcher.prototype, 'fetchBalancesForAssets') .mockResolvedValue({ chainId: MOCK_CHAIN_ID_HEX, accountId: MOCK_ACCOUNT_ID, @@ -565,7 +565,7 @@ describe('RpcDataSource', () => { it('initializes assetsBalance[accountId] in catch when first fetch for account throws', async () => { await withController(async ({ controller }) => { jest - .spyOn(BalanceFetcher.prototype, 'fetchBalancesForTokens') + .spyOn(BalanceFetcher.prototype, 'fetchBalancesForAssets') .mockRejectedValue(new Error('RPC unavailable')); const request = createDataRequest(); const response = await controller.fetch(request); @@ -759,12 +759,12 @@ describe('RpcDataSource', () => { ); }); - it('passes custom ERC20 token addresses to BalanceFetcher', async () => { + it('passes custom ERC20 asset entries (plus native) to BalanceFetcher', async () => { const customAssetId = 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; const fetchSpy = jest - .spyOn(BalanceFetcher.prototype, 'fetchBalancesForTokens') + .spyOn(BalanceFetcher.prototype, 'fetchBalancesForAssets') .mockResolvedValue(createBalanceFetchResult()); await withController(async ({ controller }) => { @@ -774,12 +774,18 @@ describe('RpcDataSource', () => { await controller.fetch(request); expect(fetchSpy).toHaveBeenCalledWith( - MOCK_CHAIN_ID_HEX, MOCK_ACCOUNT_ID, MOCK_ADDRESS, - ['0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'], - { includeNative: true }, - [], + [ + { + assetId: `${MOCK_CHAIN_ID_CAIP}/slip44:60`, + address: '0x0000000000000000000000000000000000000000', + }, + expect.objectContaining({ + assetId: customAssetId, + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + }), + ], ); }); @@ -793,7 +799,7 @@ describe('RpcDataSource', () => { 'eip155:137/erc20:0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174' as Caip19AssetId; const fetchSpy = jest - .spyOn(BalanceFetcher.prototype, 'fetchBalancesForTokens') + .spyOn(BalanceFetcher.prototype, 'fetchBalancesForAssets') .mockResolvedValue(createBalanceFetchResult()); await withController(async ({ controller }) => { @@ -803,12 +809,18 @@ describe('RpcDataSource', () => { await controller.fetch(request); expect(fetchSpy).toHaveBeenCalledWith( - MOCK_CHAIN_ID_HEX, MOCK_ACCOUNT_ID, MOCK_ADDRESS, - ['0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'], - { includeNative: true }, - [], + [ + { + assetId: `${MOCK_CHAIN_ID_CAIP}/slip44:60`, + address: '0x0000000000000000000000000000000000000000', + }, + expect.objectContaining({ + assetId: matchingAsset, + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + }), + ], ); }); diff --git a/packages/assets-controller/src/data-sources/RpcDataSource.ts b/packages/assets-controller/src/data-sources/RpcDataSource.ts index da913d14ac9..cf49dd01b15 100644 --- a/packages/assets-controller/src/data-sources/RpcDataSource.ts +++ b/packages/assets-controller/src/data-sources/RpcDataSource.ts @@ -41,11 +41,11 @@ import type { } from './evm-rpc-services'; import type { Address, + AssetFetchEntry, Provider as RpcProvider, TokenListState, BalanceFetchResult, TokenDetectionResult, - TokenFetchInfo, } from './evm-rpc-services/types'; import type { AssetsControllerGetStateAction, @@ -66,6 +66,8 @@ import { normalizeAssetId } from '../utils'; const CONTROLLER_NAME = 'RpcDataSource'; const DEFAULT_BALANCE_INTERVAL = 30_000; // 30 seconds const DEFAULT_DETECTION_INTERVAL = 180_000; // 3 minutes +const ZERO_ADDRESS: Address = + '0x0000000000000000000000000000000000000000' as Address; const log = createModuleLogger(projectLogger, CONTROLLER_NAME); @@ -898,11 +900,15 @@ export class RpcDataSource extends AbstractDataSource< const { address, id: accountId } = account; for (const chainId of chainsForAccount) { - const hexChainId = caipChainIdToHex(chainId); + // Build a single AssetFetchEntry[] for native + custom ERC-20s + const nativeAssetId = this.#buildNativeAssetId(chainId); + const assetsToFetch: AssetFetchEntry[] = [ + { assetId: nativeAssetId, address: ZERO_ADDRESS }, + ]; - // Extract ERC20 token addresses from customAssets for this chain - const customTokenAddresses: Address[] = []; if (request.customAssets) { + const existingMetadata = this.#getExistingAssetsMetadata(); + for (const assetId of request.customAssets) { try { const parsed = parseCaipAssetType(assetId); @@ -911,7 +917,17 @@ export class RpcDataSource extends AbstractDataSource< assetChainId === chainId && parsed.assetNamespace === 'erc20' ) { - customTokenAddresses.push(parsed.assetReference as Address); + const tokenAddress = parsed.assetReference as Address; + const normalizedId = normalizeAssetId(assetId); + const decimals = + existingMetadata[normalizedId]?.decimals ?? + this.#getTokenMetadataFromTokenList(normalizedId)?.decimals; + + assetsToFetch.push({ + assetId, + address: tokenAddress.toLowerCase() as Address, + decimals, + }); } } catch { // Skip unparseable asset IDs @@ -920,19 +936,10 @@ export class RpcDataSource extends AbstractDataSource< } try { - const tokenInfos = this.#tokenFetchInfosForCustomErc20s( - chainId, - customTokenAddresses, - ); - - // Use BalanceFetcher for batched balance fetching - const result = await this.#balanceFetcher.fetchBalancesForTokens( - hexChainId, + const result = await this.#balanceFetcher.fetchBalancesForAssets( accountId, address as Address, - customTokenAddresses, - { includeNative: true }, - tokenInfos, + assetsToFetch, ); if (!assetsBalance[accountId]) { @@ -992,7 +999,6 @@ export class RpcDataSource extends AbstractDataSource< if (!assetsBalance[accountId]) { assetsBalance[accountId] = {}; } - const nativeAssetId = this.#buildNativeAssetId(chainId); assetsBalance[accountId][nativeAssetId] = { amount: '0' }; // Even on error, include native token metadata @@ -1354,39 +1360,6 @@ export class RpcDataSource extends AbstractDataSource< return nativeAssetIdentifiers[chainId] ?? `${chainId}/slip44:60`; } - /** - * Build token infos for custom ERC-20s when decimals are already known from - * state or token list so BalanceFetcher can format balances; unknown decimals - * are left out and resolved in `fetch` / `#handleBalanceUpdate`. - * - * @param caipChainId - CAIP-2 chain id (e.g. `eip155:1`). - * @param tokenAddresses - ERC-20 contract addresses on that chain. - * @returns Token fetch infos that include only entries with known decimals. - */ - #tokenFetchInfosForCustomErc20s( - caipChainId: ChainId, - tokenAddresses: Address[], - ): TokenFetchInfo[] { - const existingMetadata = this.#getExistingAssetsMetadata(); - const infos: TokenFetchInfo[] = []; - - for (const tokenAddress of tokenAddresses) { - const { reference } = parseCaipChainId(caipChainId); - const rawAssetId = - `eip155:${reference}/erc20:${tokenAddress.toLowerCase()}` as Caip19AssetId; - const assetId = normalizeAssetId(rawAssetId); - const decimals = - existingMetadata[assetId]?.decimals ?? - this.#getTokenMetadataFromTokenList(assetId)?.decimals; - - if (decimals !== undefined) { - infos.push({ address: tokenAddress, decimals }); - } - } - - return infos; - } - /** * Get existing assets metadata from AssetsController state. * Used to include metadata for ERC20 tokens when returning balance updates. diff --git a/packages/assets-controller/src/data-sources/evm-rpc-services/index.ts b/packages/assets-controller/src/data-sources/evm-rpc-services/index.ts index e47693f7299..5b30f3b7d36 100644 --- a/packages/assets-controller/src/data-sources/evm-rpc-services/index.ts +++ b/packages/assets-controller/src/data-sources/evm-rpc-services/index.ts @@ -1,5 +1,6 @@ export type { Address, + AssetFetchEntry, AssetsBalanceState, ChainId, GetProviderFunction, diff --git a/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.test.ts b/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.test.ts index d953496f0e3..779ff93b1fd 100644 --- a/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.test.ts +++ b/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.test.ts @@ -1,3 +1,5 @@ +import type { CaipAssetType } from '@metamask/utils'; + import { BalanceFetcher } from './BalanceFetcher'; import type { BalanceFetcherConfig, @@ -7,10 +9,10 @@ import type { import type { MulticallClient } from '../clients'; import type { Address, + AssetFetchEntry, AssetsBalanceState, BalanceOfResponse, ChainId, - TokenFetchInfo, } from '../types'; // ============================================================================= @@ -30,13 +32,30 @@ const ZERO_ADDRESS: Address = const MAINNET_CHAIN_ID: ChainId = '0x1' as ChainId; const POLYGON_CHAIN_ID: ChainId = '0x89' as ChainId; -/** Decimals for TEST_TOKEN_1 (USDC) / TEST_TOKEN_2 (USDT) in fetch tests */ -const TEST_TOKEN_1_WITH_DECIMALS: TokenFetchInfo = { - address: TEST_TOKEN_1, +const NATIVE_ETH_ASSET_ID = 'eip155:1/slip44:60' as CaipAssetType; +const TOKEN_1_ASSET_ID = + `eip155:1/erc20:${TEST_TOKEN_1.toLowerCase()}` as CaipAssetType; +const TOKEN_2_ASSET_ID = + `eip155:1/erc20:${TEST_TOKEN_2.toLowerCase()}` as CaipAssetType; + +const NATIVE_ETH_ENTRY: AssetFetchEntry = { + assetId: NATIVE_ETH_ASSET_ID, + address: ZERO_ADDRESS, +}; +const TOKEN_1_ENTRY: AssetFetchEntry = { + assetId: TOKEN_1_ASSET_ID, + address: TEST_TOKEN_1.toLowerCase() as Address, +}; +const TOKEN_1_ENTRY_WITH_DECIMALS: AssetFetchEntry = { + ...TOKEN_1_ENTRY, decimals: 6, }; -const TEST_TOKEN_2_WITH_DECIMALS: TokenFetchInfo = { - address: TEST_TOKEN_2, +const TOKEN_2_ENTRY: AssetFetchEntry = { + assetId: TOKEN_2_ASSET_ID, + address: TEST_TOKEN_2.toLowerCase() as Address, +}; +const TOKEN_2_ENTRY_WITH_DECIMALS: AssetFetchEntry = { + ...TOKEN_2_ENTRY, decimals: 6, }; @@ -151,7 +170,6 @@ describe('BalanceFetcher', () => { config: { defaultBatchSize: 100, defaultTimeoutMs: 60000, - includeNativeByDefault: false, pollingInterval: 45000, }, }, @@ -183,35 +201,42 @@ describe('BalanceFetcher', () => { describe('setOnBalanceUpdate', () => { it('sets the balance update callback', async () => { - await withController(async ({ controller, mockMulticallClient }) => { - const mockCallback = jest.fn(); - controller.setOnBalanceUpdate(mockCallback); - - mockMulticallClient.batchBalanceOf.mockResolvedValue([ - createMockBalanceResponse( - ZERO_ADDRESS, - TEST_ACCOUNT, - true, - '1000000000000000000', - ), - ]); + const mockState = createMockAssetsBalanceState(TEST_ACCOUNT_ID, { + [NATIVE_ETH_ASSET_ID]: { amount: '0' }, + }); - const input: BalancePollingInput = { - chainId: MAINNET_CHAIN_ID, - accountId: TEST_ACCOUNT_ID, - accountAddress: TEST_ACCOUNT, - }; + await withController( + { assetsBalanceState: mockState }, + async ({ controller, mockMulticallClient }) => { + const mockCallback = jest.fn(); + controller.setOnBalanceUpdate(mockCallback); - await controller._executePoll(input); + mockMulticallClient.batchBalanceOf.mockResolvedValue([ + createMockBalanceResponse( + ZERO_ADDRESS, + TEST_ACCOUNT, + true, + '1000000000000000000', + ), + ]); - expect(mockCallback).toHaveBeenCalledWith( - expect.objectContaining({ + const input: BalancePollingInput = { chainId: MAINNET_CHAIN_ID, accountId: TEST_ACCOUNT_ID, - balances: expect.any(Array), - }), - ); - }); + accountAddress: TEST_ACCOUNT, + }; + + await controller._executePoll(input); + + expect(mockCallback).toHaveBeenCalledWith( + expect.objectContaining({ + chainId: MAINNET_CHAIN_ID, + accountId: TEST_ACCOUNT_ID, + balances: expect.any(Array), + }), + ); + }, + ); }); it('does not call callback when balances are empty', async () => { @@ -285,55 +310,71 @@ describe('BalanceFetcher', () => { }); }); - describe('getTokensToFetch', () => { + describe('getAssetsToFetch', () => { it('returns empty array when no balances exist', async () => { await withController(async ({ controller }) => { - const tokens = controller.getTokensToFetch( + const entries = controller.getAssetsToFetch( MAINNET_CHAIN_ID, TEST_ACCOUNT_ID, ); - expect(tokens).toStrictEqual([]); + expect(entries).toStrictEqual([]); }); }); - it('returns tokens from assetsBalance state', async () => { + it('returns asset fetch entries from assetsBalance state', async () => { const mockState = createMockAssetsBalanceState(TEST_ACCOUNT_ID, { - [`eip155:1/erc20:${TEST_TOKEN_1}`]: { amount: '100' }, - [`eip155:1/erc20:${TEST_TOKEN_2}`]: { amount: '200' }, + [NATIVE_ETH_ASSET_ID]: { amount: '1' }, + [TOKEN_1_ASSET_ID]: { amount: '100' }, + [TOKEN_2_ASSET_ID]: { amount: '200' }, }); await withController( { assetsBalanceState: mockState }, async ({ controller }) => { - const tokens = controller.getTokensToFetch( + const entries = controller.getAssetsToFetch( MAINNET_CHAIN_ID, TEST_ACCOUNT_ID, ); - expect(tokens).toHaveLength(2); + expect(entries).toHaveLength(3); + + const assetIds = entries.map((e) => e.assetId); + expect(assetIds).toContain(NATIVE_ETH_ASSET_ID); + expect(assetIds).toContain(TOKEN_1_ASSET_ID); + expect(assetIds).toContain(TOKEN_2_ASSET_ID); + + const nativeEntry = entries.find( + (e) => e.assetId === NATIVE_ETH_ASSET_ID, + ); + expect(nativeEntry?.address).toBe(ZERO_ADDRESS); + + const erc20Entry = entries.find( + (e) => e.assetId === TOKEN_1_ASSET_ID, + ); + expect(erc20Entry?.address).toBe(TEST_TOKEN_1.toLowerCase()); }, ); }); - it('returns empty array when chain has no tokens', async () => { + it('returns empty array when chain has no assets', async () => { const mockState = createMockAssetsBalanceState(TEST_ACCOUNT_ID, { - [`eip155:1/erc20:${TEST_TOKEN_1}`]: { amount: '100' }, + [TOKEN_1_ASSET_ID]: { amount: '100' }, }); await withController( { assetsBalanceState: mockState }, async ({ controller }) => { - const tokens = controller.getTokensToFetch( + const entries = controller.getAssetsToFetch( POLYGON_CHAIN_ID, TEST_ACCOUNT_ID, ); - expect(tokens).toStrictEqual([]); + expect(entries).toStrictEqual([]); }, ); }); }); - describe('fetchBalancesForTokens', () => { - it('fetches balances for specified token addresses', async () => { + describe('fetchBalancesForAssets', () => { + it('fetches balances for specified asset entries', async () => { await withController(async ({ controller, mockMulticallClient }) => { mockMulticallClient.batchBalanceOf.mockResolvedValue([ createMockBalanceResponse( @@ -350,13 +391,10 @@ describe('BalanceFetcher', () => { ), ]); - const result = await controller.fetchBalancesForTokens( - MAINNET_CHAIN_ID, + const result = await controller.fetchBalancesForAssets( TEST_ACCOUNT_ID, TEST_ACCOUNT, - [TEST_TOKEN_1], - undefined, - [TEST_TOKEN_1_WITH_DECIMALS], + [NATIVE_ETH_ENTRY, TOKEN_1_ENTRY_WITH_DECIMALS], ); expect(result.balances).toHaveLength(2); @@ -364,7 +402,7 @@ describe('BalanceFetcher', () => { }); }); - it('creates correct CAIP-19 asset ID for native token', async () => { + it('preserves the native asset ID provided by the caller', async () => { await withController(async ({ controller, mockMulticallClient }) => { mockMulticallClient.batchBalanceOf.mockResolvedValue([ createMockBalanceResponse( @@ -375,45 +413,42 @@ describe('BalanceFetcher', () => { ), ]); - const result = await controller.fetchBalancesForTokens( - MAINNET_CHAIN_ID, + const result = await controller.fetchBalancesForAssets( TEST_ACCOUNT_ID, TEST_ACCOUNT, - [], - { includeNative: true }, + [NATIVE_ETH_ENTRY], ); - expect(result.balances[0].assetId).toBe('eip155:1/slip44:60'); + expect(result.balances[0].assetId).toBe(NATIVE_ETH_ASSET_ID); }); }); - it('creates correct CAIP-19 asset ID for ERC-20 token', async () => { + it('preserves a non-ETH native asset ID (e.g. Avalanche)', async () => { + const avaxNativeAssetId = + 'eip155:43114/slip44:9005' as CaipAssetType; + await withController(async ({ controller, mockMulticallClient }) => { mockMulticallClient.batchBalanceOf.mockResolvedValue([ createMockBalanceResponse( - TEST_TOKEN_1, + ZERO_ADDRESS, TEST_ACCOUNT, true, - '1000000000', + '5000000000000000000', ), ]); - const result = await controller.fetchBalancesForTokens( - MAINNET_CHAIN_ID, + const result = await controller.fetchBalancesForAssets( TEST_ACCOUNT_ID, TEST_ACCOUNT, - [TEST_TOKEN_1], - { includeNative: false }, - [TEST_TOKEN_1_WITH_DECIMALS], + [{ assetId: avaxNativeAssetId, address: ZERO_ADDRESS }], ); - expect(result.balances[0].assetId).toBe( - 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', - ); + expect(result.balances[0].assetId).toBe(avaxNativeAssetId); + expect(result.balances[0].formattedBalance).toBe('5'); }); }); - it('includes ERC-20 raw balance when tokenInfos are omitted (decimals resolved downstream)', async () => { + it('preserves the ERC-20 asset ID provided by the caller', async () => { await withController(async ({ controller, mockMulticallClient }) => { mockMulticallClient.batchBalanceOf.mockResolvedValue([ createMockBalanceResponse( @@ -424,23 +459,17 @@ describe('BalanceFetcher', () => { ), ]); - const result = await controller.fetchBalancesForTokens( - MAINNET_CHAIN_ID, + const result = await controller.fetchBalancesForAssets( TEST_ACCOUNT_ID, TEST_ACCOUNT, - [TEST_TOKEN_1], - { includeNative: false }, + [TOKEN_1_ENTRY_WITH_DECIMALS], ); - expect(result.balances).toHaveLength(1); - expect(result.balances[0].decimals).toBeUndefined(); - expect(result.balances[0].balance).toBe('1000000000'); - expect(result.balances[0].formattedBalance).toBe('1000000000'); - expect(result.failedAddresses).toHaveLength(0); + expect(result.balances[0].assetId).toBe(TOKEN_1_ASSET_ID); }); }); - it('includes ERC-20 raw balance when token info has no decimals', async () => { + it('includes ERC-20 raw balance when decimals omitted (resolved downstream)', async () => { await withController(async ({ controller, mockMulticallClient }) => { mockMulticallClient.batchBalanceOf.mockResolvedValue([ createMockBalanceResponse( @@ -451,44 +480,30 @@ describe('BalanceFetcher', () => { ), ]); - const tokenInfoMissingDecimals = { - address: TEST_TOKEN_1, - } as TokenFetchInfo; - - const result = await controller.fetchBalancesForTokens( - MAINNET_CHAIN_ID, + const result = await controller.fetchBalancesForAssets( TEST_ACCOUNT_ID, TEST_ACCOUNT, - [TEST_TOKEN_1], - { includeNative: false }, - [tokenInfoMissingDecimals], + [TOKEN_1_ENTRY], ); expect(result.balances).toHaveLength(1); expect(result.balances[0].decimals).toBeUndefined(); + expect(result.balances[0].balance).toBe('1000000000'); expect(result.balances[0].formattedBalance).toBe('1000000000'); expect(result.failedAddresses).toHaveLength(0); }); }); - it('includes ERC-20 balance when token info has zero decimals', async () => { + it('includes ERC-20 balance when entry has zero decimals', async () => { await withController(async ({ controller, mockMulticallClient }) => { mockMulticallClient.batchBalanceOf.mockResolvedValue([ createMockBalanceResponse(TEST_TOKEN_1, TEST_ACCOUNT, true, '42'), ]); - const tokenInfoZeroDecimals: TokenFetchInfo = { - address: TEST_TOKEN_1, - decimals: 0, - }; - - const result = await controller.fetchBalancesForTokens( - MAINNET_CHAIN_ID, + const result = await controller.fetchBalancesForAssets( TEST_ACCOUNT_ID, TEST_ACCOUNT, - [TEST_TOKEN_1], - { includeNative: false }, - [tokenInfoZeroDecimals], + [{ ...TOKEN_1_ENTRY, decimals: 0 }], ); expect(result.balances).toHaveLength(1); @@ -503,12 +518,10 @@ describe('BalanceFetcher', () => { createMockBalanceResponse(TEST_TOKEN_1, TEST_ACCOUNT, false), ]); - const result = await controller.fetchBalancesForTokens( - MAINNET_CHAIN_ID, + const result = await controller.fetchBalancesForAssets( TEST_ACCOUNT_ID, TEST_ACCOUNT, - [TEST_TOKEN_1], - { includeNative: false }, + [TOKEN_1_ENTRY], ); expect(result.balances).toHaveLength(0); @@ -516,30 +529,70 @@ describe('BalanceFetcher', () => { }); }); - it('returns empty result when no tokens to fetch', async () => { - await withController( - { config: { includeNativeByDefault: false } }, - async ({ controller, mockMulticallClient }) => { - const result = await controller.fetchBalancesForTokens( - MAINNET_CHAIN_ID, - TEST_ACCOUNT_ID, + it('returns empty result when no entries provided', async () => { + await withController(async ({ controller, mockMulticallClient }) => { + const result = await controller.fetchBalancesForAssets( + TEST_ACCOUNT_ID, + TEST_ACCOUNT, + [], + ); + + expect(result).toStrictEqual({ + chainId: '0x0', + accountId: TEST_ACCOUNT_ID, + accountAddress: TEST_ACCOUNT, + balances: [], + failedAddresses: [], + timestamp: 1700000000000, + }); + + expect(mockMulticallClient.batchBalanceOf).not.toHaveBeenCalled(); + }); + }); + + it('derives hex chainId from asset entries', async () => { + await withController(async ({ controller, mockMulticallClient }) => { + mockMulticallClient.batchBalanceOf.mockResolvedValue([ + createMockBalanceResponse( + ZERO_ADDRESS, TEST_ACCOUNT, - [], - { includeNative: false }, - ); + true, + '1000000000000000000', + ), + ]); - expect(result).toStrictEqual({ - chainId: MAINNET_CHAIN_ID, - accountId: TEST_ACCOUNT_ID, - accountAddress: TEST_ACCOUNT, - balances: [], - failedAddresses: [], - timestamp: 1700000000000, - }); + const result = await controller.fetchBalancesForAssets( + TEST_ACCOUNT_ID, + TEST_ACCOUNT, + [NATIVE_ETH_ENTRY], + ); - expect(mockMulticallClient.batchBalanceOf).not.toHaveBeenCalled(); - }, - ); + expect(result.chainId).toBe(MAINNET_CHAIN_ID); + }); + }); + + it('deduplicates entries with same address', async () => { + await withController(async ({ controller, mockMulticallClient }) => { + mockMulticallClient.batchBalanceOf.mockResolvedValue([ + createMockBalanceResponse( + ZERO_ADDRESS, + TEST_ACCOUNT, + true, + '1000000000000000000', + ), + ]); + + const result = await controller.fetchBalancesForAssets( + TEST_ACCOUNT_ID, + TEST_ACCOUNT, + [NATIVE_ETH_ENTRY, NATIVE_ETH_ENTRY], + ); + + expect(mockMulticallClient.batchBalanceOf).toHaveBeenCalledTimes(1); + const calls = mockMulticallClient.batchBalanceOf.mock.calls[0]; + expect(calls[1]).toHaveLength(1); + expect(result.balances).toHaveLength(1); + }); }); }); @@ -555,13 +608,10 @@ describe('BalanceFetcher', () => { ), ]); - const result = await controller.fetchBalancesForTokens( - MAINNET_CHAIN_ID, + const result = await controller.fetchBalancesForAssets( TEST_ACCOUNT_ID, TEST_ACCOUNT, - [TEST_TOKEN_1], - { includeNative: false }, - [{ address: TEST_TOKEN_1, decimals: 6, symbol: 'USDC' }], + [{ ...TOKEN_1_ENTRY, decimals: 6, symbol: 'USDC' }], ); expect(result.balances[0].formattedBalance).toBe('1234.56789'); @@ -574,13 +624,10 @@ describe('BalanceFetcher', () => { createMockBalanceResponse(TEST_TOKEN_1, TEST_ACCOUNT, true, '0'), ]); - const result = await controller.fetchBalancesForTokens( - MAINNET_CHAIN_ID, + const result = await controller.fetchBalancesForAssets( TEST_ACCOUNT_ID, TEST_ACCOUNT, - [TEST_TOKEN_1], - { includeNative: false }, - [TEST_TOKEN_1_WITH_DECIMALS], + [TOKEN_1_ENTRY_WITH_DECIMALS], ); expect(result.balances[0].formattedBalance).toBe('0'); @@ -598,13 +645,10 @@ describe('BalanceFetcher', () => { ), ]); - const result = await controller.fetchBalancesForTokens( - MAINNET_CHAIN_ID, + const result = await controller.fetchBalancesForAssets( TEST_ACCOUNT_ID, TEST_ACCOUNT, - [TEST_TOKEN_1], - { includeNative: false }, - [TEST_TOKEN_1_WITH_DECIMALS], + [TOKEN_1_ENTRY_WITH_DECIMALS], ); expect(result.balances[0].balance).toBe('0'); @@ -623,13 +667,10 @@ describe('BalanceFetcher', () => { ), ]); - const result = await controller.fetchBalancesForTokens( - MAINNET_CHAIN_ID, + const result = await controller.fetchBalancesForAssets( TEST_ACCOUNT_ID, TEST_ACCOUNT, - [TEST_TOKEN_1], - { includeNative: false }, - [TEST_TOKEN_1_WITH_DECIMALS], + [TOKEN_1_ENTRY_WITH_DECIMALS], ); expect(result.balances[0].formattedBalance).toBe('invalid-balance'); @@ -649,13 +690,11 @@ describe('BalanceFetcher', () => { ), ]); - await controller.fetchBalancesForTokens( - MAINNET_CHAIN_ID, + await controller.fetchBalancesForAssets( TEST_ACCOUNT_ID, TEST_ACCOUNT, - [TEST_TOKEN_1, TEST_TOKEN_2], - { includeNative: false, batchSize: 1 }, - [TEST_TOKEN_1_WITH_DECIMALS, TEST_TOKEN_2_WITH_DECIMALS], + [TOKEN_1_ENTRY_WITH_DECIMALS, TOKEN_2_ENTRY_WITH_DECIMALS], + { batchSize: 1 }, ); expect(mockMulticallClient.batchBalanceOf).toHaveBeenCalledTimes(2); @@ -682,13 +721,11 @@ describe('BalanceFetcher', () => { ), ]); - const result = await controller.fetchBalancesForTokens( - MAINNET_CHAIN_ID, + const result = await controller.fetchBalancesForAssets( TEST_ACCOUNT_ID, TEST_ACCOUNT, - [TEST_TOKEN_1, TEST_TOKEN_2], - { includeNative: false, batchSize: 1 }, - [TEST_TOKEN_1_WITH_DECIMALS, TEST_TOKEN_2_WITH_DECIMALS], + [TOKEN_1_ENTRY_WITH_DECIMALS, TOKEN_2_ENTRY_WITH_DECIMALS], + { batchSize: 1 }, ); expect(mockMulticallClient.batchBalanceOf).toHaveBeenCalledTimes(2); diff --git a/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.ts b/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.ts index 0de7d311238..4356f28f28d 100644 --- a/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.ts +++ b/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.ts @@ -1,18 +1,17 @@ import { StaticIntervalPollingControllerOnly } from '@metamask/polling-controller'; -import type { CaipAssetType } from '@metamask/utils'; import type { MulticallClient } from '../clients'; import type { AccountId, Address, AssetBalance, + AssetFetchEntry, AssetsBalanceState, BalanceFetchOptions, BalanceFetchResult, BalanceOfRequest, BalanceOfResponse, ChainId, - TokenFetchInfo, } from '../types'; import { reduceInBatchesSerially } from '../utils'; @@ -31,7 +30,6 @@ export type BalanceFetcherMessenger = { export type BalanceFetcherConfig = { defaultBatchSize?: number; defaultTimeoutMs?: number; - includeNativeByDefault?: boolean; /** Polling interval in ms (default: 30s) */ pollingInterval?: number; }; @@ -58,6 +56,11 @@ export type OnBalanceUpdateCallback = ( /** * BalanceFetcher - Fetches token balances via multicall. * Extends StaticIntervalPollingControllerOnly for built-in polling support. + * + * Callers provide CAIP-19 asset IDs; the fetcher extracts on-chain addresses + * (or uses the zero address for native assets) and maps multicall responses + * back to the original asset IDs. This ensures the returned balance entries + * always carry the correct identifier regardless of chain. */ export class BalanceFetcher extends StaticIntervalPollingControllerOnly() { readonly #multicallClient: MulticallClient; @@ -79,7 +82,6 @@ export class BalanceFetcher extends StaticIntervalPollingControllerOnly(); - - for (const assetId of Object.keys(accountBalances)) { - // Only process ERC20 tokens on the current chain - if (assetId.startsWith(caipChainPrefix) && assetId.includes('/erc20:')) { - // Parse token address from CAIP-19: eip155:1/erc20:0x... - const tokenAddress = assetId.split('/erc20:')[1] as Address; - if (tokenAddress) { - const lowerAddress = tokenAddress.toLowerCase(); - if (!tokenMap.has(lowerAddress)) { - tokenMap.set(lowerAddress, { - address: tokenAddress, - symbol: '', - }); - } + const seen = new Set(); + const entries: AssetFetchEntry[] = []; + + for (const rawAssetId of Object.keys(accountBalances)) { + if (rawAssetId.startsWith(caipChainPrefix)) { + const lower = rawAssetId.toLowerCase(); + if (!seen.has(lower)) { + seen.add(lower); + entries.push(BalanceFetcher.#assetIdToEntry(rawAssetId)); } } } - return Array.from(tokenMap.values()); + return entries; } + /** + * Fetch balances for assets already tracked in state for the given + * account and chain. + * + * @param chainId - Hex chain ID. + * @param accountId - Account UUID. + * @param accountAddress - On-chain address of the account. + * @param options - Optional fetch options (batch size, timeout). + * @returns Balance fetch result. + */ async fetchBalances( chainId: ChainId, accountId: AccountId, accountAddress: Address, options?: BalanceFetchOptions, ): Promise { - const tokens = this.getTokensToFetch(chainId, accountId); - const tokenAddresses = tokens.map((token) => token.address); + const assets = this.getAssetsToFetch(chainId, accountId); - return this.fetchBalancesForTokens( - chainId, + return this.fetchBalancesForAssets( accountId, accountAddress, - tokenAddresses, + assets, options, - tokens, ); } - async fetchBalancesForTokens( - chainId: ChainId, + /** + * Fetch balances for the given assets via multicall. + * + * Each entry bundles a CAIP-19 asset ID with its on-chain address and + * optional metadata (decimals, symbol), so callers never need to maintain + * separate parallel arrays. + * + * @param accountId - Account UUID. + * @param accountAddress - On-chain address of the account. + * @param assets - Asset fetch entries to fetch balances for. + * @param options - Optional fetch options (batch size, timeout). + * @returns Balance fetch result. + */ + async fetchBalancesForAssets( accountId: AccountId, accountAddress: Address, - tokenAddresses: Address[], + assets: AssetFetchEntry[], options?: BalanceFetchOptions, - tokenInfos?: TokenFetchInfo[], ): Promise { const batchSize = options?.batchSize ?? this.#config.defaultBatchSize; - const includeNative = - options?.includeNative ?? this.#config.includeNativeByDefault; const timestamp = Date.now(); - const tokenInfoMap = new Map(); - if (tokenInfos) { - for (const info of tokenInfos) { - tokenInfoMap.set(info.address.toLowerCase(), info); - } - } - + // Build a single map keyed by lowercase address that holds all info + // needed to match multicall responses back to their original entries. const balanceRequests: BalanceOfRequest[] = []; + const entryByAddress = new Map(); - if (includeNative) { - balanceRequests.push({ - tokenAddress: ZERO_ADDRESS, - accountAddress, - }); - } + for (const entry of assets) { + const lowerAddress = entry.address.toLowerCase(); + if (entryByAddress.has(lowerAddress)) { + continue; // deduplicate + } - for (const tokenAddress of tokenAddresses) { - balanceRequests.push({ - tokenAddress, - accountAddress, - }); + entryByAddress.set(lowerAddress, entry); + balanceRequests.push({ tokenAddress: entry.address, accountAddress }); } if (balanceRequests.length === 0) { return { - chainId, + chainId: '0x0' as ChainId, accountId, accountAddress, balances: [], @@ -217,6 +228,10 @@ export class BalanceFetcher extends StaticIntervalPollingControllerOnly, + entryByAddress: Map, ): { balances: AssetBalance[]; failedAddresses: Address[]; @@ -280,31 +318,29 @@ export class BalanceFetcher extends StaticIntervalPollingControllerOnly Date: Tue, 24 Mar 2026 14:29:03 +0000 Subject: [PATCH 02/17] export constant --- .../assets-controller/src/data-sources/RpcDataSource.ts | 3 +-- .../evm-rpc-services/clients/MulticallClient.ts | 7 +------ .../evm-rpc-services/services/BalanceFetcher.ts | 4 +--- packages/assets-controller/src/utils/constants.ts | 2 ++ 4 files changed, 5 insertions(+), 11 deletions(-) create mode 100644 packages/assets-controller/src/utils/constants.ts diff --git a/packages/assets-controller/src/data-sources/RpcDataSource.ts b/packages/assets-controller/src/data-sources/RpcDataSource.ts index cf49dd01b15..fa86a051db2 100644 --- a/packages/assets-controller/src/data-sources/RpcDataSource.ts +++ b/packages/assets-controller/src/data-sources/RpcDataSource.ts @@ -62,12 +62,11 @@ import type { Middleware, } from '../types'; import { normalizeAssetId } from '../utils'; +import { ZERO_ADDRESS } from '../utils/constants'; const CONTROLLER_NAME = 'RpcDataSource'; const DEFAULT_BALANCE_INTERVAL = 30_000; // 30 seconds const DEFAULT_DETECTION_INTERVAL = 180_000; // 3 minutes -const ZERO_ADDRESS: Address = - '0x0000000000000000000000000000000000000000' as Address; const log = createModuleLogger(projectLogger, CONTROLLER_NAME); diff --git a/packages/assets-controller/src/data-sources/evm-rpc-services/clients/MulticallClient.ts b/packages/assets-controller/src/data-sources/evm-rpc-services/clients/MulticallClient.ts index 7fa65e28dd3..d3e767dae22 100644 --- a/packages/assets-controller/src/data-sources/evm-rpc-services/clients/MulticallClient.ts +++ b/packages/assets-controller/src/data-sources/evm-rpc-services/clients/MulticallClient.ts @@ -1,6 +1,7 @@ import { Interface } from '@ethersproject/abi'; import type { Hex } from '@metamask/utils'; +import { ZERO_ADDRESS } from '../../../utils/constants'; import type { Address, BalanceOfRequest, @@ -79,12 +80,6 @@ const erc20Interface = new Interface(ERC20_ABI); // CONSTANTS // ============================================================================= -/** - * Zero address constant for native token. - */ -const ZERO_ADDRESS: Address = - '0x0000000000000000000000000000000000000000' as Address; - /** * Multicall3 contract addresses by chain ID. * Source: https://github.com/mds1/multicall/blob/main/deployments.json diff --git a/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.ts b/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.ts index 4356f28f28d..a4e3011487a 100644 --- a/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.ts +++ b/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.ts @@ -1,5 +1,6 @@ import { StaticIntervalPollingControllerOnly } from '@metamask/polling-controller'; +import { ZERO_ADDRESS } from '../../../utils/constants'; import type { MulticallClient } from '../clients'; import type { AccountId, @@ -17,9 +18,6 @@ import { reduceInBatchesSerially } from '../utils'; const DEFAULT_BALANCE_INTERVAL = 30_000; // 30 seconds -const ZERO_ADDRESS: Address = - '0x0000000000000000000000000000000000000000' as Address; - /** * Minimal messenger interface for BalanceFetcher. */ diff --git a/packages/assets-controller/src/utils/constants.ts b/packages/assets-controller/src/utils/constants.ts new file mode 100644 index 00000000000..6e57f60ba8d --- /dev/null +++ b/packages/assets-controller/src/utils/constants.ts @@ -0,0 +1,2 @@ +export const ZERO_ADDRESS = + '0x0000000000000000000000000000000000000000' as const; From 9ba12541985c2fd04d953ee742652d79251b2005 Mon Sep 17 00:00:00 2001 From: Bernardo Garces Chapero Date: Tue, 24 Mar 2026 14:46:35 +0000 Subject: [PATCH 03/17] restore chainId parameter --- .../assets-controller/src/data-sources/RpcDataSource.ts | 3 +++ .../evm-rpc-services/services/BalanceFetcher.ts | 9 ++++----- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/assets-controller/src/data-sources/RpcDataSource.ts b/packages/assets-controller/src/data-sources/RpcDataSource.ts index fa86a051db2..7d1faeac1a3 100644 --- a/packages/assets-controller/src/data-sources/RpcDataSource.ts +++ b/packages/assets-controller/src/data-sources/RpcDataSource.ts @@ -899,6 +899,8 @@ export class RpcDataSource extends AbstractDataSource< const { address, id: accountId } = account; for (const chainId of chainsForAccount) { + const hexChainId = caipChainIdToHex(chainId); + // Build a single AssetFetchEntry[] for native + custom ERC-20s const nativeAssetId = this.#buildNativeAssetId(chainId); const assetsToFetch: AssetFetchEntry[] = [ @@ -936,6 +938,7 @@ export class RpcDataSource extends AbstractDataSource< try { const result = await this.#balanceFetcher.fetchBalancesForAssets( + hexChainId, accountId, address as Address, assetsToFetch, diff --git a/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.ts b/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.ts index a4e3011487a..e0b12290bb1 100644 --- a/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.ts +++ b/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.ts @@ -171,6 +171,7 @@ export class BalanceFetcher extends StaticIntervalPollingControllerOnly Date: Tue, 24 Mar 2026 15:02:26 +0000 Subject: [PATCH 04/17] refactors --- .../src/data-sources/RpcDataSource.ts | 5 +- .../services/BalanceFetcher.test.ts | 109 ++++++++++-------- .../services/BalanceFetcher.ts | 9 +- .../evm-rpc-services/types/index.ts | 1 - .../evm-rpc-services/types/services.ts | 10 -- 5 files changed, 68 insertions(+), 66 deletions(-) diff --git a/packages/assets-controller/src/data-sources/RpcDataSource.ts b/packages/assets-controller/src/data-sources/RpcDataSource.ts index 7d1faeac1a3..f5f00cde2c1 100644 --- a/packages/assets-controller/src/data-sources/RpcDataSource.ts +++ b/packages/assets-controller/src/data-sources/RpcDataSource.ts @@ -918,7 +918,8 @@ export class RpcDataSource extends AbstractDataSource< assetChainId === chainId && parsed.assetNamespace === 'erc20' ) { - const tokenAddress = parsed.assetReference as Address; + const tokenAddress = + parsed.assetReference.toLowerCase() as Address; const normalizedId = normalizeAssetId(assetId); const decimals = existingMetadata[normalizedId]?.decimals ?? @@ -926,7 +927,7 @@ export class RpcDataSource extends AbstractDataSource< assetsToFetch.push({ assetId, - address: tokenAddress.toLowerCase() as Address, + address: tokenAddress, decimals, }); } diff --git a/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.test.ts b/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.test.ts index 779ff93b1fd..157100d6d3c 100644 --- a/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.test.ts +++ b/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.test.ts @@ -392,6 +392,7 @@ describe('BalanceFetcher', () => { ]); const result = await controller.fetchBalancesForAssets( + MAINNET_CHAIN_ID, TEST_ACCOUNT_ID, TEST_ACCOUNT, [NATIVE_ETH_ENTRY, TOKEN_1_ENTRY_WITH_DECIMALS], @@ -414,6 +415,7 @@ describe('BalanceFetcher', () => { ]); const result = await controller.fetchBalancesForAssets( + MAINNET_CHAIN_ID, TEST_ACCOUNT_ID, TEST_ACCOUNT, [NATIVE_ETH_ENTRY], @@ -424,8 +426,7 @@ describe('BalanceFetcher', () => { }); it('preserves a non-ETH native asset ID (e.g. Avalanche)', async () => { - const avaxNativeAssetId = - 'eip155:43114/slip44:9005' as CaipAssetType; + const avaxNativeAssetId = 'eip155:43114/slip44:9005' as CaipAssetType; await withController(async ({ controller, mockMulticallClient }) => { mockMulticallClient.batchBalanceOf.mockResolvedValue([ @@ -438,6 +439,7 @@ describe('BalanceFetcher', () => { ]); const result = await controller.fetchBalancesForAssets( + MAINNET_CHAIN_ID, TEST_ACCOUNT_ID, TEST_ACCOUNT, [{ assetId: avaxNativeAssetId, address: ZERO_ADDRESS }], @@ -460,6 +462,7 @@ describe('BalanceFetcher', () => { ]); const result = await controller.fetchBalancesForAssets( + MAINNET_CHAIN_ID, TEST_ACCOUNT_ID, TEST_ACCOUNT, [TOKEN_1_ENTRY_WITH_DECIMALS], @@ -481,6 +484,7 @@ describe('BalanceFetcher', () => { ]); const result = await controller.fetchBalancesForAssets( + MAINNET_CHAIN_ID, TEST_ACCOUNT_ID, TEST_ACCOUNT, [TOKEN_1_ENTRY], @@ -501,6 +505,7 @@ describe('BalanceFetcher', () => { ]); const result = await controller.fetchBalancesForAssets( + MAINNET_CHAIN_ID, TEST_ACCOUNT_ID, TEST_ACCOUNT, [{ ...TOKEN_1_ENTRY, decimals: 0 }], @@ -519,6 +524,7 @@ describe('BalanceFetcher', () => { ]); const result = await controller.fetchBalancesForAssets( + MAINNET_CHAIN_ID, TEST_ACCOUNT_ID, TEST_ACCOUNT, [TOKEN_1_ENTRY], @@ -532,6 +538,7 @@ describe('BalanceFetcher', () => { it('returns empty result when no entries provided', async () => { await withController(async ({ controller, mockMulticallClient }) => { const result = await controller.fetchBalancesForAssets( + MAINNET_CHAIN_ID, TEST_ACCOUNT_ID, TEST_ACCOUNT, [], @@ -562,6 +569,7 @@ describe('BalanceFetcher', () => { ]); const result = await controller.fetchBalancesForAssets( + MAINNET_CHAIN_ID, TEST_ACCOUNT_ID, TEST_ACCOUNT, [NATIVE_ETH_ENTRY], @@ -583,6 +591,7 @@ describe('BalanceFetcher', () => { ]); const result = await controller.fetchBalancesForAssets( + MAINNET_CHAIN_ID, TEST_ACCOUNT_ID, TEST_ACCOUNT, [NATIVE_ETH_ENTRY, NATIVE_ETH_ENTRY], @@ -609,6 +618,7 @@ describe('BalanceFetcher', () => { ]); const result = await controller.fetchBalancesForAssets( + MAINNET_CHAIN_ID, TEST_ACCOUNT_ID, TEST_ACCOUNT, [{ ...TOKEN_1_ENTRY, decimals: 6, symbol: 'USDC' }], @@ -625,6 +635,7 @@ describe('BalanceFetcher', () => { ]); const result = await controller.fetchBalancesForAssets( + MAINNET_CHAIN_ID, TEST_ACCOUNT_ID, TEST_ACCOUNT, [TOKEN_1_ENTRY_WITH_DECIMALS], @@ -646,6 +657,7 @@ describe('BalanceFetcher', () => { ]); const result = await controller.fetchBalancesForAssets( + MAINNET_CHAIN_ID, TEST_ACCOUNT_ID, TEST_ACCOUNT, [TOKEN_1_ENTRY_WITH_DECIMALS], @@ -668,6 +680,7 @@ describe('BalanceFetcher', () => { ]); const result = await controller.fetchBalancesForAssets( + MAINNET_CHAIN_ID, TEST_ACCOUNT_ID, TEST_ACCOUNT, [TOKEN_1_ENTRY_WITH_DECIMALS], @@ -680,57 +693,63 @@ describe('BalanceFetcher', () => { describe('batching behavior', () => { it('uses custom batch size from options', async () => { - await withController(async ({ controller, mockMulticallClient }) => { - mockMulticallClient.batchBalanceOf.mockResolvedValue([ - createMockBalanceResponse( - TEST_TOKEN_1, - TEST_ACCOUNT, - true, - '1000000000', - ), - ]); - - await controller.fetchBalancesForAssets( - TEST_ACCOUNT_ID, - TEST_ACCOUNT, - [TOKEN_1_ENTRY_WITH_DECIMALS, TOKEN_2_ENTRY_WITH_DECIMALS], - { batchSize: 1 }, - ); - - expect(mockMulticallClient.batchBalanceOf).toHaveBeenCalledTimes(2); - }); - }); - - it('accumulates results across multiple batches', async () => { - await withController(async ({ controller, mockMulticallClient }) => { - mockMulticallClient.batchBalanceOf - .mockResolvedValueOnce([ + await withController( + { config: { defaultBatchSize: 1 } }, + async ({ controller, mockMulticallClient }) => { + mockMulticallClient.batchBalanceOf.mockResolvedValue([ createMockBalanceResponse( TEST_TOKEN_1, TEST_ACCOUNT, true, - '1000000', - ), - ]) - .mockResolvedValueOnce([ - createMockBalanceResponse( - TEST_TOKEN_2, - TEST_ACCOUNT, - true, - '2000000', + '1000000000', ), ]); - const result = await controller.fetchBalancesForAssets( - TEST_ACCOUNT_ID, - TEST_ACCOUNT, - [TOKEN_1_ENTRY_WITH_DECIMALS, TOKEN_2_ENTRY_WITH_DECIMALS], - { batchSize: 1 }, - ); + await controller.fetchBalancesForAssets( + MAINNET_CHAIN_ID, + TEST_ACCOUNT_ID, + TEST_ACCOUNT, + [TOKEN_1_ENTRY_WITH_DECIMALS, TOKEN_2_ENTRY_WITH_DECIMALS], + ); - expect(mockMulticallClient.batchBalanceOf).toHaveBeenCalledTimes(2); - expect(result.balances).toHaveLength(2); - }); + expect(mockMulticallClient.batchBalanceOf).toHaveBeenCalledTimes(2); + }, + ); + }); + + it('accumulates results across multiple batches', async () => { + await withController( + { config: { defaultBatchSize: 1 } }, + async ({ controller, mockMulticallClient }) => { + mockMulticallClient.batchBalanceOf + .mockResolvedValueOnce([ + createMockBalanceResponse( + TEST_TOKEN_1, + TEST_ACCOUNT, + true, + '1000000', + ), + ]) + .mockResolvedValueOnce([ + createMockBalanceResponse( + TEST_TOKEN_2, + TEST_ACCOUNT, + true, + '2000000', + ), + ]); + + const result = await controller.fetchBalancesForAssets( + MAINNET_CHAIN_ID, + TEST_ACCOUNT_ID, + TEST_ACCOUNT, + [TOKEN_1_ENTRY_WITH_DECIMALS, TOKEN_2_ENTRY_WITH_DECIMALS], + ); + + expect(mockMulticallClient.batchBalanceOf).toHaveBeenCalledTimes(2); + expect(result.balances).toHaveLength(2); + }, + ); }); }); }); diff --git a/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.ts b/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.ts index e0b12290bb1..76b87643e72 100644 --- a/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.ts +++ b/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.ts @@ -8,7 +8,6 @@ import type { AssetBalance, AssetFetchEntry, AssetsBalanceState, - BalanceFetchOptions, BalanceFetchResult, BalanceOfRequest, BalanceOfResponse, @@ -159,14 +158,12 @@ export class BalanceFetcher extends StaticIntervalPollingControllerOnly { const assets = this.getAssetsToFetch(chainId, accountId); @@ -175,7 +172,6 @@ export class BalanceFetcher extends StaticIntervalPollingControllerOnly { - const batchSize = options?.batchSize ?? this.#config.defaultBatchSize; const timestamp = Date.now(); // Build a single map keyed by lowercase address that holds all info @@ -239,7 +232,7 @@ export class BalanceFetcher extends StaticIntervalPollingControllerOnly({ values: balanceRequests, - batchSize, + batchSize: this.#config.defaultBatchSize, initialResult: { balances: [], failedAddresses: [], diff --git a/packages/assets-controller/src/data-sources/evm-rpc-services/types/index.ts b/packages/assets-controller/src/data-sources/evm-rpc-services/types/index.ts index a084a23da31..ac9a8989041 100644 --- a/packages/assets-controller/src/data-sources/evm-rpc-services/types/index.ts +++ b/packages/assets-controller/src/data-sources/evm-rpc-services/types/index.ts @@ -27,7 +27,6 @@ export type { BalanceOfRequest, BalanceOfResponse } from './multicall'; // Service types export type { AssetFetchEntry, - BalanceFetchOptions, BalanceFetchResult, TokenDetectionOptions, TokenDetectionResult, diff --git a/packages/assets-controller/src/data-sources/evm-rpc-services/types/services.ts b/packages/assets-controller/src/data-sources/evm-rpc-services/types/services.ts index 40be15606ac..6ffb7ce320a 100644 --- a/packages/assets-controller/src/data-sources/evm-rpc-services/types/services.ts +++ b/packages/assets-controller/src/data-sources/evm-rpc-services/types/services.ts @@ -57,16 +57,6 @@ export type BalanceFetchResult = { timestamp: number; }; -/** - * Balance fetch options. - */ -export type BalanceFetchOptions = { - /** Maximum number of tokens to fetch per batch */ - batchSize?: number; - /** Timeout for fetch in milliseconds */ - timeout?: number; -}; - /** * Entry describing a single asset to fetch a balance for. * Bundles the CAIP-19 asset ID with the on-chain address (zero address for From c71d3e29666cdbe07de7591b9c3815de8671a186 Mon Sep 17 00:00:00 2001 From: Bernardo Garces Chapero Date: Tue, 24 Mar 2026 15:15:07 +0000 Subject: [PATCH 05/17] more changes --- .../evm-rpc-services/services/BalanceFetcher.test.ts | 2 +- .../evm-rpc-services/services/BalanceFetcher.ts | 3 +-- .../src/data-sources/evm-rpc-services/types/services.ts | 8 +++----- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.test.ts b/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.test.ts index 157100d6d3c..ecdf2a3d503 100644 --- a/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.test.ts +++ b/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.test.ts @@ -621,7 +621,7 @@ describe('BalanceFetcher', () => { MAINNET_CHAIN_ID, TEST_ACCOUNT_ID, TEST_ACCOUNT, - [{ ...TOKEN_1_ENTRY, decimals: 6, symbol: 'USDC' }], + [{ ...TOKEN_1_ENTRY, decimals: 6 }], ); expect(result.balances[0].formattedBalance).toBe('1234.56789'); diff --git a/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.ts b/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.ts index 76b87643e72..ed44fb12d6f 100644 --- a/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.ts +++ b/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.ts @@ -179,8 +179,7 @@ export class BalanceFetcher extends StaticIntervalPollingControllerOnly Date: Tue, 24 Mar 2026 15:16:50 +0000 Subject: [PATCH 06/17] fix test --- .../src/data-sources/RpcDataSource.test.ts | 25 ++++++++----------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/packages/assets-controller/src/data-sources/RpcDataSource.test.ts b/packages/assets-controller/src/data-sources/RpcDataSource.test.ts index 7be443576ed..0628c8b1ba1 100644 --- a/packages/assets-controller/src/data-sources/RpcDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/RpcDataSource.test.ts @@ -774,6 +774,7 @@ describe('RpcDataSource', () => { await controller.fetch(request); expect(fetchSpy).toHaveBeenCalledWith( + MOCK_CHAIN_ID_HEX, MOCK_ACCOUNT_ID, MOCK_ADDRESS, [ @@ -808,20 +809,16 @@ describe('RpcDataSource', () => { }); await controller.fetch(request); - expect(fetchSpy).toHaveBeenCalledWith( - MOCK_ACCOUNT_ID, - MOCK_ADDRESS, - [ - { - assetId: `${MOCK_CHAIN_ID_CAIP}/slip44:60`, - address: '0x0000000000000000000000000000000000000000', - }, - expect.objectContaining({ - assetId: matchingAsset, - address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', - }), - ], - ); + expect(fetchSpy).toHaveBeenCalledWith(MOCK_ACCOUNT_ID, MOCK_ADDRESS, [ + { + assetId: `${MOCK_CHAIN_ID_CAIP}/slip44:60`, + address: '0x0000000000000000000000000000000000000000', + }, + expect.objectContaining({ + assetId: matchingAsset, + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + }), + ]); }); fetchSpy.mockRestore(); From 071f5f196fa40b2208efb90e37f8f0fe6c26d068 Mon Sep 17 00:00:00 2001 From: Bernardo Garces Chapero Date: Tue, 24 Mar 2026 15:19:20 +0000 Subject: [PATCH 07/17] fix test --- .../evm-rpc-services/services/BalanceFetcher.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.test.ts b/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.test.ts index ecdf2a3d503..372e235e1ed 100644 --- a/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.test.ts +++ b/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.test.ts @@ -545,7 +545,7 @@ describe('BalanceFetcher', () => { ); expect(result).toStrictEqual({ - chainId: '0x0', + chainId: MAINNET_CHAIN_ID, accountId: TEST_ACCOUNT_ID, accountAddress: TEST_ACCOUNT, balances: [], From eac5e7fc73de0f0599a5f2988ce74694a567856e Mon Sep 17 00:00:00 2001 From: Bernardo Garces Chapero Date: Tue, 24 Mar 2026 15:20:19 +0000 Subject: [PATCH 08/17] fix test --- .../src/data-sources/RpcDataSource.test.ts | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/packages/assets-controller/src/data-sources/RpcDataSource.test.ts b/packages/assets-controller/src/data-sources/RpcDataSource.test.ts index 0628c8b1ba1..1355065a592 100644 --- a/packages/assets-controller/src/data-sources/RpcDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/RpcDataSource.test.ts @@ -809,16 +809,21 @@ describe('RpcDataSource', () => { }); await controller.fetch(request); - expect(fetchSpy).toHaveBeenCalledWith(MOCK_ACCOUNT_ID, MOCK_ADDRESS, [ - { - assetId: `${MOCK_CHAIN_ID_CAIP}/slip44:60`, - address: '0x0000000000000000000000000000000000000000', - }, - expect.objectContaining({ - assetId: matchingAsset, - address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', - }), - ]); + expect(fetchSpy).toHaveBeenCalledWith( + MOCK_CHAIN_ID_HEX, + MOCK_ACCOUNT_ID, + MOCK_ADDRESS, + [ + { + assetId: `${MOCK_CHAIN_ID_CAIP}/slip44:60`, + address: '0x0000000000000000000000000000000000000000', + }, + expect.objectContaining({ + assetId: matchingAsset, + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + }), + ], + ); }); fetchSpy.mockRestore(); From 1f79c0bdb37e5e4ca4478a30bf509b3b8c6caa62 Mon Sep 17 00:00:00 2001 From: Bernardo Garces Chapero Date: Tue, 24 Mar 2026 16:16:29 +0000 Subject: [PATCH 09/17] refactor --- .../services/BalanceFetcher.ts | 54 +++++++++---------- 1 file changed, 25 insertions(+), 29 deletions(-) diff --git a/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.ts b/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.ts index ed44fb12d6f..852af853faa 100644 --- a/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.ts +++ b/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.ts @@ -1,4 +1,5 @@ import { StaticIntervalPollingControllerOnly } from '@metamask/polling-controller'; +import { parseCaipAssetType } from '@metamask/utils'; import { ZERO_ADDRESS } from '../../../utils/constants'; import type { MulticallClient } from '../clients'; @@ -11,6 +12,7 @@ import type { BalanceFetchResult, BalanceOfRequest, BalanceOfResponse, + CaipAssetType, ChainId, } from '../types'; import { reduceInBatchesSerially } from '../utils'; @@ -132,18 +134,35 @@ export class BalanceFetcher extends StaticIntervalPollingControllerOnly(); const entries: AssetFetchEntry[] = []; - for (const rawAssetId of Object.keys(accountBalances)) { - if (rawAssetId.startsWith(caipChainPrefix)) { - const lower = rawAssetId.toLowerCase(); + for (const assetId of Object.keys(accountBalances) as CaipAssetType[]) { + const { + chain: { reference: chainReference }, + assetNamespace, + assetReference, + } = parseCaipAssetType(assetId); + if (chainReference === chainIdDecimal) { + const lower = assetId.toLowerCase(); if (!seen.has(lower)) { seen.add(lower); - entries.push(BalanceFetcher.#assetIdToEntry(rawAssetId)); + + const isNative = assetNamespace === 'slip44'; + + if (isNative) { + entries.push({ + assetId, + address: ZERO_ADDRESS, + }); + } else { + entries.push({ + assetId, + address: assetReference.toLowerCase() as Address, + }); + } } } } @@ -262,29 +281,6 @@ export class BalanceFetcher extends StaticIntervalPollingControllerOnly Date: Tue, 24 Mar 2026 16:58:08 +0000 Subject: [PATCH 10/17] remove changelog entry --- packages/assets-controller/CHANGELOG.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index 8380d20fb7d..ef770496051 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -7,13 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Fixed - -- Fix `BalanceFetcher` producing incorrect native asset IDs for non-Ethereum EVM chains (e.g. Avalanche). Previously all native balances were hardcoded to `slip44:60` (ETH); now `BalanceFetcher` accepts CAIP-19 asset IDs from callers and preserves them in results, so `RpcDataSource` passes the correct chain-specific native identifier from `NetworkEnablementController.nativeAssetIdentifiers`. - ### Changed -- **BREAKING:** `BalanceFetcher.fetchBalancesForTokens` is renamed to `fetchBalancesForAssets` and now accepts `CaipAssetType[]` (CAIP-19 asset IDs) instead of `Address[]` token addresses. `BalanceFetcher.getTokensToFetch` is renamed to `getAssetIdsToFetch` and returns `CaipAssetType[]`. `BalanceFetchOptions.includeNative` and `BalanceFetcherConfig.includeNativeByDefault` are removed; include the native asset ID in the asset IDs array instead. - EVM RPC balance pipeline (`RpcDataSource`, `BalanceFetcher`, `TokenDetector`) no longer falls back to 18 decimals for ERC-20 when decimals are unknown; human-readable balances and `detectedBalances` entries are omitted until decimals are available from state, token list metadata, or on-chain `decimals()` (native token handling unchanged) ([#8267](https://github.com/MetaMask/core/pull/8267)) - Bump `@metamask/keyring-api` from `^21.5.0` to `^21.6.0` ([#8259](https://github.com/MetaMask/core/pull/8259)) From 9213566de02f7f896e81cc6b4c9c79f456ebc606 Mon Sep 17 00:00:00 2001 From: Bernardo Garces Chapero Date: Tue, 24 Mar 2026 17:02:46 +0000 Subject: [PATCH 11/17] refactor --- .../services/BalanceFetcher.ts | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.ts b/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.ts index 852af853faa..1ccc0814b15 100644 --- a/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.ts +++ b/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.ts @@ -152,17 +152,14 @@ export class BalanceFetcher extends StaticIntervalPollingControllerOnly Date: Tue, 24 Mar 2026 17:09:59 +0000 Subject: [PATCH 12/17] make methods private --- .../evm-rpc-services/services/BalanceFetcher.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.ts b/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.ts index 1ccc0814b15..1eb82bfa582 100644 --- a/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.ts +++ b/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.ts @@ -103,7 +103,7 @@ export class BalanceFetcher extends StaticIntervalPollingControllerOnly { - const result = await this.fetchBalances( + const result = await this.#fetchBalances( input.chainId, input.accountId, input.accountAddress, @@ -122,7 +122,7 @@ export class BalanceFetcher extends StaticIntervalPollingControllerOnly { - const assets = this.getAssetsToFetch(chainId, accountId); + const assets = this.#getAssetsToFetch(chainId, accountId); return this.fetchBalancesForAssets( chainId, From 327aef2560b4075d5b44f96afb06db197105e184 Mon Sep 17 00:00:00 2001 From: Bernardo Garces Chapero Date: Tue, 24 Mar 2026 17:15:43 +0000 Subject: [PATCH 13/17] refactor --- .../services/BalanceFetcher.ts | 31 +++++++++---------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.ts b/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.ts index 1eb82bfa582..c060e0131b8 100644 --- a/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.ts +++ b/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.ts @@ -136,8 +136,7 @@ export class BalanceFetcher extends StaticIntervalPollingControllerOnly(); - const entries: AssetFetchEntry[] = []; + const assetsToFetch = new Map(); for (const assetId of Object.keys(accountBalances) as CaipAssetType[]) { const { @@ -145,26 +144,26 @@ export class BalanceFetcher extends StaticIntervalPollingControllerOnly Date: Tue, 24 Mar 2026 17:17:25 +0000 Subject: [PATCH 14/17] comment --- .../data-sources/evm-rpc-services/services/BalanceFetcher.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.ts b/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.ts index c060e0131b8..980a79fc588 100644 --- a/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.ts +++ b/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.ts @@ -134,6 +134,8 @@ export class BalanceFetcher extends StaticIntervalPollingControllerOnly(); From 3b5b50613156f1fdcc6fff57e46e469702dcc17b Mon Sep 17 00:00:00 2001 From: Bernardo Garces Chapero Date: Tue, 24 Mar 2026 19:28:35 +0000 Subject: [PATCH 15/17] fix test --- .../services/BalanceFetcher.test.ts | 63 ------------------- 1 file changed, 63 deletions(-) diff --git a/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.test.ts b/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.test.ts index 372e235e1ed..780be4a4ff0 100644 --- a/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.test.ts +++ b/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.test.ts @@ -310,69 +310,6 @@ describe('BalanceFetcher', () => { }); }); - describe('getAssetsToFetch', () => { - it('returns empty array when no balances exist', async () => { - await withController(async ({ controller }) => { - const entries = controller.getAssetsToFetch( - MAINNET_CHAIN_ID, - TEST_ACCOUNT_ID, - ); - expect(entries).toStrictEqual([]); - }); - }); - - it('returns asset fetch entries from assetsBalance state', async () => { - const mockState = createMockAssetsBalanceState(TEST_ACCOUNT_ID, { - [NATIVE_ETH_ASSET_ID]: { amount: '1' }, - [TOKEN_1_ASSET_ID]: { amount: '100' }, - [TOKEN_2_ASSET_ID]: { amount: '200' }, - }); - - await withController( - { assetsBalanceState: mockState }, - async ({ controller }) => { - const entries = controller.getAssetsToFetch( - MAINNET_CHAIN_ID, - TEST_ACCOUNT_ID, - ); - expect(entries).toHaveLength(3); - - const assetIds = entries.map((e) => e.assetId); - expect(assetIds).toContain(NATIVE_ETH_ASSET_ID); - expect(assetIds).toContain(TOKEN_1_ASSET_ID); - expect(assetIds).toContain(TOKEN_2_ASSET_ID); - - const nativeEntry = entries.find( - (e) => e.assetId === NATIVE_ETH_ASSET_ID, - ); - expect(nativeEntry?.address).toBe(ZERO_ADDRESS); - - const erc20Entry = entries.find( - (e) => e.assetId === TOKEN_1_ASSET_ID, - ); - expect(erc20Entry?.address).toBe(TEST_TOKEN_1.toLowerCase()); - }, - ); - }); - - it('returns empty array when chain has no assets', async () => { - const mockState = createMockAssetsBalanceState(TEST_ACCOUNT_ID, { - [TOKEN_1_ASSET_ID]: { amount: '100' }, - }); - - await withController( - { assetsBalanceState: mockState }, - async ({ controller }) => { - const entries = controller.getAssetsToFetch( - POLYGON_CHAIN_ID, - TEST_ACCOUNT_ID, - ); - expect(entries).toStrictEqual([]); - }, - ); - }); - }); - describe('fetchBalancesForAssets', () => { it('fetches balances for specified asset entries', async () => { await withController(async ({ controller, mockMulticallClient }) => { From 58d500b397c3245c1aade738628e2adebdd5dc0f Mon Sep 17 00:00:00 2001 From: Bernardo Garces Chapero Date: Tue, 24 Mar 2026 19:32:46 +0000 Subject: [PATCH 16/17] better coverage --- .../evm-rpc-services/services/BalanceFetcher.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.ts b/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.ts index 980a79fc588..4c4a6a4b429 100644 --- a/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.ts +++ b/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.ts @@ -125,11 +125,7 @@ export class BalanceFetcher extends StaticIntervalPollingControllerOnly Date: Wed, 25 Mar 2026 10:13:55 +0000 Subject: [PATCH 17/17] submit changelog --- packages/assets-controller/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index 06d1e87c326..6838457ed3f 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Refactored `BalanceFetcher` and `RpcDataSource` to ensure the correct `assetId` is used for EVM native assets that are not ETH ([#8284](https://github.com/MetaMask/core/pull/8284)) + ## [3.1.0] ### Changed