Skip to content
Draft
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
17 changes: 17 additions & 0 deletions packages/assets-controllers/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Add optional JWT token authentication to multi-chain accounts API calls ([#7084](https://github.com/MetaMask/core/pull/7084))
- `fetchMultiChainBalances` and `fetchMultiChainBalancesV4` now accept an optional `jwtToken` parameter
- `TokenDetectionController` fetches and passes JWT token from `AuthenticationController` when using Accounts API
- `TokenBalancesController` fetches and passes JWT token through balance fetcher chain
- JWT token is included in `Authorization: Bearer <token>` header when provided
- Backward compatible: token parameter is optional and APIs work without authentication

### Fixed

- Add 30-second timeout protection for Accounts API calls in `TokenDetectionController` to prevent hanging requests ([#7084](https://github.com/MetaMask/core/pull/7084))
- Prevents token detection from hanging indefinitely on slow or unresponsive API requests
- Automatically falls back to RPC-based token detection when API call times out or fails
- Includes error logging for debugging timeout and failure events
- Importing a non-evm asset with positive balance sets balance to 0 after import ([#7094](https://github.com/MetaMask/core/pull/7094))

## [88.0.0]

### Changed
Expand Down
2 changes: 2 additions & 0 deletions packages/assets-controllers/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@
"@metamask/permission-controller": "^12.1.0",
"@metamask/phishing-controller": "^15.0.0",
"@metamask/preferences-controller": "^21.0.0",
"@metamask/profile-sync-controller": "^26.0.0",
"@metamask/providers": "^22.1.0",
"@metamask/snaps-controllers": "^14.0.1",
"@metamask/transaction-controller": "^61.1.0",
Expand Down Expand Up @@ -124,6 +125,7 @@
"@metamask/permission-controller": "^12.0.0",
"@metamask/phishing-controller": "^15.0.0",
"@metamask/preferences-controller": "^21.0.0",
"@metamask/profile-sync-controller": "^26.0.0",
"@metamask/providers": "^22.0.0",
"@metamask/snaps-controllers": "^14.0.0",
"@metamask/transaction-controller": "^61.0.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,10 @@ import { Mutex } from 'async-mutex';
import { cloneDeep, isEqual } from 'lodash';

import {
STAKING_CONTRACT_ADDRESS_BY_CHAINID,
type AssetsContractController,
type StakedBalance,
} from './AssetsContractController';
import { STAKING_CONTRACT_ADDRESS_BY_CHAINID } from './assetsUtil';
import {
AccountsApiBalanceFetcher,
type BalanceFetcher,
Expand Down
269 changes: 108 additions & 161 deletions packages/assets-controllers/src/AssetsContractController.test.ts

Large diffs are not rendered by default.

60 changes: 30 additions & 30 deletions packages/assets-controllers/src/AssetsContractController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,17 @@ import type {
import type { PreferencesControllerStateChangeEvent } from '@metamask/preferences-controller';
import { getKnownPropertyNames, type Hex } from '@metamask/utils';
import type BN from 'bn.js';
import abiSingleCallBalancesContract from 'single-call-balance-checker-abi';

import {
STAKING_CONTRACT_ADDRESS_BY_CHAINID,
SupportedStakedBalanceNetworks,
SupportedTokenDetectionNetworks,
} from './assetsUtil';
import type { Call } from './multicall';
import { multicallOrFallback } from './multicall';
import {
multicallOrFallback,
getTokenBalancesForMultipleAddresses,
} from './multicall';
import { ERC20Standard } from './Standards/ERC20Standard';
import { ERC1155Standard } from './Standards/NftStandards/ERC1155/ERC1155Standard';
import { ERC721Standard } from './Standards/NftStandards/ERC721/ERC721Standard';
Expand Down Expand Up @@ -73,13 +76,6 @@ export const SINGLE_CALL_BALANCES_ADDRESS_BY_CHAINID = {
'0x6aa75276052d96696134252587894ef5ffa520af',
} as const satisfies Record<Hex, string>;

export const STAKING_CONTRACT_ADDRESS_BY_CHAINID = {
[SupportedStakedBalanceNetworks.mainnet]:
'0x4fef9d741011476750a243ac70b9789a63dd47df',
[SupportedStakedBalanceNetworks.hoodi]:
'0xe96ac18cfe5a7af8fe1fe7bc37ff110d88bc67ff',
} as Record<Hex, string>;

export const MISSING_PROVIDER_ERROR =
'AssetsContractController failed to set the provider correctly. A provider must be set for this method to be available';

Expand Down Expand Up @@ -661,6 +657,9 @@ export class AssetsContractController {
* Get the token balance for a list of token addresses in a single call. Only non-zero balances
* are returned.
*
* This method now uses Multicall3 which is deployed on 200+ networks, providing much broader
* network support than the legacy single-call balances contract.
*
* @param selectedAddress - The address to check token balances for.
* @param tokensToDetect - The token addresses to detect balances for.
* @param networkClientId - Network Client ID to fetch the provider with.
Expand All @@ -673,32 +672,33 @@ export class AssetsContractController {
) {
const chainId = this.#getCorrectChainId(networkClientId);
const provider = this.#getCorrectProvider(networkClientId);
if (
!((id): id is keyof typeof SINGLE_CALL_BALANCES_ADDRESS_BY_CHAINID =>
id in SINGLE_CALL_BALANCES_ADDRESS_BY_CHAINID)(chainId)
) {
// Only fetch balance if contract address exists
return {};
}
const contractAddress = SINGLE_CALL_BALANCES_ADDRESS_BY_CHAINID[chainId];

const contract = new Contract(
contractAddress,
abiSingleCallBalancesContract,
// Use getTokenBalancesForMultipleAddresses which supports 200+ networks via Multicall3
const { tokenBalances } = await getTokenBalancesForMultipleAddresses(
[
{
accountAddress: selectedAddress as Hex,
tokenAddresses: tokensToDetect as Hex[],
},
],
chainId,
provider,
false, // includeNative
false, // includeStaked
);
const result = await contract.balances([selectedAddress], tokensToDetect);

// Convert the result format to match the original method's return type
// tokenBalances is a map of tokenAddress -> { userAddress -> balance }
const nonZeroBalances: BalanceMap = {};
/* istanbul ignore else */
if (result.length > 0) {
tokensToDetect.forEach((tokenAddress, index) => {
const balance: BN = result[index];
/* istanbul ignore else */
if (String(balance) !== '0') {
nonZeroBalances[tokenAddress] = balance;
}
});
for (const [tokenAddress, addressBalances] of Object.entries(
tokenBalances,
)) {
const balance = addressBalances[selectedAddress];
if (balance && String(balance) !== '0') {
nonZeroBalances[tokenAddress] = balance;
}
}

return nonZeroBalances;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import { BigNumber } from '@ethersproject/bignumber';
import { BUILT_IN_NETWORKS } from '@metamask/controller-utils';
import { NetworkClientType } from '@metamask/network-controller';

import {
setupAssetContractControllers,
Expand Down Expand Up @@ -702,114 +700,6 @@ describe('AssetsContractController with NetworkClientId', () => {
messenger.clearEventSubscriptions('NetworkController:networkDidChange');
});

it('should get balance of ERC-20 token in a single call on network with token detection support', async () => {
const { messenger, networkClientConfiguration } =
await setupAssetContractControllers();
mockNetworkWithDefaultChainId({
networkClientConfiguration,
mocks: [
{
request: {
method: 'eth_call',
params: [
{
to: '0xb1f8e55c7f64d203c1400b9d8555d050f94adf39',
data: '0xf0002ea900000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000100000000000000000000000089d24a6b4ccb1b6faa2625fe562bdd9a23260359000000000000000000000000000000000000000000000000000000000000000100000000000000000000000089d24a6b4ccb1b6faa2625fe562bdd9a23260359',
},
'latest',
],
},
response: {
result:
'0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000733ed8ef4c4a0155d09',
},
},
],
});
const balances = await messenger.call(
`AssetsContractController:getBalancesInSingleCall`,
ERC20_SAI_ADDRESS,
[ERC20_SAI_ADDRESS],
'mainnet',
);
expect(balances[ERC20_SAI_ADDRESS]).toBeDefined();
messenger.clearEventSubscriptions('NetworkController:networkDidChange');
});

it('should not have balance in a single call after switching to network without token detection support', async () => {
const { messenger, networkClientConfiguration } =
await setupAssetContractControllers();
mockNetworkWithDefaultChainId({
networkClientConfiguration,
mocks: [
{
request: {
method: 'eth_call',
params: [
{
to: '0xb1f8e55c7f64d203c1400b9d8555d050f94adf39',
data: '0xf0002ea900000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000100000000000000000000000089d24a6b4ccb1b6faa2625fe562bdd9a23260359000000000000000000000000000000000000000000000000000000000000000100000000000000000000000089d24a6b4ccb1b6faa2625fe562bdd9a23260359',
},
'latest',
],
},
response: {
result:
'0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000733ed8ef4c4a0155d09',
},
},
],
});
mockNetworkWithDefaultChainId({
networkClientConfiguration: {
chainId: BUILT_IN_NETWORKS.sepolia.chainId,
ticker: BUILT_IN_NETWORKS.sepolia.ticker,
type: NetworkClientType.Infura,
network: 'sepolia',
failoverRpcUrls: [],
infuraProjectId: networkClientConfiguration.infuraProjectId,
},
mocks: [
{
request: {
method: 'eth_blockNumber',
params: [],
},
response: {
result: '0x3b3301',
},
},
{
request: {
method: 'eth_getBlockByNumber',
params: ['0x3b3301'],
},
response: {
result:
'1f8b08000000000000ffb4784dab5d598ee57fb963d3e85bda316c92fe801e340d394a7220696b67b871d881df8bcaa84ae2bf17fb5d674016f6ccf1060fee39f76ae9682f2d2d9d7f3cfeffcba78f9f7feec70f0ffa6ff078f778bf1f3f182106e89220b2507bf7f83c2fbf7c787dfcf08f47e5cbfc8f99ff3b9fff67be3c7e78c0affe78f7d8efcf79dfbf7c78fdf7b74b37d0fcfafa39ff94aff97665473020146888b9a98b4584b91d461a2686fb3fd4da968dabc7e3dde36ff9f27fdefff4fef52d02f66a0e785efef3cbec2f57a5d8e7f1eef163befcf804b72c858283403996284b77837a07e1de75784042fd1c2baba1dca347912a8ad44f3ede3d3e7cfadbcb7ffff0e9d34f6f01491510d616dc6d0e905520001c68828002ba2a09a8383241a0e12c927005e0054511690b51241a122170903b8382e204b4300b9009bb6f615cbaa0e62c68e4ea0029508b850202a8b8023a082a38d1445320ef37f781b5783674c48a5b6c58410204b06498571f6482c60d34a92b8d150e1224181c6b808316dd2148b8a60111a6945712086d4a5060b514819508404d04444b004bee8f041b919745546f290e48912222212829cc8cd1015880ac28b0e070d06e4b206868760900588894b04001066447d36a025f484082042d1b49a25953a821415d30e01e0b8bc126a0458b17b67942808982ec2dda09f0f6c02bc08901e850024224ec869b2440c800219d4c0f38806b48c72fe57e7aff713ebf91a16d4874ad857586bc3b6d8739d161dd499c38d2b5fced47bffeaf7f92b2975cc613982c4346c6341c415db26b8909b1c91a863d5b5720fbb9748a8eb2a0bce13e7efad8f364f87ff9bb377ff9a9be64c8c50cf878f7f8393fcfc7d7df5300d7f1132cdb85a26ec55c8ed1e64c37d3b3706d56176f3845066bf991044c3e86721bf2f3f4bcfff9f5e5ff7dfaf46ccacd0eb3e2a47886069ced195e6b8982b6f90e3f2d21aa1cb750d22b0417642d67a69be5cb8fc97ffed81fe6a926b8bb654fec69d7ed99155a6a5ed6bd0573339228d6924817e4038942670b6c592cb7502fefffe35927f7bcb57979cdd7f93de5643b9c45a87a9477aded65513ba5d7042f0241f1c8b5a07784b28def2eefd24d58fa78f7787dffd3bcbce64f3fbf0534c94375fadef8f49a1ffef4af5ac86d6afb0adccabae9bc7ece8f2fd9afef3f7d7c79fcf097fbcc59a5e48620d83d79aa76c58aa1d6dd22ec2827669f592ab8e7c819a336b7b82c5a8f770ff8b52c7b86672ba763eef2cd3d23236b2f9c0d8c9a9b548cc1d6ae91a8955dc00910996f416c14a57d7275c22217a76db2fbc0716f097631f71558e336768e1459368355eca96710e05dc6834bfd829e4ef02e775d72d6b0a2b293878bcce0060199124e319b157de84b260bfcc4de6cb2294678ebe8a91cdb950b44270859c7053a4a107accd0e2b652e3339376188e3e6b83876f94845683ec72b28a5e3b51b3ba040a90aaf56c3eea78562ffa67907189e8f6b0708eb5958cc00e56ab8396ea248b6df7e3cb6620e270862dc0da09f216642a4f801ef743e3477a1a43e49c66aba41e5a790527d73a9db5c20e526f950170157c1ef19c5cba632d3c8ba73727b1e5aa9ebd76eb38b7230dcc9d2a1a6a462e66541e52616f417816c33a038600bd0096794084c09d89bde38c6e25f60a24a253769c67348cc1b73fc9363dda4cc15813458d7b49b8953929561d6958576c1583f114c47ecb53259843d4df82ec26f2cd3b214fe158385eba5b9a8de1de60ab36dcf9167e92f37440a9af25e81833cf20214265b94bba767b92572ce839630ce7c4c8c1c0bd9adf7209983d628bf7dedefb19c4c5ce8995d875f2c421b14067d8bae8f0aef68c339e95c59b30d5d15c3ceb4c52723f197b34adc3598aaf9581125dc845cd88a7a2c3a3b6a2ac39dbdb04c420330c5bd517bc05512d27ce00ef60a448184f5f039cd3ee53cbb6c094c38ac21d4b9193aa140db8de5c13fcca6b04652bc239db4374c54c4c3278931f06a39058c7f1e072eaa9a3b319b53573213f0b2b934e5048726fdbd892c92bc3245ecbce9a144561a78310bd14a71279c6bc259f8f73986c55495025c40a4bd88710ba3c37a10385cf395404becf9c2d167a96cbdc3ef067905b84f5a679dc5744d07755994c6a05b8d91c8f6acd657eee2caff2b3ab029dc0e379c484b06dc66feb5622904f6ace3ef75322a3ef8d52e81481c0ce50d09405809d66e7793a2c8bcf66d8e8b67347c9f8aea8b2898100d1eee658dddbbc77d159e53e7972117d61ec64558aa1a0bb6e06a83ce25ccdbbde481e6219d3da5657e64e6b8bb35d283cf0ec1d4a75c873aa44671d9b025a9c4bac49ce62d2eb4ef7e4c15c00682ae75abbab4133f54563b7c1907afa00c42add7bea36211955faacd377b44f9945c13e185e568c23be80e8cbe314c695b045b9e21cd561395c7c45da74ca5775988e68afc2bb4f9c3ab4930fd5f5816f41d0b1f646d33a4a090c385751fa6c276350b8edd8b580afd1c0663949dc077dd6707fc924d6fdaea243e2cce985e3b6012a645de7cc8d0871bdf0baf6863978417a43659a7d517b896c2d0795c540b188113a54b16ed323ef5dc2dc89c961d790ef961e2ce56cde4f29e80d93b855927a1bd7414e5e875186f2a8fad1d856b88ac3ef3ab2e836fe69385baaeba9f6c4b9d61d778a72a6f3305fc6a45bb5011af275d47e333a73e54ba268db54f4b03d7bc74f6dd83e0641807529d78e682bbce0ec1962c8f0cb178ebaf93593d8a4dd896ecfb9e3add9b2d5afa2274a6c9cd45e870ae8ba3d636a877d9db1f7b81d85d47df48876807d918274e073467adf67590ee8c00ab46074b6a649b4c6eab04619950562c5d2d6c3ac4f29304533ec3629a54159b8e520f059e2a6e177ecdef1b9ef8ab37a89135557dc19bdec99899788541a3afada7bf3ac1e0eb71623d9a67eb0ccf388e23a7b980ed915b1a52bd617bb45b8cfd6eab62da47186eb9e2c86acabc3864781296eb1b8976ebf3b2d1905edb4bd9f5d2c357a0542a8c6f49e860fe9a113da6206836b13abaa01017a03d3ba2a7bd5b4e89fca1649a27db6eea93e4d7de58c02246b520ed24e3ab00837979d3a4be898373216ce7ad2be5032ceb603475316425b39509eeeed29dadc4cc938fb8adc1a155db47455679f96276391a61175c48a746ba41d93d9749a0edd5540ee70aeaca0ccd39b0d783a0ef03658f8d458baa419a7b3b2272d7d91f28674be7c32a555d5748e4af70ee04815b20d8e367cbe0caf409decdd7a37fd61374294f2d82ea12878e7978e071323f6610e95139104da924d8fbffeab4fff7d7b98c547ba7876c4cee5bca7ae375f79ae7538332a8c6fb6bb76e49ea463456b925a3b6e9d7ef9b2ecfce5afef1e7f7ffffae3fe9c7fcf0ff7c23f1eb9f7e77979f9f2daa125fd6e1f88e1a0e39334ab7c47eb5a0c3bb237df90f9d3a75f3e3e1364e8b7d7381ff7fcfae5cdcbf89b7ffcb7fcf07ee7eba7cffffbf77bb8e0f1dbbb3f0e96be054bdf0996367c0596bf056b7f28ac7c0bb6fe5058fd16ecfe4361ed5bb0f3bd60977c05d6bf019bfa87c2c6b760f30f855ddf82fd6e67fb55d8fc16ec773b5b8dafc0d63760cbff50d8fe16ec77ebdbafc2ee6fc0f6771347fc9a26cfb760bf5b037d15f67c0bf6bb69f2d76015be05db8fdffe75fefe3ee26b6d645456b59aa377ef6c44769c24573b543abee5002ad7388b2c4bf3544baa107efcf6db7f060000ffff40acd52957190000',
},
},
],
});

const balances = await messenger.call(
`AssetsContractController:getBalancesInSingleCall`,
ERC20_SAI_ADDRESS,
[ERC20_SAI_ADDRESS],
'mainnet',
);
expect(balances[ERC20_SAI_ADDRESS]).toBeDefined();

const noBalances = await messenger.call(
`AssetsContractController:getBalancesInSingleCall`,
ERC20_SAI_ADDRESS,
[ERC20_SAI_ADDRESS],
'sepolia',
);
expect(noBalances).toStrictEqual({});
messenger.clearEventSubscriptions('NetworkController:networkDidChange');
});

it('should throw error when transferring single ERC-1155 when networkClientId is invalid', async () => {
const { messenger } = await setupAssetContractControllers();
await expect(
Expand Down
76 changes: 74 additions & 2 deletions packages/assets-controllers/src/TokenBalancesController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,18 @@ const { safelyExecuteWithTimeout } = jest.requireMock(
);
const mockedSafelyExecuteWithTimeout = safelyExecuteWithTimeout as jest.Mock;

type SetupControllerConfig = Partial<
ConstructorParameters<typeof TokenBalancesController>[0]
> & {
mockBearerToken?: string;
};

const setupController = ({
config,
tokens = { allTokens: {}, allDetectedTokens: {}, allIgnoredTokens: {} },
listAccounts = [],
}: {
config?: Partial<ConstructorParameters<typeof TokenBalancesController>[0]>;
config?: SetupControllerConfig;
tokens?: Partial<TokensControllerState>;
listAccounts?: InternalAccount[];
} = {}) => {
Expand Down Expand Up @@ -95,6 +101,7 @@ const setupController = ({
'AccountTrackerController:getState',
'AccountTrackerController:updateNativeBalances',
'AccountTrackerController:updateStakedBalances',
'AuthenticationController:getBearerToken',
],
events: [
'NetworkController:stateChange',
Expand Down Expand Up @@ -192,9 +199,18 @@ const setupController = ({
getBlockNumber: jest.fn().mockResolvedValue(1),
}),
);

messenger.registerActionHandler(
'AuthenticationController:getBearerToken',
jest.fn().mockResolvedValue(config?.mockBearerToken ?? 'mock-jwt-token'),
);

// Extract mockBearerToken from config before passing to controller
const { mockBearerToken, ...controllerConfig } = config || {};

const controller = new TokenBalancesController({
messenger: tokenBalancesControllerMessenger,
...config,
...controllerConfig,
});
const updateSpy = jest.spyOn(controller, 'update' as never);

Expand Down Expand Up @@ -4314,6 +4330,62 @@ describe('TokenBalancesController', () => {
// @ts-expect-error - deleting global fetch for test cleanup
delete global.fetch;
});

it('should pass JWT token to AccountsApiBalanceFetcher fetch method', async () => {
const chainId1 = '0x1';
const accountAddress = '0x1234567890123456789012345678901234567890';
const mockJwtToken = 'test-jwt-token-67890';

// Create mock account for testing
const account = createMockInternalAccount({ address: accountAddress });

// Mock AccountsApiBalanceFetcher to capture fetch calls
const mockApiFetch = jest.fn().mockResolvedValue([
{
success: true,
value: new BN('1000000000000000000'),
account: accountAddress,
token: NATIVE_TOKEN_ADDRESS,
chainId: chainId1,
},
]);

const apiBalanceFetcher = jest.requireActual(
'./multi-chain-accounts-service/api-balance-fetcher',
);

const fetchSpy = jest
.spyOn(apiBalanceFetcher.AccountsApiBalanceFetcher.prototype, 'fetch')
.mockImplementation(mockApiFetch);

const { controller } = setupController({
config: {
accountsApiChainIds: () => [chainId1],
allowExternalServices: () => true,
mockBearerToken: mockJwtToken,
},
listAccounts: [account],
});

await controller.updateBalances({
chainIds: [chainId1],
queryAllAccounts: true,
});

// Verify fetch was called with JWT token
expect(mockApiFetch).toHaveBeenCalledWith(
expect.objectContaining({
chainIds: [chainId1],
queryAllAccounts: true,
selectedAccount: expect.any(String),
allAccounts: expect.any(Array),
jwtToken: mockJwtToken,
}),
);

// Clean up
fetchSpy.mockRestore();
});
});

describe('AccountActivityService integration', () => {
Expand Down
Loading
Loading