diff --git a/app/component-library/components-temp/CellSelectWithMenu/__snapshots__/CellSelectWithMenu.test.tsx.snap b/app/component-library/components-temp/CellSelectWithMenu/__snapshots__/CellSelectWithMenu.test.tsx.snap index f8632714685..315ea9c6914 100644 --- a/app/component-library/components-temp/CellSelectWithMenu/__snapshots__/CellSelectWithMenu.test.tsx.snap +++ b/app/component-library/components-temp/CellSelectWithMenu/__snapshots__/CellSelectWithMenu.test.tsx.snap @@ -7,8 +7,6 @@ exports[`CellSelectWithMenu should render with default settings correctly 1`] = "alignItems": "center", "backgroundColor": "#ffffff", "flexDirection": "row", - "paddingRight": 20, - "width": "100%", } } > @@ -16,10 +14,10 @@ exports[`CellSelectWithMenu should render with default settings correctly 1`] = disabled={false} style={ { + "flex": 1, "opacity": 1, "padding": 16, "position": "relative", - "width": "90%", "zIndex": 1, } } diff --git a/app/component-library/components-temp/ListItemMultiSelectButton/ListItemMultiSelectButton.constants.ts b/app/component-library/components-temp/ListItemMultiSelectButton/ListItemMultiSelectButton.constants.ts index fa1de756de1..06fa3a2118c 100644 --- a/app/component-library/components-temp/ListItemMultiSelectButton/ListItemMultiSelectButton.constants.ts +++ b/app/component-library/components-temp/ListItemMultiSelectButton/ListItemMultiSelectButton.constants.ts @@ -8,6 +8,7 @@ import { ListItemMultiSelectButtonProps } from './ListItemMultiSelectButton.type // Defaults export const DEFAULT_LISTITEMMULTISELECT_GAP = 16; export const BUTTON_TEST_ID = 'button-menu-select-test-id'; +export const BUTTON_TEXT_TEST_ID = 'button-text-select-test-id'; // Sample consts export const SAMPLE_LISTITEMMULTISELECT_PROPS: ListItemMultiSelectButtonProps = diff --git a/app/component-library/components-temp/ListItemMultiSelectButton/ListItemMultiSelectButton.styles.ts b/app/component-library/components-temp/ListItemMultiSelectButton/ListItemMultiSelectButton.styles.ts index d3ff43c9cb2..4af6d6f86a9 100644 --- a/app/component-library/components-temp/ListItemMultiSelectButton/ListItemMultiSelectButton.styles.ts +++ b/app/component-library/components-temp/ListItemMultiSelectButton/ListItemMultiSelectButton.styles.ts @@ -25,10 +25,10 @@ const styleSheet = (params: { return StyleSheet.create({ base: Object.assign( { + flex: 1, position: 'relative', opacity: isDisabled ? 0.5 : 1, padding: 16, - width: '90%', zIndex: 1, } as ViewStyle, style, @@ -71,10 +71,8 @@ const styleSheet = (params: { backgroundColor: isSelected ? colors.primary.muted : colors.background.default, - paddingRight: 20, flexDirection: 'row', alignItems: 'center', - width: '100%', }, itemColumn: { display: 'flex', diff --git a/app/component-library/components-temp/ListItemMultiSelectButton/__snapshots__/ListItemMultiSelectButton.test.tsx.snap b/app/component-library/components-temp/ListItemMultiSelectButton/__snapshots__/ListItemMultiSelectButton.test.tsx.snap index a1a0b239ae6..0d1b7d3f4f5 100644 --- a/app/component-library/components-temp/ListItemMultiSelectButton/__snapshots__/ListItemMultiSelectButton.test.tsx.snap +++ b/app/component-library/components-temp/ListItemMultiSelectButton/__snapshots__/ListItemMultiSelectButton.test.tsx.snap @@ -7,8 +7,6 @@ exports[`ListItemMultiSelectButton should render correctly with default props 1` "alignItems": "center", "backgroundColor": "#ffffff", "flexDirection": "row", - "paddingRight": 20, - "width": "100%", } } > @@ -16,10 +14,10 @@ exports[`ListItemMultiSelectButton should render correctly with default props 1` disabled={false} style={ { + "flex": 1, "opacity": 1, "padding": 16, "position": "relative", - "width": "90%", "zIndex": 1, } } diff --git a/app/components/Nav/App/index.js b/app/components/Nav/App/index.js index a2a8b16d485..a587ef6c3ae 100644 --- a/app/components/Nav/App/index.js +++ b/app/components/Nav/App/index.js @@ -711,7 +711,6 @@ const App = (props) => { component={MultiRpcModal} /> ) : null} - { networkImageSource: networkImage, }); } - previousNetworkConfigurations.current = networkConfigurations; }, [networkConfigurations, networkName, networkImage, toastRef]); 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/NetworkAdded/index.tsx b/app/components/UI/NetworkModal/NetworkAdded/index.tsx index 17a9e360336..d4dc06b8dc6 100644 --- a/app/components/UI/NetworkModal/NetworkAdded/index.tsx +++ b/app/components/UI/NetworkModal/NetworkAdded/index.tsx @@ -14,6 +14,9 @@ const createStyles = (colors: any) => flexDirection: 'row', paddingVertical: 16, }, + base: { + padding: 16, + }, button: { flex: 1, }, @@ -41,7 +44,7 @@ const NetworkAdded = (props: NetworkAddedProps) => { const styles = createStyles(colors); return ( - + {strings('networks.new_network')} diff --git a/app/components/UI/NetworkModal/index.tsx b/app/components/UI/NetworkModal/index.tsx index 61493f66945..4da8d8d5f1b 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,154 @@ 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]; + let networkClientId; + + if (existingNetwork) { + const updatedNetwork = await NetworkController.updateNetwork( + existingNetwork.chainId, + existingNetwork, + existingNetwork.chainId === chainId + ? { + replacementSelectedRpcEndpointIndex: + existingNetwork.defaultRpcEndpointIndex, + } + : undefined, + ); + + networkClientId = + updatedNetwork?.rpcEndpoints?.[updatedNetwork.defaultRpcEndpointIndex] + ?.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, + }, + ], + }); + + networkClientId = + addedNetwork?.rpcEndpoints?.[addedNetwork.defaultRpcEndpointIndex] + ?.networkClientId; + } + + if (networkClientId) { + 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/Ramp/Views/NetworkSwitcher/__snapshots__/NetworkSwitcher.test.tsx.snap b/app/components/UI/Ramp/Views/NetworkSwitcher/__snapshots__/NetworkSwitcher.test.tsx.snap index 7f21f4f5907..92cb998fc5d 100644 --- a/app/components/UI/Ramp/Views/NetworkSwitcher/__snapshots__/NetworkSwitcher.test.tsx.snap +++ b/app/components/UI/Ramp/Views/NetworkSwitcher/__snapshots__/NetworkSwitcher.test.tsx.snap @@ -549,7 +549,7 @@ exports[`NetworkSwitcher View renders and dismisses network modal when pressing { "borderRadius": 10, "height": 20, - "marginRight": 10, + "marginRight": 20, "width": 20, } } @@ -703,7 +703,7 @@ exports[`NetworkSwitcher View renders and dismisses network modal when pressing { "borderRadius": 10, "height": 20, - "marginRight": 10, + "marginRight": 20, "width": 20, } } @@ -1444,7 +1444,7 @@ exports[`NetworkSwitcher View renders and dismisses network modal when pressing { "borderRadius": 10, "height": 20, - "marginRight": 10, + "marginRight": 20, "width": 20, } } @@ -1492,10 +1492,7 @@ exports[`NetworkSwitcher View renders and dismisses network modal when pressing }, undefined, undefined, - { - "fontFamily": "EuclidCircularB-Bold", - "fontWeight": "600", - }, + false, undefined, undefined, undefined, @@ -1598,7 +1595,7 @@ exports[`NetworkSwitcher View renders and dismisses network modal when pressing { "borderRadius": 10, "height": 20, - "marginRight": 10, + "marginRight": 20, "width": 20, } } @@ -1648,10 +1645,7 @@ exports[`NetworkSwitcher View renders and dismisses network modal when pressing }, undefined, undefined, - { - "fontFamily": "EuclidCircularB-Bold", - "fontWeight": "600", - }, + false, undefined, undefined, undefined, @@ -2297,7 +2291,7 @@ exports[`NetworkSwitcher View renders and dismisses network modal when pressing { "borderRadius": 10, "height": 20, - "marginRight": 10, + "marginRight": 20, "width": 20, } } @@ -2451,7 +2445,7 @@ exports[`NetworkSwitcher View renders and dismisses network modal when pressing { "borderRadius": 10, "height": 20, - "marginRight": 10, + "marginRight": 20, "width": 20, } } @@ -2605,7 +2599,7 @@ exports[`NetworkSwitcher View renders and dismisses network modal when pressing { "borderRadius": 10, "height": 20, - "marginRight": 10, + "marginRight": 20, "width": 20, } } @@ -2653,10 +2647,7 @@ exports[`NetworkSwitcher View renders and dismisses network modal when pressing }, undefined, undefined, - { - "fontFamily": "EuclidCircularB-Bold", - "fontWeight": "600", - }, + false, undefined, undefined, undefined, @@ -2759,7 +2750,7 @@ exports[`NetworkSwitcher View renders and dismisses network modal when pressing { "borderRadius": 10, "height": 20, - "marginRight": 10, + "marginRight": 20, "width": 20, } } @@ -2809,10 +2800,7 @@ exports[`NetworkSwitcher View renders and dismisses network modal when pressing }, undefined, undefined, - { - "fontFamily": "EuclidCircularB-Bold", - "fontWeight": "600", - }, + false, undefined, undefined, undefined, @@ -3458,7 +3446,7 @@ exports[`NetworkSwitcher View renders correctly 1`] = ` { "borderRadius": 10, "height": 20, - "marginRight": 10, + "marginRight": 20, "width": 20, } } @@ -3612,7 +3600,7 @@ exports[`NetworkSwitcher View renders correctly 1`] = ` { "borderRadius": 10, "height": 20, - "marginRight": 10, + "marginRight": 20, "width": 20, } } @@ -3766,7 +3754,7 @@ exports[`NetworkSwitcher View renders correctly 1`] = ` { "borderRadius": 10, "height": 20, - "marginRight": 10, + "marginRight": 20, "width": 20, } } @@ -3814,10 +3802,7 @@ exports[`NetworkSwitcher View renders correctly 1`] = ` }, undefined, undefined, - { - "fontFamily": "EuclidCircularB-Bold", - "fontWeight": "600", - }, + false, undefined, undefined, undefined, @@ -3920,7 +3905,7 @@ exports[`NetworkSwitcher View renders correctly 1`] = ` { "borderRadius": 10, "height": 20, - "marginRight": 10, + "marginRight": 20, "width": 20, } } @@ -3970,10 +3955,7 @@ exports[`NetworkSwitcher View renders correctly 1`] = ` }, undefined, undefined, - { - "fontFamily": "EuclidCircularB-Bold", - "fontWeight": "600", - }, + false, undefined, undefined, undefined, @@ -4619,7 +4601,7 @@ exports[`NetworkSwitcher View renders correctly 2`] = ` { "borderRadius": 10, "height": 20, - "marginRight": 10, + "marginRight": 20, "width": 20, } } @@ -4773,7 +4755,7 @@ exports[`NetworkSwitcher View renders correctly 2`] = ` { "borderRadius": 10, "height": 20, - "marginRight": 10, + "marginRight": 20, "width": 20, } } @@ -4927,7 +4909,7 @@ exports[`NetworkSwitcher View renders correctly 2`] = ` { "borderRadius": 10, "height": 20, - "marginRight": 10, + "marginRight": 20, "width": 20, } } @@ -4975,10 +4957,7 @@ exports[`NetworkSwitcher View renders correctly 2`] = ` }, undefined, undefined, - { - "fontFamily": "EuclidCircularB-Bold", - "fontWeight": "600", - }, + false, undefined, undefined, undefined, @@ -5081,7 +5060,7 @@ exports[`NetworkSwitcher View renders correctly 2`] = ` { "borderRadius": 10, "height": 20, - "marginRight": 10, + "marginRight": 20, "width": 20, } } @@ -5131,10 +5110,7 @@ exports[`NetworkSwitcher View renders correctly 2`] = ` }, undefined, undefined, - { - "fontFamily": "EuclidCircularB-Bold", - "fontWeight": "600", - }, + false, undefined, undefined, undefined, 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..8dbd03768f6 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,14 +445,15 @@ 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, }) } + onLongPress={() => { + openModal(chainId, false, MAINNET, true); + }} /> ); } @@ -406,14 +461,14 @@ const NetworkSelector = () => { return ( onNetworkChange(MAINNET)} style={styles.networkCell} /> @@ -422,6 +477,7 @@ const NetworkSelector = () => { const renderLineaMainnet = () => { const { name: lineaMainnetName, chainId } = Networks['linea-mainnet']; + const name = networkConfigurations?.[chainId]?.name ?? lineaMainnetName; if (isNetworkUiRedesignEnabled() && isNoSearchResults('linea-mainnet')) return null; @@ -431,7 +487,7 @@ 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, }) } + onLongPress={() => { + openModal(chainId, false, LINEA_MAINNET, true); + }} /> ); } @@ -463,92 +520,106 @@ const NetworkSelector = () => { return ( 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, + }) + } + onLongPress={() => { + openModal(chainId, true, rpcUrl, false); + }} + /> ); - }, - ); + } + + return ( + onSetRpcTarget(networkConfiguration)} + style={styles.networkCell} + > + {Boolean( + chainId === selectedChainId && selectedRpcUrl === rpcUrl, + ) && } + + ); + }); const renderOtherNetworks = () => { const getAllNetworksTyped = @@ -561,6 +632,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 +648,7 @@ const NetworkSelector = () => { { openModal(chainId, false, networkType, true); }, }} + onTextClick={() => + openRpcModal({ + chainId, + networkName: name, + }) + } + onLongPress={() => { + openModal(chainId, false, networkType, true); + }} /> ); } @@ -704,39 +794,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 +856,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 +916,14 @@ const NetworkSelector = () => { ); - }, [showMultiRpcSelectModal, rpcMenuSheetRef, closeRpcModal, styles]); + }, [ + showMultiRpcSelectModal, + rpcMenuSheetRef, + closeRpcModal, + styles, + networkConfigurations, + onRpcSelect, + ]); const renderBottomSheetContent = () => ( <> @@ -896,17 +1002,15 @@ const NetworkSelector = () => { > { - 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, + }); }); }} /> @@ -916,6 +1020,7 @@ const NetworkSelector = () => { actionTitle={strings('app_settings.delete')} iconName={IconName.Trash} onPress={() => removeRpcUrl(showNetworkMenuModal.chainId)} + testID={`delete-network-button-${showNetworkMenuModal.chainId}`} /> ) : null} 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..e2ee56493dc 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,34 @@ 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 BottomSheet from '../../../../../component-library/components/BottomSheets/BottomSheet'; +import BottomSheetHeader from '../../../../../component-library/components/BottomSheets/BottomSheetHeader'; +import { TextVariant } from '../../../../../component-library/components/Texts/Text'; +import ButtonLink from '../../../../../component-library/components/Buttons/Button/variants/ButtonLink'; +import ButtonPrimary from '../../../../../component-library/components/Buttons/Button/variants/ButtonPrimary'; +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 +118,11 @@ const createStyles = (colors) => flex: 1, paddingVertical: 12, }, + scrollWrapperOverlay: { + flex: 1, + paddingVertical: 12, + opacity: 0.5, + }, onboardingInput: { borderColor: staticColors.transparent, padding: 0, @@ -115,6 +135,11 @@ const createStyles = (colors) => padding: 10, color: colors.text.default, }, + dropDownInput: { + borderColor: colors.border.default, + borderRadius: 5, + borderWidth: 2, + }, inputWithError: { ...typography.sBodyMD, borderColor: colors.error.default, @@ -151,6 +176,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 +297,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 +352,12 @@ export class NetworkSettings extends PureComponent { state = { rpcUrl: undefined, + rpcName: undefined, + rpcUrlFrom: undefined, + rpcNameForm: '', + rpcUrls: [], + blockExplorerUrls: [], + selectedRpcEndpointIndex: 0, blockExplorerUrl: undefined, nickname: undefined, chainId: undefined, @@ -344,15 +382,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 +429,98 @@ 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 ?? + networkConfiguration?.rpcEndpoints[ + networkConfiguration?.defaultRpcEndpointIndex + ]?.type; + + 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 +569,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 +612,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 +694,113 @@ 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, + }) => { + 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]; + + 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, + }; + + if (isNetworkExists.length === 0) { + await NetworkController.updateNetwork( + existingNetwork.chainId, + networkConfig, + existingNetwork.chainId === chainId + ? { + replacementSelectedRpcEndpointIndex: indexRpc, + } + : undefined, + ); + } else { + await NetworkController.addNetwork({ + ...networkConfig, + }); + } + + 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, + editable, } = 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 +825,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 +898,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 +969,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 +1062,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 +1084,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 +1113,68 @@ 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) { + return; + } + + const rpcName = name ?? ''; + + await this.setState((prevState) => ({ + rpcUrls: [ + ...prevState.rpcUrls, + { url, name: rpcName, type: RpcEndpointType.Custom }, + ], + })); + + await this.setState({ + rpcUrl: url, + 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 +1183,78 @@ 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.validateSymbol(); this.getCurrentState(); }; @@ -1025,11 +1289,6 @@ export class NetworkSettings extends PureComponent { }); }; - onBlockExplorerUrlChange = async (blockExplorerUrl) => { - await this.setState({ blockExplorerUrl }); - this.getCurrentState(); - }; - onNameFocused = () => { this.setState({ isNameFieldFocused: true }); }; @@ -1079,11 +1338,61 @@ 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 }, + rpcUrlForm: '', + rpcNameForm: '', + }); + 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 +1410,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 +1451,8 @@ export class NetworkSettings extends PureComponent { customNetwork = (networkTypeOrRpcUrl) => { const { rpcUrl, + rpcUrls, + blockExplorerUrls, blockExplorerUrl, nickname, chainId, @@ -1154,8 +1469,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 +1543,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 } @@ -1346,6 +1665,24 @@ export class NetworkSettings extends PureComponent { }; const renderButtons = () => { + if (isNetworkUiRedesignEnabled()) { + return ( + + +