Skip to content

Conversation

@wantedsystem
Copy link
Contributor

@wantedsystem wantedsystem commented Jan 20, 2026

Explanation

Core Package (@metamask/config-registry-controller)

What was delivered:

  • New controller that fetches network configurations from a remote API
  • Automatic updates every 24 hours
  • Filters to show only featured, active, non-testnet networks
  • Feature flag control to enable/disable the feature
  • Fallback to static network list when disabled

Business value:

  • Networks can be updated remotely without extension releases
  • Users get the additional network configurations automatically

References

Checklist

  • I've updated the test suite for new or updated code as appropriate
  • I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate
  • I've communicated my changes to consumers by updating changelogs for packages I've changed
  • I've introduced breaking changes in this PR and have prepared draft pull requests for clients and consumer packages to resolve them

Note

Introduces a new controller package to source network configurations remotely and keep them updated.

  • New @metamask/config-registry-controller with persisted state (configs, version, lastFetched, etag) and 24h polling via StaticIntervalPollingController
  • ConfigRegistryApiService (ETag/If-None-Match, retries, circuit breaker, degraded handling) and network filtering utilities
  • Feature flag gating (configRegistryApiEnabled) with fallback config when disabled/unavailable
  • Handles duplicate chainIds by priority, integrates with KeyringController lock/unlock for polling lifecycle
  • Added package wiring: README list, CODEOWNERS, teams.json, tsconfig refs, yarn.lock; minor test helper refactor and .gitignore updates

Written by Cursor Bugbot for commit 839e62c. This will update automatically on new commits. Configure here.

cursor[bot]

This comment was marked as outdated.

cursor[bot]

This comment was marked as outdated.

@socket-security
Copy link

socket-security bot commented Jan 20, 2026

No dependency changes detected. Learn more about Socket for GitHub.

👍 No dependency changes detected in pull request

cursor[bot]

This comment was marked as outdated.

cursor[bot]

This comment was marked as outdated.

@mikesposito

This comment was marked as outdated.

@mikesposito

This comment was marked as outdated.

@mikesposito
Copy link
Member

@metamaskbot publish-previews

@github-actions
Copy link
Contributor

Preview builds have been published. See these instructions for more information about preview builds.

