diff --git a/app/components/UI/CustomNetworkSelector/CustomNetworkSelector.test.tsx b/app/components/UI/CustomNetworkSelector/CustomNetworkSelector.test.tsx index b147cad5d18c..dea4d141e04b 100644 --- a/app/components/UI/CustomNetworkSelector/CustomNetworkSelector.test.tsx +++ b/app/components/UI/CustomNetworkSelector/CustomNetworkSelector.test.tsx @@ -19,6 +19,7 @@ import CustomNetworkSelector from './CustomNetworkSelector'; import { CustomNetworkItem } from './CustomNetworkSelector.types'; import { selectMultichainAccountsState2Enabled } from '../../../selectors/featureFlagController/multichainAccounts/enabledMultichainAccounts'; import { InternalAccount } from '@metamask/keyring-internal-api'; +import { selectIsEvmNetworkSelected } from '../../../selectors/multichainNetworkController'; jest.mock('@react-navigation/native', () => ({ useNavigation: jest.fn(), @@ -142,6 +143,10 @@ jest.mock('@shopify/flash-list', () => { }; }); +jest.mock('../../../selectors/multichainNetworkController', () => ({ + selectIsEvmNetworkSelected: jest.fn(), +})); + // Mock store setup const mockStore = createStore(() => ({ featureFlags: { @@ -178,7 +183,10 @@ describe('CustomNetworkSelector', () => { typeof useNetworksToUse >; const mockUseSelector = jest.mocked(useSelector); - + const mockSelectIsEvmNetworkSelected = + selectIsEvmNetworkSelected as jest.MockedFunction< + typeof selectIsEvmNetworkSelected + >; const mockNetworks: CustomNetworkItem[] = [ { id: 'eip155:137', @@ -262,10 +270,15 @@ describe('CustomNetworkSelector', () => { areAllSolanaNetworksSelected: false, }); + mockSelectIsEvmNetworkSelected.mockReturnValue(true); + mockUseSelector.mockImplementation((selector) => { if (selector === selectMultichainAccountsState2Enabled) { return true; } + if (selector === mockSelectIsEvmNetworkSelected) { + return true; + } return undefined; }); }); diff --git a/app/components/UI/CustomNetworkSelector/CustomNetworkSelector.tsx b/app/components/UI/CustomNetworkSelector/CustomNetworkSelector.tsx index ee411822bbaa..dd140dfc7a1c 100644 --- a/app/components/UI/CustomNetworkSelector/CustomNetworkSelector.tsx +++ b/app/components/UI/CustomNetworkSelector/CustomNetworkSelector.tsx @@ -7,6 +7,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useNavigation } from '@react-navigation/native'; import { parseCaipChainId } from '@metamask/utils'; import { toHex } from '@metamask/controller-utils'; +import { useSelector } from 'react-redux'; // external dependencies import { strings } from '../../../../locales/i18n'; @@ -41,6 +42,7 @@ import { CustomNetworkItem, CustomNetworkSelectorProps, } from './CustomNetworkSelector.types'; +import { selectIsEvmNetworkSelected } from '../../../selectors/multichainNetworkController'; import { useNetworksToUse } from '../../hooks/useNetworksToUse/useNetworksToUse'; const CustomNetworkSelector = ({ @@ -51,6 +53,7 @@ const CustomNetworkSelector = ({ const { styles } = useStyles(createStyles, { colors }); const { navigate } = useNavigation(); const safeAreaInsets = useSafeAreaInsets(); + const isEvmSelected = useSelector(selectIsEvmNetworkSelected); // Use custom hooks for network management const { networks, areAllNetworksSelected } = useNetworksByNamespace({ @@ -78,7 +81,7 @@ const CustomNetworkSelector = ({ ({ item }) => { const { name, caipChainId, networkTypeOrRpcUrl, isSelected } = item; const rawChainId = parseCaipChainId(caipChainId).reference; - const chainId = toHex(rawChainId); + const chainId = isEvmSelected ? toHex(rawChainId) : rawChainId; const handlePress = async () => { await selectCustomNetwork(caipChainId, dismissModal); @@ -115,7 +118,7 @@ const CustomNetworkSelector = ({ ); }, - [selectCustomNetwork, openModal, dismissModal], + [selectCustomNetwork, openModal, dismissModal, isEvmSelected], ); const renderFooter = useCallback( diff --git a/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.test.tsx b/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.test.tsx index f32f6c77f627..9d3af172004b 100644 --- a/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.test.tsx +++ b/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.test.tsx @@ -8,7 +8,8 @@ import { formatChainIdToCaip } from '@metamask/bridge-controller'; import { debounce, type DebouncedFunc } from 'lodash'; import { useStyles } from '../../../component-library/hooks/index.ts'; import { isTestNet } from '../../../util/networks/index.js'; -import { selectEvmChainId } from '../../../selectors/networkController'; +import { selectChainId } from '../../../selectors/networkController'; +import { selectIsEvmNetworkSelected } from '../../../selectors/multichainNetworkController'; import NetworkMultiSelectorList from './NetworkMultiSelectorList'; import { NetworkMultiSelectorListProps, @@ -78,6 +79,11 @@ jest.mock('../../../util/device/index.js', () => ({ jest.mock('../../../selectors/networkController', () => ({ selectEvmChainId: jest.fn(), + selectChainId: jest.fn(), +})); + +jest.mock('../../../selectors/multichainNetworkController', () => ({ + selectIsEvmNetworkSelected: jest.fn(), })); // Mock component library components @@ -149,9 +155,13 @@ describe('NetworkMultiSelectorList', () => { const mockDebounce = debounce as jest.MockedFunction; const mockUseStyles = useStyles as jest.MockedFunction; const mockIsTestNet = isTestNet as jest.MockedFunction; - const mockSelectEvmChainId = selectEvmChainId as jest.MockedFunction< - typeof selectEvmChainId + const mockSelectChainId = selectChainId as jest.MockedFunction< + typeof selectChainId >; + const mockSelectIsEvmNetworkSelected = + selectIsEvmNetworkSelected as jest.MockedFunction< + typeof selectIsEvmNetworkSelected + >; const mockOnSelectNetwork = jest.fn(); const mockOpenModal = jest.fn(); @@ -194,6 +204,7 @@ describe('NetworkMultiSelectorList', () => { jest.clearAllMocks(); mockUseSelector.mockReturnValue('0x1'); + mockSelectIsEvmNetworkSelected.mockReturnValue(true); mockUseSafeAreaInsets.mockReturnValue({ top: 0, right: 0, @@ -232,9 +243,9 @@ describe('NetworkMultiSelectorList', () => { expect(mockUseSafeAreaInsets).toHaveBeenCalled(); }); - it('calls useSelector with selectEvmChainId', () => { + it('calls useSelector with selectChainId', () => { render(); - expect(mockUseSelector).toHaveBeenCalledWith(mockSelectEvmChainId); + expect(mockUseSelector).toHaveBeenCalledWith(mockSelectChainId); }); it('calls useStyles with styleSheet', () => { @@ -361,6 +372,21 @@ describe('NetworkMultiSelectorList', () => { reference: chainId.split(':')[1], })); + mockToHex.mockClear(); + mockUseSelector.mockClear(); + + // Set useSelector to return different values based on the selector + mockUseSelector.mockImplementation((selector) => { + if (selector === mockSelectChainId) { + return '0x1'; + } + if (selector === mockSelectIsEvmNetworkSelected) { + // return false for isEvmSelected + return false; + } + return undefined; + }); + const props = { ...defaultProps, networks: [solanaNetwork] }; render(); diff --git a/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.tsx b/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.tsx index 44cd669d349c..6d523f7b27d8 100644 --- a/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.tsx +++ b/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.tsx @@ -28,7 +28,7 @@ import Cell, { } from '../../../component-library/components/Cells/Cell/index.ts'; import { isTestNet } from '../../../util/networks/index.js'; import Device from '../../../util/device/index.js'; -import { selectEvmChainId } from '../../../selectors/networkController'; +import { selectChainId } from '../../../selectors/networkController'; // Internal dependencies. import { @@ -48,6 +48,7 @@ import { ITEM_TYPE_NETWORK, SELECT_ALL_NETWORKS_SECTION_ID, } from './NetworkMultiSelectorList.constants'; +import { selectIsEvmNetworkSelected } from '../../../selectors/multichainNetworkController'; const SELECTION_DEBOUNCE_DELAY = 150; @@ -76,7 +77,8 @@ const NetworkMultiSelectList = ({ const networkListRef = useRef(null); const networksLengthRef = useRef(0); const safeAreaInsets = useSafeAreaInsets(); - const selectedChainId = useSelector(selectEvmChainId); + const selectedChainId = useSelector(selectChainId); + const isEvmSelected = useSelector(selectIsEvmNetworkSelected); const selectedChainIdCaip = formatChainIdToCaip(selectedChainId); const { styles } = useStyles(styleSheet, {}); @@ -85,10 +87,10 @@ const NetworkMultiSelectList = ({ (): ProcessedNetwork[] => networks.map((network) => { const parsedCaipChainId = parseCaipChainId(network.caipChainId); - const chainId = - parsedCaipChainId.namespace !== 'solana' - ? toHex(parsedCaipChainId.reference) - : ''; + const chainId = isEvmSelected + ? toHex(parsedCaipChainId.reference) + : parsedCaipChainId.reference; + return { ...network, chainId, @@ -98,7 +100,7 @@ const NetworkMultiSelectList = ({ isSelected: areAllNetworksSelected ? false : network.isSelected, }; }), - [areAllNetworksSelected, networks], + [areAllNetworksSelected, networks, isEvmSelected], ); const combinedData: NetworkListItem[] = useMemo(() => { @@ -111,7 +113,7 @@ const NetworkMultiSelectList = ({ data.push(...filteredNetworks); } - if (selectAllNetworksComponent) { + if (selectAllNetworksComponent && isEvmSelected) { data.unshift({ id: SELECT_ALL_NETWORKS_SECTION_ID, type: NetworkListItemType.SelectAllNetworksListItem, @@ -132,7 +134,9 @@ const NetworkMultiSelectList = ({ processedNetworks, additionalNetworksComponent, selectAllNetworksComponent, + isEvmSelected, ]); + const contentContainerStyle = useMemo( () => ({ paddingBottom: @@ -241,7 +245,6 @@ const NetworkMultiSelectList = ({ const isDisabled = isLoading || isSelectionDisabled; const showButtonIcon = Boolean(networkTypeOrRpcUrl); - return ( { expect(goToAddToken).toHaveBeenCalled(); }); - - it('should not call handleFilterControls when EVM is not selected', () => { - // Ensure EVM is not selected - mockSelectIsEvmNetworkSelected.mockReturnValue(false); - mockSelectChainId.mockReturnValue( - 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', - ); - - const { getByTestId } = renderComponent(); - - const filterButton = getByTestId('token-network-filter'); - fireEvent.press(filterButton); - - expect(mockNavigate).not.toHaveBeenCalled(); - }); }); describe('Button states', () => { diff --git a/app/components/UI/Tokens/TokenListControlBar/TokenListControlBar.tsx b/app/components/UI/Tokens/TokenListControlBar/TokenListControlBar.tsx index 0096ec07c08e..127b16f3f98a 100644 --- a/app/components/UI/Tokens/TokenListControlBar/TokenListControlBar.tsx +++ b/app/components/UI/Tokens/TokenListControlBar/TokenListControlBar.tsx @@ -36,7 +36,7 @@ export const TokenListControlBar = ({ ); diff --git a/app/components/hooks/useCurrentNetworkInfo.ts b/app/components/hooks/useCurrentNetworkInfo.ts index 0046ab4b72ac..6487cd5e0b01 100644 --- a/app/components/hooks/useCurrentNetworkInfo.ts +++ b/app/components/hooks/useCurrentNetworkInfo.ts @@ -1,7 +1,11 @@ import { useMemo, useCallback } from 'react'; import { useSelector } from 'react-redux'; +import { KnownCaipNamespace } from '@metamask/utils'; import { formatChainIdToCaip } from '@metamask/bridge-controller'; -import { selectNetworkConfigurationsByCaipChainId } from '../../selectors/networkController'; +import { + selectNetworkConfigurationsByCaipChainId, + selectChainId, +} from '../../selectors/networkController'; import { selectIsEvmNetworkSelected } from '../../selectors/multichainNetworkController'; import { useNetworkEnablement } from './useNetworkEnablement/useNetworkEnablement'; import { selectMultichainAccountsState2Enabled } from '../../selectors/featureFlagController/multichainAccounts'; @@ -28,6 +32,9 @@ export const useCurrentNetworkInfo = (): CurrentNetworkInfo => { selectNetworkConfigurationsByCaipChainId, ); const isEvmSelected = useSelector(selectIsEvmNetworkSelected); + const selectedChainId = useSelector(selectChainId); + const isSolanaSelected = + selectedChainId?.includes(KnownCaipNamespace.Solana) ?? false; const isMultichainAccountsState2Enabled = useSelector( selectMultichainAccountsState2Enabled, ); @@ -87,7 +94,11 @@ export const useCurrentNetworkInfo = (): CurrentNetworkInfo => { [enabledNetworks, networksByCaipChainId], ); - const isDisabled = !isEvmSelected && !isMultichainAccountsState2Enabled; + let isDisabled: boolean = Boolean(!isEvmSelected); + // We don't have Solana testnet networks, so we disable the network selector if Solana is selected + // TODO: Come back when we have Solana devnet available + isDisabled = Boolean(isSolanaSelected); + const hasEnabledNetworks = enabledNetworks.length > 0; return { diff --git a/app/components/hooks/useNetworkSelection/useNetworkSelection.test.ts b/app/components/hooks/useNetworkSelection/useNetworkSelection.test.ts index dedaaf5ecea3..fc0f99af3742 100644 --- a/app/components/hooks/useNetworkSelection/useNetworkSelection.test.ts +++ b/app/components/hooks/useNetworkSelection/useNetworkSelection.test.ts @@ -1,16 +1,64 @@ import { renderHook } from '@testing-library/react-native'; import { useSelector } from 'react-redux'; -import { CaipChainId, Hex, parseCaipChainId } from '@metamask/utils'; +import { + CaipChainId, + Hex, + parseCaipChainId, + isCaipChainId, +} from '@metamask/utils'; import { toHex } from '@metamask/controller-utils'; import { formatChainIdToCaip } from '@metamask/bridge-controller'; import { useNetworkEnablement } from '../useNetworkEnablement/useNetworkEnablement'; import { ProcessedNetwork } from '../useNetworksByNamespace/useNetworksByNamespace'; import { useNetworkSelection } from './useNetworkSelection'; +import { selectMultichainAccountsState2Enabled } from '../../../selectors/featureFlagController/multichainAccounts/enabledMultichainAccounts'; +import { selectPopularNetworkConfigurationsByCaipChainId } from '../../../selectors/networkController'; +import { selectInternalAccounts } from '../../../selectors/accountsController'; +import Engine from '../../../core/Engine'; +import NavigationService from '../../../core/NavigationService'; + +const mockEnableNetwork = jest.fn(); +const mockDisableNetwork = jest.fn(); +const mockEnableAllPopularNetworks = jest.fn(); +const mockSetActiveNetwork = jest.fn(); +const mockFindNetworkClientIdByChainId = jest.fn(); jest.mock('@metamask/keyring-utils', () => ({})); +jest.mock('@metamask/transaction-controller', () => ({})); +jest.mock('@metamask/multichain-network-controller', () => ({})); +jest.mock('@metamask/keyring-controller', () => ({})); +jest.mock('@metamask/utils', () => ({ + parseCaipChainId: jest.fn(), + isCaipChainId: jest.fn(), + KnownCaipNamespace: { + Eip155: 'eip155', + Bip122: 'bip122', + Solana: 'solana', + }, + createProjectLogger: jest.fn(), + createModuleLogger: jest.fn(), +})); + jest.mock('@metamask/keyring-api', () => ({ SolScope: { Mainnet: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + Devnet: 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1', + Testnet: 'solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z', + }, + BtcScope: { + Mainnet: 'bip122:000000000019d6689c085ae165831e93', + Testnet: 'bip122:000000000933ea01ad0ee984209779ba', + Testnet4: 'bip122:00000000da84f2bafbbc53dee25a72ae', + Signet: 'bip122:00000008819873e925422c1ff0f99f7c', + }, + BtcAccountType: { + P2pkh: 'bip122:p2pkh', + P2sh: 'bip122:p2sh', + P2wpkh: 'bip122:p2wpkh', + P2tr: 'bip122:p2tr', + }, + SolAccountType: { + DataAccount: 'solana:data-account', }, })); jest.mock('@metamask/rpc-errors', () => ({})); @@ -21,11 +69,6 @@ jest.mock('@metamask/controller-utils', () => ({ toHex: jest.fn(), })); -jest.mock('@metamask/utils', () => ({ - parseCaipChainId: jest.fn(), - isCaipChainId: jest.fn(), -})); - jest.mock('react-redux', () => ({ useSelector: jest.fn(), })); @@ -42,10 +85,6 @@ jest.mock('../useNetworkEnablement/useNetworkEnablement', () => ({ useNetworkEnablement: jest.fn(), })); -jest.mock('../../../selectors/networkController', () => ({ - selectPopularNetworkConfigurationsByCaipChainId: jest.fn(), -})); - jest.mock( '../../../selectors/featureFlagController/multichainAccounts/enabledMultichainAccounts', () => ({ @@ -62,6 +101,7 @@ jest.mock('../../../core/Engine', () => ({ findNetworkClientIdByChainId: jest.fn(), }, }, + setSelectedAddress: jest.fn(), // Add this line })); jest.mock('@react-navigation/native', () => { @@ -72,6 +112,77 @@ jest.mock('@react-navigation/native', () => { }; }); +jest.mock('@metamask/snaps-sdk', () => ({})); +jest.mock('@metamask/snaps-utils', () => ({})); +jest.mock('@metamask/keyring-snap-client', () => ({})); + +jest.mock('../../../util/transactions', () => ({})); + +jest.mock('../../../constants/transaction', () => ({ + TransactionType: { + stakingClaim: 'stakingClaim', + stakingDeposit: 'stakingDeposit', + stakingWithdraw: 'stakingWithdraw', + }, +})); + +jest.mock('../../../reducers/swaps', () => ({ + swapsControllerAndUserTokensMultichain: jest.fn(), + swapsControllerTokens: jest.fn(), +})); + +jest.mock('../../../selectors/tokensController', () => ({ + selectTokens: jest.fn(), + selectTokensControllerState: jest.fn(), + selectAllTokens: jest.fn(), +})); + +jest.mock('../../../selectors/smartTransactionsController', () => ({ + selectSortedTransactions: jest.fn(), + selectNonReplacedTransactions: jest.fn(), + selectPendingSmartTransactionsBySender: jest.fn(), +})); + +jest.mock('../../../selectors/accountsController', () => ({ + selectSelectedInternalAccountAddress: jest.fn(), + selectSelectedInternalAccountFormattedAddress: jest.fn(), + selectInternalAccounts: jest.fn(), +})); + +jest.mock('../../../selectors/networkController', () => ({ + selectEvmChainId: jest.fn(), + selectPopularNetworkConfigurationsByCaipChainId: jest.fn(), +})); + +jest.mock('../../../core/NavigationService', () => { + const mockNavigate = jest.fn(); + return { + navigation: { + navigate: mockNavigate, + }, + default: { + navigation: { + navigate: mockNavigate, + }, + }, + }; +}); + +jest.mock('../../../core/SnapKeyring/MultichainWalletSnapClient', () => ({ + WalletClientType: { + Bitcoin: 'bitcoin', + }, +})); + +jest.mock('../../../constants/navigation/Routes', () => ({ + MODAL: { + ROOT_MODAL_FLOW: 'RootModalFlow', + }, + SHEET: { + ADD_ACCOUNT: 'AddAccount', + }, +})); + describe('useNetworkSelection', () => { const mockUseSelector = useSelector as jest.MockedFunction< typeof useSelector @@ -87,10 +198,6 @@ describe('useNetworkSelection', () => { typeof parseCaipChainId >; - const mockEnableNetwork = jest.fn(); - const mockDisableNetwork = jest.fn(); - const mockEnableAllPopularNetworks = jest.fn(); - const mockNetworks: ProcessedNetwork[] = [ { id: 'eip155:1', @@ -130,6 +237,24 @@ describe('useNetworkSelection', () => { beforeEach(() => { jest.clearAllMocks(); + mockEnableNetwork.mockReset(); + mockDisableNetwork.mockReset(); + mockEnableAllPopularNetworks.mockReset(); + mockSetActiveNetwork.mockReset(); + mockFindNetworkClientIdByChainId.mockReset(); + + mockUseSelector.mockImplementation((selector) => { + if (selector === selectPopularNetworkConfigurationsByCaipChainId) { + return mockPopularNetworkConfigurations; + } + if (selector === selectMultichainAccountsState2Enabled) { + return false; + } + if (selector === selectInternalAccounts) { + return []; + } + return undefined; + }); mockUseNetworkEnablement.mockReturnValue({ namespace: 'eip155', @@ -159,10 +284,6 @@ describe('useNetworkSelection', () => { tryEnableEvmNetwork: jest.fn(), }); - mockUseSelector - .mockReturnValueOnce(mockPopularNetworkConfigurations) // selectPopularNetworkConfigurationsByCaipChainId - .mockReturnValueOnce(false); // selectMultichainAccountsState2Enabled - mockToHex.mockImplementation((value) => { if (typeof value === 'string') { if (value.startsWith('0x')) { @@ -187,8 +308,6 @@ describe('useNetworkSelection', () => { namespace: caipChainId.split(':')[0], reference: caipChainId.split(':')[1], })); - - jest.clearAllMocks(); }); describe('basic functionality', () => { @@ -715,23 +834,43 @@ describe('useNetworkSelection', () => { }); it('resetSolanaNetworks disables Solana mainnet', async () => { - // Set up mocks for multichain enabled - mockUseSelector - .mockReturnValueOnce(mockPopularNetworkConfigurations) - .mockReturnValueOnce(true); // isMultichainAccountsState2Enabled = true + mockUseSelector.mockImplementation((selector) => { + if (selector === selectPopularNetworkConfigurationsByCaipChainId) { + return mockPopularNetworkConfigurations; + } + if (selector === selectMultichainAccountsState2Enabled) { + return true; + } + if (selector === selectInternalAccounts) { + return []; + } + return undefined; + }); + + mockParseCaipChainId.mockReturnValue({ + namespace: 'eip155', + reference: '999', + }); + mockToHex.mockReturnValue('0x999'); + + const mockSetActiveNetwork = jest.fn(); + const mockFindNetworkClientIdByChainId = jest + .fn() + .mockReturnValue('network-client-id'); + jest + .spyOn(Engine.context.MultichainNetworkController, 'setActiveNetwork') + .mockImplementation(mockSetActiveNetwork); + jest + .spyOn(Engine.context.NetworkController, 'findNetworkClientIdByChainId') + .mockImplementation(mockFindNetworkClientIdByChainId); const { result } = renderHook(() => useNetworkSelection({ networks: mockNetworks }), ); - // Clear mocks after hook initialization to isolate the function call - jest.clearAllMocks(); - const customChainId = 'eip155:999' as CaipChainId; - await result.current.selectCustomNetwork(customChainId); - // resetSolanaNetworks should be called internally expect(mockDisableNetwork).toHaveBeenCalledWith( 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', ); @@ -918,4 +1057,251 @@ describe('useNetworkSelection', () => { }); }); }); + + describe('non-EVM network handling', () => { + it('treats Solana networks as popular by default', () => { + const { result } = renderHook(() => + useNetworkSelection({ networks: mockNetworks }), + ); + + (isCaipChainId as unknown as jest.Mock).mockReturnValue(true); + (parseCaipChainId as jest.Mock).mockReturnValue({ + namespace: 'solana', + reference: '5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + }); + + const solanaChainId = '' as CaipChainId; + result.current.selectNetwork(solanaChainId); + + expect(mockEnableNetwork).toHaveBeenCalledWith(solanaChainId); + expect(mockDisableNetwork).not.toHaveBeenCalledWith(solanaChainId); + }); + + it('treats Bitcoin networks as popular by default', () => { + const { result } = renderHook(() => + useNetworkSelection({ networks: mockNetworks }), + ); + + (isCaipChainId as unknown as jest.Mock).mockReturnValue(true); + (parseCaipChainId as jest.Mock).mockReturnValue({ + namespace: 'bip122', + reference: '000000000019d6689c085ae165831e93', + }); + + const bitcoinChainId = + 'bip122:000000000019d6689c085ae165831e93' as CaipChainId; + result.current.selectNetwork(bitcoinChainId); + + expect(mockEnableNetwork).toHaveBeenCalledWith(bitcoinChainId); + expect(mockDisableNetwork).not.toHaveBeenCalledWith(bitcoinChainId); + }); + }); + + describe('Bitcoin network handling', () => { + const bitcoinMainnet = + 'bip122:000000000019d6689c085ae165831e93' as CaipChainId; + const mockBitcoinAccount = { + address: '0xbitcoinAddress', + type: 'bip122:p2wpkh', + scopes: [bitcoinMainnet], + }; + + beforeEach(() => { + jest.clearAllMocks(); + + mockUseSelector.mockImplementation((selector) => { + if (selector === selectPopularNetworkConfigurationsByCaipChainId) { + return [ + { + caipChainId: 'eip155:1' as CaipChainId, + chainId: '0x1', + name: 'Ethereum Mainnet', + }, + { + caipChainId: bitcoinMainnet, + chainId: 'btc-mainnet', + name: 'Bitcoin Mainnet', + }, + ]; + } + if (selector === selectMultichainAccountsState2Enabled) { + return false; + } + if (selector === selectInternalAccounts) { + return [mockBitcoinAccount]; + } + return undefined; + }); + + (isCaipChainId as unknown as jest.Mock).mockReturnValue(true); + (parseCaipChainId as jest.Mock).mockReturnValue({ + namespace: 'bip122', + reference: '000000000019d6689c085ae165831e93', + }); + }); + + it('selectCustomNetwork creates new account when no Bitcoin account exists', async () => { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectPopularNetworkConfigurationsByCaipChainId) { + return [ + { + caipChainId: 'eip155:1' as CaipChainId, + chainId: '0x1', + name: 'Ethereum Mainnet', + }, + { + caipChainId: bitcoinMainnet, + chainId: 'btc-mainnet', + name: 'Bitcoin Mainnet', + }, + ]; + } + if (selector === selectMultichainAccountsState2Enabled) { + return false; + } + if (selector === selectInternalAccounts) { + return []; + } + return undefined; + }); + + const { result } = renderHook(() => + useNetworkSelection({ networks: mockNetworks }), + ); + + await result.current.selectCustomNetwork(bitcoinMainnet); + + expect(NavigationService.navigation.navigate).toHaveBeenCalledWith( + 'RootModalFlow', + { + screen: 'AddAccount', + params: { + clientType: 'bitcoin', + scope: bitcoinMainnet, + }, + }, + ); + expect(mockEnableNetwork).not.toHaveBeenCalled(); + }); + + it('selectCustomNetwork sets selected address when Bitcoin account exists', async () => { + const setSelectedAddressSpy = jest.spyOn(Engine, 'setSelectedAddress'); + + const { result } = renderHook(() => + useNetworkSelection({ networks: mockNetworks }), + ); + + await result.current.selectCustomNetwork(bitcoinMainnet); + + expect(setSelectedAddressSpy).toHaveBeenCalledWith( + mockBitcoinAccount.address, + ); + expect(mockEnableNetwork).toHaveBeenCalledWith(bitcoinMainnet); + }); + + it('selectPopularNetwork sets selected address for Bitcoin networks', async () => { + const setSelectedAddressSpy = jest.spyOn(Engine, 'setSelectedAddress'); + + const { result } = renderHook(() => + useNetworkSelection({ networks: mockNetworks }), + ); + + await result.current.selectPopularNetwork(bitcoinMainnet); + + expect(setSelectedAddressSpy).toHaveBeenCalledWith( + mockBitcoinAccount.address, + ); + expect(mockEnableNetwork).toHaveBeenCalledWith(bitcoinMainnet); + }); + }); + + describe('Solana network handling with multichain', () => { + const solanaMainnet = + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as CaipChainId; + + beforeEach(() => { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectPopularNetworkConfigurationsByCaipChainId) { + return mockPopularNetworkConfigurations; + } + if (selector === selectMultichainAccountsState2Enabled) { + return true; + } + if (selector === selectInternalAccounts) { + return []; + } + return undefined; + }); + }); + + it('handles Solana network selection error gracefully', async () => { + const mockSetActiveNetworkWithError = jest + .fn() + .mockRejectedValueOnce(new Error('Network error')); + jest + .spyOn(Engine.context.MultichainNetworkController, 'setActiveNetwork') + .mockImplementation(mockSetActiveNetworkWithError); + + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + + const { result } = renderHook(() => + useNetworkSelection({ networks: mockNetworks }), + ); + + await result.current.selectPopularNetwork(solanaMainnet); + + expect(consoleSpy).toHaveBeenCalledWith( + 'Error setting active network:', + new Error('Network error'), + ); + expect(mockEnableNetwork).toHaveBeenCalledWith(solanaMainnet); + expect(mockSetActiveNetworkWithError).toHaveBeenCalledWith(solanaMainnet); + + consoleSpy.mockRestore(); + }); + + it('resets EVM networks when selecting Solana mainnet', async () => { + jest + .spyOn(Engine.context.MultichainNetworkController, 'setActiveNetwork') + .mockImplementation(mockSetActiveNetwork); + + const { result } = renderHook(() => + useNetworkSelection({ networks: mockNetworks }), + ); + + await result.current.selectPopularNetwork(solanaMainnet); + + expect(mockEnableNetwork).toHaveBeenCalledWith(solanaMainnet); + expect(mockSetActiveNetwork).toHaveBeenCalledWith(solanaMainnet); + + mockNetworks.forEach(({ caipChainId }) => { + if (caipChainId !== solanaMainnet) { + expect(mockDisableNetwork).toHaveBeenCalledWith(caipChainId); + } + }); + }); + + it('resets Solana networks when selecting EVM network', async () => { + const evmChainId = 'eip155:1' as CaipChainId; + + mockFindNetworkClientIdByChainId.mockReturnValue('evm-client-id'); + jest + .spyOn(Engine.context.NetworkController, 'findNetworkClientIdByChainId') + .mockReturnValue('evm-client-id'); + + jest + .spyOn(Engine.context.MultichainNetworkController, 'setActiveNetwork') + .mockImplementation(mockSetActiveNetwork); + + const { result } = renderHook(() => + useNetworkSelection({ networks: mockNetworks }), + ); + + await result.current.selectPopularNetwork(evmChainId); + + expect(mockEnableNetwork).toHaveBeenCalledWith(evmChainId); + expect(mockSetActiveNetwork).toHaveBeenCalledWith('evm-client-id'); + expect(mockDisableNetwork).toHaveBeenCalledWith(solanaMainnet); + }); + }); }); diff --git a/app/components/hooks/useNetworkSelection/useNetworkSelection.ts b/app/components/hooks/useNetworkSelection/useNetworkSelection.ts index cfa6894d76d2..466cdd0b4cc2 100644 --- a/app/components/hooks/useNetworkSelection/useNetworkSelection.ts +++ b/app/components/hooks/useNetworkSelection/useNetworkSelection.ts @@ -5,6 +5,9 @@ import { Hex, isCaipChainId, parseCaipChainId, + ///: BEGIN:ONLY_INCLUDE_IF(bitcoin) + KnownCaipNamespace, + ///: END:ONLY_INCLUDE_IF } from '@metamask/utils'; import { toHex } from '@metamask/controller-utils'; import { formatChainIdToCaip } from '@metamask/bridge-controller'; @@ -12,6 +15,12 @@ import { selectPopularNetworkConfigurationsByCaipChainId } from '../../../select import { useNetworkEnablement } from '../useNetworkEnablement/useNetworkEnablement'; import { ProcessedNetwork } from '../useNetworksByNamespace/useNetworksByNamespace'; import { POPULAR_NETWORK_CHAIN_IDS } from '../../../constants/popular-networks'; +///: BEGIN:ONLY_INCLUDE_IF(bitcoin) +import { selectInternalAccounts } from '../../../selectors/accountsController'; +import Routes from '../../../constants/navigation/Routes'; +import NavigationService from '../../../core/NavigationService'; +import { WalletClientType } from '../../../core/SnapKeyring/MultichainWalletSnapClient'; +///: END:ONLY_INCLUDE_IF import { selectMultichainAccountsState2Enabled } from '../../../selectors/featureFlagController/multichainAccounts/enabledMultichainAccounts'; import { SolScope } from '@metamask/keyring-api'; import Engine from '../../../core/Engine'; @@ -60,6 +69,10 @@ export const useNetworkSelection = ({ selectPopularNetworkConfigurationsByCaipChainId, ); + ///: BEGIN:ONLY_INCLUDE_IF(bitcoin) + const internalAccounts = useSelector(selectInternalAccounts); + ///: END:ONLY_INCLUDE_IF + const isMultichainAccountsState2Enabled = useSelector( selectMultichainAccountsState2Enabled, ); @@ -90,6 +103,16 @@ export const useNetworkSelection = ({ [currentEnabledNetworks, popularNetworkChainIds], ); + ///: BEGIN:ONLY_INCLUDE_IF(bitcoin) + const bitcoinInternalAccounts = useMemo( + () => + internalAccounts.filter((account) => + account.type.includes(KnownCaipNamespace.Bip122), + ), + [internalAccounts], + ); + ///: END:ONLY_INCLUDE_IF + /** Disables all custom networks except the optionally specified one */ const resetCustomNetworks = useCallback( (excludeChainId?: CaipChainId) => { @@ -123,6 +146,31 @@ export const useNetworkSelection = ({ /** Selects a custom network exclusively (disables other custom networks) */ const selectCustomNetwork = useCallback( async (chainId: CaipChainId, onComplete?: () => void) => { + ///: BEGIN:ONLY_INCLUDE_IF(bitcoin) + const bitcoAccountInScope = bitcoinInternalAccounts.find((account) => + account.scopes.includes(chainId), + ); + + if (chainId.includes(KnownCaipNamespace.Bip122)) { + // if the network is bitcoin and there is no bitcoin account in the scope + // create a new bitcoin account (Bitcoin accounts are different per network) + if (!bitcoAccountInScope) { + // TODO: I cannot cancel or go back from the add account screen + NavigationService.navigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, { + screen: Routes.SHEET.ADD_ACCOUNT, + params: { + clientType: WalletClientType.Bitcoin, + scope: chainId, + }, + }); + + return; + } + // if the network is bitcoin and there is a bitcoin account in the scope + // set the selected address to the bitcoin account + Engine.setSelectedAddress(bitcoAccountInScope.address); + } + ///: END:ONLY_INCLUDE_IF await enableNetwork(chainId); await resetCustomNetworks(chainId); if (isMultichainAccountsState2Enabled) { @@ -142,6 +190,9 @@ export const useNetworkSelection = ({ MultichainNetworkController, isMultichainAccountsState2Enabled, NetworkController, + ///: BEGIN:ONLY_INCLUDE_IF(bitcoin) + bitcoinInternalAccounts, + ///: END:ONLY_INCLUDE_IF ], ); @@ -157,6 +208,18 @@ export const useNetworkSelection = ({ /** Toggles a popular network and resets all custom networks */ const selectPopularNetwork = useCallback( async (chainId: CaipChainId, onComplete?: () => void) => { + ///: BEGIN:ONLY_INCLUDE_IF(bitcoin) + if (chainId.includes(KnownCaipNamespace.Bip122)) { + const bitcoAccountInScope = bitcoinInternalAccounts.find((account) => + account.scopes.includes(chainId), + ); + + if (bitcoAccountInScope) { + Engine.setSelectedAddress(bitcoAccountInScope.address); + } + } + ///: END:ONLY_INCLUDE_IF + await enableNetwork(chainId); await resetCustomNetworks(); if (isMultichainAccountsState2Enabled && chainId === SolScope.Mainnet) { @@ -164,6 +227,7 @@ export const useNetworkSelection = ({ await MultichainNetworkController.setActiveNetwork(chainId); } catch (error) { // Handle error silently for now + console.error('Error setting active network:', error); } await resetEvmNetworks(); } @@ -185,6 +249,9 @@ export const useNetworkSelection = ({ resetEvmNetworks, MultichainNetworkController, NetworkController, + ///: BEGIN:ONLY_INCLUDE_IF(bitcoin) + bitcoinInternalAccounts, + ///: END:ONLY_INCLUDE_IF ], ); diff --git a/app/constants/popular-networks.ts b/app/constants/popular-networks.ts index e3b1ae56c1fb..3df39a0485b6 100644 --- a/app/constants/popular-networks.ts +++ b/app/constants/popular-networks.ts @@ -1,3 +1,4 @@ +import { BtcScope, SolScope } from '@metamask/keyring-api'; import { PopularList } from '../util/networks/customNetworks'; import { NETWORKS_CHAIN_ID } from './network'; @@ -5,10 +6,14 @@ export const POPULAR_NETWORK_CHAIN_IDS = new Set([ ...PopularList.map((popular) => popular.chainId), NETWORKS_CHAIN_ID.MAINNET, NETWORKS_CHAIN_ID.LINEA_MAINNET, + SolScope.Mainnet, + BtcScope.Mainnet, ]); export const POPULAR_NETWORK_CHAIN_IDS_CAIP = new Set([ ...PopularList.map((popular) => popular.chainId), NETWORKS_CHAIN_ID.MAINNET, NETWORKS_CHAIN_ID.LINEA_MAINNET, + SolScope.Mainnet, + BtcScope.Mainnet, ]); diff --git a/app/selectors/accountsController.ts b/app/selectors/accountsController.ts index 7f1f02bf4960..0f2983abd436 100644 --- a/app/selectors/accountsController.ts +++ b/app/selectors/accountsController.ts @@ -10,6 +10,7 @@ import { selectFlattenedKeyringAccounts } from './keyringController'; import { BtcMethod, EthMethod, + SolAccountType, SolMethod, isEvmAccountType, } from '@metamask/keyring-api'; @@ -161,7 +162,7 @@ export const selectLastSelectedEvmAccount = createSelector( export const selectLastSelectedSolanaAccount = createSelector( selectOrderedInternalAccountsByLastSelected, (accounts) => - accounts.find((account) => account.type === 'solana:data-account'), + accounts.find((account) => account.type === SolAccountType.DataAccount), ); /** diff --git a/app/selectors/networkController.test.ts b/app/selectors/networkController.test.ts index 1cea3aa0c843..045132f04e19 100644 --- a/app/selectors/networkController.test.ts +++ b/app/selectors/networkController.test.ts @@ -17,7 +17,7 @@ import { selectIsAllPopularNetworks, } from './networkController'; import { RootState } from '../reducers'; -import { SolScope } from '@metamask/keyring-api'; +import { BtcScope, SolScope } from '@metamask/keyring-api'; import { KnownCaipNamespace } from '@metamask/utils'; describe('networkSelectors', () => { @@ -175,7 +175,7 @@ describe('networkSelectors', () => { }); describe('selectCustomNetworkConfigurationsByCaipChainId', () => { - it('should return only custom networks (excluding popular and Solana networks)', () => { + it('should return non-popular networks and testnet networks', () => { const stateWithVariousNetworks = { ...mockState, engine: { @@ -249,6 +249,13 @@ describe('networkSelectors', () => { name: 'Solana Mainnet', rpcEndpoints: [], }, + 'bip122:000000000933ea01ad0ee984209779ba': { + // Bitcoin Testnet + chainId: 'bip122:000000000933ea01ad0ee984209779ba', + nativeCurrency: 'BTC', + name: 'Bitcoin Testnet', + rpcEndpoints: [], + }, }, }, }, @@ -259,19 +266,26 @@ describe('networkSelectors', () => { stateWithVariousNetworks, ); - // Should only include custom networks (not mainnet, linea, or solana) - expect(result).toHaveLength(2); - expect(result.map((n) => n.chainId)).toEqual( - expect.arrayContaining(['0x12345', '0x67890']), + // Should include both custom networks and testnet networks + // 2 custom networks + 1 btc testnet + expect(result).toHaveLength(3); + // btc testnet is included + expect(result.map((n) => n.chainId)).toContain( + 'bip122:000000000933ea01ad0ee984209779ba', ); + // custom networks are included + expect(result.map((n) => n.chainId)).toContain('0x12345'); + expect(result.map((n) => n.chainId)).toContain('0x67890'); + // popular networks aren't included expect(result.map((n) => n.chainId)).not.toContain('0x1'); expect(result.map((n) => n.chainId)).not.toContain('0xe708'); + // solana mainnet isn't included expect(result.map((n) => n.caipChainId)).not.toContain( 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', ); }); - it('should return empty array when there are no custom networks', () => { + it('should return empty array when there are no custom networks or testnets', () => { const stateWithOnlyPopularNetworks = { ...mockState, engine: { @@ -302,7 +316,7 @@ describe('networkSelectors', () => { }); describe('selectPopularNetworkConfigurationsByCaipChainId', () => { - it('should return only popular networks including Solana', () => { + it('should return popular networks', () => { const stateWithVariousNetworks = { ...mockState, engine: { @@ -356,9 +370,9 @@ describe('networkSelectors', () => { MultichainNetworkController: { ...mockState.engine.backgroundState.MultichainNetworkController, multichainNetworkConfigurationsByChainId: { - 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': { + [SolScope.Mainnet]: { // Solana Mainnet - popular - chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + chainId: SolScope.Mainnet, nativeCurrency: 'SOL', name: 'Solana Mainnet', rpcEndpoints: [], @@ -367,6 +381,17 @@ describe('networkSelectors', () => { imageSource: 1, isTestnet: false, }, + [BtcScope.Mainnet]: { + // Bitcoin Mainnet - popular + chainId: BtcScope.Mainnet, + nativeCurrency: 'BTC', + name: 'Bitcoin Mainnet', + rpcEndpoints: [], + ticker: 'BTC', + decimals: 8, + imageSource: 1, + isTestnet: false, + }, 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1': { // Solana Devnet - popular chainId: 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1', @@ -388,11 +413,11 @@ describe('networkSelectors', () => { stateWithVariousNetworks, ); - // Should include mainnet, linea, and solana networks but not custom - // All Solana networks should be included since they have Solana namespace - expect(result.length).toBeGreaterThanOrEqual(3); // At least mainnet, linea, and one solana + // Should include only popular networks that aren't testnets + // This includes: mainnet, linea, solana mainnet, and bitcoin mainnet + expect(result.length).toBeGreaterThanOrEqual(4); // mainnet, linea, solana mainnet, and bitcoin mainnet - // Check EVM popular networks are included + // Popular networks are included expect(result.map((n) => n.chainId)).toEqual( expect.arrayContaining(['0x1', '0xe708']), ); @@ -400,15 +425,16 @@ describe('networkSelectors', () => { expect.arrayContaining(['eip155:1', 'eip155:59144']), ); - // Check Solana networks are included - const solanaNetworks = result.filter((n) => - n.caipChainId.includes(KnownCaipNamespace.Solana), - ); - expect(solanaNetworks.length).toBeGreaterThanOrEqual(1); - expect(solanaNetworks[0].caipChainId).toContain('solana:'); - - // Custom network should not be included + // Custom networks shouldn't be included expect(result.map((n) => n.chainId)).not.toContain('0x12345'); + expect(result.map((n) => n.chainId)).not.toContain('0x67890'); + + // Check non-EVM mainnet networks are included + expect(result.map((n) => n.caipChainId)).toContain(SolScope.Mainnet); + expect(result.map((n) => n.caipChainId)).toContain(BtcScope.Mainnet); + expect(result.map((n) => n.chainId)).not.toContain( + 'bip122:000000000933ea01ad0ee984209779ba', + ); }); it('should return empty array when there are no popular networks', () => { diff --git a/app/selectors/networkController.ts b/app/selectors/networkController.ts index 9fa431cfee70..1f51da1b9b3c 100644 --- a/app/selectors/networkController.ts +++ b/app/selectors/networkController.ts @@ -8,6 +8,10 @@ import { NetworkState, RpcEndpointType, } from '@metamask/network-controller'; +import { + NON_EVM_TESTNET_IDS, + MultichainNetworkConfiguration, +} from '@metamask/multichain-network-controller'; import { RootState } from '../reducers'; import { createDeepEqualSelector } from './util'; import { NETWORKS_CHAIN_ID } from '../constants/network'; @@ -22,7 +26,6 @@ import { selectSelectedNonEvmNetworkChainId, selectSelectedNonEvmNetworkSymbol, } from './multichainNetworkController'; -import { MultichainNetworkConfiguration } from '@metamask/multichain-network-controller'; export type EvmAndMultichainNetworkConfigurationsWithCaipChainId = ( | NetworkConfiguration @@ -269,8 +272,11 @@ export const selectCustomNetworkConfigurationsByCaipChainId = createSelector( (networkConfigurationsByChainId) => Object.values(networkConfigurationsByChainId).filter( (networkConfiguration) => - !POPULAR_NETWORK_CHAIN_IDS.has(networkConfiguration.chainId as Hex) && - !networkConfiguration.caipChainId.includes(KnownCaipNamespace.Solana), + (networkConfiguration.chainId.startsWith('0x') && + !POPULAR_NETWORK_CHAIN_IDS.has( + networkConfiguration.chainId as Hex, + )) || + NON_EVM_TESTNET_IDS.includes(networkConfiguration.caipChainId), ), ); @@ -279,8 +285,8 @@ export const selectPopularNetworkConfigurationsByCaipChainId = createSelector( (networkConfigurationsByChainId) => Object.values(networkConfigurationsByChainId).filter( (networkConfiguration) => - POPULAR_NETWORK_CHAIN_IDS.has(networkConfiguration.chainId as Hex) || - networkConfiguration.caipChainId.includes(KnownCaipNamespace.Solana), + POPULAR_NETWORK_CHAIN_IDS.has(networkConfiguration.chainId as Hex) && + !NON_EVM_TESTNET_IDS.includes(networkConfiguration.caipChainId), ), );