diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index 0ee10cc64aa..c9329f0b836 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 + +- **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 + - 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 diff --git a/packages/assets-controllers/package.json b/packages/assets-controllers/package.json index 46a53c72171..3ca766fb1ff 100644 --- a/packages/assets-controllers/package.json +++ b/packages/assets-controllers/package.json @@ -96,6 +96,7 @@ "@metamask/permission-controller": "^12.1.1", "@metamask/phishing-controller": "^16.1.0", "@metamask/preferences-controller": "^22.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", @@ -124,6 +125,7 @@ "@metamask/permission-controller": "^12.0.0", "@metamask/phishing-controller": "^16.0.0", "@metamask/preferences-controller": "^22.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/packages/assets-controllers/src/TokenBalancesController.test.ts b/packages/assets-controllers/src/TokenBalancesController.test.ts index 085b356ae43..19bb6dd9f4f 100644 --- a/packages/assets-controllers/src/TokenBalancesController.test.ts +++ b/packages/assets-controllers/src/TokenBalancesController.test.ts @@ -95,6 +95,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..066f7a21a4d 100644 --- a/packages/assets-controllers/src/TokenDetectionController.ts +++ b/packages/assets-controllers/src/TokenDetectionController.ts @@ -37,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'; @@ -62,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; @@ -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..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 @@ -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); @@ -499,7 +501,7 @@ describe('AccountsApiBalanceFetcher', () => { expect(mockReduceInBatchesSerially).toHaveBeenCalledWith({ values: caipAddresses, - batchSize: 50, + batchSize: 20, eachBatch: expect.any(Function), initialResult: [], }); @@ -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..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,10 +21,10 @@ 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 (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; @@ -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..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 @@ -118,6 +118,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 e8700395b0f..0498374a2cc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2666,6 +2666,7 @@ __metadata: "@metamask/phishing-controller": "npm:^16.1.0" "@metamask/polling-controller": "npm:^16.0.0" "@metamask/preferences-controller": "npm:^22.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" @@ -2708,6 +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": ^27.0.0 "@metamask/providers": ^22.0.0 "@metamask/snaps-controllers": ^14.0.0 "@metamask/transaction-controller": ^62.0.0