Skip to content

feat: add rate limiting support to controllers leveraging the accounts API #6066

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
Open
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
9 changes: 1 addition & 8 deletions eslint-warning-thresholds.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,13 +74,9 @@
"jsdoc/tag-lines": 11
},
"packages/assets-controllers/src/TokenDetectionController.test.ts": {
"import-x/namespace": 11,
"jsdoc/tag-lines": 1
"import-x/namespace": 11
},
"packages/assets-controllers/src/TokenDetectionController.ts": {
"@typescript-eslint/prefer-readonly": 3,
"jsdoc/check-tag-names": 8,
"jsdoc/tag-lines": 6,
"no-unused-private-class-members": 2
},
"packages/assets-controllers/src/TokenListController.test.ts": {
Expand Down Expand Up @@ -112,9 +108,6 @@
"packages/assets-controllers/src/assetsUtil.ts": {
"jsdoc/tag-lines": 2
},
"packages/assets-controllers/src/multi-chain-accounts-service/multi-chain-accounts.ts": {
"jsdoc/tag-lines": 2
},
"packages/assets-controllers/src/multicall.test.ts": {
"@typescript-eslint/prefer-promise-reject-errors": 2
},
Expand Down
6 changes: 6 additions & 0 deletions packages/assets-controllers/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **BREAKING:** Add peer dependency `@metamask/profile-sync-controller`
- **BREAKING:** Add `profileId`-based rate limiting support for Accounts API calls
- Use `AuthenticationController:getBearerToken` in order to get a bearer token that gets attached to every request to the Accounts API

## [70.0.1]

### 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 @@ -89,6 +89,7 @@
"@metamask/permission-controller": "^11.0.6",
"@metamask/phishing-controller": "^12.6.0",
"@metamask/preferences-controller": "^18.4.1",
"@metamask/profile-sync-controller": "^20.0.0",
"@metamask/providers": "^22.1.0",
"@metamask/snaps-controllers": "^14.0.1",
"@metamask/transaction-controller": "^58.1.1",
Expand All @@ -114,6 +115,7 @@
"@metamask/permission-controller": "^11.0.0",
"@metamask/phishing-controller": "^12.5.0",
"@metamask/preferences-controller": "^18.0.0",
"@metamask/profile-sync-controller": "^20.0.0",
"@metamask/providers": "^22.0.0",
"@metamask/snaps-controllers": "^14.0.0",
"@metamask/transaction-controller": "^58.0.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ function buildTokenDetectionControllerMessenger(
'TokensController:addDetectedTokens',
'TokenListController:getState',
'PreferencesController:getState',
'AuthenticationController:getBearerToken',
],
allowedEvents: [
'AccountsController:selectedEvmAccountChange',
Expand Down Expand Up @@ -2615,11 +2616,7 @@ describe('TokenDetectionController', () => {
category: 'Wallet',
properties: {
tokens: [`${sampleTokenA.symbol} - ${sampleTokenA.address}`],
// TODO: Either fix this lint violation or explain why it's necessary to ignore.
// eslint-disable-next-line @typescript-eslint/naming-convention
Copy link
Member

Choose a reason for hiding this comment

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

Was this a mistake? It looks like the line below would still violate this lint rule.

If the rule doesn't apply for some reason, we should ensure the accompanying TODO comment is also removed (as well as this empty line).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

autofixOnSave was the culprit here 🕵️ Fixed with 0ce01be

token_standard: 'ERC20',
// TODO: Either fix this lint violation or explain why it's necessary to ignore.
// eslint-disable-next-line @typescript-eslint/naming-convention
asset_type: 'TOKEN',
},
});
Expand Down Expand Up @@ -2759,6 +2756,7 @@ describe('TokenDetectionController', () => {
/**
* Test Utility - Arrange and Act `detectTokens()` with the Accounts API feature
* RPC flow will return `sampleTokenA` and the Accounts API flow will use `sampleTokenB`
*
* @param props - options to modify these tests
* @param props.overrideMockTokensCache - change the tokens cache
* @param props.mockMultiChainAPI - change the Accounts API responses
Expand Down
96 changes: 72 additions & 24 deletions packages/assets-controllers/src/TokenDetectionController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import type {
PreferencesControllerGetStateAction,
PreferencesControllerStateChangeEvent,
} from '@metamask/preferences-controller';
import type { AuthenticationControllerGetBearerToken } from '@metamask/profile-sync-controller/auth';
import type { TransactionControllerTransactionConfirmedEvent } from '@metamask/transaction-controller';
import type { Hex } from '@metamask/utils';
import { hexToNumber } from '@metamask/utils';
Expand Down Expand Up @@ -93,6 +94,7 @@ export const STATIC_MAINNET_TOKEN_LIST = Object.entries<LegacyToken>(

/**
* Function that takes a TokensChainsCache object and maps chainId with TokenListMap.
*
* @param tokensChainsCache - TokensChainsCache input object
* @returns returns the map of chainId with TokenListMap
*/
Expand Down Expand Up @@ -129,7 +131,8 @@ export type AllowedActions =
| KeyringControllerGetStateAction
| PreferencesControllerGetStateAction
| TokensControllerGetStateAction
| TokensControllerAddDetectedTokensAction;
| TokensControllerAddDetectedTokensAction
| AuthenticationControllerGetBearerToken;

export type TokenDetectionControllerStateChangeEvent =
ControllerStateChangeEvent<typeof controllerName, TokenDetectionState>;
Expand Down Expand Up @@ -162,13 +165,20 @@ type TokenDetectionPollingInput = {

/**
* Controller that passively polls on a set interval for Tokens auto detection
* @property intervalId - Polling interval used to fetch new token rates
* @property selectedAddress - Vault selected address
* @property networkClientId - The network client ID of the current selected network
* @property disabled - Boolean to track if network requests are blocked
* @property isUnlocked - Boolean to track if the keyring state is unlocked
* @property isDetectionEnabledFromPreferences - Boolean to track if detection is enabled from PreferencesController
* @property isDetectionEnabledForNetwork - Boolean to track if detected is enabled for current network
*
* intervalId - Polling interval used to fetch new token rates
*
* selectedAddress - Vault selected address
*
* networkClientId - The network client ID of the current selected network
*
* disabled - Boolean to track if network requests are blocked
*
* isUnlocked - Boolean to track if the keyring state is unlocked
*
* isDetectionEnabledFromPreferences - Boolean to track if detection is enabled from PreferencesController
*
* isDetectionEnabledForNetwork - Boolean to track if detected is enabled for current network
*/
export class TokenDetectionController extends StaticIntervalPollingController<TokenDetectionPollingInput>()<
typeof controllerName,
Expand All @@ -179,7 +189,7 @@ export class TokenDetectionController extends StaticIntervalPollingController<To

#selectedAccountId: string;

#networkClientId: NetworkClientId;
readonly #networkClientId: NetworkClientId;

#tokensChainsCache: TokensChainsCache = {};

Expand All @@ -189,7 +199,7 @@ export class TokenDetectionController extends StaticIntervalPollingController<To

#isDetectionEnabledFromPreferences: boolean;

#isDetectionEnabledForNetwork: boolean;
readonly #isDetectionEnabledForNetwork: boolean;

readonly #getBalancesInSingleCall: AssetsContractController['getBalancesInSingleCall'];

Expand All @@ -199,20 +209,24 @@ export class TokenDetectionController extends StaticIntervalPollingController<To
properties: {
tokens: string[];
// TODO: Either fix this lint violation or explain why it's necessary to ignore.
// eslint-disable-next-line @typescript-eslint/naming-convention

token_standard: string;
// TODO: Either fix this lint violation or explain why it's necessary to ignore.
// eslint-disable-next-line @typescript-eslint/naming-convention

asset_type: string;
};
}) => void;

#accountsAPI = {
readonly #accountsAPI = {
isAccountsAPIEnabled: true,
supportedNetworksCache: null as number[] | null,
platform: '' as 'extension' | 'mobile',

async getSupportedNetworks() {
async getSupportedNetworks(options: {
getAuthenticationControllerBearerToken: () => ReturnType<
AuthenticationControllerGetBearerToken['handler']
>;
}) {
/* istanbul ignore next */
if (!this.isAccountsAPIEnabled) {
throw new Error('Accounts API Feature Switch is disabled');
Expand All @@ -223,7 +237,11 @@ export class TokenDetectionController extends StaticIntervalPollingController<To
return this.supportedNetworksCache;
}

const result = await fetchSupportedNetworks().catch(() => null);
const { getAuthenticationControllerBearerToken } = options;

const result = await fetchSupportedNetworks({
getAuthenticationControllerBearerToken,
}).catch(() => null);
this.supportedNetworksCache = result;
return result;
},
Expand All @@ -232,7 +250,13 @@ export class TokenDetectionController extends StaticIntervalPollingController<To
address: string,
chainIds: Hex[],
supportedNetworks: number[] | null,
options: {
getAuthenticationControllerBearerToken: () => ReturnType<
AuthenticationControllerGetBearerToken['handler']
>;
},
) {
const { getAuthenticationControllerBearerToken } = options;
const chainIdNumbers = chainIds.map((chainId) => hexToNumber(chainId));

if (
Expand All @@ -249,6 +273,7 @@ export class TokenDetectionController extends StaticIntervalPollingController<To
address,
{
networks: chainIdNumbers,
getAuthenticationControllerBearerToken,
},
this.platform,
);
Expand Down Expand Up @@ -287,10 +312,10 @@ export class TokenDetectionController extends StaticIntervalPollingController<To
properties: {
tokens: string[];
// TODO: Either fix this lint violation or explain why it's necessary to ignore.
// eslint-disable-next-line @typescript-eslint/naming-convention

token_standard: string;
// TODO: Either fix this lint violation or explain why it's necessary to ignore.
// eslint-disable-next-line @typescript-eslint/naming-convention

asset_type: string;
};
}) => void;
Expand Down Expand Up @@ -439,7 +464,8 @@ export class TokenDetectionController extends StaticIntervalPollingController<To

/**
* Internal isActive state
* @type {boolean}
*
* @returns boolean indicating if the controller is active
*/
get isActive(): boolean {
return !this.#disabled && this.#isUnlocked;
Expand Down Expand Up @@ -485,6 +511,7 @@ export class TokenDetectionController extends StaticIntervalPollingController<To

/**
* Compares current and previous tokensChainsCache object focusing only on the data object.
*
* @param tokensChainsCache - current tokensChainsCache input object
* @param previousTokensChainsCache - previous tokensChainsCache input object
* @returns boolean indicating if the two objects are equal
Expand Down Expand Up @@ -711,7 +738,10 @@ export class TokenDetectionController extends StaticIntervalPollingController<To

let supportedNetworks;
if (this.#accountsAPI.isAccountsAPIEnabled) {
supportedNetworks = await this.#accountsAPI.getSupportedNetworks();
supportedNetworks = await this.#accountsAPI.getSupportedNetworks({
getAuthenticationControllerBearerToken:
this.#getAuthenticationControllerBearerToken.bind(this),
});
}
const { chainsToDetectUsingRpc, chainsToDetectUsingAccountAPI } =
this.#getChainsToDetect(clientNetworks, supportedNetworks);
Expand Down Expand Up @@ -812,6 +842,7 @@ export class TokenDetectionController extends StaticIntervalPollingController<To

/**
* This adds detected tokens from the Accounts API, avoiding the multi-call RPC calls for balances
*
* @param options - method arguments
* @param options.selectedAddress - address to check against
* @param options.chainIds - array of chainIds to check tokens for
Expand All @@ -830,7 +861,15 @@ export class TokenDetectionController extends StaticIntervalPollingController<To
return await safelyExecute(async () => {
// Fetch balances for multiple chain IDs at once
const tokenBalancesByChain = await this.#accountsAPI
.getMultiNetworksBalances(selectedAddress, chainIds, supportedNetworks)
.getMultiNetworksBalances(
selectedAddress,
chainIds,
supportedNetworks,
{
getAuthenticationControllerBearerToken:
this.#getAuthenticationControllerBearerToken.bind(this),
},
)
.catch(() => null);

if (tokenBalancesByChain === null) {
Expand Down Expand Up @@ -879,10 +918,10 @@ export class TokenDetectionController extends StaticIntervalPollingController<To
properties: {
tokens: eventTokensDetails,
// TODO: Either fix this lint violation or explain why it's necessary to ignore.
Copy link
Contributor

Choose a reason for hiding this comment

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

Should we remove this comment too?

Suggested change
// TODO: Either fix this lint violation or explain why it's necessary to ignore.

// eslint-disable-next-line @typescript-eslint/naming-convention

token_standard: ERC20,
// TODO: Either fix this lint violation or explain why it's necessary to ignore.
Copy link
Contributor

Choose a reason for hiding this comment

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

Should we remove this comment too?

Suggested change
// TODO: Either fix this lint violation or explain why it's necessary to ignore.

// eslint-disable-next-line @typescript-eslint/naming-convention

asset_type: ASSET_TYPES.TOKEN,
},
});
Expand All @@ -904,6 +943,7 @@ export class TokenDetectionController extends StaticIntervalPollingController<To

/**
* Helper function to filter and build token data for detected tokens
*
* @param options.tokenCandidateSlices - these are tokens we know a user does not have (by checking the tokens controller).
* We will use these these token candidates to determine if a token found from the API is valid to be added on the users wallet.
* It will also prevent us to adding tokens a user already has
Expand Down Expand Up @@ -1009,10 +1049,10 @@ export class TokenDetectionController extends StaticIntervalPollingController<To
properties: {
tokens: eventTokensDetails,
// TODO: Either fix this lint violation or explain why it's necessary to ignore.
Copy link
Contributor

Choose a reason for hiding this comment

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

Should we remove this comment too?

Suggested change
// TODO: Either fix this lint violation or explain why it's necessary to ignore.

// eslint-disable-next-line @typescript-eslint/naming-convention

token_standard: ERC20,
// TODO: Either fix this lint violation or explain why it's necessary to ignore.
Copy link
Contributor

Choose a reason for hiding this comment

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

Should we remove this comment too?

Suggested change
// TODO: Either fix this lint violation or explain why it's necessary to ignore.

// eslint-disable-next-line @typescript-eslint/naming-convention

asset_type: ASSET_TYPES.TOKEN,
},
});
Expand Down Expand Up @@ -1041,6 +1081,14 @@ export class TokenDetectionController extends StaticIntervalPollingController<To
);
return account?.address || '';
}

async #getAuthenticationControllerBearerToken(): ReturnType<
AuthenticationControllerGetBearerToken['handler']
> {
return await this.messagingSystem.call(
'AuthenticationController:getBearerToken',
Copy link
Member

@matthewwalsh0 matthewwalsh0 Jul 8, 2025

Choose a reason for hiding this comment

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

If all these controllers ultimately call the same action / method, why can't this just be the default where needed in a central accounts API service to avoid all these breaking changes and new dependencies?

Maybe in @metamask/utils or @metamask/controller-utils to avoid a new dependency?

Or even a util such as fetchWithBearerToken?

Or could the clients just implement a proxy to inject this token automatically to avoid any core changes at all and decouple all this from the controllers?

Copy link
Contributor

Choose a reason for hiding this comment

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

Great callout. Do we only use the Accounts API in the accounts controller?

Copy link
Member

@matthewwalsh0 matthewwalsh0 Jul 8, 2025

Choose a reason for hiding this comment

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

I think multiple controllers have their own partial abstractions over the accounts API.

My fear is if we keep casually adding peer dependencies between the controllers, we make the release process harder, breaking changes more common, and eventually we'll just have one interdependent bundle meaning no single controller can have a major release without it cascading through the others.

Copy link
Contributor Author

@mathieuartu mathieuartu Jul 8, 2025

Choose a reason for hiding this comment

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

@matthewwalsh0 I'm not sure I understand your comment correctly 😄 AFAIK, AccountsController does not make Accounts API requests, but each of those controllers directly do in their own "bubble". They all have their own logic and wrappers around fetch and also often attach their own headers to these requests. This is why we added this logic at this level.

LMK if that clarifies it - or if I completely missed your point!

Copy link
Member

@matthewwalsh0 matthewwalsh0 Jul 8, 2025

Choose a reason for hiding this comment

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

Apologies, I initially mentioned the AccountsController by mistake, but updated it to clarify how we could abstract this complexity in various ways.

Copy link
Member

Choose a reason for hiding this comment

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

Exactly, right now it's a lot of duplication and new dependencies on three new controllers, to ultimately do the same logic. So a central mechanism would be great, either client proxy, util, or shared service.

Copy link
Contributor Author

@mathieuartu mathieuartu Jul 8, 2025

Choose a reason for hiding this comment

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

Thanks everyone for your input!
I think I see the pattern here, and I agree. Nobody wants a new peerDep.

It's just unfortunate that I'll need to juggle around 3 different fetch implementations 😅

  • handleFetch for TokenDetectionController
  • successfulFetch for TransactionController
  • fetch: window.fetch.bind(window) for MultichainNetworkController (as seen on extension)

Copy link
Member

@matthewwalsh0 matthewwalsh0 Jul 8, 2025

Choose a reason for hiding this comment

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

How do we feel about the proxy / hook idea, to automatically inject this from any client request, meaning no controller changes needed at all? And rather the clients themselves are conceptually "authorised" and so any requests they generate are automatically authorised also?

Copy link
Contributor

Choose a reason for hiding this comment

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

What would automatically injecting the token from any client request look like? Why would it mean no controller changes?

Copy link
Contributor

@cryptodev-2s cryptodev-2s Jul 8, 2025

Choose a reason for hiding this comment

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

Instead of modifying individual service/controller functions. We can also use a rule-based fetch interceptor that automatically adds authentication headers to matching URLs.

Note: The interceptor below only works with globalThis.fetch, so it has limitations when used with other HTTP clients. Additionally, it may need to be adapted to support both extension and mobile environments. We should also consider addressing known issues related to differences in polyfill timing, global scope variations, and Metro bundler behavior. The example is only for idea sharing.

// packages/controller-utils/src/fetch-interceptor.ts
type AuthRule = {
  pattern: string | RegExp;
  getToken: () => Promise<string>;
  headerName?: string;
  enabled?: boolean;
};

class FetchInterceptor {
  private authRules: AuthRule[] = [];
  private originalFetch: typeof globalThis.fetch;
  private isSetup = false;

  constructor() {
    this.originalFetch = globalThis.fetch;
  }

  setup() {
    if (this.isSetup) return;
    
    globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
      const url = this.extractUrl(input);
      const matchedRule = this.findMatchingRule(url);
      
      if (matchedRule && matchedRule.enabled !== false) {
        try {
          const token = await matchedRule.getToken();
          const headerName = matchedRule.headerName || 'Authorization';
          const headerValue = headerName === 'Authorization' ? `Bearer ${token}` : token;
          
          init = {
            ...init,
            headers: {
              ...init?.headers,
              [headerName]: headerValue,
            },
          };
        } catch (error) {
          console.warn(`Failed to get auth token for ${url}:`, error);
          // Continue with original request
        }
      }
      
      return this.originalFetch(input, init);
    };
    
    this.isSetup = true;
  }

  addAuthRule(rule: AuthRule) {
    this.authRules.push(rule);
  }

  teardown() {
    if (this.isSetup) {
      globalThis.fetch = this.originalFetch;
      this.isSetup = false;
    }
  }

  private extractUrl(input: RequestInfo | URL): string {
    if (typeof input === 'string') return input;
    if (input instanceof URL) return input.href;
    return input.url || '';
  }

  private findMatchingRule(url: string): AuthRule | null {
    return this.authRules.find(rule => {
      if (typeof rule.pattern === 'string') {
        return url.includes(rule.pattern);
      }
      return rule.pattern.test(url);
    }) || null;
  }
}

export const fetchInterceptor = new FetchInterceptor();

That we can use in the clients side

import { fetchInterceptor } from '@metamask/controller-utils';

export async function initializeControllers() {
  // Initialize auth controller first
  const authController = new AuthenticationController({...});
  
  // Setup authentication rules
  fetchInterceptor.addAuthRule({
    pattern: 'accounts.api.cx.metamask.io',
    getToken: () => authController.getBearerToken(),
  });
  
  // Activate the interceptor
  fetchInterceptor.setup();
  
  // All subsequent fetch calls are automatically authenticated
  const tokenDetectionController = new TokenDetectionController({...});
}

);
}
}

export default TokenDetectionController;
Loading
Loading