Expand for full list of packages and versions.
{
  "@metamask-previews/account-tree-controller": "4.0.0-preview-ab102d86",
  "@metamask-previews/accounts-controller": "35.0.2-preview-ab102d86",
  "@metamask-previews/address-book-controller": "7.0.1-preview-ab102d86",
  "@metamask-previews/analytics-controller": "1.0.0-preview-ab102d86",
  "@metamask-previews/announcement-controller": "8.0.0-preview-ab102d86",
  "@metamask-previews/app-metadata-controller": "2.0.0-preview-ab102d86",
  "@metamask-previews/approval-controller": "8.0.0-preview-ab102d86",
  "@metamask-previews/assets-controller": "0.0.0-preview-ab102d86",
  "@metamask-previews/assets-controllers": "95.3.0-preview-ab102d86",
  "@metamask-previews/base-controller": "9.0.0-preview-ab102d86",
  "@metamask-previews/bridge-controller": "64.6.1-preview-ab102d86",
  "@metamask-previews/bridge-status-controller": "64.4.3-preview-ab102d86",
  "@metamask-previews/build-utils": "3.0.4-preview-ab102d86",
  "@metamask-previews/chain-agnostic-permission": "1.4.0-preview-ab102d86",
  "@metamask-previews/claims-controller": "0.4.1-preview-ab102d86",
  "@metamask-previews/composable-controller": "12.0.0-preview-ab102d86",
  "@metamask-previews/config-registry-controller": "0.0.1-preview-ab102d86",
  "@metamask-previews/connectivity-controller": "0.1.0-preview-ab102d86",
  "@metamask-previews/controller-utils": "11.18.0-preview-ab102d86",
  "@metamask-previews/core-backend": "5.0.0-preview-ab102d86",
  "@metamask-previews/delegation-controller": "2.0.0-preview-ab102d86",
  "@metamask-previews/earn-controller": "11.1.0-preview-ab102d86",
  "@metamask-previews/eip-5792-middleware": "2.1.0-preview-ab102d86",
  "@metamask-previews/eip-7702-internal-rpc-middleware": "0.1.0-preview-ab102d86",
  "@metamask-previews/eip1193-permission-middleware": "1.0.3-preview-ab102d86",
  "@metamask-previews/ens-controller": "19.0.2-preview-ab102d86",
  "@metamask-previews/error-reporting-service": "3.0.1-preview-ab102d86",
  "@metamask-previews/eth-block-tracker": "15.0.1-preview-ab102d86",
  "@metamask-previews/eth-json-rpc-middleware": "23.0.0-preview-ab102d86",
  "@metamask-previews/eth-json-rpc-provider": "6.0.0-preview-ab102d86",
  "@metamask-previews/foundryup": "1.0.1-preview-ab102d86",
  "@metamask-previews/gas-fee-controller": "26.0.2-preview-ab102d86",
  "@metamask-previews/gator-permissions-controller": "1.1.0-preview-ab102d86",
  "@metamask-previews/json-rpc-engine": "10.2.1-preview-ab102d86",
  "@metamask-previews/json-rpc-middleware-stream": "8.0.8-preview-ab102d86",
  "@metamask-previews/keyring-controller": "25.0.0-preview-ab102d86",
  "@metamask-previews/logging-controller": "7.0.1-preview-ab102d86",
  "@metamask-previews/message-manager": "14.1.0-preview-ab102d86",
  "@metamask-previews/messenger": "0.3.0-preview-ab102d86",
  "@metamask-previews/multichain-account-service": "5.1.0-preview-ab102d86",
  "@metamask-previews/multichain-api-middleware": "1.2.6-preview-ab102d86",
  "@metamask-previews/multichain-network-controller": "3.0.2-preview-ab102d86",
  "@metamask-previews/multichain-transactions-controller": "7.0.0-preview-ab102d86",
  "@metamask-previews/name-controller": "9.0.0-preview-ab102d86",
  "@metamask-previews/network-controller": "29.0.0-preview-ab102d86",
  "@metamask-previews/network-enablement-controller": "4.1.0-preview-ab102d86",
  "@metamask-previews/notification-services-controller": "21.0.0-preview-ab102d86",
  "@metamask-previews/permission-controller": "12.2.0-preview-ab102d86",
  "@metamask-previews/permission-log-controller": "5.0.0-preview-ab102d86",
  "@metamask-previews/perps-controller": "0.0.0-preview-ab102d86",
  "@metamask-previews/phishing-controller": "16.1.0-preview-ab102d86",
  "@metamask-previews/polling-controller": "16.0.2-preview-ab102d86",
  "@metamask-previews/preferences-controller": "22.0.0-preview-ab102d86",
  "@metamask-previews/profile-metrics-controller": "3.0.0-preview-ab102d86",
  "@metamask-previews/profile-sync-controller": "27.0.0-preview-ab102d86",
  "@metamask-previews/ramps-controller": "4.1.0-preview-ab102d86",
  "@metamask-previews/rate-limit-controller": "7.0.0-preview-ab102d86",
  "@metamask-previews/remote-feature-flag-controller": "4.0.0-preview-ab102d86",
  "@metamask-previews/sample-controllers": "4.0.2-preview-ab102d86",
  "@metamask-previews/seedless-onboarding-controller": "7.1.0-preview-ab102d86",
  "@metamask-previews/selected-network-controller": "26.0.2-preview-ab102d86",
  "@metamask-previews/shield-controller": "5.0.0-preview-ab102d86",
  "@metamask-previews/signature-controller": "39.0.1-preview-ab102d86",
  "@metamask-previews/storage-service": "0.0.1-preview-ab102d86",
  "@metamask-previews/subscription-controller": "5.4.0-preview-ab102d86",
  "@metamask-previews/token-search-discovery-controller": "4.0.0-preview-ab102d86",
  "@metamask-previews/transaction-controller": "62.9.2-preview-ab102d86",
  "@metamask-previews/transaction-pay-controller": "11.0.2-preview-ab102d86",
  "@metamask-previews/user-operation-controller": "41.0.2-preview-ab102d86"
}

