Skip to content

Commit c85107f

Browse files
authored
feat: Add optional JWT token authentication to multi-chain accounts API (#7165)
## Explanation ### What is the current state and why does it need to change? Currently, the multi-chain accounts API calls in `TokenDetectionController` and `TokenBalancesController` are made without authentication. This limits the ability to provide user-specific data and secure API endpoints that require authenticated requests. ### What is the solution and how does it work? This PR adds optional JWT token authentication and timeout protection to the accounts API calls: 1. **API Layer Changes** (`multi-chain-accounts.ts`): - Added optional `jwtToken` parameter to `fetchMultiChainBalances` and `fetchMultiChainBalancesV4` - When a JWT token is provided, it's included in the `Authorization: Bearer <token>` header - The token is optional to maintain backward compatibility 2. **Controller Integration**: - **TokenDetectionController**: - Fetches JWT token from `AuthenticationController:getBearerToken` and passes it to `fetchMultiChainBalances` when detecting tokens via Accounts API - **TokenBalancesController**: Fetches JWT token and passes it through the balance fetcher chain to `fetchMultiChainBalancesV4` 3. **Balance Fetcher Updates** (`api-balance-fetcher.ts`): - Updated `AccountsApiBalanceFetcher` to accept and pass JWT token through the fetch chain - Token flows from `updateBalances` → `fetch` → `#fetchBalances` → API calls ### Key Design Decisions - **Optional Parameter**: The JWT token is optional throughout the call chain, ensuring backward compatibility for environments where authentication is not available or required - **Graceful Degradation**: If no token is provided, API calls proceed without authentication, allowing the system to work in both authenticated and unauthenticated scenarios - **No Breaking Changes**: Existing callers continue to work without modification ## References <!-- Add any related issue numbers here, for example: - Related to #XXXXX (if there's a tracking issue for JWT authentication) --> ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - Added tests in `TokenDetectionController.test.ts` to verify JWT token is passed correctly - Added test in `TokenDetectionController.test.ts` to verify 30-second timeout triggers RPC fallback - Added tests in `TokenBalancesController.test.ts` to verify JWT token flows through balance fetcher - Added tests in `multi-chain-accounts.test.ts` to verify Authorization header is set correctly - Added tests for both scenarios: with and without JWT token - Verified timeout behavior using fake timers (sinon) and the `advanceTime` helper - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - Updated JSDoc comments for `fetchMultiChainBalances` and `fetchMultiChainBalancesV4` - Updated JSDoc for controller methods that now handle JWT tokens - Added inline comments explaining timeout logic and fallback mechanism - [ ] I've communicated my changes to consumers by updating changelogs for packages I've changed, highlighting breaking changes as necessary - **Note**: No breaking changes - all JWT token parameters are optional and timeout is an internal improvement - [ ] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes - **Note**: Not required - no breaking changes introduced <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Adds optional JWT bearer authentication to multi‑chain Accounts API calls and wires token retrieval through controllers, while lowering timeouts to 10s and reducing API batch size to 20. > > - **API/Services**: > - Add optional `jwtToken` to `fetchMultiChainBalances`/`fetchMultiChainBalancesV4`; include `Authorization: Bearer <token>` header when provided. > - Reduce Accounts API request timeout to `10s`; reduce V4 batch size from `50` to `20`. > - **Controllers**: > - `TokenDetectionController` and `TokenBalancesController` fetch JWT via `AuthenticationController:getBearerToken` and pass it through balance/token detection flows. > - `TokenDetectionController` Accounts API timeout lowered to `10s`. > - **Balance Fetcher**: > - `AccountsApiBalanceFetcher` accepts/forwards `jwtToken`; applies 10s timeout and 20-size batching. > - **Tests**: > - Add coverage for JWT header behavior (with/without token) and updated batching/timeout. > - Test scaffolding updated to mock `AuthenticationController:getBearerToken` and token list state injection. > - **Dependencies/Meta**: > - Add `@metamask/profile-sync-controller` as dev/peer dep. > - Update `CHANGELOG.md` to document the new optional auth. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit d645dcf. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 4238ec7 commit c85107f

File tree

11 files changed

+176
-92
lines changed

11 files changed

+176
-92
lines changed

packages/assets-controllers/CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- **BREAKING:** Add optional JWT token authentication to multi-chain accounts API calls ([#7165](https://github.com/MetaMask/core/pull/7165))
13+
- `fetchMultiChainBalances` and `fetchMultiChainBalancesV4` now accept an optional `jwtToken` parameter
14+
- `TokenDetectionController` fetches and passes JWT token from `AuthenticationController` when using Accounts API
15+
- `TokenBalancesController` fetches and passes JWT token through balance fetcher chain
16+
- JWT token is included in `Authorization: Bearer <token>` header when provided
17+
- Backward compatible: token parameter is optional and APIs work without authentication
18+
1019
## [91.0.0]
1120

1221
### Changed

packages/assets-controllers/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@
9696
"@metamask/permission-controller": "^12.1.1",
9797
"@metamask/phishing-controller": "^16.1.0",
9898
"@metamask/preferences-controller": "^22.0.0",
99+
"@metamask/profile-sync-controller": "^27.0.0",
99100
"@metamask/providers": "^22.1.0",
100101
"@metamask/snaps-controllers": "^14.0.1",
101102
"@metamask/transaction-controller": "^62.1.0",
@@ -124,6 +125,7 @@
124125
"@metamask/permission-controller": "^12.0.0",
125126
"@metamask/phishing-controller": "^16.0.0",
126127
"@metamask/preferences-controller": "^22.0.0",
128+
"@metamask/profile-sync-controller": "^27.0.0",
127129
"@metamask/providers": "^22.0.0",
128130
"@metamask/snaps-controllers": "^14.0.0",
129131
"@metamask/transaction-controller": "^62.0.0",

packages/assets-controllers/src/TokenBalancesController.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ const setupController = ({
9595
'AccountTrackerController:getState',
9696
'AccountTrackerController:updateNativeBalances',
9797
'AccountTrackerController:updateStakedBalances',
98+
'AuthenticationController:getBearerToken',
9899
],
99100
events: [
100101
'NetworkController:stateChange',

packages/assets-controllers/src/TokenBalancesController.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type {
1111
import {
1212
BNToHex,
1313
isValidHexAddress,
14+
safelyExecuteWithTimeout,
1415
toChecksumHexAddress,
1516
toHex,
1617
} from '@metamask/controller-utils';
@@ -32,6 +33,7 @@ import type {
3233
PreferencesControllerGetStateAction,
3334
PreferencesControllerStateChangeEvent,
3435
} from '@metamask/preferences-controller';
36+
import type { AuthenticationController } from '@metamask/profile-sync-controller';
3537
import type { Hex } from '@metamask/utils';
3638
import {
3739
isCaipAssetType,
@@ -130,7 +132,8 @@ export type AllowedActions =
130132
| AccountsControllerListAccountsAction
131133
| AccountTrackerControllerGetStateAction
132134
| AccountTrackerUpdateNativeBalancesAction
133-
| AccountTrackerUpdateStakedBalancesAction;
135+
| AccountTrackerUpdateStakedBalancesAction
136+
| AuthenticationController.AuthenticationControllerGetBearerToken;
134137

135138
export type AllowedEvents =
136139
| TokensControllerStateChangeEvent
@@ -640,6 +643,14 @@ export class TokenBalancesController extends StaticIntervalPollingController<{
640643
);
641644
const allAccounts = this.messenger.call('AccountsController:listAccounts');
642645

646+
const jwtToken = await safelyExecuteWithTimeout<string | undefined>(
647+
() => {
648+
return this.messenger.call('AuthenticationController:getBearerToken');
649+
},
650+
false,
651+
5000,
652+
);
653+
643654
const aggregated: ProcessedBalance[] = [];
644655
let remainingChains = [...targetChains];
645656

@@ -658,6 +669,7 @@ export class TokenBalancesController extends StaticIntervalPollingController<{
658669
queryAllAccounts: queryAllAccounts ?? this.#queryAllAccounts,
659670
selectedAccount: selected as ChecksumAddress,
660671
allAccounts,
672+
jwtToken,
661673
});
662674

663675
if (result.balances && result.balances.length > 0) {

packages/assets-controllers/src/TokenDetectionController.test.ts

Lines changed: 38 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@ function buildTokenDetectionControllerMessenger(
204204
'PreferencesController:getState',
205205
'TokensController:addTokens',
206206
'NetworkController:findNetworkClientIdByChainId',
207+
'AuthenticationController:getBearerToken',
207208
],
208209
events: [
209210
'AccountsController:selectedEvmAccountChange',
@@ -3748,15 +3749,7 @@ describe('TokenDetectionController', () => {
37483749
options: {
37493750
disabled: false,
37503751
},
3751-
},
3752-
async ({
3753-
controller,
3754-
mockTokenListGetState,
3755-
callActionSpy,
3756-
triggerTokenListStateChange,
3757-
}) => {
3758-
const tokenListState = {
3759-
...getDefaultTokenListState(),
3752+
mockTokenListState: {
37603753
tokensChainsCache: {
37613754
[chainId]: {
37623755
timestamp: 0,
@@ -3773,11 +3766,9 @@ describe('TokenDetectionController', () => {
37733766
},
37743767
},
37753768
},
3776-
};
3777-
3778-
mockTokenListGetState(tokenListState);
3779-
triggerTokenListStateChange(tokenListState);
3780-
3769+
},
3770+
},
3771+
async ({ controller, callActionSpy }) => {
37813772
await controller.addDetectedTokensViaWs({
37823773
tokensSlice: [mockTokenAddress],
37833774
chainId: chainId as Hex,
@@ -3813,27 +3804,16 @@ describe('TokenDetectionController', () => {
38133804
options: {
38143805
disabled: false,
38153806
},
3816-
},
3817-
async ({
3818-
controller,
3819-
mockTokenListGetState,
3820-
callActionSpy,
3821-
triggerTokenListStateChange,
3822-
}) => {
3823-
// Empty token cache - token not found
3824-
const tokenListState = {
3825-
...getDefaultTokenListState(),
3807+
mockTokenListState: {
38263808
tokensChainsCache: {
38273809
[chainId]: {
38283810
timestamp: 0,
38293811
data: {},
38303812
},
38313813
},
3832-
};
3833-
3834-
mockTokenListGetState(tokenListState);
3835-
triggerTokenListStateChange(tokenListState);
3836-
3814+
},
3815+
},
3816+
async ({ controller, callActionSpy }) => {
38373817
await controller.addDetectedTokensViaWs({
38383818
tokensSlice: [mockTokenAddress],
38393819
chainId: chainId as Hex,
@@ -3877,16 +3857,7 @@ describe('TokenDetectionController', () => {
38773857
getSelectedAccount: selectedAccount,
38783858
getAccount: selectedAccount,
38793859
},
3880-
},
3881-
async ({
3882-
controller,
3883-
mockTokenListGetState,
3884-
callActionSpy,
3885-
triggerTokenListStateChange,
3886-
}) => {
3887-
// Set up token list with both tokens
3888-
const tokenListState = {
3889-
...getDefaultTokenListState(),
3860+
mockTokenListState: {
38903861
tokensChainsCache: {
38913862
[chainId]: {
38923863
timestamp: 0,
@@ -3912,11 +3883,9 @@ describe('TokenDetectionController', () => {
39123883
},
39133884
},
39143885
},
3915-
};
3916-
3917-
mockTokenListGetState(tokenListState);
3918-
triggerTokenListStateChange(tokenListState);
3919-
3886+
},
3887+
},
3888+
async ({ controller, callActionSpy }) => {
39203889
// Add both tokens via websocket
39213890
await controller.addDetectedTokensViaWs({
39223891
tokensSlice: [mockTokenAddress, secondTokenAddress],
@@ -3965,15 +3934,7 @@ describe('TokenDetectionController', () => {
39653934
disabled: false,
39663935
trackMetaMetricsEvent: mockTrackMetricsEvent,
39673936
},
3968-
},
3969-
async ({
3970-
controller,
3971-
mockTokenListGetState,
3972-
callActionSpy,
3973-
triggerTokenListStateChange,
3974-
}) => {
3975-
const tokenListState = {
3976-
...getDefaultTokenListState(),
3937+
mockTokenListState: {
39773938
tokensChainsCache: {
39783939
[chainId]: {
39793940
timestamp: 0,
@@ -3990,11 +3951,9 @@ describe('TokenDetectionController', () => {
39903951
},
39913952
},
39923953
},
3993-
};
3994-
3995-
mockTokenListGetState(tokenListState);
3996-
triggerTokenListStateChange(tokenListState);
3997-
3954+
},
3955+
},
3956+
async ({ controller, callActionSpy }) => {
39983957
await controller.addDetectedTokensViaWs({
39993958
tokensSlice: [mockTokenAddress],
40003959
chainId: chainId as Hex,
@@ -4031,15 +3990,7 @@ describe('TokenDetectionController', () => {
40313990
options: {
40323991
disabled: false,
40333992
},
4034-
},
4035-
async ({
4036-
controller,
4037-
mockTokenListGetState,
4038-
callActionSpy,
4039-
triggerTokenListStateChange,
4040-
}) => {
4041-
const tokenListState = {
4042-
...getDefaultTokenListState(),
3993+
mockTokenListState: {
40433994
tokensChainsCache: {
40443995
[chainId]: {
40453996
timestamp: 0,
@@ -4056,11 +4007,9 @@ describe('TokenDetectionController', () => {
40564007
},
40574008
},
40584009
},
4059-
};
4060-
4061-
mockTokenListGetState(tokenListState);
4062-
triggerTokenListStateChange(tokenListState);
4063-
4010+
},
4011+
},
4012+
async ({ controller, callActionSpy }) => {
40644013
// Call the public method directly on the controller instance
40654014
await controller.addDetectedTokensViaWs({
40664015
tokensSlice: [mockTokenAddress],
@@ -4157,7 +4106,9 @@ type WithControllerOptions = {
41574106
mocks?: {
41584107
getAccount?: InternalAccount;
41594108
getSelectedAccount?: InternalAccount;
4109+
getBearerToken?: string;
41604110
};
4111+
mockTokenListState?: Partial<TokenListState>;
41614112
};
41624113

41634114
type WithControllerArgs<ReturnValue> =
@@ -4177,7 +4128,7 @@ async function withController<ReturnValue>(
41774128
...args: WithControllerArgs<ReturnValue>
41784129
): Promise<ReturnValue> {
41794130
const [{ ...rest }, fn] = args.length === 2 ? args : [{}, args[0]];
4180-
const { options, isKeyringUnlocked, mocks } = rest;
4131+
const { options, isKeyringUnlocked, mocks, mockTokenListState } = rest;
41814132
const messenger = buildRootMessenger();
41824133

41834134
const mockGetAccount = jest.fn<InternalAccount, []>();
@@ -4240,10 +4191,13 @@ async function withController<ReturnValue>(
42404191
'TokensController:getState',
42414192
mockTokensState.mockReturnValue({ ...getDefaultTokensState() }),
42424193
);
4243-
const mockTokenListState = jest.fn<TokenListState, []>();
4194+
const mockTokenListStateFunc = jest.fn<TokenListState, []>();
42444195
messenger.registerActionHandler(
42454196
'TokenListController:getState',
4246-
mockTokenListState.mockReturnValue({ ...getDefaultTokenListState() }),
4197+
mockTokenListStateFunc.mockReturnValue({
4198+
...getDefaultTokenListState(),
4199+
...mockTokenListState,
4200+
}),
42474201
);
42484202
const mockPreferencesState = jest.fn<PreferencesState, []>();
42494203
messenger.registerActionHandler(
@@ -4253,6 +4207,14 @@ async function withController<ReturnValue>(
42534207
}),
42544208
);
42554209

4210+
const mockGetBearerToken = jest.fn<Promise<string>, []>();
4211+
messenger.registerActionHandler(
4212+
'AuthenticationController:getBearerToken',
4213+
mockGetBearerToken.mockResolvedValue(
4214+
mocks?.getBearerToken ?? 'mock-jwt-token',
4215+
),
4216+
);
4217+
42564218
const mockFindNetworkClientIdByChainId = jest.fn<NetworkClientId, [Hex]>();
42574219
messenger.registerActionHandler(
42584220
'NetworkController:findNetworkClientIdByChainId',
@@ -4312,7 +4274,7 @@ async function withController<ReturnValue>(
43124274
mockPreferencesState.mockReturnValue(state);
43134275
},
43144276
mockTokenListGetState: (state: TokenListState) => {
4315-
mockTokenListState.mockReturnValue(state);
4277+
mockTokenListStateFunc.mockReturnValue(state);
43164278
},
43174279
mockGetNetworkClientById: (
43184280
handler: (

0 commit comments

Comments
 (0)