From d179d14e0c5a54ac327596e628189aba617618c9 Mon Sep 17 00:00:00 2001 From: salimtb Date: Fri, 14 Nov 2025 23:55:30 +0100 Subject: [PATCH 1/9] feat: Add optional JWT token authentication to multi-chain accounts API --- packages/assets-controllers/package.json | 2 + .../src/TokenBalancesController.test.ts | 8 ++ .../src/TokenBalancesController.ts | 14 ++- .../src/TokenDetectionController.test.ts | 114 ++++++------------ .../src/TokenDetectionController.ts | 27 ++++- .../api-balance-fetcher.test.ts | 4 + .../api-balance-fetcher.ts | 8 +- .../multi-chain-accounts.test.ts | 75 ++++++++++++ .../multi-chain-accounts.ts | 29 ++++- yarn.lock | 2 + 10 files changed, 196 insertions(+), 87 deletions(-) diff --git a/packages/assets-controllers/package.json b/packages/assets-controllers/package.json index dd767e6f23a..9d4dd7776a2 100644 --- a/packages/assets-controllers/package.json +++ b/packages/assets-controllers/package.json @@ -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.3.0", @@ -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", diff --git a/packages/assets-controllers/src/TokenBalancesController.test.ts b/packages/assets-controllers/src/TokenBalancesController.test.ts index 085b356ae43..8ffc11f63b1 100644 --- a/packages/assets-controllers/src/TokenBalancesController.test.ts +++ b/packages/assets-controllers/src/TokenBalancesController.test.ts @@ -60,6 +60,13 @@ const { safelyExecuteWithTimeout } = jest.requireMock( ); const mockedSafelyExecuteWithTimeout = safelyExecuteWithTimeout as jest.Mock; + +type SetupControllerConfig = Partial< + ConstructorParameters[0] +> & { + mockBearerToken?: string; +}; + const setupController = ({ config, tokens = { allTokens: {}, allDetectedTokens: {}, allIgnoredTokens: {} }, @@ -95,6 +102,7 @@ const setupController = ({ 'AccountTrackerController:getState', 'AccountTrackerController:updateNativeBalances', 'AccountTrackerController:updateStakedBalances', + 'AuthenticationController:getBearerToken', ], events: [ 'NetworkController:stateChange', diff --git a/packages/assets-controllers/src/TokenBalancesController.ts b/packages/assets-controllers/src/TokenBalancesController.ts index f8bfed95b13..ec0ece978f8 100644 --- a/packages/assets-controllers/src/TokenBalancesController.ts +++ b/packages/assets-controllers/src/TokenBalancesController.ts @@ -11,6 +11,7 @@ import type { import { BNToHex, isValidHexAddress, + safelyExecuteWithTimeout, toChecksumHexAddress, toHex, } from '@metamask/controller-utils'; @@ -32,6 +33,7 @@ import type { PreferencesControllerGetStateAction, PreferencesControllerStateChangeEvent, } from '@metamask/preferences-controller'; +import type { AuthenticationController } from '@metamask/profile-sync-controller'; import type { Hex } from '@metamask/utils'; import { isCaipAssetType, @@ -130,7 +132,8 @@ export type AllowedActions = | AccountsControllerListAccountsAction | AccountTrackerControllerGetStateAction | AccountTrackerUpdateNativeBalancesAction - | AccountTrackerUpdateStakedBalancesAction; + | AccountTrackerUpdateStakedBalancesAction + | AuthenticationController.AuthenticationControllerGetBearerToken; export type AllowedEvents = | TokensControllerStateChangeEvent @@ -640,6 +643,14 @@ export class TokenBalancesController extends StaticIntervalPollingController<{ ); const allAccounts = this.messenger.call('AccountsController:listAccounts'); + const jwtToken = await safelyExecuteWithTimeout( + () => { + return this.messenger.call('AuthenticationController:getBearerToken'); + }, + false, + 5000, + ); + const aggregated: ProcessedBalance[] = []; let remainingChains = [...targetChains]; @@ -658,6 +669,7 @@ export class TokenBalancesController extends StaticIntervalPollingController<{ queryAllAccounts: queryAllAccounts ?? this.#queryAllAccounts, selectedAccount: selected as ChecksumAddress, allAccounts, + jwtToken, }); if (result.balances && result.balances.length > 0) { diff --git a/packages/assets-controllers/src/TokenDetectionController.test.ts b/packages/assets-controllers/src/TokenDetectionController.test.ts index 15d8d6f59cc..bf5de501976 100644 --- a/packages/assets-controllers/src/TokenDetectionController.test.ts +++ b/packages/assets-controllers/src/TokenDetectionController.test.ts @@ -204,6 +204,7 @@ function buildTokenDetectionControllerMessenger( 'PreferencesController:getState', 'TokensController:addTokens', 'NetworkController:findNetworkClientIdByChainId', + 'AuthenticationController:getBearerToken', ], events: [ 'AccountsController:selectedEvmAccountChange', @@ -3748,15 +3749,7 @@ describe('TokenDetectionController', () => { options: { disabled: false, }, - }, - async ({ - controller, - mockTokenListGetState, - callActionSpy, - triggerTokenListStateChange, - }) => { - const tokenListState = { - ...getDefaultTokenListState(), + mockTokenListState: { tokensChainsCache: { [chainId]: { timestamp: 0, @@ -3773,11 +3766,9 @@ describe('TokenDetectionController', () => { }, }, }, - }; - - mockTokenListGetState(tokenListState); - triggerTokenListStateChange(tokenListState); - + }, + }, + async ({ controller, callActionSpy }) => { await controller.addDetectedTokensViaWs({ tokensSlice: [mockTokenAddress], chainId: chainId as Hex, @@ -3813,27 +3804,16 @@ describe('TokenDetectionController', () => { options: { disabled: false, }, - }, - async ({ - controller, - mockTokenListGetState, - callActionSpy, - triggerTokenListStateChange, - }) => { - // Empty token cache - token not found - const tokenListState = { - ...getDefaultTokenListState(), + mockTokenListState: { tokensChainsCache: { [chainId]: { timestamp: 0, data: {}, }, }, - }; - - mockTokenListGetState(tokenListState); - triggerTokenListStateChange(tokenListState); - + }, + }, + async ({ controller, callActionSpy }) => { await controller.addDetectedTokensViaWs({ tokensSlice: [mockTokenAddress], chainId: chainId as Hex, @@ -3877,16 +3857,7 @@ describe('TokenDetectionController', () => { getSelectedAccount: selectedAccount, getAccount: selectedAccount, }, - }, - async ({ - controller, - mockTokenListGetState, - callActionSpy, - triggerTokenListStateChange, - }) => { - // Set up token list with both tokens - const tokenListState = { - ...getDefaultTokenListState(), + mockTokenListState: { tokensChainsCache: { [chainId]: { timestamp: 0, @@ -3912,11 +3883,9 @@ describe('TokenDetectionController', () => { }, }, }, - }; - - mockTokenListGetState(tokenListState); - triggerTokenListStateChange(tokenListState); - + }, + }, + async ({ controller, callActionSpy }) => { // Add both tokens via websocket await controller.addDetectedTokensViaWs({ tokensSlice: [mockTokenAddress, secondTokenAddress], @@ -3965,15 +3934,7 @@ describe('TokenDetectionController', () => { disabled: false, trackMetaMetricsEvent: mockTrackMetricsEvent, }, - }, - async ({ - controller, - mockTokenListGetState, - callActionSpy, - triggerTokenListStateChange, - }) => { - const tokenListState = { - ...getDefaultTokenListState(), + mockTokenListState: { tokensChainsCache: { [chainId]: { timestamp: 0, @@ -3990,11 +3951,9 @@ describe('TokenDetectionController', () => { }, }, }, - }; - - mockTokenListGetState(tokenListState); - triggerTokenListStateChange(tokenListState); - + }, + }, + async ({ controller, callActionSpy }) => { await controller.addDetectedTokensViaWs({ tokensSlice: [mockTokenAddress], chainId: chainId as Hex, @@ -4031,15 +3990,7 @@ describe('TokenDetectionController', () => { options: { disabled: false, }, - }, - async ({ - controller, - mockTokenListGetState, - callActionSpy, - triggerTokenListStateChange, - }) => { - const tokenListState = { - ...getDefaultTokenListState(), + mockTokenListState: { tokensChainsCache: { [chainId]: { timestamp: 0, @@ -4056,11 +4007,9 @@ describe('TokenDetectionController', () => { }, }, }, - }; - - mockTokenListGetState(tokenListState); - triggerTokenListStateChange(tokenListState); - + }, + }, + async ({ controller, callActionSpy }) => { // Call the public method directly on the controller instance await controller.addDetectedTokensViaWs({ tokensSlice: [mockTokenAddress], @@ -4157,7 +4106,9 @@ type WithControllerOptions = { mocks?: { getAccount?: InternalAccount; getSelectedAccount?: InternalAccount; + getBearerToken?: string; }; + mockTokenListState?: Partial; }; type WithControllerArgs = @@ -4177,7 +4128,7 @@ async function withController( ...args: WithControllerArgs ): Promise { const [{ ...rest }, fn] = args.length === 2 ? args : [{}, args[0]]; - const { options, isKeyringUnlocked, mocks } = rest; + const { options, isKeyringUnlocked, mocks, mockTokenListState } = rest; const messenger = buildRootMessenger(); const mockGetAccount = jest.fn(); @@ -4240,10 +4191,13 @@ async function withController( 'TokensController:getState', mockTokensState.mockReturnValue({ ...getDefaultTokensState() }), ); - const mockTokenListState = jest.fn(); + const mockTokenListStateFunc = jest.fn(); messenger.registerActionHandler( 'TokenListController:getState', - mockTokenListState.mockReturnValue({ ...getDefaultTokenListState() }), + mockTokenListStateFunc.mockReturnValue({ + ...getDefaultTokenListState(), + ...mockTokenListState, + }), ); const mockPreferencesState = jest.fn(); messenger.registerActionHandler( @@ -4253,6 +4207,14 @@ async function withController( }), ); + const mockGetBearerToken = jest.fn, []>(); + messenger.registerActionHandler( + 'AuthenticationController:getBearerToken', + mockGetBearerToken.mockResolvedValue( + mocks?.getBearerToken ?? 'mock-jwt-token', + ), + ); + const mockFindNetworkClientIdByChainId = jest.fn(); messenger.registerActionHandler( 'NetworkController:findNetworkClientIdByChainId', @@ -4312,7 +4274,7 @@ async function withController( mockPreferencesState.mockReturnValue(state); }, mockTokenListGetState: (state: TokenListState) => { - mockTokenListState.mockReturnValue(state); + mockTokenListStateFunc.mockReturnValue(state); }, mockGetNetworkClientById: ( handler: ( diff --git a/packages/assets-controllers/src/TokenDetectionController.ts b/packages/assets-controllers/src/TokenDetectionController.ts index cd3193d5d77..3154b768329 100644 --- a/packages/assets-controllers/src/TokenDetectionController.ts +++ b/packages/assets-controllers/src/TokenDetectionController.ts @@ -23,6 +23,7 @@ import type { KeyringControllerLockEvent, KeyringControllerUnlockEvent, } from '@metamask/keyring-controller'; +import type { AuthenticationController } from '@metamask/profile-sync-controller'; import type { Messenger } from '@metamask/messenger'; import type { NetworkClientId, @@ -144,7 +145,8 @@ export type AllowedActions = | TokensControllerGetStateAction | TokensControllerAddDetectedTokensAction | TokensControllerAddTokensAction - | NetworkControllerFindNetworkClientIdByChainIdAction; + | NetworkControllerFindNetworkClientIdByChainIdAction + | AuthenticationController.AuthenticationControllerGetBearerToken; export type TokenDetectionControllerStateChangeEvent = ControllerStateChangeEvent; @@ -247,6 +249,7 @@ export class TokenDetectionController extends StaticIntervalPollingController hexToNumber(chainId)); @@ -266,6 +269,7 @@ export class TokenDetectionController extends StaticIntervalPollingController { @@ -613,6 +618,7 @@ export class TokenDetectionController extends StaticIntervalPollingController( + () => { + return this.messenger.call('AuthenticationController:getBearerToken'); + }, + false, + 5000, + ); + let supportedNetworks; if (this.#accountsAPI.isAccountsAPIEnabled && this.#useExternalServices()) { supportedNetworks = await this.#accountsAPI.getSupportedNetworks(); @@ -734,6 +748,7 @@ export class TokenDetectionController extends StaticIntervalPollingController { // Fetch balances for multiple chain IDs at once const apiResponse = await this.#accountsAPI - .getMultiNetworksBalances(selectedAddress, chainIds, supportedNetworks) + .getMultiNetworksBalances( + selectedAddress, + chainIds, + supportedNetworks, + jwtToken, + ) .catch(() => null); if (apiResponse === null) { diff --git a/packages/assets-controllers/src/multi-chain-accounts-service/api-balance-fetcher.test.ts b/packages/assets-controllers/src/multi-chain-accounts-service/api-balance-fetcher.test.ts index 660b2a47af9..9125ab19efa 100644 --- a/packages/assets-controllers/src/multi-chain-accounts-service/api-balance-fetcher.test.ts +++ b/packages/assets-controllers/src/multi-chain-accounts-service/api-balance-fetcher.test.ts @@ -358,6 +358,7 @@ describe('AccountsApiBalanceFetcher', () => { ], }, 'extension', + undefined, ); expect(result.balances).toHaveLength(2); @@ -395,6 +396,7 @@ describe('AccountsApiBalanceFetcher', () => { ], }, 'extension', + undefined, ); expect(result.balances).toHaveLength(3); @@ -716,6 +718,7 @@ describe('AccountsApiBalanceFetcher', () => { ], }, 'extension', + undefined, ); }); @@ -737,6 +740,7 @@ describe('AccountsApiBalanceFetcher', () => { ], }, 'mobile', + undefined, ); }); }); diff --git a/packages/assets-controllers/src/multi-chain-accounts-service/api-balance-fetcher.ts b/packages/assets-controllers/src/multi-chain-accounts-service/api-balance-fetcher.ts index 80f5df7591d..f605249ba71 100644 --- a/packages/assets-controllers/src/multi-chain-accounts-service/api-balance-fetcher.ts +++ b/packages/assets-controllers/src/multi-chain-accounts-service/api-balance-fetcher.ts @@ -49,6 +49,7 @@ export type BalanceFetcher = { queryAllAccounts: boolean; selectedAccount: ChecksumAddress; allAccounts: InternalAccount[]; + jwtToken?: string; }): Promise; }; @@ -211,12 +212,13 @@ export class AccountsApiBalanceFetcher implements BalanceFetcher { return results; } - async #fetchBalances(addrs: CaipAccountAddress[]) { + async #fetchBalances(addrs: CaipAccountAddress[], jwtToken?: string) { // If we have fewer than or equal to the batch size, make a single request if (addrs.length <= ACCOUNTS_API_BATCH_SIZE) { return await fetchMultiChainBalancesV4( { accountAddresses: addrs }, this.#platform, + jwtToken, ); } @@ -238,6 +240,7 @@ export class AccountsApiBalanceFetcher implements BalanceFetcher { const response = await fetchMultiChainBalancesV4( { accountAddresses: batch }, this.#platform, + jwtToken, ); // Collect unprocessed networks from each batch if (response.unprocessedNetworks) { @@ -261,6 +264,7 @@ export class AccountsApiBalanceFetcher implements BalanceFetcher { queryAllAccounts, selectedAccount, allAccounts, + jwtToken, }: Parameters[0]): Promise { const caipAddrs: CaipAccountAddress[] = []; @@ -281,7 +285,7 @@ export class AccountsApiBalanceFetcher implements BalanceFetcher { // Let errors propagate to TokenBalancesController for RPC fallback // Use timeout to prevent hanging API calls (30 seconds) const apiResponse = await safelyExecuteWithTimeout( - () => this.#fetchBalances(caipAddrs), + () => this.#fetchBalances(caipAddrs, jwtToken), false, // don't log error here, let it propagate ACCOUNTS_API_TIMEOUT_MS, ); diff --git a/packages/assets-controllers/src/multi-chain-accounts-service/multi-chain-accounts.test.ts b/packages/assets-controllers/src/multi-chain-accounts-service/multi-chain-accounts.test.ts index d6dd686ad03..60d6c5c9d2a 100644 --- a/packages/assets-controllers/src/multi-chain-accounts-service/multi-chain-accounts.test.ts +++ b/packages/assets-controllers/src/multi-chain-accounts-service/multi-chain-accounts.test.ts @@ -57,6 +57,33 @@ describe('fetchMultiChainBalances()', () => { expect(mockAPI.isDone()).toBe(true); }); + + it('should include JWT token in Authorization header when provided', async () => { + const mockJwtToken = 'test-jwt-token-123'; + const mockAPI = createMockAPI() + .matchHeader('authorization', `Bearer ${mockJwtToken}`) + .reply(200, MOCK_GET_BALANCES_RESPONSE); + + const result = await fetchMultiChainBalances( + MOCK_ADDRESS, + {}, + 'extension', + mockJwtToken, + ); + expect(result).toBeDefined(); + expect(result).toStrictEqual(MOCK_GET_BALANCES_RESPONSE); + expect(mockAPI.isDone()).toBe(true); + }); + + it('should work without JWT token when not provided', async () => { + const mockAPI = createMockAPI().reply(200, MOCK_GET_BALANCES_RESPONSE); + + const result = await fetchMultiChainBalances(MOCK_ADDRESS, {}, 'extension'); + expect(result).toBeDefined(); + expect(result).toStrictEqual(MOCK_GET_BALANCES_RESPONSE); + expect(mockAPI.isDone()).toBe(true); + }); + it('should successfully return balances response with query params to refine search', async () => { const mockAPI = createMockAPI() .query({ @@ -118,6 +145,54 @@ describe('fetchMultiChainBalancesV4()', () => { expect(mockAPI.isDone()).toBe(true); }); + it('should include JWT token in Authorization header when provided', async () => { + const mockJwtToken = 'test-jwt-token-v4-456'; + const mockAPI = createMockAPI() + .matchHeader('authorization', `Bearer ${mockJwtToken}`) + .reply(200, MOCK_GET_BALANCES_RESPONSE); + + const result = await fetchMultiChainBalancesV4( + {}, + 'extension', + mockJwtToken, + ); + expect(result).toBeDefined(); + expect(result).toStrictEqual(MOCK_GET_BALANCES_RESPONSE); + expect(mockAPI.isDone()).toBe(true); + }); + + it('should work without JWT token when not provided', async () => { + const mockAPI = createMockAPI().reply(200, MOCK_GET_BALANCES_RESPONSE); + + const result = await fetchMultiChainBalancesV4({}, 'extension'); + expect(result).toBeDefined(); + expect(result).toStrictEqual(MOCK_GET_BALANCES_RESPONSE); + expect(mockAPI.isDone()).toBe(true); + }); + + it('should include JWT token with account addresses and networks', async () => { + const mockJwtToken = 'test-jwt-token-v4-789'; + const mockAPI = createMockAPI() + .query({ + networks: '1,137', + accountAddresses: MOCK_CAIP_ADDRESSES.join(), + }) + .matchHeader('authorization', `Bearer ${mockJwtToken}`) + .reply(200, MOCK_GET_BALANCES_RESPONSE); + + const result = await fetchMultiChainBalancesV4( + { + accountAddresses: MOCK_CAIP_ADDRESSES, + networks: [1, 137], + }, + 'extension', + mockJwtToken, + ); + expect(result).toBeDefined(); + expect(result).toStrictEqual(MOCK_GET_BALANCES_RESPONSE); + expect(mockAPI.isDone()).toBe(true); + }); + it('should successfully return balances response with account addresses', async () => { const mockAPI = createMockAPI() .query({ diff --git a/packages/assets-controllers/src/multi-chain-accounts-service/multi-chain-accounts.ts b/packages/assets-controllers/src/multi-chain-accounts-service/multi-chain-accounts.ts index 067c6130190..14c52d44823 100644 --- a/packages/assets-controllers/src/multi-chain-accounts-service/multi-chain-accounts.ts +++ b/packages/assets-controllers/src/multi-chain-accounts-service/multi-chain-accounts.ts @@ -57,20 +57,29 @@ export async function fetchSupportedNetworks(): Promise { * @param options - params to pass down for a more refined search * @param options.networks - the networks (in decimal) that you want to filter by * @param platform - indicates whether the platform is extension or mobile + * @param jwtToken - JWT token for authentication * @returns a Balances Response */ export async function fetchMultiChainBalances( address: string, options: { networks?: number[] }, platform: 'extension' | 'mobile', + jwtToken?: string, ) { const url = getBalancesUrl(address, { networks: options?.networks?.join(), }); + + const headers: Record = { + 'x-metamask-clientproduct': `metamask-${platform}`, + }; + + if (jwtToken) { + headers.Authorization = `Bearer ${jwtToken}`; + } + const response: GetBalancesResponse = await handleFetch(url, { - headers: { - 'x-metamask-clientproduct': `metamask-${platform}`, - }, + headers, }); return response; } @@ -82,21 +91,29 @@ export async function fetchMultiChainBalances( * @param options.accountAddresses - the account addresses that you want to filter by * @param options.networks - the networks (in decimal) that you want to filter by * @param platform - indicates whether the platform is extension or mobile + * @param jwtToken - JWT token for authentication * @returns a Balances Response */ export async function fetchMultiChainBalancesV4( options: { accountAddresses?: CaipAccountAddress[]; networks?: number[] }, platform: 'extension' | 'mobile', + jwtToken?: string, ) { const url = getBalancesUrlV4({ accountAddresses: options?.accountAddresses?.join(), networks: options?.networks?.join(), }); + const headers: Record = { + 'x-metamask-clientproduct': `metamask-${platform}`, + }; + + if (jwtToken) { + headers.Authorization = `Bearer ${jwtToken}`; + } + const response: GetBalancesResponse = await handleFetch(url, { - headers: { - 'x-metamask-clientproduct': `metamask-${platform}`, - }, + headers, }); return response; } diff --git a/yarn.lock b/yarn.lock index b6e8e39bf13..ebb751d097d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2666,6 +2666,7 @@ __metadata: "@metamask/phishing-controller": "npm:^15.0.0" "@metamask/polling-controller": "npm:^15.0.0" "@metamask/preferences-controller": "npm:^21.0.0" + "@metamask/profile-sync-controller": "npm:^26.0.0" "@metamask/providers": "npm:^22.1.0" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/snaps-controllers": "npm:^14.0.1" @@ -2708,6 +2709,7 @@ __metadata: "@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 From db4050022f1a4fecba5468a33fbd26dab46fc4e5 Mon Sep 17 00:00:00 2001 From: salimtb Date: Sat, 15 Nov 2025 00:00:13 +0100 Subject: [PATCH 2/9] fix: add changelog --- packages/assets-controllers/CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index 53e6242bad2..3b2899acb21 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -7,6 +7,15 @@ 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 ([#7165](https://github.com/MetaMask/core/pull/7165)) + - `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 ` header when provided + - Backward compatible: token parameter is optional and APIs work without authentication + ### Fixed - Enable RPC fallback when Accounts API fails or times out in `TokenBalancesController` ([#7155](https://github.com/MetaMask/core/pull/7155)) From 81857e911a5e2c0a81eb1b76b111530f27440e36 Mon Sep 17 00:00:00 2001 From: salimtb Date: Sat, 15 Nov 2025 00:07:55 +0100 Subject: [PATCH 3/9] fix: fix linter --- .../src/TokenBalancesController.test.ts | 7 ----- .../src/TokenDetectionController.ts | 2 +- .../multi-chain-accounts.test.ts | 27 ------------------- 3 files changed, 1 insertion(+), 35 deletions(-) diff --git a/packages/assets-controllers/src/TokenBalancesController.test.ts b/packages/assets-controllers/src/TokenBalancesController.test.ts index 8ffc11f63b1..19bb6dd9f4f 100644 --- a/packages/assets-controllers/src/TokenBalancesController.test.ts +++ b/packages/assets-controllers/src/TokenBalancesController.test.ts @@ -60,13 +60,6 @@ const { safelyExecuteWithTimeout } = jest.requireMock( ); const mockedSafelyExecuteWithTimeout = safelyExecuteWithTimeout as jest.Mock; - -type SetupControllerConfig = Partial< - ConstructorParameters[0] -> & { - mockBearerToken?: string; -}; - const setupController = ({ config, tokens = { allTokens: {}, allDetectedTokens: {}, allIgnoredTokens: {} }, diff --git a/packages/assets-controllers/src/TokenDetectionController.ts b/packages/assets-controllers/src/TokenDetectionController.ts index 3154b768329..e3cfde240fc 100644 --- a/packages/assets-controllers/src/TokenDetectionController.ts +++ b/packages/assets-controllers/src/TokenDetectionController.ts @@ -23,7 +23,6 @@ import type { KeyringControllerLockEvent, KeyringControllerUnlockEvent, } from '@metamask/keyring-controller'; -import type { AuthenticationController } from '@metamask/profile-sync-controller'; import type { Messenger } from '@metamask/messenger'; import type { NetworkClientId, @@ -38,6 +37,7 @@ import type { PreferencesControllerGetStateAction, PreferencesControllerStateChangeEvent, } from '@metamask/preferences-controller'; +import type { AuthenticationController } from '@metamask/profile-sync-controller'; import type { TransactionControllerTransactionConfirmedEvent } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; import { hexToNumber } from '@metamask/utils'; diff --git a/packages/assets-controllers/src/multi-chain-accounts-service/multi-chain-accounts.test.ts b/packages/assets-controllers/src/multi-chain-accounts-service/multi-chain-accounts.test.ts index 60d6c5c9d2a..184b06c6934 100644 --- a/packages/assets-controllers/src/multi-chain-accounts-service/multi-chain-accounts.test.ts +++ b/packages/assets-controllers/src/multi-chain-accounts-service/multi-chain-accounts.test.ts @@ -57,33 +57,6 @@ describe('fetchMultiChainBalances()', () => { expect(mockAPI.isDone()).toBe(true); }); - - it('should include JWT token in Authorization header when provided', async () => { - const mockJwtToken = 'test-jwt-token-123'; - const mockAPI = createMockAPI() - .matchHeader('authorization', `Bearer ${mockJwtToken}`) - .reply(200, MOCK_GET_BALANCES_RESPONSE); - - const result = await fetchMultiChainBalances( - MOCK_ADDRESS, - {}, - 'extension', - mockJwtToken, - ); - expect(result).toBeDefined(); - expect(result).toStrictEqual(MOCK_GET_BALANCES_RESPONSE); - expect(mockAPI.isDone()).toBe(true); - }); - - it('should work without JWT token when not provided', async () => { - const mockAPI = createMockAPI().reply(200, MOCK_GET_BALANCES_RESPONSE); - - const result = await fetchMultiChainBalances(MOCK_ADDRESS, {}, 'extension'); - expect(result).toBeDefined(); - expect(result).toStrictEqual(MOCK_GET_BALANCES_RESPONSE); - expect(mockAPI.isDone()).toBe(true); - }); - it('should successfully return balances response with query params to refine search', async () => { const mockAPI = createMockAPI() .query({ From 454368b9b1b5c33a430454d6bde314e4ff33b105 Mon Sep 17 00:00:00 2001 From: salimtb Date: Wed, 19 Nov 2025 22:40:43 +0100 Subject: [PATCH 4/9] fix: reduce timeout to 10s --- packages/assets-controllers/src/TokenDetectionController.ts | 2 +- .../src/multi-chain-accounts-service/api-balance-fetcher.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/assets-controllers/src/TokenDetectionController.ts b/packages/assets-controllers/src/TokenDetectionController.ts index e3cfde240fc..066f7a21a4d 100644 --- a/packages/assets-controllers/src/TokenDetectionController.ts +++ b/packages/assets-controllers/src/TokenDetectionController.ts @@ -63,7 +63,7 @@ import type { } from './TokensController'; const DEFAULT_INTERVAL = 180000; -const ACCOUNTS_API_TIMEOUT_MS = 30000; +const ACCOUNTS_API_TIMEOUT_MS = 10000; type LegacyToken = { name: string; diff --git a/packages/assets-controllers/src/multi-chain-accounts-service/api-balance-fetcher.ts b/packages/assets-controllers/src/multi-chain-accounts-service/api-balance-fetcher.ts index f605249ba71..8221606cdfa 100644 --- a/packages/assets-controllers/src/multi-chain-accounts-service/api-balance-fetcher.ts +++ b/packages/assets-controllers/src/multi-chain-accounts-service/api-balance-fetcher.ts @@ -23,8 +23,8 @@ import { SUPPORTED_NETWORKS_ACCOUNTS_API_V4 } from '../constants'; // Maximum number of account addresses that can be sent to the accounts API in a single request const ACCOUNTS_API_BATCH_SIZE = 50; -// Timeout for accounts API requests (30 seconds) -const ACCOUNTS_API_TIMEOUT_MS = 30_000; +// Timeout for accounts API requests (10 seconds) +const ACCOUNTS_API_TIMEOUT_MS = 10_000; export type ChainIdHex = Hex; export type ChecksumAddress = Hex; From 313f5066c90ea67d42b428ad518475c227d12506 Mon Sep 17 00:00:00 2001 From: salimtb Date: Wed, 19 Nov 2025 22:41:13 +0100 Subject: [PATCH 5/9] fix: update changelog --- packages/assets-controllers/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index 3b2899acb21..6c9f4378e10 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add optional JWT token authentication to multi-chain accounts API calls ([#7165](https://github.com/MetaMask/core/pull/7165)) +- **BREAKING:** Add optional JWT token authentication to multi-chain accounts API calls ([#7165](https://github.com/MetaMask/core/pull/7165)) - `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 From 5910a4c3720a3bf8d8ff3003b2970dbe5b0ee351 Mon Sep 17 00:00:00 2001 From: salimtb Date: Thu, 20 Nov 2025 20:23:53 +0100 Subject: [PATCH 6/9] fix: reduce batch size to 20 --- .../src/multi-chain-accounts-service/api-balance-fetcher.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/assets-controllers/src/multi-chain-accounts-service/api-balance-fetcher.ts b/packages/assets-controllers/src/multi-chain-accounts-service/api-balance-fetcher.ts index 8221606cdfa..6319989a47c 100644 --- a/packages/assets-controllers/src/multi-chain-accounts-service/api-balance-fetcher.ts +++ b/packages/assets-controllers/src/multi-chain-accounts-service/api-balance-fetcher.ts @@ -21,7 +21,7 @@ import { import { SUPPORTED_NETWORKS_ACCOUNTS_API_V4 } from '../constants'; // Maximum number of account addresses that can be sent to the accounts API in a single request -const ACCOUNTS_API_BATCH_SIZE = 50; +const ACCOUNTS_API_BATCH_SIZE = 20; // Timeout for accounts API requests (10 seconds) const ACCOUNTS_API_TIMEOUT_MS = 10_000; From 7e9effe47c334641d89f01c2e89621bf2fd1ea99 Mon Sep 17 00:00:00 2001 From: salimtb Date: Fri, 21 Nov 2025 01:20:42 +0100 Subject: [PATCH 7/9] fix: clean up --- packages/assets-controllers/CHANGELOG.md | 4 +--- packages/assets-controllers/package.json | 17 ++-------------- yarn.lock | 26 +++++++++++++++++++++++- 3 files changed, 28 insertions(+), 19 deletions(-) diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index 4f66c9597cd..c9329f0b836 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -7,7 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -<<<<<<< HEAD ### Added - **BREAKING:** Add optional JWT token authentication to multi-chain accounts API calls ([#7165](https://github.com/MetaMask/core/pull/7165)) @@ -16,7 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `TokenBalancesController` fetches and passes JWT token through balance fetcher chain - JWT token is included in `Authorization: Bearer ` header when provided - Backward compatible: token parameter is optional and APIs work without authentication -======= + ## [91.0.0] ### Changed @@ -45,7 +44,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **BREAKING:** Bump `@metamask/core-backend` from `^4.1.0` to `^5.0.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) - **BREAKING:** Bump `@metamask/accounts-controller` from `^34.0.0` to `^35.0.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) - **BREAKING:** Bump `@metamask/account-tree-controller` from `^3.0.0` to `^4.0.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) ->>>>>>> main ## [89.0.1] diff --git a/packages/assets-controllers/package.json b/packages/assets-controllers/package.json index 4753ae52b68..f444aa5d76f 100644 --- a/packages/assets-controllers/package.json +++ b/packages/assets-controllers/package.json @@ -91,20 +91,12 @@ "@metamask/keyring-controller": "^25.0.0", "@metamask/keyring-internal-api": "^9.0.0", "@metamask/keyring-snap-client": "^8.0.0", -<<<<<<< HEAD - "@metamask/multichain-account-service": "^3.0.0", - "@metamask/network-controller": "^25.0.0", - "@metamask/permission-controller": "^12.1.0", - "@metamask/phishing-controller": "^15.0.1", - "@metamask/preferences-controller": "^21.0.0", - "@metamask/profile-sync-controller": "^26.0.0", -======= "@metamask/multichain-account-service": "^4.0.0", "@metamask/network-controller": "^26.0.0", "@metamask/permission-controller": "^12.1.1", "@metamask/phishing-controller": "^16.0.0", "@metamask/preferences-controller": "^22.0.0", ->>>>>>> main + "@metamask/profile-sync-controller": "^26.0.0", "@metamask/providers": "^22.1.0", "@metamask/snaps-controllers": "^14.0.1", "@metamask/transaction-controller": "^62.0.0", @@ -131,14 +123,9 @@ "@metamask/keyring-controller": "^25.0.0", "@metamask/network-controller": "^26.0.0", "@metamask/permission-controller": "^12.0.0", -<<<<<<< HEAD - "@metamask/phishing-controller": "^15.0.0", - "@metamask/preferences-controller": "^21.0.0", - "@metamask/profile-sync-controller": "^26.0.0", -======= "@metamask/phishing-controller": "^16.0.0", "@metamask/preferences-controller": "^22.0.0", ->>>>>>> main + "@metamask/profile-sync-controller": "^26.0.0", "@metamask/providers": "^22.0.0", "@metamask/snaps-controllers": "^14.0.0", "@metamask/transaction-controller": "^62.0.0", diff --git a/yarn.lock b/yarn.lock index 910bb94d2ee..54463495013 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2665,7 +2665,7 @@ __metadata: "@metamask/permission-controller": "npm:^12.1.1" "@metamask/phishing-controller": "npm:^16.0.0" "@metamask/polling-controller": "npm:^16.0.0" - "@metamask/preferences-controller": "npm:^22.0.0", + "@metamask/preferences-controller": "npm:^22.0.0" "@metamask/profile-sync-controller": "npm:^26.0.0" "@metamask/providers": "npm:^22.1.0" "@metamask/rpc-errors": "npm:^7.0.2" @@ -4566,6 +4566,30 @@ __metadata: languageName: unknown linkType: soft +"@metamask/profile-sync-controller@npm:^26.0.0": + version: 26.0.0 + resolution: "@metamask/profile-sync-controller@npm:26.0.0" + dependencies: + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/messenger": "npm:^0.3.0" + "@metamask/snaps-sdk": "npm:^9.0.0" + "@metamask/snaps-utils": "npm:^11.0.0" + "@metamask/utils": "npm:^11.8.1" + "@noble/ciphers": "npm:^1.3.0" + "@noble/hashes": "npm:^1.8.0" + immer: "npm:^9.0.6" + loglevel: "npm:^1.8.1" + siwe: "npm:^2.3.2" + peerDependencies: + "@metamask/address-book-controller": ^7.0.0 + "@metamask/keyring-controller": ^24.0.0 + "@metamask/providers": ^22.0.0 + "@metamask/snaps-controllers": ^14.0.0 + webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 + checksum: 10/5cff666b961041ef5a38ce42ed6f77173c4d41d9c57574243c0d53169f8cd339920a010e10eafc88c162380ef5aa24269896e0ea01e888ad3b4e3df82bc481ce + languageName: node + linkType: hard + "@metamask/profile-sync-controller@npm:^27.0.0, @metamask/profile-sync-controller@workspace:packages/profile-sync-controller": version: 0.0.0-use.local resolution: "@metamask/profile-sync-controller@workspace:packages/profile-sync-controller" From 71af222a836fc3791f00e080f8129500ffe6b9f1 Mon Sep 17 00:00:00 2001 From: salimtb Date: Fri, 21 Nov 2025 09:55:22 +0100 Subject: [PATCH 8/9] fix: clean up --- packages/assets-controllers/package.json | 4 ++-- yarn.lock | 28 ++---------------------- 2 files changed, 4 insertions(+), 28 deletions(-) diff --git a/packages/assets-controllers/package.json b/packages/assets-controllers/package.json index f444aa5d76f..14f99715c5e 100644 --- a/packages/assets-controllers/package.json +++ b/packages/assets-controllers/package.json @@ -96,7 +96,7 @@ "@metamask/permission-controller": "^12.1.1", "@metamask/phishing-controller": "^16.0.0", "@metamask/preferences-controller": "^22.0.0", - "@metamask/profile-sync-controller": "^26.0.0", + "@metamask/profile-sync-controller": "^27.0.0", "@metamask/providers": "^22.1.0", "@metamask/snaps-controllers": "^14.0.1", "@metamask/transaction-controller": "^62.0.0", @@ -125,7 +125,7 @@ "@metamask/permission-controller": "^12.0.0", "@metamask/phishing-controller": "^16.0.0", "@metamask/preferences-controller": "^22.0.0", - "@metamask/profile-sync-controller": "^26.0.0", + "@metamask/profile-sync-controller": "^27.0.0", "@metamask/providers": "^22.0.0", "@metamask/snaps-controllers": "^14.0.0", "@metamask/transaction-controller": "^62.0.0", diff --git a/yarn.lock b/yarn.lock index 54463495013..875e06973e6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2666,7 +2666,7 @@ __metadata: "@metamask/phishing-controller": "npm:^16.0.0" "@metamask/polling-controller": "npm:^16.0.0" "@metamask/preferences-controller": "npm:^22.0.0" - "@metamask/profile-sync-controller": "npm:^26.0.0" + "@metamask/profile-sync-controller": "npm:^27.0.0" "@metamask/providers": "npm:^22.1.0" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/snaps-controllers": "npm:^14.0.1" @@ -2709,7 +2709,7 @@ __metadata: "@metamask/permission-controller": ^12.0.0 "@metamask/phishing-controller": ^16.0.0 "@metamask/preferences-controller": ^22.0.0 - "@metamask/profile-sync-controller": ^26.0.0 + "@metamask/profile-sync-controller": ^27.0.0 "@metamask/providers": ^22.0.0 "@metamask/snaps-controllers": ^14.0.0 "@metamask/transaction-controller": ^62.0.0 @@ -4566,30 +4566,6 @@ __metadata: languageName: unknown linkType: soft -"@metamask/profile-sync-controller@npm:^26.0.0": - version: 26.0.0 - resolution: "@metamask/profile-sync-controller@npm:26.0.0" - dependencies: - "@metamask/base-controller": "npm:^9.0.0" - "@metamask/messenger": "npm:^0.3.0" - "@metamask/snaps-sdk": "npm:^9.0.0" - "@metamask/snaps-utils": "npm:^11.0.0" - "@metamask/utils": "npm:^11.8.1" - "@noble/ciphers": "npm:^1.3.0" - "@noble/hashes": "npm:^1.8.0" - immer: "npm:^9.0.6" - loglevel: "npm:^1.8.1" - siwe: "npm:^2.3.2" - peerDependencies: - "@metamask/address-book-controller": ^7.0.0 - "@metamask/keyring-controller": ^24.0.0 - "@metamask/providers": ^22.0.0 - "@metamask/snaps-controllers": ^14.0.0 - webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/5cff666b961041ef5a38ce42ed6f77173c4d41d9c57574243c0d53169f8cd339920a010e10eafc88c162380ef5aa24269896e0ea01e888ad3b4e3df82bc481ce - languageName: node - linkType: hard - "@metamask/profile-sync-controller@npm:^27.0.0, @metamask/profile-sync-controller@workspace:packages/profile-sync-controller": version: 0.0.0-use.local resolution: "@metamask/profile-sync-controller@workspace:packages/profile-sync-controller" From 500a696b8536b6ca413d641cd000b0732c7460db Mon Sep 17 00:00:00 2001 From: salimtb Date: Fri, 21 Nov 2025 10:00:36 +0100 Subject: [PATCH 9/9] fix: clean up --- .../multi-chain-accounts-service/api-balance-fetcher.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/assets-controllers/src/multi-chain-accounts-service/api-balance-fetcher.test.ts b/packages/assets-controllers/src/multi-chain-accounts-service/api-balance-fetcher.test.ts index 9125ab19efa..50c31211271 100644 --- a/packages/assets-controllers/src/multi-chain-accounts-service/api-balance-fetcher.test.ts +++ b/packages/assets-controllers/src/multi-chain-accounts-service/api-balance-fetcher.test.ts @@ -501,7 +501,7 @@ describe('AccountsApiBalanceFetcher', () => { expect(mockReduceInBatchesSerially).toHaveBeenCalledWith({ values: caipAddresses, - batchSize: 50, + batchSize: 20, eachBatch: expect.any(Function), initialResult: [], });