Comment on lines 40 to 76
const stateMetadata = {
configs: {
persist: true,
anonymous: false,
includeInStateLogs: false,
includeInDebugSnapshot: true,
usedInUi: true,
},
version: {
persist: true,
anonymous: false,
includeInStateLogs: true,
includeInDebugSnapshot: true,
usedInUi: false,
},
lastFetched: {
persist: true,
anonymous: false,
includeInStateLogs: true,
includeInDebugSnapshot: true,
usedInUi: false,
},
fetchError: {
persist: true,
anonymous: false,
includeInStateLogs: true,
includeInDebugSnapshot: true,
usedInUi: false,
},
etag: {
persist: true,
anonymous: false,
includeInStateLogs: false,
includeInDebugSnapshot: false,
usedInUi: false,
},
};
Copy link
Member

Choose a reason for hiding this comment

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

We can use satisfies StateMetadata<ConfigRegistryState> to ensure that the stateMetadata object conforms to the expected structure for state metadata. As an example, anonymous is not accepted as a valid key anymore:

Suggested change
const stateMetadata = {
configs: {
persist: true,
anonymous: false,
includeInStateLogs: false,
includeInDebugSnapshot: true,
usedInUi: true,
},
version: {
persist: true,
anonymous: false,
includeInStateLogs: true,
includeInDebugSnapshot: true,
usedInUi: false,
},
lastFetched: {
persist: true,
anonymous: false,
includeInStateLogs: true,
includeInDebugSnapshot: true,
usedInUi: false,
},
fetchError: {
persist: true,
anonymous: false,
includeInStateLogs: true,
includeInDebugSnapshot: true,
usedInUi: false,
},
etag: {
persist: true,
anonymous: false,
includeInStateLogs: false,
includeInDebugSnapshot: false,
usedInUi: false,
},
};
const stateMetadata = {
configs: {
persist: true,
includeInStateLogs: false,
includeInDebugSnapshot: true,
usedInUi: true,
},
version: {
persist: true,
includeInStateLogs: true,
includeInDebugSnapshot: true,
usedInUi: false,
},
lastFetched: {
persist: true,
includeInStateLogs: true,
includeInDebugSnapshot: true,
usedInUi: false,
},
fetchError: {
persist: true,
includeInStateLogs: true,
includeInDebugSnapshot: true,
usedInUi: false,
},
etag: {
persist: true,
includeInStateLogs: false,
includeInDebugSnapshot: false,
usedInUi: false,
},
} satisfies StateMetadata<ConfigRegistryState>;


const DEFAULT_FALLBACK_CONFIG: Record<string, RegistryConfigEntry> = {};

type ConfigRegistryPollingInput = Record<string, never>;
Copy link
Member

Choose a reason for hiding this comment

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

I don't think consumers should be able to arbitrarily set different polling cycles. We can probably remove this type and always (and only) accept null as polling input

Comment on lines 335 to 341
startPolling(input: ConfigRegistryPollingInput = {}): string {
return super.startPolling(input);
}

stopPolling(): void {
super.stopAllPolling();
}
Copy link
Member

@mikesposito mikesposito Jan 22, 2026

Choose a reason for hiding this comment

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

Do you think it would make sense to start and stop the polling autonomously by listening to KeyringController:unlock and KeyringController:lock events?

This way we would simplify the controller API, and make the client implementation easier

Comment on lines 330 to 331
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(state.configs as any) = { networks: {} };
Copy link
Member

Choose a reason for hiding this comment

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

This cast to any seems unnecessary:

Suggested change
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(state.configs as any) = { networks: {} };
state.configs = { networks: {} };

});
}

removeConfig(key: string): void {
Copy link
Member

Choose a reason for hiding this comment

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

Curious: why should the client arbitrarily choose to remove config keys from the registry?

Comment on lines 328 to 333
clearConfigs(): void {
this.update((state) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(state.configs as any) = { networks: {} };
});
}
Copy link
Member

Choose a reason for hiding this comment

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

Similarly to removeConfig, I'm curious to know in what scenarios should the consumer clear all configurations

Copy link
Contributor Author

Choose a reason for hiding this comment

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

You're right. The controller should be read-only for consumers, registry configs should be managed internally via polling. I'll remove these methods


