Skip to content

chore: create network enablement controller #6028

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 26 commits into
base: main
Choose a base branch
from

Conversation

salimtb
Copy link
Contributor

@salimtb salimtb commented Jun 24, 2025

Explanation

The NetworkEnablementController is a core implementation of network enablement functionality that was previously only available in the extension. This PR moves and expands this functionality from the extension's NetworkOrder controller to core, enabling network visibility features across both extension and mobile platforms. It also renames it to NetworkEnablementController to more accurately represent what it does. Maybe in the future this can be expanded to include other UX enhancements that don't need to be tightly coupled with the NetworkController

Key motivations for this change:

Network Enablement Feature: This work is part of the broader Network Enablement initiative (#5737). The controller has been expanded to include enabledNetworkMap state, allowing us to:

Track which networks are enabled/disabled
Support the new network enablement feature
Code Quality Improvements: The move to core has provided an opportunity to:

Improve test coverage of the existing network ordering logic
The controller now handles three key aspects:

Network enablement (new functionality. recently introduced on extension)

References

Changelog

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, highlighting breaking changes as necessary
  • I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes

@salimtb salimtb marked this pull request as ready for review June 24, 2025 16:47
@salimtb salimtb requested a review from a team as a code owner June 24, 2025 16:47
@salimtb salimtb requested a review from a team as a code owner July 22, 2025 11:33
cursor[bot]

This comment was marked as outdated.

cursor[bot]

This comment was marked as outdated.

cursor[bot]

This comment was marked as outdated.

@salimtb salimtb requested review from mcmire and cryptodev-2s July 22, 2025 13:06
cursor[bot]

This comment was marked as outdated.

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.

Bug: Network Controller Errors: Type Handling & Nested Updates

The NetworkEnablementController has two bugs:

  1. TypeError when disabling networks: The #toggleNetwork method can throw a TypeError when attempting to disable a network. This occurs because selectAllEnabledNetworks(this.state)[namespace] can return undefined if the namespace has no enabled networks, leading to Object.values(undefined). Additionally, the logic for counting enabled networks is unnecessarily complex and redundant, as selectAllEnabledNetworks(...)[namespace] already returns a string array whose length should be used directly.
  2. Nested state updates: The #toggleNetwork method calls this.#ensureNetworkEntry(chainId) within its this.update() callback. Since #ensureNetworkEntry also calls this.update(), this creates nested state updates, which can lead to state inconsistencies or unexpected behavior.

packages/network-enablement-controller/src/NetworkEnablementController.ts#L455-L466

// Don't disable the last remaining enabled network
if (
!enable &&
Object.values(selectAllEnabledNetworks(this.state)[namespace]).flat()
.length <= 1
) {
return;
}
this.update((s) => {
// Ensure entry exists first
this.#ensureNetworkEntry(chainId);

Fix in CursorFix in Web


Was this report helpful? Give feedback by reacting with 👍 or 👎

Comment on lines +43 to +45
controller.setEnabledNetwork('0x1'); // Hex format for EVM
controller.setEnabledNetwork('eip155:1'); // CAIP-2 format for EVM
controller.setEnabledNetwork('solana:mainnet'); // CAIP-2 format for Solana
Copy link
Contributor

Choose a reason for hiding this comment

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

Nice! Should we allow an array, to allow enabling multiple networks. Just considering for the "select all" networks flow.

We can have this in a future version if performance is a concern.

const hasEvmNetworks = selectHasEnabledNetworksForNamespace('eip155')(state);
```

## API Reference
Copy link
Contributor

@Prithpal-Sooriya Prithpal-Sooriya Jul 22, 2025

Choose a reason for hiding this comment

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

Hmmm, open to discussion, but I don't think we should include a manually written API reference.

I'm not against this - it is really great for open source devs to view. But I am wondering if the full API spec can be auto-generated through types (we do include a typedoc file).

E.g. our README would look like:

## Usage

... our useage section for controller/selectors (amazing!)

## API Reference

For full API reference, look at [path to auto generated type doc].

Copy link
Contributor

Choose a reason for hiding this comment

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

Unfortunately typedoc generation is currently broken due to the way that we build the repo 😞 I've been slowly working on fixing this (#1845 (comment)) but I'm not sure when that will be complete. Whatever path you want to take here is fine with me, but I just wanted to let you know about that.

Having some kind of usage examples at the very least would be great though, we don't do that often enough.

"dependencies": {
"@metamask/base-controller": "^8.0.1",
"@metamask/controller-utils": "^11.11.0",
"@metamask/keyring-api": "^19.0.0",
Copy link
Contributor

Choose a reason for hiding this comment

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

I was wondering why we needed this dep. Looks like it is to provide us the SolScope property. Shame, it would be nice if this was provided through other utils.

import { SolScope } from '@metamask/keyring-api';

Copy link
Contributor

Choose a reason for hiding this comment

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

If you don't want the dependency, could you copy the type? Sometimes we've done that before. Just a thought.

import { POPULAR_NETWORKS } from './constants';
import { selectAllEnabledNetworks } from './selectors';

// Unique name for the controller
Copy link
Contributor

Choose a reason for hiding this comment

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

We don't really need this comment do we?

Comment on lines +119 to +121
[ChainId[BuiltInNetworkName.Mainnet]]: true,
[ChainId[BuiltInNetworkName.LineaMainnet]]: true,
[ChainId[BuiltInNetworkName.BaseMainnet]]: true,
Copy link
Contributor

Choose a reason for hiding this comment

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

I think it would be good to align the default networks with what is done in the NetworkController?
Just to avoid having multiple places to update.

Only mentioning since we will soon add Arbitrum as another default network.

Copy link
Contributor

Choose a reason for hiding this comment

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

So that you don't have to maintain a list here as well, what are your thoughts on adding an init method to this controller that intelligently populates the initial state from whatever network configurations are present in NetworkController state if no initial state was passed to the constructor? That is, the state of this controller would be set to {} initially but would be populated in init.

/**
* A map of enabled networks by namespace and chain id.
*/
type EnabledMap = Record<CaipNamespace, Record<string, boolean>>;
Copy link
Contributor

Choose a reason for hiding this comment

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

Because the CaipNamespace can be deleted, can we make the value optional? Also thoughts on expanding this type so we can insert some JSDoc comments?

interface EnabledMap {
    /**
     * caipNamespace
     * @example eip155 or solana
     * NOTE - a namespace may be deleted, hence indexing can result in undefined.
     */
    [caipNamespace: string]: {
        /**
         * chainId, provided as hex or CAIP-2
         * @example 0x1 or solana:mainnet
         */
        [chainId: string]: boolean
    } | undefined
}

Copy link
Contributor

@mcmire mcmire Jul 22, 2025

Choose a reason for hiding this comment

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

I know what you mean, however, this type says that all keys in this object must be a CaipNamespace and all values must be a Record<string, boolean>. If you delete a key, wouldn't that still hold true?

Regardless, should chain IDs be CAIP chain IDs?

Suggested change
type EnabledMap = Record<CaipNamespace, Record<string, boolean>>;
type EnabledMap = Record<CaipNamespace, Record<CaipChainId, boolean>>;

Comment on lines +325 to +327
if (this.#hasOneEnabled(this.state, namespace, chainId)) {
return;
}
Copy link
Contributor

@Prithpal-Sooriya Prithpal-Sooriya Jul 22, 2025

Choose a reason for hiding this comment

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

Q - will this hasOneEnabled prevent a enabledNetworkMap entry from being deleted?

Copy link
Contributor

@mcmire mcmire left a comment

Choose a reason for hiding this comment

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

Took a pass at this. Had a lot of comments so I decided to stop. :) I will do another pass later.

In general it seems like there is more complexity in this controller than I would expect even though we are tracking whether networks are enabled or disabled and organizing them by group. It seems that we are constantly having to convert CAIP chain IDs so I'm curious if there is a more succinct way we can represent them so we don't have to do that. I will take a closer look later.

Comment on lines +175 to +176
this.messagingSystem = messenger;

Copy link
Contributor

Choose a reason for hiding this comment

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

This property is already set in BaseController, do we need this here?

Suggested change
this.messagingSystem = messenger;

* - A CAIP-2 chain ID (e.g., 'eip155:1' for Ethereum mainnet, 'solana:mainnet' for Solana)
* @param enable - Whether to enable (true) or disable (false) the network. Defaults to true.
*/
setEnabledNetwork(chainId: Hex | CaipChainId, enable = true): void {
Copy link
Contributor

Choose a reason for hiding this comment

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

This method seems to either set a network to enabled or disabled. Yet there is a method that explicitly sets a network to disabled below. That means there are two ways to disable a network using this controller. You can also set a network to disabled despite the name being setEnabledNetwork.

To make the API more consistent and reduce the number of code paths, what are your thoughts on making this method only enable a network? Then, what are your thoughts on calling this enableNetwork rather than setEnabledNetwork? I think that it would be more clear what this method does.

* - A CAIP-2 chain ID (e.g., 'eip155:1' for Ethereum mainnet, 'solana:mainnet' for Solana)
* @returns True if the network is enabled, false otherwise.
*/
isNetworkEnabled(chainId: Hex | CaipChainId): boolean {
Copy link
Contributor

Choose a reason for hiding this comment

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

It looks like you already have an isNetworkEnabled selector. To make this API lighter what are your thoughts on removing this method and having consumers always use the selector instead?

* @returns An object containing namespace, storageKey, and caipId
* @throws Error if the chain ID cannot be parsed
*/
#deriveKeys(chainId: Hex | CaipChainId) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Does this need to be an instance method or can it be a function outside of the class? This way the selectors can use it.

Comment on lines +178 to +179
this.#ensureNetworkEntry(chainId, false);
this.#toggleNetwork(chainId, true);
Copy link
Contributor

Choose a reason for hiding this comment

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

It looks like each of these lines makes a state update. It's generally good practice to batch state updates together in the same operation. What are your thoughts on refactoring this so there is only one state update?

Object.values(selectAllEnabledNetworks(this.state)[namespace]).flat()
.length <= 1
) {
return;
Copy link
Contributor

Choose a reason for hiding this comment

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

Similar here. If this is something that's not allowed, should we throw an error so that we push the responsibility to prevent this from happening on to the UI layer in the client?

Comment on lines +342 to +343
* This method is used to prevent unnecessary state updates when trying to enable
* a network that is already the only enabled network in its namespace.
Copy link
Contributor

Choose a reason for hiding this comment

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

We added something a while back to BaseController that prevents redundant updates: c580f85. So I am not sure that using this method to prevent unnecessary state updates would be necessary. However, I can see how this might prevent the last network in a namespace from being disabled. Perhaps we should update this wording?

Suggested change
* This method is used to prevent unnecessary state updates when trying to enable
* a network that is already the only enabled network in its namespace.
* This method is used to prevent the last network in a namespace from being removed.

} else {
storageKey = caipId;
}
return { namespace, storageKey, caipId };
Copy link
Contributor

Choose a reason for hiding this comment

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

What are your thoughts on having this method/function return reference as well? It seems that this would prevent us from needing to re-parse caipId any time we want the reference (which we are doing a lot in this file).

Comment on lines +464 to +480
this.update((s) => {
// Ensure entry exists first
this.#ensureNetworkEntry(chainId);

if (this.#hasOneEnabled(s, namespace, chainId)) {
return;
}

// If enabling a non-popular network, disable all networks in the same namespace
if (enable && !this.#isPopularNetwork(caipId)) {
Object.keys(s.enabledNetworkMap[namespace]).forEach((key) => {
s.enabledNetworkMap[namespace][key] = false;
});
}

s.enabledNetworkMap[namespace][storageKey] = enable;
});
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we need to do anything if #hasOneEnabled returns true?

Suggested change
this.update((s) => {
// Ensure entry exists first
this.#ensureNetworkEntry(chainId);
if (this.#hasOneEnabled(s, namespace, chainId)) {
return;
}
// If enabling a non-popular network, disable all networks in the same namespace
if (enable && !this.#isPopularNetwork(caipId)) {
Object.keys(s.enabledNetworkMap[namespace]).forEach((key) => {
s.enabledNetworkMap[namespace][key] = false;
});
}
s.enabledNetworkMap[namespace][storageKey] = enable;
});
this.update((s) => {
// Ensure entry exists first
this.#ensureNetworkEntry(chainId);
// If enabling a non-popular network, disable all networks in the same namespace
if (enable && !this.#isPopularNetwork(caipId)) {
Object.keys(s.enabledNetworkMap[namespace]).forEach((key) => {
s.enabledNetworkMap[namespace][key] = false;
});
}
s.enabledNetworkMap[namespace][storageKey] = enable;
});

return;
}

// Don't disable the last remaining enabled network
Copy link
Contributor

Choose a reason for hiding this comment

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

Should this say:

Suggested change
// Don't disable the last remaining enabled network
// Don't update the last remaining enabled network

But also, do we need this? Is this covered already by the #hasOneEnabled check below?

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

Successfully merging this pull request may close these issues.

5 participants