diff --git a/app/components/UI/AccountInfoCard/index.test.tsx b/app/components/UI/AccountInfoCard/index.test.tsx index fe727c0ee84..a2f1c1bcdde 100644 --- a/app/components/UI/AccountInfoCard/index.test.tsx +++ b/app/components/UI/AccountInfoCard/index.test.tsx @@ -9,6 +9,8 @@ import { MOCK_ADDRESS_1, } from '../../../util/test/accountsControllerTestUtils'; import { RootState } from '../../../reducers'; +import { RpcEndpointType } from '@metamask/network-controller'; +import { mockNetworkState } from '../../../util/test/network'; jest.mock('../../../core/Engine', () => ({ resetState: jest.fn(), @@ -48,20 +50,13 @@ const mockInitialState: DeepPartial = { }, }, NetworkController: { - selectedNetworkClientId: 'sepolia', - networksMetadata: {}, - networkConfigurations: { - sepolia: { - id: 'sepolia', - rpcUrl: 'http://localhost/v3/', - chainId: '0xaa36a7', - ticker: 'ETH', - nickname: 'sepolia', - rpcPrefs: { - blockExplorerUrl: 'https://etherscan.com', - }, - }, - }, + ...mockNetworkState({ + chainId: '0xaa36a7', + id: 'mainnet', + nickname: 'Sepolia', + ticker: 'SepoliaETH', + type: RpcEndpointType.Infura, + }), }, TokenBalancesController: { contractBalances: {}, diff --git a/app/components/UI/NetworkModal/index.tsx b/app/components/UI/NetworkModal/index.tsx index 61493f66945..6d8a7bd14b2 100644 --- a/app/components/UI/NetworkModal/index.tsx +++ b/app/components/UI/NetworkModal/index.tsx @@ -36,6 +36,12 @@ import { useMetrics } from '../../../components/hooks/useMetrics'; import { toHex } from '@metamask/controller-utils'; import { rpcIdentifierUtility } from '../../../components/hooks/useSafeChains'; import Logger from '../../../util/Logger'; +import { selectNetworkConfigurations } from '../../../selectors/networkController'; +import { + NetworkConfiguration, + RpcEndpointType, + AddNetworkFields, +} from '@metamask/network-controller'; export interface SafeChain { chainId: string; @@ -162,6 +168,10 @@ const NetworkModals = (props: NetworkProps) => { selectUseSafeChainsListValidation, ); + const networkConfigurationByChainId = useSelector( + selectNetworkConfigurations, + ); + const customNetworkInformation = { chainId, blockExplorerUrl, @@ -189,52 +199,153 @@ const NetworkModals = (props: NetworkProps) => { checkNetwork(); }, [checkNetwork]); - const closeModal = () => { + const closeModal = async () => { const { NetworkController } = Engine.context; const url = new URLPARSE(rpcUrl); !isPrivateConnection(url.hostname) && url.set('protocol', 'https:'); - NetworkController.upsertNetworkConfiguration( - { - rpcUrl: url.href, + + const existingNetwork = networkConfigurationByChainId[chainId]; + + if (existingNetwork) { + const updatedNetwork = await NetworkController.updateNetwork( + existingNetwork.chainId, + existingNetwork, + existingNetwork.chainId === chainId + ? { + replacementSelectedRpcEndpointIndex: + existingNetwork.defaultRpcEndpointIndex, + } + : undefined, + ); + + const { networkClientId } = + updatedNetwork?.rpcEndpoints?.[ + updatedNetwork.defaultRpcEndpointIndex + ] ?? {}; + + await NetworkController.setActiveNetwork(networkClientId); + } else { + const addedNetwork = await NetworkController.addNetwork({ chainId, - ticker, - nickname, - rpcPrefs: { blockExplorerUrl }, - }, - { - // Metrics-related properties required, but the metric event is a no-op - // TODO: Use events for controller metric events - referrer: 'ignored', - source: 'ignored', - }, - ); + blockExplorerUrls: [blockExplorerUrl], + defaultRpcEndpointIndex: 0, + defaultBlockExplorerUrlIndex: 0, + name: nickname, + nativeCurrency: ticker, + rpcEndpoints: [ + { + url: rpcUrl, + name: nickname, + type: RpcEndpointType.Custom, + }, + ], + }); + + const { networkClientId } = + addedNetwork?.rpcEndpoints?.[addedNetwork.defaultRpcEndpointIndex] ?? + {}; + + await NetworkController.setActiveNetwork(networkClientId); + } onClose(); }; - const switchNetwork = () => { + const handleExistingNetwork = async ( + existingNetwork: NetworkConfiguration, + networkId: string, + ) => { + const { NetworkController } = Engine.context; + const updatedNetwork = await NetworkController.updateNetwork( + existingNetwork.chainId, + existingNetwork, + existingNetwork.chainId === networkId + ? { + replacementSelectedRpcEndpointIndex: + existingNetwork.defaultRpcEndpointIndex, + } + : undefined, + ); + + const { networkClientId } = + updatedNetwork?.rpcEndpoints?.[updatedNetwork.defaultRpcEndpointIndex] ?? + {}; + + await NetworkController.setActiveNetwork(networkClientId); + }; + + const handleNewNetwork = async ( + networkId: `0x${string}`, + networkRpcUrl: string, + name: string, + nativeCurrency: string, + networkBlockExplorerUrl: string, + ) => { + const { NetworkController } = Engine.context; + const networkConfig = { + chainId: networkId, + blockExplorerUrls: networkBlockExplorerUrl + ? [networkBlockExplorerUrl] + : [], + defaultRpcEndpointIndex: 0, + defaultBlockExplorerUrlIndex: blockExplorerUrl ? 0 : undefined, + name, + nativeCurrency, + rpcEndpoints: [ + { + url: networkRpcUrl, + name, + type: RpcEndpointType.Custom, + }, + ], + } as AddNetworkFields; + + return NetworkController.addNetwork(networkConfig); + }; + + const handleNavigation = ( + onSwitchNetwork: () => void, + networkSwitchPopToWallet: boolean, + ) => { + if (onSwitchNetwork) { + onSwitchNetwork(); + } else { + networkSwitchPopToWallet + ? navigation.navigate('WalletView') + : navigation.goBack(); + } + }; + + const switchNetwork = async () => { const { NetworkController, CurrencyRateController } = Engine.context; const url = new URLPARSE(rpcUrl); + const existingNetwork = networkConfigurationByChainId[chainId]; + CurrencyRateController.updateExchangeRate(ticker); - !isPrivateConnection(url.hostname) && url.set('protocol', 'https:'); - NetworkController.upsertNetworkConfiguration( - { - rpcUrl: url.href, + + if (!isPrivateConnection(url.hostname)) { + url.set('protocol', 'https:'); + } + + if (existingNetwork) { + await handleExistingNetwork(existingNetwork, chainId); + } else { + const addedNetwork = await handleNewNetwork( chainId, - ticker, + rpcUrl, nickname, - rpcPrefs: { blockExplorerUrl }, - }, - { - setActive: true, - // Metrics-related properties required, but the metric event is a no-op - // TODO: Use events for controller metric events - referrer: 'ignored', - source: 'ignored', - }, - ); - closeModal(); + ticker, + blockExplorerUrl, + ); + const { networkClientId } = + addedNetwork?.rpcEndpoints?.[addedNetwork.defaultRpcEndpointIndex] ?? + {}; + + NetworkController.setActiveNetwork(networkClientId); + } + onClose(); + if (onNetworkSwitch) { - onNetworkSwitch(); + handleNavigation(onNetworkSwitch, shouldNetworkSwitchPopToWallet); } else { shouldNetworkSwitchPopToWallet ? navigation.navigate('WalletView') diff --git a/app/components/UI/Ramp/Views/NetworkSwitcher/NetworkSwitcher.test.tsx b/app/components/UI/Ramp/Views/NetworkSwitcher/NetworkSwitcher.test.tsx index d4921f31148..c79c4001d27 100644 --- a/app/components/UI/Ramp/Views/NetworkSwitcher/NetworkSwitcher.test.tsx +++ b/app/components/UI/Ramp/Views/NetworkSwitcher/NetworkSwitcher.test.tsx @@ -90,7 +90,7 @@ function render(Component: React.ComponentType, chainId?: `0x${string}`) { chainId: '0x89', id: 'networkId1', nickname: 'Polygon Mainnet', - ticker: 'MATIC', + ticker: 'POL', }, ), }, diff --git a/app/components/UI/Ramp/Views/NetworkSwitcher/NetworkSwitcher.tsx b/app/components/UI/Ramp/Views/NetworkSwitcher/NetworkSwitcher.tsx index cf30293c73b..d1235a4f8b4 100644 --- a/app/components/UI/Ramp/Views/NetworkSwitcher/NetworkSwitcher.tsx +++ b/app/components/UI/Ramp/Views/NetworkSwitcher/NetworkSwitcher.tsx @@ -154,16 +154,22 @@ function NetworkSwitcher() { const switchNetwork = useCallback( (networkConfiguration) => { const { CurrencyRateController, NetworkController } = Engine.context; - const entry = Object.entries(networkConfigurations).find( - ([_a, { chainId }]) => chainId === networkConfiguration.chainId, + const config = Object.values(networkConfigurations).find( + ({ chainId }) => chainId === networkConfiguration.chainId, ); - if (entry) { - const [networkConfigurationId] = entry; - const { ticker } = networkConfiguration; + if (config) { + const { + nativeCurrency: ticker, + rpcEndpoints, + defaultRpcEndpointIndex, + } = config; + + const { networkClientId } = + rpcEndpoints?.[defaultRpcEndpointIndex] ?? {}; CurrencyRateController.updateExchangeRate(ticker); - NetworkController.setActiveNetwork(networkConfigurationId); + NetworkController.setActiveNetwork(networkClientId); navigateToGetStarted(); } }, diff --git a/app/components/UI/ReceiveRequest/index.test.tsx b/app/components/UI/ReceiveRequest/index.test.tsx index 6431db9c31a..cc627eb8e64 100644 --- a/app/components/UI/ReceiveRequest/index.test.tsx +++ b/app/components/UI/ReceiveRequest/index.test.tsx @@ -1,19 +1,23 @@ import { cloneDeep } from 'lodash'; +import { RpcEndpointType } from '@metamask/network-controller'; import ReceiveRequest from './'; import { renderScreen } from '../../../util/test/renderWithProvider'; import { backgroundState } from '../../../util/test/initial-root-state'; import { MOCK_ACCOUNTS_CONTROLLER_STATE } from '../../../util/test/accountsControllerTestUtils'; +import { mockNetworkState } from '../../../util/test/network'; const initialState = { engine: { backgroundState: { ...backgroundState, NetworkController: { - providerConfig: { - type: 'mainnet', - chainId: '0x1', + ...mockNetworkState({ + id: 'mainnet', + nickname: 'Ethereum', ticker: 'ETH', - }, + chainId: '0x1', + type: RpcEndpointType.Infura, + }), }, AccountsController: MOCK_ACCOUNTS_CONTROLLER_STATE, }, @@ -47,7 +51,6 @@ describe('ReceiveRequest', () => { const { toJSON } = renderScreen( ReceiveRequest, { name: 'ReceiveRequest' }, - // @ts-expect-error initialBackgroundState throws error { state: initialState }, ); expect(toJSON()).toMatchSnapshot(); @@ -55,12 +58,12 @@ describe('ReceiveRequest', () => { it('render with different ticker matches snapshot', () => { const state = cloneDeep(initialState); - state.engine.backgroundState.NetworkController.providerConfig.ticker = - 'DIFF'; + state.engine.backgroundState.NetworkController.networkConfigurationsByChainId[ + '0x1' + ].nativeCurrency = 'DIFF'; const { toJSON } = renderScreen( ReceiveRequest, { name: 'ReceiveRequest' }, - // @ts-expect-error initialBackgroundState throws error { state }, ); expect(toJSON()).toMatchSnapshot(); @@ -74,7 +77,6 @@ describe('ReceiveRequest', () => { const { toJSON } = renderScreen( ReceiveRequest, { name: 'ReceiveRequest' }, - // @ts-expect-error initialBackgroundState throws error { state }, ); expect(toJSON()).toMatchSnapshot(); diff --git a/app/components/Views/AssetDetails/index.tsx b/app/components/Views/AssetDetails/index.tsx index a67d3920e0d..56d48e62bd2 100644 --- a/app/components/Views/AssetDetails/index.tsx +++ b/app/components/Views/AssetDetails/index.tsx @@ -46,6 +46,7 @@ import { useMetrics } from '../../../components/hooks/useMetrics'; import { RootState } from 'app/reducers'; import { Colors } from '../../../util/theme/models'; import { Hex } from '@metamask/utils'; +import { RpcEndpointType } from '@metamask/network-controller'; const createStyles = (colors: Colors) => StyleSheet.create({ @@ -138,8 +139,7 @@ const AssetDetails = (props: Props) => { * removes goerli from provider config types */ // eslint-disable-next-line @typescript-eslint/no-explicit-any - (Networks as any)[providerConfig.type]?.name || - { ...Networks.rpc, color: null }.name; + (Networks as any)[providerConfig?.type ?? RpcEndpointType.Custom]; } return name; }; diff --git a/app/components/Views/MultiRpcModal/MultiRpcModal.tsx b/app/components/Views/MultiRpcModal/MultiRpcModal.tsx index 71845b62a19..7925bd6c862 100644 --- a/app/components/Views/MultiRpcModal/MultiRpcModal.tsx +++ b/app/components/Views/MultiRpcModal/MultiRpcModal.tsx @@ -28,6 +28,7 @@ import { useSelector } from 'react-redux'; import Cell, { CellVariant, } from '../../../component-library/components/Cells/Cell'; +import { NetworkConfiguration } from '@metamask/network-controller'; import { AvatarSize, AvatarVariant, @@ -81,43 +82,49 @@ const MultiRpcModal = () => { {Object.values(networkConfigurations).map( - (networkConfiguration, index) => ( - { - sheetRef.current?.onCloseBottomSheet(() => { - navigate(Routes.ADD_NETWORK, { - shouldNetworkSwitchPopToWallet: false, - shouldShowPopularNetworks: false, - network: networkConfiguration.rpcUrl, + (networkConfiguration: NetworkConfiguration, index) => + networkConfiguration.rpcEndpoints.length > 1 ? ( + { + sheetRef.current?.onCloseBottomSheet(() => { + navigate(Routes.ADD_NETWORK, { + shouldNetworkSwitchPopToWallet: false, + shouldShowPopularNetworks: false, + network: + networkConfiguration?.rpcEndpoints?.[ + networkConfiguration?.defaultRpcEndpointIndex + ].url, + }); }); - }); - }, - }} - /> - ), + }, + }} + /> + ) : null, )} diff --git a/app/components/Views/NetworkSelector/NetworkSelector.test.tsx b/app/components/Views/NetworkSelector/NetworkSelector.test.tsx index 9e53784fbb2..6dacf921a1a 100644 --- a/app/components/Views/NetworkSelector/NetworkSelector.test.tsx +++ b/app/components/Views/NetworkSelector/NetworkSelector.test.tsx @@ -12,6 +12,8 @@ import NetworkSelector from './NetworkSelector'; import { CHAIN_IDS } from '@metamask/transaction-controller'; import { NetworkListModalSelectorsIDs } from '../../../../e2e/selectors/Modals/NetworkListModal.selectors'; import { isNetworkUiRedesignEnabled } from '../../../util/networks/isNetworkUiRedesignEnabled'; +import { mockNetworkState } from '../../../util/test/network'; + const mockEngine = Engine; const setShowTestNetworksSpy = jest.spyOn( @@ -35,6 +37,23 @@ jest.mock('../../../core/Engine', () => ({ setActiveNetwork: jest.fn(), setProviderType: jest.fn(), getNetworkClientById: jest.fn().mockReturnValue({ chainId: '0x1' }), + findNetworkClientIdByChainId: jest + .fn() + .mockReturnValue({ chainId: '0x1' }), + getNetworkConfigurationByChainId: jest.fn().mockReturnValue({ + blockExplorerUrls: [], + chainId: '0x1', + defaultRpcEndpointIndex: 0, + name: 'Mainnet', + nativeCurrency: 'ETH', + rpcEndpoints: [ + { + networkClientId: 'mainnet', + type: 'infura', + url: 'https://mainnet.infura.io/v3/{infuraProjectId}', + }, + ], + }), }, PreferencesController: { setShowTestNetworks: jest.fn(), @@ -63,37 +82,65 @@ const initialState = { }, NetworkController: { selectedNetworkClientId: 'mainnet', - networksMetadata: {}, - networkConfigurations: { - networkId1: { + networksMetadata: { + mainnet: { status: 'available', EIPS: { '1559': true } }, + }, + networkConfigurationsByChainId: { + '0xa86a': { + blockExplorerUrls: ['https://snowtrace.io'], chainId: '0xa86a', - nickname: 'Avalanche Mainnet C-Chain', - rpcPrefs: { blockExplorerUrl: 'https://snowtrace.io' }, - rpcUrl: 'https://api.avax.network/ext/bc/C/rpc', - ticker: 'AVAX', + defaultRpcEndpointIndex: 0, + name: 'Avalanche Mainnet C-Chain', + nativeCurrency: 'AVAX', + rpcEndpoints: [ + { + networkClientId: 'networkId1', + type: 'custom', + url: 'https://api.avax.network/ext/bc/C/rpc', + }, + ], }, - networkId2: { + '0x89': { + blockExplorerUrls: ['https://polygonscan.com'], chainId: '0x89', - nickname: 'Polygon Mainnet', - rpcPrefs: { blockExplorerUrl: 'https://polygonscan.com' }, - rpcUrl: 'https://polygon-mainnet.infura.io/v3/12345', - ticker: 'MATIC', + defaultRpcEndpointIndex: 0, + name: 'Polygon Mainnet', + nativeCurrency: 'MATIC', + rpcEndpoints: [ + { + networkClientId: 'networkId2', + type: 'infura', + url: 'https://polygon-mainnet.infura.io/v3/12345', + }, + ], }, - networkId3: { + '0xa': { + blockExplorerUrls: ['https://optimistic.etherscan.io'], chainId: '0xa', - nickname: 'Optimism', - rpcPrefs: { blockExplorerUrl: 'https://optimistic.etherscan.io' }, - rpcUrl: 'https://optimism-mainnet.infura.io/v3/12345', - ticker: 'ETH', + defaultRpcEndpointIndex: 0, + name: 'Optimism', + nativeCurrency: 'ETH', + rpcEndpoints: [ + { + networkClientId: 'networkId3', + type: 'infura', + url: 'https://optimism-mainnet.infura.io/v3/12345', + }, + ], }, - networkId4: { + '0x64': { + blockExplorerUrls: ['https://blockscout.com/xdai/mainnet/'], chainId: '0x64', - nickname: 'Gnosis Chain', - rpcPrefs: { - blockExplorerUrl: 'https://blockscout.com/xdai/mainnet/', - }, - rpcUrl: 'https://rpc.gnosischain.com/', - ticker: 'XDAI', + defaultRpcEndpointIndex: 0, + name: 'Gnosis Chain', + nativeCurrency: 'XDAI', + rpcEndpoints: [ + { + networkClientId: 'networkId4', + type: 'custom', + url: 'https://rpc.gnosischain.com/', + }, + ], }, }, }, @@ -171,29 +218,39 @@ describe('Network Selector', () => { backgroundState: { ...initialState.engine.backgroundState, NetworkController: { - ...initialState.engine.backgroundState.NetworkController, selectedNetworkClientId: 'sepolia', - networksMetadata: {}, - networkConfigurations: { - mainnet: { - id: 'mainnet', - rpcUrl: 'http://mainnet.infura.io', + networksMetadata: { + mainnet: { status: 'available', EIPS: { '1559': true } }, + sepolia: { status: 'available', EIPS: { '1559': true } }, + }, + networkConfigurationsByChainId: { + [CHAIN_IDS.MAINNET]: { + blockExplorerUrls: ['https://etherscan.com'], chainId: CHAIN_IDS.MAINNET, - ticker: 'ETH', - nickname: 'Ethereum Mainnet', - rpcPrefs: { - blockExplorerUrl: 'https://etherscan.com', - }, + defaultRpcEndpointIndex: 0, + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + rpcEndpoints: [ + { + networkClientId: 'mainnet', + type: 'infura', + url: 'http://mainnet.infura.io', + }, + ], }, - sepolia: { - id: 'sepolia', - rpcUrl: 'http://sepolia.infura.io', + [CHAIN_IDS.SEPOLIA]: { + blockExplorerUrls: ['https://etherscan.com'], chainId: CHAIN_IDS.SEPOLIA, - ticker: 'ETH', - nickname: 'Sepolia', - rpcPrefs: { - blockExplorerUrl: 'https://etherscan.com', - }, + defaultRpcEndpointIndex: 0, + name: 'Sepolia', + nativeCurrency: 'ETH', + rpcEndpoints: [ + { + networkClientId: 'sepolia', + type: 'infura', + url: 'http://sepolia.infura.io', + }, + ], }, }, }, @@ -229,6 +286,43 @@ describe('Network Selector', () => { PreferencesController: { showTestNetworks: true, }, + NetworkController: { + selectedNetworkClientId: 'sepolia', + networksMetadata: { + mainnet: { status: 'available', EIPS: { '1559': true } }, + sepolia: { status: 'available', EIPS: { '1559': true } }, + }, + networkConfigurationsByChainId: { + [CHAIN_IDS.MAINNET]: { + blockExplorerUrls: ['https://etherscan.com'], + chainId: CHAIN_IDS.MAINNET, + defaultRpcEndpointIndex: 0, + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + rpcEndpoints: [ + { + networkClientId: 'mainnet', + type: 'infura', + url: 'http://mainnet.infura.io', + }, + ], + }, + [CHAIN_IDS.SEPOLIA]: { + blockExplorerUrls: ['https://etherscan.com'], + chainId: CHAIN_IDS.SEPOLIA, + defaultRpcEndpointIndex: 0, + name: 'Sepolia', + nativeCurrency: 'ETH', + rpcEndpoints: [ + { + networkClientId: 'sepolia', + type: 'infura', + url: 'http://sepolia.infura.io', + }, + ], + }, + }, + }, }, }, }); @@ -248,8 +342,12 @@ describe('Network Selector', () => { backgroundState: { ...initialState.engine.backgroundState, NetworkController: { - ...initialState.engine.backgroundState.NetworkController, - networkConfigurations: {}, + ...mockNetworkState({ + chainId: '0x1', + id: 'Mainnet', + nickname: 'Ethereum Main Network', + ticker: 'ETH', + }), }, }, }, @@ -289,70 +387,4 @@ describe('Network Selector', () => { fireEvent.press(rpcOption); }); }); - - // Add this test for selecting between two Polygon networks - it('should select only one Polygon network when two networks with different RPC URLs exist', async () => { - jest.clearAllMocks(); // Clears mock data, ensuring that no mock has been called - jest.resetAllMocks(); // Resets mock implementation and mock instances - - const customState = { - ...initialState, - engine: { - backgroundState: { - ...initialState.engine.backgroundState, - NetworkController: { - networkConfigurations: { - polygonNetwork1: { - chainId: '0x89', // Polygon Mainnet - nickname: 'Polygon Mainnet 1', - rpcUrl: 'https://polygon-mainnet-1.rpc', - ticker: 'POL', - }, - polygonNetwork2: { - chainId: '0x89', // Polygon Mainnet (same chainId, different RPC URL) - nickname: 'Polygon Mainnet 2', - rpcUrl: 'https://polygon-mainnet-2.rpc', - ticker: 'POL', - }, - }, - }, - }, - }, - }; - - ( - Engine.context.NetworkController.getNetworkClientById as jest.Mock - ).mockReturnValue({ - configuration: { - chainId: '0x89', // Polygon Mainnet - nickname: 'Polygon Mainnet 1', - rpcUrl: 'https://polygon-mainnet-1.rpc', - ticker: 'POL', - type: 'custom', - }, - }); - - const { getByText, queryByTestId } = renderComponent(customState); - - // Ensure both networks are rendered - const polygonNetwork1 = getByText('Polygon Mainnet 1'); - const polygonNetwork2 = getByText('Polygon Mainnet 2'); - expect(polygonNetwork1).toBeTruthy(); - expect(polygonNetwork2).toBeTruthy(); - - // Select the first network - fireEvent.press(polygonNetwork1); - - // Wait for the selection to be applied - await waitFor(() => { - const polygonNetwork1Selected = queryByTestId( - 'Polygon Mainnet 1-selected', - ); - expect(polygonNetwork1Selected).toBeTruthy(); - }); - - // Assert that the second network is NOT selected - const polygonNetwork2Selected = queryByTestId('Polygon Mainnet 2-selected'); - expect(polygonNetwork2Selected).toBeNull(); // Not selected - }); }); diff --git a/app/components/Views/NetworkSelector/NetworkSelector.tsx b/app/components/Views/NetworkSelector/NetworkSelector.tsx index 87e6b01901c..112884966e7 100644 --- a/app/components/Views/NetworkSelector/NetworkSelector.tsx +++ b/app/components/Views/NetworkSelector/NetworkSelector.tsx @@ -10,7 +10,6 @@ import React, { useCallback, useRef, useState } from 'react'; import { ScrollView } from 'react-native-gesture-handler'; import images from 'images/image-icons'; import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; -import { NetworkConfiguration } from '@metamask/network-controller'; // External dependencies. import SheetHeader from '../../../component-library/components/Sheet/SheetHeader'; @@ -34,11 +33,11 @@ import { } from '../../../selectors/networkController'; import { selectShowTestNetworks } from '../../../selectors/preferencesController'; import Networks, { - compareRpcUrls, getAllNetworks, getDecimalChainId, isTestNet, getNetworkImageSource, + isMainNet, } from '../../../util/networks'; import { LINEA_MAINNET, @@ -68,6 +67,7 @@ import { useMetrics } from '../../../components/hooks/useMetrics'; // Internal dependencies import createStyles from './NetworkSelector.styles'; import { + BUILT_IN_NETWORKS, InfuraNetworkType, TESTNET_TICKER_SYMBOLS, } from '@metamask/controller-utils'; @@ -89,11 +89,10 @@ import { Hex } from '@metamask/utils'; import ListItemSelect from '../../../component-library/components/List/ListItemSelect'; import hideProtocolFromUrl from '../../../util/hideProtocolFromUrl'; import { CHAIN_IDS } from '@metamask/transaction-controller'; -import { - LINEA_DEFAULT_RPC_URL, - MAINNET_DEFAULT_RPC_URL, -} from '../../../constants/urls'; +import { LINEA_DEFAULT_RPC_URL } from '../../../constants/urls'; import { useNetworkInfo } from '../../../selectors/selectedNetworkController'; +import { NetworkConfiguration } from '@metamask/network-controller'; +import Logger from '../../../util/Logger'; interface infuraNetwork { name: string; @@ -104,7 +103,7 @@ interface infuraNetwork { interface ShowConfirmDeleteModalState { isVisible: boolean; networkName: string; - entry?: [string, NetworkConfiguration & { id: string }]; + chainId?: `0x${string}`; } interface NetworkSelectorRouteParams { @@ -158,7 +157,6 @@ const NetworkSelector = () => { useState({ isVisible: false, networkName: '', - entry: undefined, }); const [showNetworkMenuModal, setNetworkMenuModal] = useState({ @@ -169,14 +167,49 @@ const NetworkSelector = () => { isReadOnly: false, }); + const onRpcSelect = useCallback( + async (clientId: string, chainId: `0x${string}`) => { + const { NetworkController } = Engine.context; + + const existingNetwork = networkConfigurations[chainId]; + if (!existingNetwork) { + Logger.error( + new Error(`No existing network found for chainId: ${chainId}`), + ); + return; + } + + const indexOfRpc = existingNetwork.rpcEndpoints.findIndex( + ({ networkClientId }) => clientId === networkClientId, + ); + + if (indexOfRpc === -1) { + Logger.error( + new Error( + `RPC endpoint with clientId: ${clientId} not found for chainId: ${chainId}`, + ), + ); + return; + } + + // Proceed to update the network with the correct index + await NetworkController.updateNetwork(existingNetwork.chainId, { + ...existingNetwork, + defaultRpcEndpointIndex: indexOfRpc, + }); + + // Set the active network + NetworkController.setActiveNetwork(clientId); + }, + [networkConfigurations], + ); + const [showMultiRpcSelectModal, setShowMultiRpcSelectModal] = useState<{ isVisible: boolean; chainId: string; - rpcUrls: string[]; networkName: string; }>({ isVisible: false, - rpcUrls: [], chainId: CHAIN_IDS.MAINNET, networkName: '', }); @@ -207,8 +240,18 @@ const NetworkSelector = () => { ticker = TESTNET_TICKER_SYMBOLS.SEPOLIA as InfuraNetworkType; } + const networkConfiguration = + NetworkController.getNetworkConfigurationByChainId( + BUILT_IN_NETWORKS[type].chainId, + ); + + const clientId = + networkConfiguration?.rpcEndpoints[ + networkConfiguration.defaultRpcEndpointIndex + ].networkClientId ?? type; + CurrencyRateController.updateExchangeRate(ticker); - NetworkController.setProviderType(type); + NetworkController.setActiveNetwork(clientId); AccountTrackerController.refresh(); setTimeout(async () => { @@ -225,20 +268,24 @@ const NetworkSelector = () => { }); }; - const onSetRpcTarget = async (rpcTarget: string) => { + const onSetRpcTarget = async (networkConfiguration: NetworkConfiguration) => { const { CurrencyRateController, NetworkController, SelectedNetworkController, } = Engine.context; - const entry = Object.entries(networkConfigurations).find(([, { rpcUrl }]) => - compareRpcUrls(rpcUrl, rpcTarget), - ); + if (networkConfiguration) { + const { + name: nickname, + chainId, + nativeCurrency: ticker, + rpcEndpoints, + defaultRpcEndpointIndex, + } = networkConfiguration; - if (entry) { - const [networkConfigurationId, networkConfiguration] = entry; - const { ticker, nickname } = networkConfiguration; + const networkConfigurationId = + rpcEndpoints[defaultRpcEndpointIndex].networkClientId; if (domainIsConnectedDapp && process.env.MULTICHAIN_V1) { SelectedNetworkController.setNetworkClientIdForDomain( @@ -247,22 +294,24 @@ const NetworkSelector = () => { ); } else { CurrencyRateController.updateExchangeRate(ticker); - NetworkController.setActiveNetwork(networkConfigurationId); + + const { networkClientId } = rpcEndpoints[defaultRpcEndpointIndex]; + + await NetworkController.setActiveNetwork(networkClientId); } sheetRef.current?.onCloseBottomSheet(); trackEvent(MetaMetricsEvents.NETWORK_SWITCHED, { - chain_id: getDecimalChainId(providerConfig.chainId), + chain_id: getDecimalChainId(chainId), from_network: selectedNetworkName, to_network: nickname, }); } }; - const openRpcModal = useCallback(({ rpcUrls, chainId, networkName }) => { + const openRpcModal = useCallback(({ chainId, networkName }) => { setShowMultiRpcSelectModal({ isVisible: true, - rpcUrls: [...rpcUrls], chainId, networkName, }); @@ -272,7 +321,6 @@ const NetworkSelector = () => { const closeRpcModal = useCallback(() => { setShowMultiRpcSelectModal({ isVisible: false, - rpcUrls: [], chainId: CHAIN_IDS.MAINNET, networkName: '', }); @@ -367,6 +415,12 @@ const NetworkSelector = () => { const renderMainnet = () => { const { name: mainnetName, chainId } = Networks.mainnet; + const rpcUrl = + networkConfigurations?.[chainId]?.rpcEndpoints?.[ + networkConfigurations?.[chainId]?.defaultRpcEndpointIndex + ].url; + const name = networkConfigurations?.[chainId]?.name ?? mainnetName; + if (isNetworkUiRedesignEnabled() && isNoSearchResults(MAINNET)) return null; if (isNetworkUiRedesignEnabled()) { @@ -374,15 +428,15 @@ const NetworkSelector = () => { onNetworkChange(MAINNET)} style={styles.networkCell} buttonIcon={IconName.MoreVertical} @@ -391,10 +445,8 @@ const NetworkSelector = () => { openModal(chainId, false, MAINNET, true); }, }} - // TODO: Substitute with the new network controller's RPC array. onTextClick={() => openRpcModal({ - rpcUrls: [hideKeyFromUrl(MAINNET_DEFAULT_RPC_URL)], chainId, networkName: mainnetName, }) @@ -413,7 +465,7 @@ const NetworkSelector = () => { imageSource: images.ETHEREUM, size: avatarSize, }} - isSelected={chainId === selectedChainId && !providerConfig.rpcUrl} + isSelected={chainId === selectedChainId && !providerConfig?.rpcUrl} onPress={() => onNetworkChange(MAINNET)} style={styles.networkCell} /> @@ -448,10 +500,8 @@ const NetworkSelector = () => { openModal(chainId, false, LINEA_MAINNET, true); }, }} - // TODO: Substitute with the new network controller's RPC array. onTextClick={() => openRpcModal({ - rpcUrls: [LINEA_DEFAULT_RPC_URL], chainId, networkName: lineaMainnetName, }) @@ -470,85 +520,96 @@ const NetworkSelector = () => { imageSource: images['LINEA-MAINNET'], size: avatarSize, }} - isSelected={chainId === selectedChainId && !providerConfig.rpcUrl} + isSelected={chainId === selectedChainId && !providerConfig?.rpcUrl} onPress={() => onNetworkChange(LINEA_MAINNET)} /> ); }; const renderRpcNetworks = () => - Object.values(networkConfigurations).map( - ({ nickname, rpcUrl, chainId }) => { - if (!chainId) return null; - const { name } = { name: nickname || rpcUrl }; - - if (isNetworkUiRedesignEnabled() && isNoSearchResults(name)) - return null; - - //@ts-expect-error - The utils/network file is still JS and this function expects a networkType, and should be optional - const image = getNetworkImageSource({ chainId: chainId?.toString() }); - - if (isNetworkUiRedesignEnabled()) { - return ( - onSetRpcTarget(rpcUrl)} - style={styles.networkCell} - buttonIcon={IconName.MoreVertical} - secondaryText={hideProtocolFromUrl(hideKeyFromUrl(rpcUrl))} - buttonProps={{ - onButtonClick: () => { - openModal(chainId, true, rpcUrl, false); - }, - }} - // TODO: Substitute with the new network controller's RPC array. - onTextClick={() => - openRpcModal({ - rpcUrls: [hideKeyFromUrl(rpcUrl)], - chainId, - networkName: name, - }) - } - /> - ); - } + Object.values(networkConfigurations).map((networkConfiguration) => { + const { + name: nickname, + rpcEndpoints, + chainId, + defaultRpcEndpointIndex, + } = networkConfiguration; + if ( + !chainId || + isTestNet(chainId) || + isMainNet(chainId) || + chainId === CHAIN_IDS.LINEA_MAINNET || + chainId === CHAIN_IDS.GOERLI + ) { + return null; + } + + const rpcUrl = rpcEndpoints[defaultRpcEndpointIndex].url; + const rpcName = rpcEndpoints[defaultRpcEndpointIndex].name ?? rpcUrl; + + const name = nickname || rpcName; + + if (isNetworkUiRedesignEnabled() && isNoSearchResults(name)) return null; + + //@ts-expect-error - The utils/network file is still JS and this function expects a networkType, and should be optional + const image = getNetworkImageSource({ chainId: chainId?.toString() }); + if (isNetworkUiRedesignEnabled()) { return ( onSetRpcTarget(rpcUrl)} + isSelected={Boolean(chainId === selectedChainId && selectedRpcUrl)} + onPress={() => onSetRpcTarget(networkConfiguration)} style={styles.networkCell} - > - {Boolean( - chainId === selectedChainId && selectedRpcUrl === rpcUrl, - ) && } - + buttonIcon={IconName.MoreVertical} + secondaryText={hideProtocolFromUrl(hideKeyFromUrl(rpcUrl))} + buttonProps={{ + onButtonClick: () => { + openModal(chainId, true, rpcUrl, false); + }, + }} + onTextClick={() => + openRpcModal({ + chainId, + networkName: name, + }) + } + /> ); - }, - ); + } + + return ( + onSetRpcTarget(networkConfiguration)} + style={styles.networkCell} + > + {Boolean( + chainId === selectedChainId && selectedRpcUrl === rpcUrl, + ) && } + + ); + }); const renderOtherNetworks = () => { const getAllNetworksTyped = @@ -561,6 +622,15 @@ const NetworkSelector = () => { >; const { name, imageSource, chainId } = TypedNetworks[networkType]; + const networkConfiguration = Object.values(networkConfigurations).find( + ({ chainId: networkId }) => networkId === chainId, + ); + + const rpcUrl = + networkConfiguration?.rpcEndpoints?.[ + networkConfiguration?.defaultRpcEndpointIndex + ].url; + if (isNetworkUiRedesignEnabled() && isNoSearchResults(name)) return null; if (isNetworkUiRedesignEnabled()) { @@ -568,6 +638,7 @@ const NetworkSelector = () => { { openModal(chainId, false, networkType, true); }, }} + onTextClick={() => + openRpcModal({ + chainId, + networkName: name, + }) + } /> ); } @@ -704,39 +781,34 @@ const NetworkSelector = () => { setSearchString(''); }; - const removeRpcUrl = (networkId: string) => { - const entry = Object.entries(networkConfigurations).find( - ([, { chainId }]) => chainId === networkId, + const removeRpcUrl = (chainId: string) => { + const networkConfiguration = Object.values(networkConfigurations).find( + (config) => config.chainId === chainId, ); - if (!entry) { - throw new Error(`Unable to find network with chain id ${networkId}`); + if (!networkConfiguration) { + throw new Error(`Unable to find network with chain id ${chainId}`); } - const [, { nickname }] = entry; - closeModal(); closeRpcModal(); setShowConfirmDeleteModal({ isVisible: true, - networkName: nickname ?? '', - entry, + networkName: networkConfiguration.name ?? '', + chainId: networkConfiguration.chainId, }); }; const confirmRemoveRpc = () => { - if (showConfirmDeleteModal.entry) { - const [networkConfigurationId] = showConfirmDeleteModal.entry; - + if (showConfirmDeleteModal.chainId) { + const { chainId } = showConfirmDeleteModal; const { NetworkController } = Engine.context; - - NetworkController.removeNetworkConfiguration(networkConfigurationId); + NetworkController.removeNetwork(chainId); setShowConfirmDeleteModal({ isVisible: false, networkName: '', - entry: undefined, }); } }; @@ -771,6 +843,11 @@ const NetworkSelector = () => { if (!showMultiRpcSelectModal.isVisible) return null; + const chainId = showMultiRpcSelectModal.chainId; + + const rpcEndpoints = + networkConfigurations[chainId as `0x${string}`]?.rpcEndpoints || []; + return ( { - {showMultiRpcSelectModal.rpcUrls.map((rpcUrl) => ( + {rpcEndpoints.map(({ url, networkClientId }, index) => ( { + onRpcSelect(networkClientId, chainId as `0x${string}`); + closeRpcModal(); + }} > - {hideProtocolFromUrl(rpcUrl)} + {hideKeyFromUrl(hideProtocolFromUrl(url))} @@ -817,7 +903,14 @@ const NetworkSelector = () => { ); - }, [showMultiRpcSelectModal, rpcMenuSheetRef, closeRpcModal, styles]); + }, [ + showMultiRpcSelectModal, + rpcMenuSheetRef, + closeRpcModal, + styles, + networkConfigurations, + onRpcSelect, + ]); const renderBottomSheetContent = () => ( <> @@ -903,10 +996,12 @@ const NetworkSelector = () => { )} iconName={IconName.Edit} onPress={() => { - navigate(Routes.ADD_NETWORK, { - shouldNetworkSwitchPopToWallet: false, - shouldShowPopularNetworks: false, - network: showNetworkMenuModal.networkTypeOrRpcUrl, + sheetRef.current?.onCloseBottomSheet(() => { + navigate(Routes.ADD_NETWORK, { + shouldNetworkSwitchPopToWallet: false, + shouldShowPopularNetworks: false, + network: showNetworkMenuModal.networkTypeOrRpcUrl, + }); }); }} /> diff --git a/app/components/Views/Notifications/Details/Footers/BlockExplorerFooter.tsx b/app/components/Views/Notifications/Details/Footers/BlockExplorerFooter.tsx index 905e9846add..8e7cfa8cd30 100644 --- a/app/components/Views/Notifications/Details/Footers/BlockExplorerFooter.tsx +++ b/app/components/Views/Notifications/Details/Footers/BlockExplorerFooter.tsx @@ -32,7 +32,7 @@ export default function BlockExplorerFooter(props: BlockExplorerFooterProps) { const hexChainId = toHex(props.chainId); return Object.values(networkConfigurations).find( (networkConfig) => networkConfig.chainId === hexChainId, - )?.rpcPrefs?.blockExplorerUrl; + )?.blockExplorerUrls?.[0]; }, [networkConfigurations, props.chainId]); const url = networkBlockExplorer ?? defaultBlockExplorer; diff --git a/app/components/Views/OnboardingSuccess/OnboardingAssetsSettings/__snapshots__/index.test.tsx.snap b/app/components/Views/OnboardingSuccess/OnboardingAssetsSettings/__snapshots__/index.test.tsx.snap index cb7709a24ac..3b81aeecbad 100644 --- a/app/components/Views/OnboardingSuccess/OnboardingAssetsSettings/__snapshots__/index.test.tsx.snap +++ b/app/components/Views/OnboardingSuccess/OnboardingAssetsSettings/__snapshots__/index.test.tsx.snap @@ -731,6 +731,864 @@ exports[`OnboardingAssetSettings should render correctly 1`] = ` + + + + + + + + Mainnet + + + etherscan.io + + + + + + + + + + + + G + + + + + Goerli + + + etherscan.io + + + + + + + + + + + + + + + Sepolia + + + etherscan.io + + + + + + + + + + + + L + + + + + Linea Goerli + + + lineascan.build + + + + + + + + + + + + + + + Linea Sepolia + + + lineascan.build + + + + + + + + + + + + + + + Linea Mainnet + + + lineascan.build + + + + + + + + + + + + + + + Mainnet + + + etherscan.io + + + + + + + + + + + + G + + + + + Goerli + + + etherscan.io + + + + + + + + + + + + + + + Sepolia + + + etherscan.io + + + + + + + + + + + + L + + + + + Linea Goerli + + + lineascan.build + + + + + + + + + + + + + + + Linea Sepolia + + + lineascan.build + + + + + + + + + + + + + + + Linea Mainnet + + + lineascan.build + + + + + + + `; diff --git a/app/components/Views/Settings/IncomingTransactionsSettings/index.tsx b/app/components/Views/Settings/IncomingTransactionsSettings/index.tsx index 34e0e70fa35..9bdc4278829 100644 --- a/app/components/Views/Settings/IncomingTransactionsSettings/index.tsx +++ b/app/components/Views/Settings/IncomingTransactionsSettings/index.tsx @@ -84,7 +84,8 @@ const IncomingTransactionsSettings = () => { const renderRpcNetworks = () => Object.values(networkConfigurations).map( - ({ nickname, rpcUrl, chainId }) => { + ({ name: nickname, rpcEndpoints, chainId, defaultRpcEndpointIndex }) => { + const rpcUrl = rpcEndpoints[defaultRpcEndpointIndex].url; if (!chainId || !Object.keys(supportedNetworks).includes(chainId)) return null; const { name } = { name: nickname || rpcUrl }; diff --git a/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.js b/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.js index 94b41f1050f..5724b12ef67 100644 --- a/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.js +++ b/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.js @@ -10,6 +10,7 @@ import { } from 'react-native'; import { connect } from 'react-redux'; import { typography } from '@metamask/design-tokens'; +import isUrl from 'is-url'; import { fontStyles, colors as staticColors, @@ -21,7 +22,6 @@ import Networks, { getAllNetworks, getIsNetworkOnboarded, } from '../../../../../util/networks'; -import { getEtherscanBaseUrl } from '../../../../../util/etherscan'; import Engine from '../../../../../core/Engine'; import { isWebUri } from 'valid-url'; import URL from 'url-parse'; @@ -34,15 +34,8 @@ import AppConstants from '../../../../../core/AppConstants'; import ScrollableTabView from 'react-native-scrollable-tab-view'; import DefaultTabBar from 'react-native-scrollable-tab-view/DefaultTabBar'; import { PopularList } from '../../../../../util/networks/customNetworks'; -import WarningMessage from '../../../confirmations/SendFlow/WarningMessage'; import InfoModal from '../../../../UI/Swaps/components/InfoModal'; -import { - DEFAULT_MAINNET_CUSTOM_NAME, - MAINNET, - NETWORKS_CHAIN_ID, - PRIVATENETWORK, - RPC, -} from '../../../../../constants/network'; +import { PRIVATENETWORK, RPC } from '../../../../../constants/network'; import { ThemeContext, mockTheme } from '../../../../../util/theme'; import { showNetworkOnboardingAction } from '../../../../../actions/onboardNetwork'; import sanitizeUrl, { @@ -82,12 +75,29 @@ import Icon, { IconSize, } from '../../../../../component-library/components/Icons/Icon'; import { isNetworkUiRedesignEnabled } from '../../../../../util/networks/isNetworkUiRedesignEnabled'; +import Cell, { + CellVariant, +} from '../../../../../component-library/components/Cells/Cell'; +import { RpcEndpointType } from '@metamask/network-controller'; +import { AvatarVariant } from '../../../../../component-library/components/Avatars/Avatar'; const createStyles = (colors) => StyleSheet.create({ base: { paddingHorizontal: 16, }, + addRpcButton: { + alignSelf: 'center', + }, + addRpcNameButton: { + alignSelf: 'center', + paddingHorizontal: 16, + paddingVertical: 16, + width: '100%', + }, + rpcMenu: { + paddingHorizontal: 16, + }, wrapper: { backgroundColor: colors.background.default, flexGrow: 1, @@ -103,6 +113,11 @@ const createStyles = (colors) => flex: 1, paddingVertical: 12, }, + scrollWrapperOverlay: { + flex: 1, + paddingVertical: 12, + opacity: 0.5, + }, onboardingInput: { borderColor: staticColors.transparent, padding: 0, @@ -115,6 +130,11 @@ const createStyles = (colors) => padding: 10, color: colors.text.default, }, + rpcUrlInput: { + borderColor: colors.border.default, + borderRadius: 5, + borderWidth: 2, + }, inputWithError: { ...typography.sBodyMD, borderColor: colors.error.default, @@ -151,6 +171,12 @@ const createStyles = (colors) => flexGrow: 1, flexShrink: 1, }, + heading: { + fontSize: 16, + paddingVertical: 12, + color: colors.text.default, + ...fontStyles.bold, + }, label: { fontSize: 14, paddingVertical: 12, @@ -266,8 +292,9 @@ const createStyles = (colors) => }); const allNetworks = getAllNetworks(); -const allNetworksblockExplorerUrl = (networkName) => - `https://${networkName}.infura.io/v3/`; + +const InfuraKey = process.env.MM_INFURA_PROJECT_ID; +const infuraProjectId = InfuraKey === 'null' ? '' : InfuraKey; /** * Main view for app configurations @@ -320,6 +347,12 @@ export class NetworkSettings extends PureComponent { state = { rpcUrl: undefined, + rpcName: undefined, + rpcUrlFrom: undefined, + rpcNameForm: undefined, + rpcUrls: [], + blockExplorerUrls: [], + selectedRpcEndpointIndex: 0, blockExplorerUrl: undefined, nickname: undefined, chainId: undefined, @@ -344,15 +377,37 @@ export class NetworkSettings extends PureComponent { isRpcUrlFieldFocused: false, isChainIdFieldFocused: false, networkList: [], + showMultiRpcAddModal: { + isVisible: false, + }, + showMultiBlockExplorerAddModal: { + isVisible: false, + }, + showAddRpcForm: { + isVisible: false, + }, + showAddBlockExplorerForm: { + isVisible: false, + }, }; inputRpcURL = React.createRef(); + inputNameRpcURL = React.createRef(); inputChainId = React.createRef(); inputSymbol = React.createRef(); inputBlockExplorerURL = React.createRef(); + rpcAddMenuSheetRef = React.createRef(); + addBlockExplorerMenuSheetRef = React.createRef(); + rpcAddFormSheetRef = React.createRef(); + blockExplorerAddFormSheetRef = React.createRef(); getOtherNetworks = () => allNetworks.slice(1); + templateInfuraRpc = (endpoint) => + endpoint.endsWith('{infuraProjectId}') + ? endpoint.replace('{infuraProjectId}', infuraProjectId ?? '') + : endpoint; + updateNavBar = () => { const { navigation, route } = this.props; const isCustomMainnet = route.params?.isCustomMainnet; @@ -369,65 +424,95 @@ export class NetworkSettings extends PureComponent { ); }; - /** - * Gets the custom mainnet RPC URL from the frequent RPC list. - * - * @returns Custom mainnet RPC URL. - */ - getCustomMainnetRPCURL = () => { - const { networkConfigurations } = this.props; - const networkConfiguration = Object.values(networkConfigurations).find( - ({ chainId: id }) => String(id) === String(Networks.mainnet.chainId), - ); - return networkConfiguration?.rpcUrl || ''; - }; - componentDidMount = () => { this.updateNavBar(); const { route, networkConfigurations } = this.props; - const isCustomMainnet = route.params?.isCustomMainnet; const networkTypeOrRpcUrl = route.params?.network; // if network is main, don't show popular network - let blockExplorerUrl, chainId, nickname, ticker, editable, rpcUrl; + let blockExplorerUrl, + chainId, + nickname, + ticker, + editable, + rpcUrl, + rpcUrls, + blockExplorerUrls, + rpcName, + selectedRpcEndpointIndex; // If no navigation param, user clicked on add network if (networkTypeOrRpcUrl) { if (allNetworks.find((net) => networkTypeOrRpcUrl === net)) { - blockExplorerUrl = getEtherscanBaseUrl(networkTypeOrRpcUrl); const networkInformation = Networks[networkTypeOrRpcUrl]; - nickname = networkInformation.name; chainId = networkInformation.chainId.toString(); + + nickname = networkConfigurations?.[chainId]?.name; editable = false; - rpcUrl = allNetworksblockExplorerUrl(networkTypeOrRpcUrl); - ticker = ![ - NETWORKS_CHAIN_ID.LINEA_GOERLI, - NETWORKS_CHAIN_ID.LINEA_SEPOLIA, - ].includes(networkInformation.chainId.toString()) - ? strings('unit.eth') - : 'LineaETH'; - // Override values if UI is updating custom mainnet RPC URL. - if (isCustomMainnet) { - nickname = DEFAULT_MAINNET_CUSTOM_NAME; - rpcUrl = this.getCustomMainnetRPCURL(); - } + blockExplorerUrl = + networkConfigurations?.[chainId]?.blockExplorerUrls[ + networkConfigurations?.[chainId]?.defaultBlockExplorerUrlIndex + ]; + rpcUrl = + networkConfigurations?.[chainId]?.rpcEndpoints[ + networkConfigurations?.[chainId]?.defaultRpcEndpointIndex + ]?.url; + rpcName = + networkConfigurations?.[chainId]?.rpcEndpoints[ + networkConfigurations?.[chainId]?.defaultRpcEndpointIndex + ]?.type ?? + networkConfigurations?.[chainId]?.rpcEndpoints[ + networkConfigurations?.[chainId]?.defaultRpcEndpointIndex + ]?.name; + rpcUrls = networkConfigurations?.[chainId]?.rpcEndpoints; + blockExplorerUrls = networkConfigurations?.[chainId]?.blockExplorerUrls; + + ticker = networkConfigurations?.[chainId]?.nativeCurrency; } else { const networkConfiguration = Object.values(networkConfigurations).find( - ({ rpcUrl }) => rpcUrl === networkTypeOrRpcUrl, + ({ rpcEndpoints, defaultRpcEndpointIndex }) => + rpcEndpoints[defaultRpcEndpointIndex].url === networkTypeOrRpcUrl || + rpcEndpoints[defaultRpcEndpointIndex].networkClientId === + networkTypeOrRpcUrl, ); - nickname = networkConfiguration.nickname; - chainId = networkConfiguration.chainId; + nickname = networkConfiguration?.name; + chainId = networkConfiguration?.chainId; blockExplorerUrl = - networkConfiguration.rpcPrefs && - networkConfiguration.rpcPrefs.blockExplorerUrl; - ticker = networkConfiguration.ticker; + networkConfiguration?.blockExplorerUrls[ + networkConfiguration?.defaultBlockExplorerUrlIndex + ]; + ticker = networkConfiguration?.nativeCurrency; editable = true; - rpcUrl = networkTypeOrRpcUrl; + rpcUrl = + networkConfigurations?.[chainId]?.rpcEndpoints[ + networkConfigurations?.[chainId]?.defaultRpcEndpointIndex + ]?.url; + rpcUrls = networkConfiguration?.rpcEndpoints; + blockExplorerUrls = networkConfiguration?.blockExplorerUrls; + rpcName = + networkConfiguration?.rpcEndpoints[ + networkConfiguration?.defaultRpcEndpointIndex + ]?.name; + + selectedRpcEndpointIndex = + networkConfiguration?.defaultRpcEndpointIndex; } + const initialState = - rpcUrl + blockExplorerUrl + nickname + chainId + ticker + editable; + rpcUrl + + blockExplorerUrl + + nickname + + chainId + + ticker + + editable + + rpcUrls + + blockExplorerUrls; this.setState({ rpcUrl, + rpcName, + rpcUrls, + blockExplorerUrls, + selectedRpcEndpointIndex, blockExplorerUrl, nickname, chainId, @@ -476,6 +561,12 @@ export class NetworkSettings extends PureComponent { return parseInt(chainId, 16).toString(10); } + isAnyModalVisible = () => + this.state.showMultiRpcAddModal.isVisible || + this.state.showMultiBlockExplorerAddModal.isVisible || + this.state.showAddRpcForm.isVisible || + this.state.showAddBlockExplorerForm.isVisible; + validateRpcAndChainId = () => { const { rpcUrl, chainId } = this.state; @@ -513,7 +604,10 @@ export class NetworkSettings extends PureComponent { let providerError; try { - endpointChainId = await jsonRpcRequest(rpcUrl, 'eth_chainId'); + endpointChainId = await jsonRpcRequest( + this.templateInfuraRpc(rpcUrl), + 'eth_chainId', + ); } catch (err) { Logger.error(err, 'Failed to fetch the chainId from the endpoint.'); providerError = err; @@ -592,43 +686,145 @@ export class NetworkSettings extends PureComponent { return []; }; + checkIfNetworkNotExistsByChainId = async (chainId) => + Object.values(this.props.networkConfigurations).filter( + (item) => item.chainId !== chainId, + ); + + handleNetworkUpdate = async ({ + rpcUrl, + chainId, + nickname, + ticker, + blockExplorerUrl, + blockExplorerUrls, + rpcUrls, + isNetworkExists, + isCustomMainnet, + shouldNetworkSwitchPopToWallet, + navigation, + nativeToken, + networkType, + networkUrl, + showNetworkOnboarding, + }) => { + const { NetworkController, CurrencyRateController } = Engine.context; + + const url = new URL(rpcUrl); + if (!isPrivateConnection(url.hostname)) { + url.set('protocol', 'https:'); + } + + CurrencyRateController.updateExchangeRate(ticker); + const existingNetwork = this.props.networkConfigurations[chainId]; + + if (isNetworkExists.length === 0) { + const indexRpc = rpcUrls.findIndex(({ url }) => url === rpcUrl); + + const blockExplorerIndex = blockExplorerUrls.findIndex( + (url) => url === blockExplorerUrl, + ); + + const networkConfig = { + blockExplorerUrls, + chainId, + rpcEndpoints: rpcUrls, + nativeCurrency: ticker, + name: nickname, + defaultRpcEndpointIndex: indexRpc, + defaultBlockExplorerUrlIndex: + blockExplorerIndex !== -1 ? blockExplorerIndex : undefined, + }; + + await NetworkController.updateNetwork( + existingNetwork.chainId, + networkConfig, + existingNetwork.chainId === chainId + ? { + replacementSelectedRpcEndpointIndex: indexRpc, + } + : undefined, + ); + } else { + const blockExplorerIndex = blockExplorerUrls.findIndex( + (url) => url === blockExplorerUrl, + ); + + const addedNetwork = await NetworkController.addNetwork({ + chainId, + blockExplorerUrls, + defaultRpcEndpointIndex: 0, + defaultBlockExplorerUrlIndex: blockExplorerIndex ?? undefined, + name: nickname, + nativeCurrency: ticker, + rpcEndpoints: [ + { + url: rpcUrl, + name: nickname, + type: RpcEndpointType.Custom, + }, + ], + }); + + const { networkClientId } = + addedNetwork?.rpcEndpoints?.[addedNetwork.defaultRpcEndpointIndex] ?? + {}; + + await NetworkController.setActiveNetwork(networkClientId); + this.props.showNetworkOnboardingAction({ + networkUrl, + networkType, + nativeToken, + showNetworkOnboarding, + }); + } + + isCustomMainnet + ? navigation.navigate('OptinMetrics') + : shouldNetworkSwitchPopToWallet + ? navigation.navigate('WalletView') + : navigation.goBack(); + }; + /** * Add or update network configuration, then switch networks */ addRpcUrl = async () => { - const { NetworkController, CurrencyRateController } = Engine.context; const { rpcUrl, chainId: stateChainId, nickname, + blockExplorerUrls, blockExplorerUrl, editable, enableAction, + rpcUrls, + addMode, } = this.state; + const ticker = this.state.ticker && this.state.ticker.toUpperCase(); const { navigation, networkOnboardedState, route } = this.props; const isCustomMainnet = route.params?.isCustomMainnet; - // This must be defined before NetworkController.upsertNetworkConfiguration. - const prevRPCURL = isCustomMainnet - ? this.getCustomMainnetRPCURL() - : route.params?.network; const shouldNetworkSwitchPopToWallet = route.params?.shouldNetworkSwitchPopToWallet ?? true; // Check if CTA is disabled const isCtaDisabled = - !enableAction || - this.disabledByRpcUrl() || - this.disabledByChainId() || - this.disabledBySymbol(); + !enableAction || this.disabledByChainId() || this.disabledBySymbol(); if (isCtaDisabled) { return; } + // Conditionally check existence of network (Only check in Add Mode) - const isNetworkExists = editable - ? [] - : await this.checkIfNetworkExists(rpcUrl); + let isNetworkExists; + if (isNetworkUiRedesignEnabled()) { + isNetworkExists = addMode + ? await this.checkIfNetworkNotExistsByChainId(stateChainId) + : []; + } else { + isNetworkExists = editable ? [] : await this.checkIfNetworkExists(rpcUrl); + } const isOnboarded = getIsNetworkOnboarded( stateChainId, @@ -653,70 +849,30 @@ export class NetworkSettings extends PureComponent { return; } - if (this.validateRpcUrl() && isNetworkExists.length === 0) { - const url = new URL(rpcUrl); - - !isPrivateConnection(url.hostname) && url.set('protocol', 'https:'); - CurrencyRateController.updateExchangeRate(ticker); - // Remove trailing slashes - NetworkController.upsertNetworkConfiguration( - { - rpcUrl: url.href, - chainId, - ticker, - nickname, - rpcPrefs: { - blockExplorerUrl, - }, - }, - { - setActive: true, - // Metrics-related properties required, but the metric event is a no-op - // TODO: Use events for controller metric events - referrer: 'ignored', - source: 'ignored', - }, - ); - // TODO: Use network configuration ID to update existing entries - // Temporary solution is to manually remove the existing network using the old RPC URL. - const isRPCDifferent = url.href !== prevRPCURL; - if ((editable || isCustomMainnet) && isRPCDifferent) { - // Only remove from frequent list if RPC URL is different. - const foundNetworkConfiguration = Object.entries( - this.props.networkConfigurations, - ).find( - ([, networkConfiguration]) => - networkConfiguration.rpcUrl === prevRPCURL, - ); - - if (foundNetworkConfiguration) { - const [prevNetworkConfigurationId] = foundNetworkConfiguration; - NetworkController.removeNetworkConfiguration( - prevNetworkConfigurationId, - ); - } - } - - this.props.showNetworkOnboardingAction({ - networkUrl, - networkType, - nativeToken, - showNetworkOnboarding, - }); - isCustomMainnet - ? navigation.navigate('OptinMetrics') - : shouldNetworkSwitchPopToWallet - ? navigation.navigate('WalletView') - : navigation.goBack(); - } + await this.handleNetworkUpdate({ + rpcUrl, + chainId, + nickname, + ticker, + blockExplorerUrl, + blockExplorerUrls, + rpcUrls, + isNetworkExists, + isCustomMainnet, + shouldNetworkSwitchPopToWallet, + navigation, + nativeToken, + networkType, + networkUrl, + showNetworkOnboarding, + }); }; /** * Validates rpc url, setting a warningRpcUrl if is invalid * It also changes validatedRpcURL to true, indicating that was validated */ - validateRpcUrl = async () => { - const { rpcUrl } = this.state; + validateRpcUrl = async (rpcUrl) => { const isNetworkExists = await this.checkIfNetworkExists(rpcUrl); if (!isWebUri(rpcUrl)) { const appendedRpc = `http://${rpcUrl}`; @@ -766,7 +922,6 @@ export class NetworkSettings extends PureComponent { */ validateChainId = async () => { const { chainId, rpcUrl, editable } = this.state; - const isChainIdExists = await this.checkIfChainIdExists(chainId); const isNetworkExists = await this.checkIfNetworkExists(rpcUrl); @@ -838,7 +993,10 @@ export class NetworkSettings extends PureComponent { let endpointChainId; let providerError; try { - endpointChainId = await jsonRpcRequest(rpcUrl, 'eth_chainId'); + endpointChainId = await jsonRpcRequest( + this.templateInfuraRpc(rpcUrl), + 'eth_chainId', + ); } catch (err) { Logger.error(err, 'Failed to fetch the chainId from the endpoint.'); providerError = err; @@ -928,10 +1086,18 @@ export class NetworkSettings extends PureComponent { chainId, ticker, editable, + rpcUrls, initialState, } = this.state; const actualState = - rpcUrl + blockExplorerUrl + nickname + chainId + ticker + editable; + rpcUrl + + blockExplorerUrl + + nickname + + chainId + + ticker + + editable + + rpcUrls; + let enableAction; // If concstenation of parameters changed, user changed something so we are going to enable the action button if (actualState !== initialState) { @@ -942,17 +1108,6 @@ export class NetworkSettings extends PureComponent { this.setState({ enableAction }); }; - /** - * Returns if action button should be disabled because of the rpc url - * No rpc url set or rpc url set but, rpc url has not been validated yet or there is a warning for rpc url - */ - disabledByRpcUrl = () => { - const { rpcUrl, validatedRpcURL, warningRpcUrl } = this.state; - return ( - !rpcUrl || (rpcUrl && (!validatedRpcURL || warningRpcUrl !== undefined)) - ); - }; - /** * Returns if action button should be disabled because of the rpc url * Chain ID set but, chain id has not been validated yet or there is a warning for chain id @@ -982,7 +1137,64 @@ export class NetworkSettings extends PureComponent { return false; }; + onRpcUrlAdd = async (url) => { + await this.setState({ + rpcUrlForm: url, + validatedRpcURL: false, + warningRpcUrl: undefined, + warningChainId: undefined, + warningSymbol: undefined, + warningName: undefined, + }); + this.validateRpcUrl(this.state.rpcUrlForm); + }; + + onRpcNameAdd = async (name) => { + await this.setState({ + rpcNameForm: name, + }); + }; + + onRpcItemAdd = async (url, name) => { + if (!url || !name) { + return; + } + + await this.setState((prevState) => ({ + rpcUrls: [...prevState.rpcUrls, { url, name }], + })); + + await this.setState({ + rpcUrl: url, + }); + await this.setState({ + rpcName: name, + }); + this.closeAddRpcForm(); + this.closeRpcModal(); + this.getCurrentState(); + }; + + onBlockExplorerItemAdd = async (url) => { + if (!url) { + return; + } + + await this.setState((prevState) => ({ + blockExplorerUrls: [...prevState.blockExplorerUrls, url], + })); + + await this.setState({ + blockExplorerUrl: url, + }); + + this.closeAddBlockExplorerRpcForm(); + this.closeBlockExplorerModal(); + this.getCurrentState(); + }; + onRpcUrlChange = async (url) => { + const { addMode } = this.state; await this.setState({ rpcUrl: url, validatedRpcURL: false, @@ -991,6 +1203,79 @@ export class NetworkSettings extends PureComponent { warningSymbol: undefined, warningName: undefined, }); + + this.validateName(); + if (addMode) { + this.validateChainId(); + } + this.validateSymbol(); + this.getCurrentState(); + }; + + onRpcUrlChangeWithName = async (url, name, type) => { + const nameToUse = name ?? type; + const { addMode } = this.state; + await this.setState({ + rpcUrl: url, + validatedRpcURL: false, + warningRpcUrl: undefined, + warningChainId: undefined, + warningSymbol: undefined, + warningName: undefined, + }); + + await this.setState({ + rpcName: nameToUse, + }); + + this.validateName(); + if (addMode) { + this.validateChainId(); + } + this.validateSymbol(); + this.getCurrentState(); + }; + + onBlockExplorerUrlChange = async (url) => { + const { addMode } = this.state; + await this.setState({ + blockExplorerUrl: url, + }); + + this.validateName(); + if (addMode) { + this.validateChainId(); + } + this.validateSymbol(); + this.getCurrentState(); + }; + + onRpcUrlDelete = async (url) => { + const { addMode } = this.state; + await this.setState((prevState) => ({ + rpcUrls: prevState.rpcUrls.filter((rpcUrl) => rpcUrl.url !== url), + })); + this.validateName(); + if (addMode) { + this.validateChainId(); + } + this.validateSymbol(); + this.getCurrentState(); + }; + + onBlockExplorerUrlDelete = async (url) => { + const { addMode } = this.state; + await this.setState((prevState) => ({ + blockExplorerUrls: prevState.blockExplorerUrls.filter( + (explorerUrl) => explorerUrl !== url, + ), + })); + this.validateName(); + if (addMode) { + this.validateChainId(); + } + this.validateRpcUrl(); + this.validateSymbol(); this.getCurrentState(); }; @@ -1025,11 +1310,6 @@ export class NetworkSettings extends PureComponent { }); }; - onBlockExplorerUrlChange = async (blockExplorerUrl) => { - await this.setState({ blockExplorerUrl }); - this.getCurrentState(); - }; - onNameFocused = () => { this.setState({ isNameFieldFocused: true }); }; @@ -1079,11 +1359,57 @@ export class NetworkSettings extends PureComponent { current && current.focus(); }; + openAddRpcForm = () => { + this.setState({ showAddRpcForm: { isVisible: true } }); + this.rpcAddFormSheetRef.current?.onOpenBottomSheet(); + }; + + closeAddRpcForm = () => { + this.setState({ showAddRpcForm: { isVisible: false } }); + this.rpcAddFormSheetRef.current?.onCloseBottomSheet(); + }; + + openAddBlockExplorerForm = () => { + this.setState({ showAddBlockExplorerForm: { isVisible: true } }); + this.blockExplorerAddFormSheetRef.current?.onOpenBottomSheet(); + }; + + closeAddBlockExplorerRpcForm = () => { + this.setState({ showAddBlockExplorerForm: { isVisible: false } }); + this.blockExplorerAddFormSheetRef.current?.onCloseBottomSheet(); + }; + + closeRpcModal = () => { + this.setState({ showMultiRpcAddModal: { isVisible: false } }); + this.rpcAddMenuSheetRef.current?.onCloseBottomSheet(); + }; + + openRpcModal = () => { + this.setState({ showMultiRpcAddModal: { isVisible: true } }); + this.rpcAddMenuSheetRef.current?.onOpenBottomSheet(); + }; + + openBlockExplorerModal = () => { + this.setState({ showMultiBlockExplorerAddModal: { isVisible: true } }); + this.addBlockExplorerMenuSheetRef.current?.onOpenBottomSheet(); + }; + + closeBlockExplorerModal = () => { + this.setState({ showMultiBlockExplorerAddModal: { isVisible: false } }); + this.addBlockExplorerMenuSheetRef.current?.onCloseBottomSheet(); + }; + switchToMainnet = () => { const { NetworkController, CurrencyRateController } = Engine.context; + const { networkConfigurations } = this.props; + + const { networkClientId } = + networkConfigurations?.rpcEndpoints?.[ + networkConfigurations.defaultRpcEndpointIndex + ] ?? {}; CurrencyRateController.updateExchangeRate(NetworksTicker.mainnet); - NetworkController.setProviderType(MAINNET); + NetworkController.setActiveNetwork(networkClientId); setTimeout(async () => { await updateIncomingTransactions(); @@ -1101,14 +1427,18 @@ export class NetworkSettings extends PureComponent { } const entry = Object.entries(networkConfigurations).find( - ([, networkConfiguration]) => networkConfiguration.rpcUrl === rpcUrl, + ([, networkConfiguration]) => + networkConfiguration.rpcEndpoints[ + networkConfiguration.defaultRpcEndpointIndex + ].url === rpcUrl, ); + if (!entry) { throw new Error(`Unable to find network with RPC URL ${rpcUrl}`); } - const [networkConfigurationId] = entry; + const [, networkConfiguration] = entry; const { NetworkController } = Engine.context; - NetworkController.removeNetworkConfiguration(networkConfigurationId); + NetworkController.removeNetwork(networkConfiguration.chainId); navigation.goBack(); }; @@ -1138,6 +1468,8 @@ export class NetworkSettings extends PureComponent { customNetwork = (networkTypeOrRpcUrl) => { const { rpcUrl, + rpcUrls, + blockExplorerUrls, blockExplorerUrl, nickname, chainId, @@ -1154,8 +1486,15 @@ export class NetworkSettings extends PureComponent { isSymbolFieldFocused, isRpcUrlFieldFocused, isChainIdFieldFocused, + showMultiRpcAddModal, + showMultiBlockExplorerAddModal, + showAddRpcForm, + showAddBlockExplorerForm, + rpcUrlForm, + rpcNameForm, + rpcName, } = this.state; - const { route } = this.props; + const { route, networkConfigurations } = this.props; const isCustomMainnet = route.params?.isCustomMainnet; const colors = this.context.colors || mockTheme.colors; const themeAppearance = @@ -1221,10 +1560,7 @@ export class NetworkSettings extends PureComponent { const isRPCEditable = isCustomMainnet || editable; const isActionDisabled = - !enableAction || - this.disabledByRpcUrl() || - this.disabledByChainId() || - this.disabledBySymbol(); + !enableAction || this.disabledByChainId() || this.disabledBySymbol(); const rpcActionStyle = isActionDisabled ? { ...styles.button, ...styles.disabledButton } @@ -1406,18 +1742,24 @@ export class NetworkSettings extends PureComponent { style={styles.wrapper} testID={NetworksViewSelectorsIDs.CONTAINER} > - - {!networkTypeOrRpcUrl ? ( - - ) : null} - + { + if (this.isAnyModalVisible()) { + this.closeAddBlockExplorerRpcForm(); + this.closeAddRpcForm(); + this.closeBlockExplorerModal(); + this.closeRpcModal(); + } + }} + > + {strings('app_settings.network_name_label')} @@ -1460,6 +1802,7 @@ export class NetworkSettings extends PureComponent { {strings('app_settings.network_rpc_url_label')} + { - this.validateRpcUrl(); + this.validateRpcUrl(rpcUrl); this.onRpcUrlBlur(); }} onFocus={this.onRpcUrlFocused} @@ -1479,6 +1822,7 @@ export class NetworkSettings extends PureComponent { testID={NetworksViewSelectorsIDs.RPC_URL_INPUT} keyboardAppearance={themeAppearance} /> + {warningRpcUrl && ( {strings('app_settings.network_block_explorer_label')} + - + {isCustomMainnet ? (