export type ConfigRegistryState = {
configs: {
networks?: Record<string, RegistryConfigEntry>;
Copy link
Member

Choose a reason for hiding this comment

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

If we already know that this configurations is for networks only, do you think it would make sense to make types more strict here, instead of generic RegistryConfigEntry?

Using specific types for network configurations would greately enhance type safety downstream, and help catch potential issues by types alone


const controllerName = 'ConfigRegistryController';

export const DEFAULT_POLLING_INTERVAL = 24 * 60 * 60 * 1000;
Copy link
Member

Choose a reason for hiding this comment

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

We have a cool function that we can use from @metamask/utils:

import { Duration, inMilliseconds } from '@metamask/utils';
Suggested change
export const DEFAULT_POLLING_INTERVAL = 24 * 60 * 60 * 1000;
export const DEFAULT_POLLING_INTERVAL = inMilliseconds(1, Duration.Day);

Copy link
Member

Choose a reason for hiding this comment

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

Going back at this, I think that having a default polling that is so long is equivalent to not having polling at all, because most sessions won't last that long so we would execute the polling cycle at init time only. Perhaps we should reduce this to something like 5 or 10 minutes?

state.etag = result.etag ?? null;
});
} catch (error) {
this.#handleFetchError(error);
Copy link
Member

Choose a reason for hiding this comment

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

We can intercept errors if we need to perform actions over them, but we should never suppress them. Errors should always be bubbled up to the caller unless there's a very good reason not to.

Copy link
Member

Choose a reason for hiding this comment

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

Hmm, going back at this, there is a good reason to suppress the error in this case. Though, perhaps we can record the event with messenger.captureException?(Error)

state = {},
pollingInterval = DEFAULT_POLLING_INTERVAL,
fallbackConfig = DEFAULT_FALLBACK_CONFIG,
apiService = new ConfigRegistryApiService(),
Copy link
Member

Choose a reason for hiding this comment

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

We could, alternatively, initialize this service in the client, allowing the client to inject custom configurations (e.g. during testing environments).

The controller would use the service via messenger

@wantedsystem wantedsystem requested a review from a team as a code owner January 22, 2026 10:42
cursor[bot]

This comment was marked as outdated.

Comment on lines 51 to 55
export type AbstractConfigRegistryApiService = Partial<
Pick<ServicePolicy, 'onBreak' | 'onDegraded'>
> & {
fetchConfig(options?: FetchConfigOptions): Promise<FetchConfigResult>;
};
Copy link
Member

Choose a reason for hiding this comment

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

What's the purpose of having an abstract class? Do we plan to add multiple services?

* @param comparisonOptions - Options for comparing with existing networks.
* @returns Result containing networks to add and existing chain IDs.
*/
export function processNetworkConfigs(
Copy link
Member

Choose a reason for hiding this comment

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

I see a lot of logic in this file that supports this function, but I don't see this used anywhere. What do we need this for?

Comment on lines 135 to 152
const response = await this.#policy.execute(async () => {
const res = await fetchWithTimeout();

if (res.status === 304) {
return {
status: 304,
headers: res.headers,
} as unknown as Response;
}

if (!res.ok) {
throw new Error(
`Failed to fetch config: ${res.status} ${res.statusText}`,
);
}

return res;
});
Copy link
Member

Choose a reason for hiding this comment

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

Thoughts on simply returning the fetchWithTimeout promise directly instead of returning another object in case 304 is received? I feel like it adds unnecessary complexity here, but maybe I'm missing something

Comment on lines 71 to 72
this.#apiBaseUrl = apiBaseUrl;
this.#endpointPath = endpointPath;
Copy link
Member

Choose a reason for hiding this comment

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

Thoughs on using something similar to ProfileMetricsService for configuring the environment?

It would make the fetchConfig method way easier as we wouldn't have to validate an arbitrary string passed to the constructor

Copy link
Member

Choose a reason for hiding this comment

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

Moreover, the service is currently constructed by the controller only, so these constructor options would never really differ from the default ones, making the added complexity probably not worth it

Comment on lines 115 to 133
const fetchWithTimeout = async (): Promise<Response> => {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.#timeout);

try {
const response = await this.#fetch(url.toString(), {
headers,
signal: controller.signal,
});
clearTimeout(timeoutId);
return response;
} catch (error) {
clearTimeout(timeoutId);
if (error instanceof Error && error.name === 'AbortError') {
throw new Error(`Request timeout after ${this.#timeout}ms`);
}
throw error;
}
};
Copy link
Member

Choose a reason for hiding this comment

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

I see that other services in the core packages don't handle directly timeouts, leaving it to the service policy execution. Was there a specific reason to handle timeouts directly in this service?

