diff --git a/app/scripts/controllers/network-order.ts b/app/scripts/controllers/network-order.ts index c619aeeb3fa4..24aaa4634913 100644 --- a/app/scripts/controllers/network-order.ts +++ b/app/scripts/controllers/network-order.ts @@ -1,9 +1,11 @@ +import { BtcScope, SolScope } from '@metamask/keyring-api'; import { BaseController, RestrictedMessenger } from '@metamask/base-controller'; import { NetworkControllerStateChangeEvent, NetworkState, } from '@metamask/network-controller'; -import { Hex } from '@metamask/utils'; +import { toEvmCaipChainId } from '@metamask/multichain-network-controller'; +import type { CaipChainId, Hex } from '@metamask/utils'; import type { Patch } from 'immer'; import { TEST_CHAINS } from '../../../shared/constants/network'; @@ -14,7 +16,7 @@ const controllerName = 'NetworkOrderController'; * Information about an ordered network. */ export type NetworksInfo = { - networkId: Hex; // The network's chain id + networkId: CaipChainId; // The network's chain id }; // State shape for NetworkOrderController @@ -112,10 +114,11 @@ export class NetworkOrderController extends BaseController< }: NetworkState) { this.update((state) => { // Filter out testnets, which are in the state but not orderable - const chainIds = Object.keys(networkConfigurationsByChainId).filter( + const hexChainIds = Object.keys(networkConfigurationsByChainId).filter( (chainId) => !TEST_CHAINS.includes(chainId as (typeof TEST_CHAINS)[number]), ) as Hex[]; + const chainIds: CaipChainId[] = hexChainIds.map(toEvmCaipChainId); const newNetworks = chainIds .filter( @@ -128,7 +131,15 @@ export class NetworkOrderController extends BaseController< state.orderedNetworkList = state.orderedNetworkList // Filter out deleted networks - .filter(({ networkId }) => chainIds.includes(networkId)) + .filter( + ({ networkId }) => + chainIds.includes(networkId) || + // Since Bitcoin and Solana are not part of the @metamask/network-controller, we have + // to add a second check to make sure it is not filtered out. + // TO DO: Update this logic to @metamask/multichain-network-controller once all networks are migrated. + // @ts-expect-error - BtcScope.Mainnet and SolScope.Mainnet are of type '`${string}:${string}`' + [BtcScope.Mainnet, SolScope.Mainnet].includes(networkId), + ) // Append new networks to the end .concat(newNetworks); }); @@ -140,7 +151,7 @@ export class NetworkOrderController extends BaseController< * @param networkList - The list of networks to update in the state. */ - updateNetworksList(chainIds: Hex[]) { + updateNetworksList(chainIds: CaipChainId[]) { this.update((state) => { state.orderedNetworkList = chainIds.map((chainId) => ({ networkId: chainId, diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index 14724fbf1baa..587704af1850 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -3287,8 +3287,10 @@ export default class MetamaskController extends EventEmitter { verifyPassword: this.verifyPassword.bind(this), // network management - setActiveNetwork: (networkConfigurationId) => { - return this.networkController.setActiveNetwork(networkConfigurationId); + setActiveNetwork: (id) => { + // The multichain network controller will proxy the call to the network controller + // in the case that the ID is an EVM network client ID. + return this.multichainNetworkController.setActiveNetwork(id); }, // Avoids returning the promise so that initial call to switch network // doesn't block on the network lookup step diff --git a/shared/constants/multichain/networks.ts b/shared/constants/multichain/networks.ts index 659228ba1199..71ce8277d745 100644 --- a/shared/constants/multichain/networks.ts +++ b/shared/constants/multichain/networks.ts @@ -54,7 +54,7 @@ export const MULTICHAIN_NETWORK_BLOCK_EXPLORER_URL_MAP = { 'https://explorer.solana.com/?cluster=testnet', } as const; -export const MULTICHAIN_TOKEN_IMAGE_MAP = { +export const MULTICHAIN_TOKEN_IMAGE_MAP: Record = { [MultichainNetworks.BITCOIN]: BITCOIN_TOKEN_IMAGE_URL, [MultichainNetworks.SOLANA]: SOLANA_TOKEN_IMAGE_URL, } as const; diff --git a/shared/modules/network.utils.test.ts b/shared/modules/network.utils.test.ts index 783d764f8825..672306d32a32 100644 --- a/shared/modules/network.utils.test.ts +++ b/shared/modules/network.utils.test.ts @@ -1,9 +1,12 @@ +import { SolScope, BtcScope } from '@metamask/keyring-api'; import { MAX_SAFE_CHAIN_ID } from '../constants/network'; import { isSafeChainId, isPrefixedFormattedHexString, isTokenDetectionEnabledForNetwork, convertNetworkId, + convertCaipToHexChainId, + sortNetworks, } from './network.utils'; describe('network utils', () => { @@ -132,4 +135,97 @@ describe('network utils', () => { expect(convertNetworkId('1.1')).toStrictEqual(null); }); }); + + describe('convertCaipToHexChainId', () => { + it('converts a CAIP chain ID to a hex chain ID', () => { + expect(convertCaipToHexChainId('eip155:1')).toBe('0x1'); + expect(convertCaipToHexChainId('eip155:56')).toBe('0x38'); + expect(convertCaipToHexChainId('eip155:80094')).toBe('0x138de'); + expect(convertCaipToHexChainId('eip155:8453')).toBe('0x2105'); + }); + + it('throws an error given a CAIP chain ID with an unsupported namespace', () => { + expect(() => + convertCaipToHexChainId('bip122:000000000019d6689c085ae165831e93'), + ).toThrow( + 'Unsupported CAIP chain ID namespace: bip122. Only eip155 is supported.', + ); + expect(() => + convertCaipToHexChainId('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'), + ).toThrow( + 'Unsupported CAIP chain ID namespace: solana. Only eip155 is supported.', + ); + }); + }); + + describe('sortNetworks', () => { + it('sorts a list of networks based on the order of their chain IDs', () => { + const networks = { + [SolScope.Mainnet]: { + chainId: SolScope.Mainnet, + name: 'Solana Mainnet', + nativeCurrency: `${SolScope.Mainnet}/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v`, + isEvm: false, + }, + 'eip155:1': { + chainId: 'eip155:1', + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + blockExplorerUrls: ['https://etherscan.io'], + defaultBlockExplorerUrlIndex: 0, + isEvm: true, + }, + 'eip155:11155111': { + chainId: 'eip155:11155111', + name: 'Sepolia', + nativeCurrency: 'SepoliaETH', + blockExplorerUrls: ['https://sepolia.etherscan.io'], + defaultBlockExplorerUrlIndex: 0, + isEvm: true, + }, + [BtcScope.Mainnet]: { + chainId: BtcScope.Mainnet, + name: 'Bitcoin Mainnet', + nativeCurreny: `${BtcScope.Mainnet}/slip44:0`, + isEvm: false, + }, + }; + const sortedChainIds = [ + { networkId: SolScope.Mainnet }, + { networkId: BtcScope.Mainnet }, + { networkId: 'eip155:1' }, + { networkId: 'eip155:11155111' }, + ]; + expect(sortNetworks(networks, sortedChainIds)).toStrictEqual([ + { + chainId: SolScope.Mainnet, + name: 'Solana Mainnet', + nativeCurrency: `${SolScope.Mainnet}/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v`, + isEvm: false, + }, + { + chainId: BtcScope.Mainnet, + name: 'Bitcoin Mainnet', + nativeCurreny: `${BtcScope.Mainnet}/slip44:0`, + isEvm: false, + }, + { + chainId: 'eip155:1', + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + blockExplorerUrls: ['https://etherscan.io'], + defaultBlockExplorerUrlIndex: 0, + isEvm: true, + }, + { + chainId: 'eip155:11155111', + name: 'Sepolia', + nativeCurrency: 'SepoliaETH', + blockExplorerUrls: ['https://sepolia.etherscan.io'], + defaultBlockExplorerUrlIndex: 0, + isEvm: true, + }, + ]); + }); + }); }); diff --git a/shared/modules/network.utils.ts b/shared/modules/network.utils.ts index 764a34bb8520..b48745763e5e 100644 --- a/shared/modules/network.utils.ts +++ b/shared/modules/network.utils.ts @@ -1,5 +1,12 @@ -import { isStrictHexString } from '@metamask/utils'; +import { + type Hex, + type CaipChainId, + KnownCaipNamespace, + isStrictHexString, + parseCaipChainId, +} from '@metamask/utils'; import { convertHexToDecimal } from '@metamask/controller-utils'; +import type { MultichainNetworkConfiguration } from '@metamask/multichain-network-controller'; import { CHAIN_IDS, MAX_SAFE_CHAIN_ID } from '../constants/network'; /** @@ -91,3 +98,37 @@ export function convertNetworkId(value: unknown): string | null { } return null; } + +/** + * Convert an eip155 CAIP chain ID to a hex chain ID. + * + * @param id - The CAIP chain ID to convert. + * @returns The hex chain ID. + */ +export function convertCaipToHexChainId(id: CaipChainId): Hex { + const { namespace, reference } = parseCaipChainId(id); + if (namespace === KnownCaipNamespace.Eip155) { + return `0x${parseInt(reference, 10).toString(16)}`; + } + + throw new Error( + `Unsupported CAIP chain ID namespace: ${namespace}. Only eip155 is supported.`, + ); +} + +/** + * Sorts a list of networks based on the order of their chain IDs. + * + * @param networks - The networks to sort. + * @param sortedChainIds - The chain IDs to sort by. + * @returns The sorted list of networks. + */ +export const sortNetworks = ( + networks: Record, + sortedChainIds: { networkId: string }[], +): MultichainNetworkConfiguration[] => + Object.values(networks).sort( + (a, b) => + sortedChainIds.findIndex(({ networkId }) => networkId === a.chainId) - + sortedChainIds.findIndex(({ networkId }) => networkId === b.chainId), + ); diff --git a/ui/components/multichain/app-header/app-header-locked-content.tsx b/ui/components/multichain/app-header/app-header-locked-content.tsx index 9923b2d31281..6b3ee13ec034 100644 --- a/ui/components/multichain/app-header/app-header-locked-content.tsx +++ b/ui/components/multichain/app-header/app-header-locked-content.tsx @@ -1,3 +1,4 @@ +import { type MultichainNetworkConfiguration } from '@metamask/multichain-network-controller'; import React from 'react'; import { useHistory } from 'react-router-dom'; import { useSelector } from 'react-redux'; @@ -6,16 +7,17 @@ import MetafoxLogo from '../../ui/metafox-logo'; import { PickerNetwork } from '../../component-library'; import { DEFAULT_ROUTE } from '../../../helpers/constants/routes'; import { getTestNetworkBackgroundColor } from '../../../selectors'; -import { MultichainNetwork } from '../../../selectors/multichain'; type AppHeaderLockedContentProps = { - currentNetwork: MultichainNetwork; + currentNetwork: MultichainNetworkConfiguration; + networkIconSrc: string; networkOpenCallback: () => void; }; export const AppHeaderLockedContent = ({ currentNetwork, networkOpenCallback, + networkIconSrc, }: AppHeaderLockedContentProps) => { const t = useI18nContext(); const history = useHistory(); @@ -29,11 +31,11 @@ export const AppHeaderLockedContent = ({ avatarNetworkProps={{ backgroundColor: testNetworkBackgroundColor, role: 'img', - name: currentNetwork?.nickname ?? '', + name: currentNetwork.name, }} - aria-label={`${t('networkMenu')} ${currentNetwork?.nickname}`} - label={currentNetwork?.nickname ?? ''} - src={currentNetwork?.network?.rpcPrefs?.imageUrl} + aria-label={`${t('networkMenu')} ${currentNetwork.name}`} + label={currentNetwork.name} + src={networkIconSrc} onClick={(e: React.MouseEvent) => { e.stopPropagation(); e.preventDefault(); diff --git a/ui/components/multichain/app-header/app-header-unlocked-content.tsx b/ui/components/multichain/app-header/app-header-unlocked-content.tsx index 84f6ccf8159a..e150c8fb82ea 100644 --- a/ui/components/multichain/app-header/app-header-unlocked-content.tsx +++ b/ui/components/multichain/app-header/app-header-unlocked-content.tsx @@ -2,6 +2,7 @@ import React, { useContext, useState } from 'react'; import browser from 'webextension-polyfill'; import { InternalAccount } from '@metamask/keyring-internal-api'; +import { type MultichainNetworkConfiguration } from '@metamask/multichain-network-controller'; import { useDispatch, useSelector } from 'react-redux'; import { useHistory } from 'react-router-dom'; import { @@ -56,12 +57,12 @@ import { useCopyToClipboard } from '../../../hooks/useCopyToClipboard'; import { MINUTE } from '../../../../shared/constants/time'; import { NotificationsTagCounter } from '../notifications-tag-counter'; import { REVIEW_PERMISSIONS } from '../../../helpers/constants/routes'; -import { MultichainNetwork } from '../../../selectors/multichain'; type AppHeaderUnlockedContentProps = { popupStatus: boolean; isEvmNetwork: boolean; - currentNetwork: MultichainNetwork; + currentNetwork: MultichainNetworkConfiguration; + networkIconSrc: string; networkOpenCallback: () => void; disableNetworkPicker: boolean; disableAccountPicker: boolean; @@ -73,6 +74,7 @@ export const AppHeaderUnlockedContent = ({ popupStatus, isEvmNetwork, currentNetwork, + networkIconSrc, networkOpenCallback, disableNetworkPicker, disableAccountPicker, @@ -126,19 +128,19 @@ export const AppHeaderUnlockedContent = ({ <> {popupStatus ? ( - + ) => { e.stopPropagation(); e.preventDefault(); diff --git a/ui/components/multichain/app-header/app-header.js b/ui/components/multichain/app-header/app-header.js index 904862ae7bb8..2ab3029af068 100644 --- a/ui/components/multichain/app-header/app-header.js +++ b/ui/components/multichain/app-header/app-header.js @@ -30,7 +30,13 @@ import { getEnvironmentType } from '../../../../app/scripts/lib/util'; import { ENVIRONMENT_TYPE_POPUP } from '../../../../shared/constants/app'; import { getIsUnlocked } from '../../../ducks/metamask/metamask'; import { SEND_STAGES, getSendStage } from '../../../ducks/send'; -import { getMultichainNetwork } from '../../../selectors/multichain'; +import { + getSelectedMultichainNetworkConfiguration, + getIsEvmMultichainNetworkSelected, +} from '../../../selectors/multichain/networks'; +import { CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP } from '../../../../shared/constants/network'; +import { MULTICHAIN_TOKEN_IMAGE_MAP } from '../../../../shared/constants/multichain/networks'; +import { convertCaipToHexChainId } from '../../../../shared/modules/network.utils'; import { MultichainMetaFoxLogo } from './multichain-meta-fox-logo'; import { AppHeaderContainer } from './app-header-container'; import { AppHeaderUnlockedContent } from './app-header-unlocked-content'; @@ -41,8 +47,15 @@ export const AppHeader = ({ location }) => { const menuRef = useRef(null); const isUnlocked = useSelector(getIsUnlocked); - const multichainNetwork = useSelector(getMultichainNetwork); - const { chainId, isEvmNetwork } = multichainNetwork; + const multichainNetwork = useSelector( + getSelectedMultichainNetworkConfiguration, + ); + const isEvmNetwork = useSelector(getIsEvmMultichainNetworkSelected); + + const { chainId } = multichainNetwork; + const networkIconSrc = multichainNetwork.isEvm + ? CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP[convertCaipToHexChainId(chainId)] + : MULTICHAIN_TOKEN_IMAGE_MAP[chainId]; const dispatch = useDispatch(); @@ -77,8 +90,7 @@ export const AppHeader = ({ location }) => { isSwapsPage || isTransactionEditPage || isConfirmationPage || - hasUnapprovedTransactions || - !isEvmNetwork; + hasUnapprovedTransactions; // Callback for network dropdown const networkOpenCallback = useCallback(() => { @@ -142,6 +154,7 @@ export const AppHeader = ({ location }) => { popupStatus={popupStatus} isEvmNetwork={isEvmNetwork} currentNetwork={multichainNetwork} + networkIconSrc={networkIconSrc} networkOpenCallback={networkOpenCallback} disableNetworkPicker={disableNetworkPicker} disableAccountPicker={disableAccountPicker} @@ -150,6 +163,7 @@ export const AppHeader = ({ location }) => { ) : ( )} diff --git a/ui/components/multichain/network-list-menu/network-list-menu.tsx b/ui/components/multichain/network-list-menu/network-list-menu.tsx index ce34e27b3a04..2fa550cef1c3 100644 --- a/ui/components/multichain/network-list-menu/network-list-menu.tsx +++ b/ui/components/multichain/network-list-menu/network-list-menu.tsx @@ -9,11 +9,13 @@ import { useDispatch, useSelector } from 'react-redux'; import Fuse from 'fuse.js'; import * as URI from 'uri-js'; import { - NetworkConfiguration, RpcEndpointType, + type UpdateNetworkFields, } from '@metamask/network-controller'; -import { Hex } from '@metamask/utils'; +import { type MultichainNetworkConfiguration } from '@metamask/multichain-network-controller'; +import { type CaipChainId, type Hex } from '@metamask/utils'; import { useI18nContext } from '../../../hooks/useI18nContext'; +import { useAccountCreationOnNetworkChange } from '../../../hooks/accounts/useAccountCreationOnNetworkChange'; import { NetworkListItem } from '../network-list-item'; import { hideNetworkBanner, @@ -33,10 +35,10 @@ import { } from '../../../store/actions'; import { CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP, - CHAIN_IDS, FEATURED_RPCS, TEST_CHAINS, } from '../../../../shared/constants/network'; +import { MULTICHAIN_TOKEN_IMAGE_MAP } from '../../../../shared/constants/multichain/networks'; import { getNetworkConfigurationsByChainId, getCurrentChainId, @@ -54,6 +56,7 @@ import { getPermittedChainsForSelectedTab, getPermittedAccountsForSelectedTab, getPreferences, + getMultichainNetworkConfigurationsByChainId, } from '../../../selectors'; import ToggleButton from '../../ui/toggle-button'; import { @@ -85,6 +88,10 @@ import { MetaMetricsEventCategory, MetaMetricsEventName, } from '../../../../shared/constants/metametrics'; +import { + convertCaipToHexChainId, + sortNetworks, +} from '../../../../shared/modules/network.utils'; import { getCompletedOnboarding, getIsUnlocked, @@ -114,6 +121,8 @@ export const NetworkListMenu = ({ onClose }: { onClose: () => void }) => { const t = useI18nContext(); const dispatch = useDispatch(); const trackEvent = useContext(MetaMetricsContext); + const { createAccount, isAccountInNetwork } = + useAccountCreationOnNetworkChange(); const { tokenNetworkFilter } = useSelector(getPreferences); const showTestNetworks = useSelector(getShowTestNetworks); @@ -127,7 +136,16 @@ export const NetworkListMenu = ({ onClose }: { onClose: () => void }) => { const completedOnboarding = useSelector(getCompletedOnboarding); const onboardedInThisUISession = useSelector(getOnboardedInThisUISession); const showNetworkBanner = useSelector(getShowNetworkBanner); - const allNetworks = useSelector(getNetworkConfigurationsByChainId); + // This selector provides all network configurations including EVM and non-EVM + // with the data type MultichainNetworkConfiguration from @metamask/multichain-network-controller + const multichainNetworks = useSelector( + getMultichainNetworkConfigurationsByChainId, + ); + // This selector provides all EVM network configurations with the + // data type NetworkConfiguration from @metamask/network-controller. + // It includes necessary data like the RPC endpoints that are not + // part of @metamask/multichain-network-controller. + const evmNetworks = useSelector(getNetworkConfigurationsByChainId); const networkConfigurations = useSelector(getNetworkConfigurationsByChainId); const { chainId: editingChainId, editCompleted } = useSelector(getEditedNetwork) ?? {}; @@ -144,30 +162,35 @@ export const NetworkListMenu = ({ onClose }: { onClose: () => void }) => { ); const [nonTestNetworks, testNetworks] = useMemo( () => - Object.entries(networkConfigurations).reduce( - ([nonTestNetworksList, testNetworksList], [chainId, network]) => { + Object.entries(multichainNetworks).reduce( + ([nonTestNetworksList, testNetworksList], [id, network]) => { + const chainId = network.isEvm ? convertCaipToHexChainId(id) : id; const isTest = (TEST_CHAINS as string[]).includes(chainId); (isTest ? testNetworksList : nonTestNetworksList)[chainId] = network; return [nonTestNetworksList, testNetworksList]; }, [ - {} as Record, - {} as Record, + {} as Record, + {} as Record, ], ), - [networkConfigurations], + [multichainNetworks], ); // The network currently being edited, or undefined // if the user is not currently editing a network. + // This memoized value is EVM specific, therefore we + // provide the networkConfigurations object as a dependency. const editedNetwork = useMemo( - () => + (): UpdateNetworkFields | undefined => !editingChainId || editCompleted ? undefined - : Object.entries(networkConfigurations).find( - ([chainId]) => chainId === editingChainId, + : Object.entries(evmNetworks).find( + ([chainId]) => + chainId === + convertCaipToHexChainId(editingChainId as CaipChainId), )?.[1], - [editingChainId, editCompleted, networkConfigurations], + [editingChainId, editCompleted, evmNetworks], ); // Tracks which page the user is on @@ -181,22 +204,12 @@ export const NetworkListMenu = ({ onClose }: { onClose: () => void }) => { const { rpcUrls, setRpcUrls, blockExplorers, setBlockExplorers } = networkFormState; - const sortNetworks = (networks: Record) => - Object.values(networks).sort( - (a, b) => - orderedNetworksList.findIndex( - ({ networkId }) => networkId === a.chainId, - ) - - orderedNetworksList.findIndex( - ({ networkId }) => networkId === b.chainId, - ), - ); - const [orderedNetworks, setOrderedNetworks] = useState( - sortNetworks(nonTestNetworks), + sortNetworks(nonTestNetworks, orderedNetworksList), ); useEffect( - () => setOrderedNetworks(sortNetworks(nonTestNetworks)), + () => + setOrderedNetworks(sortNetworks(nonTestNetworks, orderedNetworksList)), [nonTestNetworks, orderedNetworksList], ); @@ -246,40 +259,48 @@ export const NetworkListMenu = ({ onClose }: { onClose: () => void }) => { searchQuery, ); - // If any network has multiple RPC endpoints, show multi-rpc selectors for all networks - const showMultiRpcSelectors = [ - ...searchedEnabledNetworks, - ...searchedTestNetworks, - ].some((network) => network.rpcEndpoints.length > 1); + const getRpcEndpointsByChainId = (chainId: CaipChainId) => { + const hexChainId = convertCaipToHexChainId(chainId); + const evmNetworkConfig = evmNetworks[hexChainId]; + return evmNetworkConfig.rpcEndpoints; + }; + + const getDefaultRpcEndpointByChainId = (chainId: CaipChainId) => { + const hexChainId = convertCaipToHexChainId(chainId); + const evmNetworkConfig = evmNetworks[hexChainId]; + const { rpcEndpoints, defaultRpcEndpointIndex } = evmNetworkConfig; + return rpcEndpoints[defaultRpcEndpointIndex]; + }; - const handleNetworkChange = (network: NetworkConfiguration) => { - const allOpts = Object.keys(allNetworks).reduce((acc, chainId) => { - acc[chainId] = true; - return acc; - }, {} as Record); + const getClientIdByChainId = (chainId: CaipChainId) => { + const defaultRpcEndpoint = getDefaultRpcEndpointByChainId(chainId); + return defaultRpcEndpoint.networkClientId; + }; - const { networkClientId } = - network.rpcEndpoints[network.defaultRpcEndpointIndex]; + const handleEvmNetworkChange = (chainId: CaipChainId) => { + const hexChainId = convertCaipToHexChainId(chainId); + const networkClientId = getClientIdByChainId(chainId); dispatch(setActiveNetwork(networkClientId)); - dispatch(toggleNetworkMenu()); dispatch(updateCustomNonce('')); dispatch(setNextNonce('')); dispatch(detectNfts()); - // as a user, I don't want my network selection to force update my filter when I have "All Networks" toggled on - // however, if I am already filtered on "Current Network", we'll want to filter by the selected network when the network changes + dispatch(toggleNetworkMenu()); + + // as a user, I don't want my network selection to force update my filter + // when I have "All Networks" toggled on however, if I am already filtered + // on "Current Network", we'll want to filter by the selected network when + // the network changes. if (Object.keys(tokenNetworkFilter || {}).length <= 1) { - dispatch(setTokenNetworkFilter({ [network.chainId]: true })); + dispatch(setTokenNetworkFilter({ [chainId]: true })); } else if (process.env.PORTFOLIO_VIEW) { + const allOpts = Object.keys(evmNetworks).reduce((acc, id) => { + acc[id] = true; + return acc; + }, {} as Record); dispatch(setTokenNetworkFilter(allOpts)); } - if (permittedAccountAddresses.length > 0) { - dispatch(addPermittedChain(selectedTabOrigin, network.chainId)); - if (!permittedChainIds.includes(network.chainId)) { - dispatch(showPermittedNetworkToast()); - } - } // If presently on a dapp, communicate a change to // the dapp via silent switchEthereumChain that the // network has changed due to user action @@ -291,6 +312,33 @@ export const NetworkListMenu = ({ onClose }: { onClose: () => void }) => { setNetworkClientIdForDomain(selectedTabOrigin, networkClientId); } + if (permittedAccountAddresses.length > 0) { + dispatch(addPermittedChain(selectedTabOrigin, hexChainId)); + if (!permittedChainIds.includes(hexChainId)) { + dispatch(showPermittedNetworkToast()); + } + } + }; + + const handleNonEvmNetworkChange = async (chainId: CaipChainId) => { + if (isAccountInNetwork(chainId)) { + dispatch(toggleNetworkMenu()); + dispatch(setActiveNetwork(chainId)); + return; + } + + dispatch(toggleNetworkMenu()); + await createAccount(chainId); + }; + + const handleNetworkChange = async (chainId: CaipChainId) => { + const { isEvm } = multichainNetworks[chainId]; + if (isEvm) { + handleEvmNetworkChange(chainId); + } else { + await handleNonEvmNetworkChange(chainId); + } + trackEvent({ event: MetaMetricsEventName.NavNetworkSwitched, category: MetaMetricsEventCategory.Network, @@ -298,65 +346,91 @@ export const NetworkListMenu = ({ onClose }: { onClose: () => void }) => { location: 'Network Menu', chain_id: currentChainId, from_network: currentChainId, - to_network: network.chainId, + to_network: chainId, }, }); }; + const getNetworkFlags = (network: MultichainNetworkConfiguration) => { + if (!network.isEvm) { + return { + isDeletable: false, + isEditable: false, + hasMultiRpcOptions: false, + }; + } + + return { + isDeletable: + isUnlocked && + network.chainId !== currentChainId && + network.chainId !== 'eip155:1', + isEditable: true, + hasMultiRpcOptions: getRpcEndpointsByChainId(network.chainId).length > 1, + }; + }; + // Renders a network in the network list - const generateNetworkListItem = (network: NetworkConfiguration) => { + const generateMultichainNetworkListItem = ( + network: MultichainNetworkConfiguration, + ) => { const isCurrentNetwork = network.chainId === currentChainId; - const canDeleteNetwork = - isUnlocked && !isCurrentNetwork && network.chainId !== CHAIN_IDS.MAINNET; + const { isDeletable, isEditable, hasMultiRpcOptions } = + getNetworkFlags(network); + + const onDelete = () => { + dispatch(toggleNetworkMenu()); + dispatch( + showModal({ + name: 'CONFIRM_DELETE_NETWORK', + target: network.chainId, + onConfirm: () => undefined, + }), + ); + }; + + const onEdit = () => { + dispatch( + setEditedNetwork({ + chainId: network.chainId, + nickname: network.name, + }), + ); + setActionMode(ACTION_MODES.ADD_EDIT); + }; + + const onRpcConfigEdit = () => { + setActionMode(ACTION_MODES.SELECT_RPC); + dispatch(setEditedNetwork({ chainId: network.chainId })); + }; + + const iconSrc = network.isEvm + ? CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP[ + convertCaipToHexChainId( + network.chainId, + ) as keyof typeof CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP + ] + : MULTICHAIN_TOKEN_IMAGE_MAP[network.chainId]; return ( { - handleNetworkChange(network); - }} - onDeleteClick={ - canDeleteNetwork - ? () => { - dispatch(toggleNetworkMenu()); - dispatch( - showModal({ - name: 'CONFIRM_DELETE_NETWORK', - target: network.chainId, - onConfirm: () => undefined, - }), - ); - } + rpcEndpoint={ + hasMultiRpcOptions + ? getDefaultRpcEndpointByChainId(network.chainId) : undefined } - onEditClick={() => { - dispatch( - setEditedNetwork({ - chainId: network.chainId, - nickname: network.name, - }), - ); - setActionMode(ACTION_MODES.ADD_EDIT); - }} - onRpcEndpointClick={() => { - setActionMode(ACTION_MODES.SELECT_RPC); - dispatch(setEditedNetwork({ chainId: network.chainId })); + onClick={async () => { + await handleNetworkChange(network.chainId); }} + onDeleteClick={isDeletable ? () => onDelete() : undefined} + onEditClick={isEditable ? () => onEdit() : undefined} + onRpcEndpointClick={network.isEvm ? undefined : onRpcConfigEdit} /> ); }; @@ -446,7 +520,7 @@ export const NetworkListMenu = ({ onClose }: { onClose: () => void }) => { {...providedDrag.draggableProps} {...providedDrag.dragHandleProps} > - {generateNetworkListItem(network)} + {generateMultichainNetworkListItem(network)} )} @@ -493,7 +567,7 @@ export const NetworkListMenu = ({ onClose }: { onClose: () => void }) => { {showTestNetworks || currentlyOnTestNetwork ? ( {searchedTestNetworks.map((network) => - generateNetworkListItem(network), + generateMultichainNetworkListItem(network), )} ) : null} @@ -567,7 +641,7 @@ export const NetworkListMenu = ({ onClose }: { onClose: () => void }) => { return ( ); } diff --git a/ui/components/multichain/network-list-menu/select-rpc-url-modal/select-rpc-url-modal.tsx b/ui/components/multichain/network-list-menu/select-rpc-url-modal/select-rpc-url-modal.tsx index 76cb353e37d1..fc4534a145f8 100644 --- a/ui/components/multichain/network-list-menu/select-rpc-url-modal/select-rpc-url-modal.tsx +++ b/ui/components/multichain/network-list-menu/select-rpc-url-modal/select-rpc-url-modal.tsx @@ -1,5 +1,7 @@ import React from 'react'; +import { toEvmCaipChainId } from '@metamask/multichain-network-controller'; import { NetworkConfiguration } from '@metamask/network-controller'; +import { type CaipChainId } from '@metamask/utils'; import classnames from 'classnames'; import { useDispatch } from 'react-redux'; import { @@ -25,7 +27,7 @@ export const SelectRpcUrlModal = ({ onNetworkChange, }: { networkConfiguration: NetworkConfiguration; - onNetworkChange: (network: NetworkConfiguration) => void; + onNetworkChange: (chainId: CaipChainId) => void; }) => { const dispatch = useDispatch(); @@ -72,7 +74,7 @@ export const SelectRpcUrlModal = ({ }; dispatch(updateNetwork(network)); dispatch(setEditedNetwork()); - onNetworkChange(network); + onNetworkChange(toEvmCaipChainId(network.chainId)); }} className={classnames('select-rpc-url__item', { 'select-rpc-url__item--selected': diff --git a/ui/hooks/accounts/useAccountCreationOnNetworkChange.ts b/ui/hooks/accounts/useAccountCreationOnNetworkChange.ts new file mode 100644 index 000000000000..2a456093c7c9 --- /dev/null +++ b/ui/hooks/accounts/useAccountCreationOnNetworkChange.ts @@ -0,0 +1,47 @@ +import { useSelector } from 'react-redux'; +import { CaipChainId } from '@metamask/utils'; +import { MultichainNetworks } from '../../../shared/constants/multichain/networks'; +import { getMetaMaskAccountsOrdered } from '../../selectors'; +import { + useMultichainWalletSnapClient, + WalletClientType, +} from './useMultichainWalletSnapClient'; + +type UseAccountCreationOnNetworkChangeReturn = { + createAccount: (chainId: CaipChainId) => Promise; + isAccountInNetwork: (chainId: CaipChainId) => boolean; +}; + +export const useAccountCreationOnNetworkChange = + (): UseAccountCreationOnNetworkChangeReturn => { + const bitcoinWalletSnapClient = useMultichainWalletSnapClient( + WalletClientType.Bitcoin, + ); + const solanaWalletSnapClient = useMultichainWalletSnapClient( + WalletClientType.Solana, + ); + const accounts = useSelector(getMetaMaskAccountsOrdered); + + const createAccount = async (chainId: CaipChainId) => { + switch (chainId) { + case MultichainNetworks.BITCOIN: + await bitcoinWalletSnapClient.createAccount( + MultichainNetworks.BITCOIN, + ); + break; + case MultichainNetworks.SOLANA: + await solanaWalletSnapClient.createAccount(MultichainNetworks.SOLANA); + break; + default: + throw new Error(`Unsupported chainId: ${chainId}`); + } + }; + + const isAccountInNetwork = (chainId: CaipChainId) => { + return accounts.some(({ scopes }: { scopes: CaipChainId[] }) => + scopes.includes(chainId), + ); + }; + + return { createAccount, isAccountInNetwork }; + }; diff --git a/ui/pages/settings/networks-tab/networks-form/networks-form.tsx b/ui/pages/settings/networks-tab/networks-form/networks-form.tsx index 4d61c2e7c55a..95fc38f25808 100644 --- a/ui/pages/settings/networks-tab/networks-form/networks-form.tsx +++ b/ui/pages/settings/networks-tab/networks-form/networks-form.tsx @@ -2,7 +2,7 @@ import log from 'loglevel'; import React, { useContext, useEffect, useRef, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { - NetworkConfiguration, + type UpdateNetworkFields, RpcEndpointType, } from '@metamask/network-controller'; import { Hex, isStrictHexString } from '@metamask/utils'; @@ -78,7 +78,7 @@ export const NetworksForm = ({ onBlockExplorerAdd, }: { networkFormState: ReturnType; - existingNetwork?: NetworkConfiguration; + existingNetwork?: UpdateNetworkFields; onRpcAdd: () => void; onBlockExplorerAdd: () => void; }) => { diff --git a/ui/selectors/index.js b/ui/selectors/index.js index 0390d2402a06..674b5a773092 100644 --- a/ui/selectors/index.js +++ b/ui/selectors/index.js @@ -9,3 +9,4 @@ export * from './transactions'; export * from './approvals'; export * from './accounts'; export * from './origin-throttling'; +export * from './multichain/networks'; diff --git a/ui/store/actions.ts b/ui/store/actions.ts index deb1c10650e2..bbf73ecdff53 100644 --- a/ui/store/actions.ts +++ b/ui/store/actions.ts @@ -11,7 +11,7 @@ import { ThunkAction } from 'redux-thunk'; import { Action, AnyAction } from 'redux'; import { providerErrors, serializeError } from '@metamask/rpc-errors'; import type { DataWithOptionalCause } from '@metamask/rpc-errors'; -import type { Hex, Json } from '@metamask/utils'; +import type { CaipChainId, Hex, Json } from '@metamask/utils'; import { AssetsContractController, BalanceMap, @@ -2503,14 +2503,12 @@ export function updateNetwork( } export function setActiveNetwork( - networkConfigurationId: string, + id: string, ): ThunkAction { return async (dispatch) => { - log.debug(`background.setActiveNetwork: ${networkConfigurationId}`); + log.debug(`background.setActiveNetwork: ${id}`); try { - await submitRequestToBackground('setActiveNetwork', [ - networkConfigurationId, - ]); + await submitRequestToBackground('setActiveNetwork', [id]); } catch (error) { logErrorWithMessage(error); dispatch(displayWarning('Had a problem changing networks!')); @@ -3998,7 +3996,7 @@ export function removePermissionsFor( * @param chainIds - An array of hexadecimal chain IDs */ export function updateNetworksList( - chainIds: Hex[], + chainIds: CaipChainId[], ): ThunkAction { return async () => { await submitRequestToBackground('updateNetworksList', [chainIds]);