Skip to content

feat: add removeNetwork to multichain-network-controller #5516

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

Merged
merged 13 commits into from
Mar 27, 2025
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ import {
import type {
NetworkControllerGetStateAction,
NetworkControllerSetActiveNetworkAction,
NetworkControllerGetSelectedChainIdAction,
NetworkControllerRemoveNetworkAction,
NetworkControllerFindNetworkClientIdByChainIdAction,
} from '@metamask/network-controller';

import { getDefaultMultichainNetworkControllerState } from './constants';
Expand All @@ -32,12 +35,18 @@ import { createMockInternalAccount } from '../tests/utils';
* @param args.options - The constructor options for the controller.
* @param args.getNetworkState - Mock for NetworkController:getState action.
* @param args.setActiveNetwork - Mock for NetworkController:setActiveNetwork action.
* @param args.removeNetwork - Mock for NetworkController:removeNetwork action.
* @param args.getSelectedChainId - Mock for NetworkController:getSelectedChainId action.
* @param args.findNetworkClientIdByChainId - Mock for NetworkController:findNetworkClientIdByChainId action.
* @returns A collection of test controllers and mocks.
*/
function setupController({
options = {},
getNetworkState,
setActiveNetwork,
removeNetwork,
getSelectedChainId,
findNetworkClientIdByChainId,
}: {
options?: Partial<
ConstructorParameters<typeof MultichainNetworkController>[0]
Expand All @@ -50,6 +59,18 @@ function setupController({
ReturnType<NetworkControllerSetActiveNetworkAction['handler']>,
Parameters<NetworkControllerSetActiveNetworkAction['handler']>
>;
removeNetwork?: jest.Mock<
ReturnType<NetworkControllerRemoveNetworkAction['handler']>,
Parameters<NetworkControllerRemoveNetworkAction['handler']>
>;
getSelectedChainId?: jest.Mock<
ReturnType<NetworkControllerGetSelectedChainIdAction['handler']>,
Parameters<NetworkControllerGetSelectedChainIdAction['handler']>
>;
findNetworkClientIdByChainId?: jest.Mock<
ReturnType<NetworkControllerFindNetworkClientIdByChainIdAction['handler']>,
Parameters<NetworkControllerFindNetworkClientIdByChainIdAction['handler']>
>;
} = {}) {
const messenger = new Messenger<
MultichainNetworkControllerAllowedActions,
Expand Down Expand Up @@ -81,6 +102,41 @@ function setupController({
mockSetActiveNetwork,
);

const mockRemoveNetwork =
removeNetwork ??
jest.fn<
ReturnType<NetworkControllerRemoveNetworkAction['handler']>,
Parameters<NetworkControllerRemoveNetworkAction['handler']>
>();
messenger.registerActionHandler(
'NetworkController:removeNetwork',
mockRemoveNetwork,
);

const mockGetSelectedChainId =
getSelectedChainId ??
jest.fn<
ReturnType<NetworkControllerGetSelectedChainIdAction['handler']>,
Parameters<NetworkControllerGetSelectedChainIdAction['handler']>
>();
messenger.registerActionHandler(
'NetworkController:getSelectedChainId',
mockGetSelectedChainId,
);

const mockFindNetworkClientIdByChainId =
findNetworkClientIdByChainId ??
jest.fn<
ReturnType<
NetworkControllerFindNetworkClientIdByChainIdAction['handler']
>,
Parameters<NetworkControllerFindNetworkClientIdByChainIdAction['handler']>
>();
messenger.registerActionHandler(
'NetworkController:findNetworkClientIdByChainId',
mockFindNetworkClientIdByChainId,
);

const controllerMessenger = messenger.getRestricted<
typeof MULTICHAIN_NETWORK_CONTROLLER_NAME,
AllowedActions['type'],
Expand All @@ -90,6 +146,9 @@ function setupController({
allowedActions: [
'NetworkController:setActiveNetwork',
'NetworkController:getState',
'NetworkController:removeNetwork',
'NetworkController:getSelectedChainId',
'NetworkController:findNetworkClientIdByChainId',
],
allowedEvents: ['AccountsController:selectedAccountChange'],
});
Expand Down Expand Up @@ -127,14 +186,17 @@ function setupController({
controller,
mockGetNetworkState,
mockSetActiveNetwork,
mockRemoveNetwork,
mockGetSelectedChainId,
mockFindNetworkClientIdByChainId,
publishSpy,
triggerSelectedAccountChange,
};
}

describe('MultichainNetworkController', () => {
describe('constructor', () => {
it('should set default state', () => {
it('sets default state', () => {
const { controller } = setupController({
options: { state: getDefaultMultichainNetworkControllerState() },
});
Expand All @@ -145,7 +207,7 @@ describe('MultichainNetworkController', () => {
});

describe('setActiveNetwork', () => {
it('should set non-EVM network when same non-EVM chain ID is active', async () => {
it('sets a non-EVM network when same non-EVM chain ID is active', async () => {
// By default, Solana is selected but is NOT active (aka EVM network is active)
const { controller, publishSpy } = setupController();

Expand All @@ -167,7 +229,7 @@ describe('MultichainNetworkController', () => {
);
});

it('should throw error when unsupported non-EVM chainId is provided', async () => {
it('throws an error when unsupported non-EVM chainId is provided', async () => {
const { controller } = setupController();
const unsupportedChainId = 'eip155:1' as CaipChainId;

Expand All @@ -176,7 +238,7 @@ describe('MultichainNetworkController', () => {
).rejects.toThrow(`Unsupported Caip chain ID: ${unsupportedChainId}`);
});

it('should do nothing when same non-EVM chain ID is set and active', async () => {
it('does nothing when same non-EVM chain ID is set and active', async () => {
// By default, Solana is selected and active
const { controller, publishSpy } = setupController({
options: { state: { isEvmSelected: false } },
Expand All @@ -195,7 +257,7 @@ describe('MultichainNetworkController', () => {
expect(publishSpy).not.toHaveBeenCalled();
});

it('should set non-EVM network when different non-EVM chain ID is active', async () => {
it('sets a non-EVM network when different non-EVM chain ID is active', async () => {
// By default, Solana is selected but is NOT active (aka EVM network is active)
const { controller, publishSpy } = setupController({
options: { state: { isEvmSelected: false } },
Expand All @@ -219,7 +281,7 @@ describe('MultichainNetworkController', () => {
);
});

it('should set EVM network and call NetworkController:setActiveNetwork when same EVM network is selected', async () => {
it('sets an EVM network and call NetworkController:setActiveNetwork when same EVM network is selected', async () => {
const selectedNetworkClientId = InfuraNetworkType.mainnet;

const { controller, mockSetActiveNetwork, publishSpy } = setupController({
Expand Down Expand Up @@ -247,7 +309,7 @@ describe('MultichainNetworkController', () => {
expect(mockSetActiveNetwork).not.toHaveBeenCalled();
});

it('should set EVM network and call NetworkController:setActiveNetwork when different EVM network is selected', async () => {
it('sets an EVM network and call NetworkController:setActiveNetwork when different EVM network is selected', async () => {
const { controller, mockSetActiveNetwork, publishSpy } = setupController({
getNetworkState: jest.fn().mockImplementation(() => ({
selectedNetworkClientId: InfuraNetworkType.mainnet,
Expand All @@ -270,7 +332,7 @@ describe('MultichainNetworkController', () => {
expect(mockSetActiveNetwork).toHaveBeenCalledWith(evmNetworkClientId);
});

it('should not do anything when same EVM network is set and active', async () => {
it('does nothing when same EVM network is set and active', async () => {
const { controller, publishSpy } = setupController({
getNetworkState: jest.fn().mockImplementation(() => ({
selectedNetworkClientId: InfuraNetworkType.mainnet,
Expand Down Expand Up @@ -306,7 +368,7 @@ describe('MultichainNetworkController', () => {
expect(controller.state.isEvmSelected).toBe(true);
});

it('should switch to EVM network if non-EVM network is previously active', async () => {
it('switches to EVM network if non-EVM network is previously active', async () => {
// By default, Solana is selected and active
const { controller, triggerSelectedAccountChange } = setupController({
options: { state: { isEvmSelected: false } },
Expand Down Expand Up @@ -379,4 +441,87 @@ describe('MultichainNetworkController', () => {
expect(controller.state.isEvmSelected).toBe(false);
});
});

describe('removeEvmNetwork', () => {
it('switches the EVM selected network to Ethereum Mainnet and deletes previous EVM network if the current selected network is non-EVM', async () => {
const {
controller,
mockSetActiveNetwork,
mockRemoveNetwork,
mockFindNetworkClientIdByChainId,
} = setupController({
options: { state: { isEvmSelected: false } },
getSelectedChainId: jest.fn().mockImplementation(() => '0x2'),
findNetworkClientIdByChainId: jest
.fn()
.mockImplementation(() => 'linea'),
});

await controller.removeNetwork('eip155:2');
expect(mockFindNetworkClientIdByChainId).toHaveBeenCalledWith('0x1');
expect(mockSetActiveNetwork).toHaveBeenCalledWith('linea');
expect(mockRemoveNetwork).toHaveBeenCalledWith('0x2');
});

it('removes an EVM network when isEvmSelected is false and the removed network is not selected', async () => {
const {
controller,
mockRemoveNetwork,
mockSetActiveNetwork,
mockGetSelectedChainId,
mockFindNetworkClientIdByChainId,
} = setupController({
options: { state: { isEvmSelected: false } },
getSelectedChainId: jest.fn().mockImplementation(() => '0x2'),
});

await controller.removeNetwork('eip155:3');
expect(mockGetSelectedChainId).toHaveBeenCalled();
expect(mockFindNetworkClientIdByChainId).not.toHaveBeenCalled();
expect(mockSetActiveNetwork).not.toHaveBeenCalled();
expect(mockRemoveNetwork).toHaveBeenCalledWith('0x3');
});

it('removes an EVM network when isEvmSelected is true and the removed network is not selected', async () => {
const {
controller,
mockRemoveNetwork,
mockSetActiveNetwork,
mockGetSelectedChainId,
mockFindNetworkClientIdByChainId,
} = setupController({
options: { state: { isEvmSelected: false } },
getSelectedChainId: jest.fn().mockImplementation(() => '0x2'),
});

await controller.removeNetwork('eip155:3');
expect(mockGetSelectedChainId).toHaveBeenCalled();
expect(mockFindNetworkClientIdByChainId).not.toHaveBeenCalled();
expect(mockSetActiveNetwork).not.toHaveBeenCalled();
expect(mockRemoveNetwork).toHaveBeenCalledWith('0x3');
});

it('throws an error when trying to remove the currently selected network', async () => {
const { controller } = setupController({
options: { state: { isEvmSelected: true } },
getSelectedChainId: jest.fn().mockImplementation(() => '0x2'),
});

await expect(controller.removeNetwork('eip155:2')).rejects.toThrow(
'Cannot remove the currently selected network',
);
});

it('does nothing when trying to remove a non-EVM network', async () => {
const { controller, mockRemoveNetwork, mockGetSelectedChainId } =
setupController({
options: { state: { isEvmSelected: false } },
});

await controller.removeNetwork(BtcScope.Mainnet);

expect(mockGetSelectedChainId).not.toHaveBeenCalled();
expect(mockRemoveNetwork).not.toHaveBeenCalled();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { BaseController } from '@metamask/base-controller';
import { isEvmAccountType } from '@metamask/keyring-api';
import type { InternalAccount } from '@metamask/keyring-internal-api';
import type { NetworkClientId } from '@metamask/network-controller';
import { isCaipChainId } from '@metamask/utils';
import { type CaipChainId, isCaipChainId } from '@metamask/utils';

import {
MULTICHAIN_NETWORK_CONTROLLER_METADATA,
Expand All @@ -17,6 +17,8 @@ import {
import {
checkIfSupportedCaipChainId,
getChainIdForNonEvmAddress,
convertCaipToHexChainId,
isEvmCaipChainId,
} from './utils';

/**
Expand Down Expand Up @@ -137,6 +139,52 @@ export class MultichainNetworkController extends BaseController<
return await this.#setActiveEvmNetwork(id);
}

/**
* Removes an EVM network from the list of networks.
* This method re-directs the request to the network-controller.
*
* @param chainId - The chain ID of the network to remove.
* @returns - A promise that resolves when the network is removed.
*/
async #removeEvmNetwork(chainId: CaipChainId): Promise<void> {
const hexChainId = convertCaipToHexChainId(chainId);
const selectedChainId = this.messagingSystem.call(
'NetworkController:getSelectedChainId',
);

if (this.state.isEvmSelected && selectedChainId === hexChainId) {
throw new Error('Cannot remove the currently selected network');
}

if (!this.state.isEvmSelected && selectedChainId === hexChainId) {
const ethereumMainnetHexChainId = '0x1';
const clientId = this.messagingSystem.call(
'NetworkController:findNetworkClientIdByChainId',
ethereumMainnetHexChainId,
);

await this.messagingSystem.call(
'NetworkController:setActiveNetwork',
clientId,
);
}

this.messagingSystem.call('NetworkController:removeNetwork', hexChainId);
}

/**
* Removes a network from the list of networks.
* It only supports EVM networks.
*
* @param chainId - The chain ID of the network to remove.
* @returns - A promise that resolves when the network is removed.
*/
async removeNetwork(chainId: CaipChainId): Promise<void> {
if (isEvmCaipChainId(chainId)) {
await this.#removeEvmNetwork(chainId);
}
}

/**
* Handles switching between EVM and non-EVM networks when an account is changed
*
Expand Down
5 changes: 4 additions & 1 deletion packages/multichain-network-controller/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
export { MultichainNetworkController } from './MultichainNetworkController';
export { getDefaultMultichainNetworkControllerState } from './constants';
export {
getDefaultMultichainNetworkControllerState,
AVAILABLE_MULTICHAIN_NETWORK_CONFIGURATIONS,
} from './constants';
export type {
MultichainNetworkMetadata,
SupportedCaipChainId,
Expand Down
10 changes: 8 additions & 2 deletions packages/multichain-network-controller/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import type {
NetworkStatus,
NetworkControllerSetActiveNetworkAction,
NetworkControllerGetStateAction,
NetworkControllerRemoveNetworkAction,
NetworkControllerGetSelectedChainIdAction,
NetworkControllerFindNetworkClientIdByChainIdAction,
NetworkClientId,
} from '@metamask/network-controller';
import { type CaipAssetType } from '@metamask/utils';
Expand All @@ -20,7 +23,7 @@ export type MultichainNetworkMetadata = {
status: NetworkStatus;
};

export type SupportedCaipChainId = SolScope.Mainnet | BtcScope.Mainnet;
export type SupportedCaipChainId = BtcScope.Mainnet | SolScope.Mainnet;

export type CommonNetworkConfiguration = {
/**
Expand Down Expand Up @@ -146,7 +149,10 @@ export type MultichainNetworkControllerEvents =
*/
export type AllowedActions =
| NetworkControllerGetStateAction
| NetworkControllerSetActiveNetworkAction;
| NetworkControllerSetActiveNetworkAction
| NetworkControllerRemoveNetworkAction
| NetworkControllerGetSelectedChainIdAction
| NetworkControllerFindNetworkClientIdByChainIdAction;

// Re-define event here to avoid circular dependency with AccountsController
export type AccountsControllerSelectedAccountChangeEvent = {
Expand Down
Loading
Loading