return {
data,
etag,
notModified: false,
Copy link
Member

Choose a reason for hiding this comment

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

Do you think we can avoid the double negation on this parameter and call it modified (inverting the semantic)? It would probably be easier to read that way.

}

const etag = response.headers.get('ETag') ?? undefined;
const data = (await response.json()) as RegistryConfigApiResponse;
Copy link
Member

@mikesposito mikesposito Jan 22, 2026

Choose a reason for hiding this comment

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

We are using a wide type on the controller where we should know the type of data we have, while we cast a specific type in the service where we suppose to receive data in that shape. We should probably have a validation here, and we can use @metamask/superstruct for that - so we can avoid the type cast and give a guarantee that the data has the expected shape

Comment on lines +6 to +29
/**
* Checks if the config registry API feature flag is enabled.
*
* @param messenger - The controller messenger.
* @returns True if the feature flag is enabled, false otherwise.
*/
export function isConfigRegistryApiEnabled(
messenger: ConfigRegistryMessenger,
): boolean {
try {
const state = messenger.call('RemoteFeatureFlagController:getState');
const featureFlags = state.remoteFeatureFlags;

const flagValue = featureFlags[FEATURE_FLAG_KEY];

if (typeof flagValue === 'boolean') {
return flagValue;
}

return DEFAULT_FEATURE_FLAG_VALUE;
} catch {
return DEFAULT_FEATURE_FLAG_VALUE;
}
}
Copy link
Member

Choose a reason for hiding this comment

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

Clients may want to use different feature flags in different environments. Perhaps it would be easier to simply have a function constructor parameter passed to the controller, maybe even with this same name, and have the controller call that function to establish if it should continue the polling loop?

Comment on lines 62 to 75
fetchError: {
persist: true,
anonymous: false,
includeInStateLogs: true,
includeInDebugSnapshot: true,
usedInUi: false,
},
etag: {
persist: true,
anonymous: false,
includeInStateLogs: false,
includeInDebugSnapshot: false,
usedInUi: false,
},
Copy link
Member

Choose a reason for hiding this comment

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

Could you clarify what these two properties are used for? In what scenarios would they be publicly inspected?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

fetchError: Stores API fetch error messages. Updated to includeInStateLogs: false and includeInDebugSnapshot: false to avoid exposing error details.
etag: Stores the HTTP ETag header for caching. Already configured to not be exposed.
They remain persisted for internal use but are excluded from public logs and error reports

