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

Adds a new package to fetch and manage network configurations from a remote API with robust polling and fallback.

  • New @metamask/config-registry-controller with ConfigRegistryController and ConfigRegistryApiService (ETag, retries, timeout, circuit breaker)
  • Periodic updates via StaticIntervalPollingController (24h), starts/stops on Keyring unlock/lock
  • Filters to featured, active, non-testnet networks; persists version, etag, lastFetched, and configs
  • Feature flag integration via RemoteFeatureFlagController; uses fallback configs when disabled/unavailable
  • Monorepo wiring: added package to README, CODEOWNERS, teams.json, tsconfig, yarn.lock; minor .gitignore and LavaMoat updates

Written by Cursor Bugbot for commit d628f08. 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>(

cursor[bot]

This comment was marked as outdated.

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 8 potential issues.

state.etag = result.etag ?? null;
}
});
return;
Copy link

Choose a reason for hiding this comment

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

lastFetched not updated on 304 Not Modified

High Severity

When the API returns a 304 Not Modified response, the controller clears fetchError and updates etag but doesn't update lastFetched. This causes the polling interval check to use a stale timestamp, resulting in premature re-fetching even though a successful fetch just occurred. Every 304 response is treated as if no time has passed, defeating the purpose of the polling interval mechanism.

Fix in Cursor Fix in Web

}

this.#handleFetchError(error);
}
Copy link

Choose a reason for hiding this comment

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

lastFetched not updated on fetch errors

High Severity

When a fetch fails, lastFetched is never updated. The polling interval check only prevents re-fetching if lastFetched is set and recent. If the last successful fetch was 23 hours ago and a fetch fails now, when the app restarts minutes later, it will retry immediately because lastFetched still reflects the 23-hour-old timestamp. This causes retry spam when the API is down, especially across app restarts where polling reinitializes.

Additional Locations (1)

Fix in Cursor Fix in Web

key: string;
value: Json;
metadata?: Json;
};
Copy link

Choose a reason for hiding this comment

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

Unused RegistryConfigEntry type exported

Low Severity

The RegistryConfigEntry type is defined and exported but never used anywhere in the codebase. A separate NetworkConfigEntry type serves the actual needs. This unused type adds clutter to the API surface and may confuse consumers about which type to use.

Fix in Cursor Fix in Web

// Subscribe to lock event - stop polling
this.messenger.subscribe('KeyringController:lock', () => {
this.stopPolling();
});
Copy link

Choose a reason for hiding this comment

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

Event listeners never cleaned up causing memory leak

Medium Severity

The messenger event subscriptions for KeyringController:unlock and KeyringController:lock are created in the constructor but never cleaned up. If the controller is destroyed and recreated during the application lifecycle, the old event listeners persist, causing a memory leak. Each controller instance adds new listeners without removing old ones, and the old controller instances are kept alive by these listener references.

Fix in Cursor Fix in Web


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

Choose a reason for hiding this comment

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

Inconsistent API breaks independent polling control

Medium Severity

The startPolling method returns a unique token suggesting per-consumer polling control, but stopPolling calls stopAllPolling which terminates polling for all consumers. If consumer A and consumer B both call startPolling, they each receive different tokens but share the polling mechanism. When consumer A calls stopPolling, consumer B's polling unexpectedly stops too. The returned tokens cannot be used to stop individual polling sessions, violating the API contract.

Fix in Cursor Fix in Web

key: network.chainId,
value: network,
};
}
Copy link

Choose a reason for hiding this comment

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

Duplicate chainIds cause silent network data loss

Medium Severity

When the API returns multiple networks with the same chainId, the controller silently overwrites earlier networks with later ones in the loop. This causes complete data loss for the overwritten networks without any error or warning. If the API has a bug or data corruption, users lose access to networks and developers have no indication this occurred, making debugging extremely difficult.

Fix in Cursor Fix in Web

networksToAdd: transformedNetworks,
existingChainIds: [],
};
}
Copy link

Choose a reason for hiding this comment

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

Unused exported transformer functions add unnecessary complexity

Medium Severity

Functions transformNetworkConfig, compareWithExistingNetworks, and processNetworkConfigs are exported from the package but never used in the controller implementation. They only appear in tests. These functions add 239 lines of unused production code, making the API surface unnecessarily large and confusing for consumers. The controller only uses filterNetworks from this module.

Fix in Cursor Fix in Web

Pick<ServicePolicy, 'onBreak' | 'onDegraded'>
> & {
fetchConfig(options?: FetchConfigOptions): Promise<FetchConfigResult>;
};
Copy link

Choose a reason for hiding this comment

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

Misleading name AbstractConfigRegistryApiService is not abstract

Low Severity

AbstractConfigRegistryApiService is named like an abstract class but is actually a TypeScript type alias. The naming convention suggests inheritance and abstraction that doesn't exist, causing confusion. A PR comment explicitly questioned "What's the purpose of having an abstract class? Do we plan to add multiple services?" highlighting this confusion.

Fix in Cursor Fix in Web

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.

3 participants