Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/assets-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Copy link
Copy Markdown
Contributor Author

@bergarces bergarces Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All the method signatures I have changed are internal, none of them are used in the clients. So I'm not marking it as breaking.


## [3.1.0]

### Changed
Expand Down
36 changes: 25 additions & 11 deletions packages/assets-controller/src/data-sources/RpcDataSource.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 }) => {
Expand All @@ -777,9 +777,16 @@ describe('RpcDataSource', () => {
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',
}),
],
);
});

Expand All @@ -793,7 +800,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 }) => {
Expand All @@ -806,9 +813,16 @@ describe('RpcDataSource', () => {
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',
}),
],
);
});

Expand Down
72 changes: 24 additions & 48 deletions packages/assets-controller/src/data-sources/RpcDataSource.ts
Copy link
Copy Markdown
Contributor Author

@bergarces bergarces Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The goal here was to pass the assetId alongside the token addresses and the tokenInfo to the balance fetcher.

Since the last two were already being placed in two different arrays of the same length, as well as a setting to return native balance, it's all been simplified by just passing to balance fetcher a single array that contains everything needed:

  • AssetId (including the native token)
  • Address
  • TokenInfo (this is just the decimals)

That way, BalanceFetcher does not need to know how to build the native assetId for every chain.

Also, the token address passed to balance fetcher is always the zero address, regardless of the chain, as that is how BalanceFetcher determines whether to fetch the balance of a token or the balance of the account.

Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -62,6 +62,7 @@ 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
Expand Down Expand Up @@ -900,9 +901,15 @@ export class RpcDataSource extends AbstractDataSource<
for (const chainId of chainsForAccount) {
const hexChainId = caipChainIdToHex(chainId);

// Extract ERC20 token addresses from customAssets for this chain
const customTokenAddresses: Address[] = [];
// Build a single AssetFetchEntry[] for native + custom ERC-20s
const nativeAssetId = this.#buildNativeAssetId(chainId);
const assetsToFetch: AssetFetchEntry[] = [
{ assetId: nativeAssetId, address: ZERO_ADDRESS },
];

if (request.customAssets) {
const existingMetadata = this.#getExistingAssetsMetadata();

for (const assetId of request.customAssets) {
try {
const parsed = parseCaipAssetType(assetId);
Expand All @@ -911,7 +918,18 @@ export class RpcDataSource extends AbstractDataSource<
assetChainId === chainId &&
parsed.assetNamespace === 'erc20'
) {
customTokenAddresses.push(parsed.assetReference as Address);
const tokenAddress =
parsed.assetReference.toLowerCase() as Address;
const normalizedId = normalizeAssetId(assetId);
const decimals =
existingMetadata[normalizedId]?.decimals ??
this.#getTokenMetadataFromTokenList(normalizedId)?.decimals;

assetsToFetch.push({
assetId,
address: tokenAddress,
decimals,
});
}
} catch {
// Skip unparseable asset IDs
Expand All @@ -920,19 +938,11 @@ export class RpcDataSource extends AbstractDataSource<
}

try {
const tokenInfos = this.#tokenFetchInfosForCustomErc20s(
chainId,
customTokenAddresses,
);

// Use BalanceFetcher for batched balance fetching
const result = await this.#balanceFetcher.fetchBalancesForTokens(
const result = await this.#balanceFetcher.fetchBalancesForAssets(
hexChainId,
accountId,
address as Address,
customTokenAddresses,
{ includeNative: true },
tokenInfos,
assetsToFetch,
);

if (!assetsBalance[accountId]) {
Expand Down Expand Up @@ -992,7 +1002,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
Expand Down Expand Up @@ -1354,39 +1363,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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export type {
Address,
AssetFetchEntry,
AssetsBalanceState,
ChainId,
GetProviderFunction,
Expand Down
Loading
Loading