Comment on lines 279 to 280
if (hasNoConfigs) {
this.useFallbackConfig(errorMessage);
Copy link
Member

Choose a reason for hiding this comment

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

Instead of handling this case this way, have you considered assigning the fallback config to the state at controller construction time? This way, you can ensure that the state is always initialized with a valid config, and you won't need to handle the undefined case later in the code.

@mikesposito
Copy link
Member

It seems to me that we are relying on the polling cycle only, without considering how much time has passed since the last fetch. Though this means that we'll fetch configs everytime the user opens the app instead of every 24 hours

Comment on lines 105 to 129
let clock: sinon.SinonFakeTimers;
let messenger: ConfigRegistryMessenger;
let rootMessenger: RootMessenger;
let apiService: AbstractConfigRegistryApiService;
let mockRemoteFeatureFlagGetState: jest.Mock;

beforeEach(() => {
clock = useFakeTimers();
const messengers = getConfigRegistryControllerMessenger();
messenger = messengers.messenger;
rootMessenger = messengers.rootMessenger;
apiService = buildMockApiService();

mockRemoteFeatureFlagGetState = jest.fn().mockReturnValue({
remoteFeatureFlags: {
configRegistryApiEnabled: true,
},
cacheTimestamp: Date.now(),
});

rootMessenger.registerActionHandler(
'RemoteFeatureFlagController:getState',
mockRemoteFeatureFlagGetState,
);
});
Copy link
Member

@mikesposito mikesposito Jan 22, 2026

Choose a reason for hiding this comment

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

We usually try to avoid using shared variables and encoding test initialization logic in beforeEach blocks, as it can make tests harder to read and maintain. Usually this can be easily done by using wrapper functions like withController:

async function withController<ReturnValue>(

Copy link
Contributor

Choose a reason for hiding this comment

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

+1 for using a function instead of beforeEach. The rationale is explained here: https://github.com/MetaMask/contributor-docs/blob/main/docs/testing/unit-testing.md#avoid-the-use-of-beforeeach

See the SampleGasFeeController tests for a quick example on using withController. This pattern also allows you to automatically call destroy on your controller after each test. In this case it might be useful to ensure that polling is stopped so that Jest doesn't give you warnings about handles being open after tests end.

cursor[bot]

This comment was marked as outdated.


export type ConfigRegistryState = {
configs: {
networks?: Record<string, NetworkConfigEntry>;
Copy link
Contributor

Choose a reason for hiding this comment

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

Why is this property optional? Since it's a collection, would it be better to have this be required, but be empty if there is nothing configured? This you don't have to deal with undefined.

metadata?: Json;
};

export type ConfigRegistryState = {
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: It would be good to provide JSDoc which describes what these properties do and how they are used. In particular the version and etag properties — what's the difference?

return;
}

// Validate API response structure to prevent runtime crashes
Copy link
Contributor

Choose a reason for hiding this comment

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

Why are we validating the API response structure here? It seems that fetchConfig should perform response validation, or throw if the response is not what it expects.

}
}

protected useFallbackConfig(errorMessage?: string): void {
Copy link
Contributor

Choose a reason for hiding this comment

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

Is there a reason why this is marked as protected? Can we use a #private method instead?


protected useFallbackConfig(errorMessage?: string): void {
this.update((state) => {
(state.configs as ConfigRegistryState['configs']) = {
Copy link
Contributor

Choose a reason for hiding this comment

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

Why are we typecasting state.configs here?


const { url, type, networkClientId, failoverUrls } = endpoint;

if (!url || typeof url !== 'string') {
Copy link
Contributor

Choose a reason for hiding this comment

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

Is this the right place to perform runtime checks? It feels like a function whose goal is to convert from one data structure to another should already know what it's working with.

}

return networks.filter((network) => {
if (!network || typeof network !== 'object') {
Copy link
Contributor

Choose a reason for hiding this comment

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

Why do all of these runtime checks? Isn't this what types are for?

Comment on lines 66 to 78
expect(result).not.toBeNull();
expect(result?.chainId).toBe('0x1');
expect(result?.name).toBe('Ethereum Mainnet');
expect(result?.nativeCurrency).toBe('ETH');
expect(result?.rpcEndpoints).toHaveLength(1);
expect(result?.rpcEndpoints[0].type).toBe(RpcEndpointType.Infura);
expect(result?.rpcEndpoints[0].networkClientId).toBe('mainnet');
expect(result?.rpcEndpoints[0].failoverUrls).toStrictEqual([
'https://backup.infura.io/v3/{infuraProjectId}',
]);
expect(result?.blockExplorerUrls).toStrictEqual(['https://etherscan.io']);
expect(result?.defaultRpcEndpointIndex).toBe(0);
expect(result?.defaultBlockExplorerUrlIndex).toBe(0);
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: If you use assert instead of expect(...), you can drop all of the ?.'s, e.g.:

Suggested change
expect(result).not.toBeNull();
expect(result?.chainId).toBe('0x1');
expect(result?.name).toBe('Ethereum Mainnet');
expect(result?.nativeCurrency).toBe('ETH');
expect(result?.rpcEndpoints).toHaveLength(1);
expect(result?.rpcEndpoints[0].type).toBe(RpcEndpointType.Infura);
expect(result?.rpcEndpoints[0].networkClientId).toBe('mainnet');
expect(result?.rpcEndpoints[0].failoverUrls).toStrictEqual([
'https://backup.infura.io/v3/{infuraProjectId}',
]);
expect(result?.blockExplorerUrls).toStrictEqual(['https://etherscan.io']);
expect(result?.defaultRpcEndpointIndex).toBe(0);
expect(result?.defaultBlockExplorerUrlIndex).toBe(0);
assert(result, "Expected result to not be null");
expect(result.chainId).toBe('0x1');
expect(result.name).toBe('Ethereum Mainnet');
expect(result.nativeCurrency).toBe('ETH');
expect(result.rpcEndpoints).toHaveLength(1);
expect(result.rpcEndpoints[0].type).toBe(RpcEndpointType.Infura);
expect(result.rpcEndpoints[0].networkClientId).toBe('mainnet');
expect(result.rpcEndpoints[0].failoverUrls).toStrictEqual([
'https://backup.infura.io/v3/{infuraProjectId}',
]);
expect(result.blockExplorerUrls).toStrictEqual(['https://etherscan.io']);
expect(result.defaultRpcEndpointIndex).toBe(0);
expect(result.defaultBlockExplorerUrlIndex).toBe(0);


describe('transformers', () => {
describe('transformNetworkConfig', () => {
it('should transform valid network config with infura endpoint', () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Thoughts on not using "should"? Jest doesn't use it for its tests, it makes tests read like a list of specifications rather than a list of requirements, and it's shorter:

Suggested change
it('should transform valid network config with infura endpoint', () => {
it('transforms valid network config with infura endpoint', () => {

Also see: https://github.com/MetaMask/contributor-docs/blob/main/docs/testing/unit-testing.md#use-it-to-specify-the-desired-behavior-for-the-code-under-test

Comment on lines 105 to 129
let clock: sinon.SinonFakeTimers;
let messenger: ConfigRegistryMessenger;
let rootMessenger: RootMessenger;
let apiService: AbstractConfigRegistryApiService;
let mockRemoteFeatureFlagGetState: jest.Mock;

beforeEach(() => {
clock = useFakeTimers();
const messengers = getConfigRegistryControllerMessenger();
messenger = messengers.messenger;
rootMessenger = messengers.rootMessenger;
apiService = buildMockApiService();

mockRemoteFeatureFlagGetState = jest.fn().mockReturnValue({
remoteFeatureFlags: {
configRegistryApiEnabled: true,
},
cacheTimestamp: Date.now(),
});

rootMessenger.registerActionHandler(
'RemoteFeatureFlagController:getState',
mockRemoteFeatureFlagGetState,
);
});
Copy link
Contributor

Choose a reason for hiding this comment

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

+1 for using a function instead of beforeEach. The rationale is explained here: https://github.com/MetaMask/contributor-docs/blob/main/docs/testing/unit-testing.md#avoid-the-use-of-beforeeach

See the SampleGasFeeController tests for a quick example on using withController. This pattern also allows you to automatically call destroy on your controller after each test. In this case it might be useful to ensure that polling is stopped so that Jest doesn't give you warnings about handles being open after tests end.

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

@wantedsystem
Copy link
Contributor Author

@metamaskbot publish-previews

@github-actions
Copy link
Contributor

Preview builds have been published. See these instructions for more information about preview builds.

Expand for full list of packages and versions.
{
  "@metamask-previews/account-tree-controller": "4.0.0-preview-839e62c2",
  "@metamask-previews/accounts-controller": "35.0.2-preview-839e62c2",
  "@metamask-previews/address-book-controller": "7.0.1-preview-839e62c2",
  "@metamask-previews/analytics-controller": "1.0.0-preview-839e62c2",
  "@metamask-previews/announcement-controller": "8.0.0-preview-839e62c2",
  "@metamask-previews/app-metadata-controller": "2.0.0-preview-839e62c2",
  "@metamask-previews/approval-controller": "8.0.0-preview-839e62c2",
  "@metamask-previews/assets-controller": "0.0.0-preview-839e62c2",
  "@metamask-previews/assets-controllers": "95.3.0-preview-839e62c2",
  "@metamask-previews/base-controller": "9.0.0-preview-839e62c2",
  "@metamask-previews/bridge-controller": "64.6.1-preview-839e62c2",
  "@metamask-previews/bridge-status-controller": "64.4.3-preview-839e62c2",
  "@metamask-previews/build-utils": "3.0.4-preview-839e62c2",
  "@metamask-previews/chain-agnostic-permission": "1.4.0-preview-839e62c2",
  "@metamask-previews/claims-controller": "0.4.1-preview-839e62c2",
  "@metamask-previews/composable-controller": "12.0.0-preview-839e62c2",
  "@metamask-previews/config-registry-controller": "0.0.1-preview-839e62c2",
  "@metamask-previews/connectivity-controller": "0.1.0-preview-839e62c2",
  "@metamask-previews/controller-utils": "11.18.0-preview-839e62c2",
  "@metamask-previews/core-backend": "5.0.0-preview-839e62c2",
  "@metamask-previews/delegation-controller": "2.0.0-preview-839e62c2",
  "@metamask-previews/earn-controller": "11.1.0-preview-839e62c2",
  "@metamask-previews/eip-5792-middleware": "2.1.0-preview-839e62c2",
  "@metamask-previews/eip-7702-internal-rpc-middleware": "0.1.0-preview-839e62c2",
  "@metamask-previews/eip1193-permission-middleware": "1.0.3-preview-839e62c2",
  "@metamask-previews/ens-controller": "19.0.2-preview-839e62c2",
  "@metamask-previews/error-reporting-service": "3.0.1-preview-839e62c2",
  "@metamask-previews/eth-block-tracker": "15.0.1-preview-839e62c2",
  "@metamask-previews/eth-json-rpc-middleware": "23.0.0-preview-839e62c2",
  "@metamask-previews/eth-json-rpc-provider": "6.0.0-preview-839e62c2",
  "@metamask-previews/foundryup": "1.0.1-preview-839e62c2",
  "@metamask-previews/gas-fee-controller": "26.0.2-preview-839e62c2",
  "@metamask-previews/gator-permissions-controller": "1.1.0-preview-839e62c2",
  "@metamask-previews/json-rpc-engine": "10.2.1-preview-839e62c2",
  "@metamask-previews/json-rpc-middleware-stream": "8.0.8-preview-839e62c2",
  "@metamask-previews/keyring-controller": "25.0.0-preview-839e62c2",
  "@metamask-previews/logging-controller": "7.0.1-preview-839e62c2",
  "@metamask-previews/message-manager": "14.1.0-preview-839e62c2",
  "@metamask-previews/messenger": "0.3.0-preview-839e62c2",
  "@metamask-previews/multichain-account-service": "5.1.0-preview-839e62c2",
  "@metamask-previews/multichain-api-middleware": "1.2.6-preview-839e62c2",
  "@metamask-previews/multichain-network-controller": "3.0.2-preview-839e62c2",
  "@metamask-previews/multichain-transactions-controller": "7.0.0-preview-839e62c2",
  "@metamask-previews/name-controller": "9.0.0-preview-839e62c2",
  "@metamask-previews/network-controller": "29.0.0-preview-839e62c2",
  "@metamask-previews/network-enablement-controller": "4.1.0-preview-839e62c2",
  "@metamask-previews/notification-services-controller": "21.0.0-preview-839e62c2",
  "@metamask-previews/permission-controller": "12.2.0-preview-839e62c2",
  "@metamask-previews/permission-log-controller": "5.0.0-preview-839e62c2",
  "@metamask-previews/perps-controller": "0.0.0-preview-839e62c2",
  "@metamask-previews/phishing-controller": "16.1.0-preview-839e62c2",
  "@metamask-previews/polling-controller": "16.0.2-preview-839e62c2",
  "@metamask-previews/preferences-controller": "22.0.0-preview-839e62c2",
  "@metamask-previews/profile-metrics-controller": "3.0.0-preview-839e62c2",
  "@metamask-previews/profile-sync-controller": "27.0.0-preview-839e62c2",
  "@metamask-previews/ramps-controller": "4.1.0-preview-839e62c2",
  "@metamask-previews/rate-limit-controller": "7.0.0-preview-839e62c2",
  "@metamask-previews/remote-feature-flag-controller": "4.0.0-preview-839e62c2",
  "@metamask-previews/sample-controllers": "4.0.2-preview-839e62c2",
  "@metamask-previews/seedless-onboarding-controller": "7.1.0-preview-839e62c2",
  "@metamask-previews/selected-network-controller": "26.0.2-preview-839e62c2",
  "@metamask-previews/shield-controller": "5.0.0-preview-839e62c2",
  "@metamask-previews/signature-controller": "39.0.1-preview-839e62c2",
  "@metamask-previews/storage-service": "0.0.1-preview-839e62c2",
  "@metamask-previews/subscription-controller": "5.4.0-preview-839e62c2",
  "@metamask-previews/token-search-discovery-controller": "4.0.0-preview-839e62c2",
  "@metamask-previews/transaction-controller": "62.9.2-preview-839e62c2",
  "@metamask-previews/transaction-pay-controller": "11.0.2-preview-839e62c2",
  "@metamask-previews/user-operation-controller": "41.0.2-preview-839e62c2"
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants