diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index f64a6ef5215..d3beae8d767 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -43,6 +43,7 @@ app/core/Engine/controllers/remote-feature-flag-controller/ @MetaMask/mobile-pla app/core/DeeplinkManager @MetaMask/mobile-platform scripts/build.sh @MetaMask/mobile-platform scripts/update-expo-channel.js @MetaMask/mobile-admins +certs/certificate.pem @MetaMask/mobile-admins # Platform & Snaps Code Fencing File metro.transform.js @MetaMask/mobile-platform @MetaMask/core-platform diff --git a/android/app/build.gradle b/android/app/build.gradle index 47043263de1..6d435aa6f9e 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -187,8 +187,8 @@ android { applicationId "io.metamask" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionName "7.61.0" - versionCode 2993 + versionName "7.61.99" + versionCode 3092 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/app.config.js b/app.config.js index 57570311150..5f8cc34d578 100644 --- a/app.config.js +++ b/app.config.js @@ -46,6 +46,11 @@ module.exports = { owner: 'metamask-test', runtimeVersion: RUNTIME_VERSION, updates: { + codeSigningCertificate: './certs/certificate.pem', + codeSigningMetadata: { + keyid: 'main', + alg: 'rsa-v1_5-sha256', + }, url: UPDATE_URL, // Channel is set by requestHeaders, will be overridden with build script requestHeaders: { diff --git a/app/components/UI/AssetOverview/AssetOverview.tsx b/app/components/UI/AssetOverview/AssetOverview.tsx index 40a0d77b343..09a4894375d 100644 --- a/app/components/UI/AssetOverview/AssetOverview.tsx +++ b/app/components/UI/AssetOverview/AssetOverview.tsx @@ -76,7 +76,7 @@ import { useSwapBridgeNavigation, SwapBridgeNavigationLocation, } from '../Bridge/hooks/useSwapBridgeNavigation'; -import { swapsUtils } from '@metamask/swaps-controller'; +import { NATIVE_SWAPS_TOKEN_ADDRESS } from '../../../constants/bridge'; import { TraceName, endTrace } from '../../../util/trace'; ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) import { selectMultichainAssetsRates } from '../../../selectors/multichain'; @@ -185,7 +185,7 @@ const AssetOverview: React.FC = ({ sourcePage: 'MainView', sourceToken: { ...asset, - address: asset.address ?? swapsUtils.NATIVE_SWAPS_TOKEN_ADDRESS, + address: asset.address ?? NATIVE_SWAPS_TOKEN_ADDRESS, chainId: asset.chainId as Hex, decimals: asset.decimals, symbol: asset.symbol, diff --git a/app/components/UI/NetworkManager/index.test.tsx b/app/components/UI/NetworkManager/index.test.tsx index 1e004fce25b..d6855323a8f 100644 --- a/app/components/UI/NetworkManager/index.test.tsx +++ b/app/components/UI/NetworkManager/index.test.tsx @@ -5,6 +5,7 @@ import configureStore from 'redux-mock-store'; import NetworkManager from './index'; import { useNetworksByNamespace } from '../../hooks/useNetworksByNamespace/useNetworksByNamespace'; +import { useNetworkEnablement } from '../../hooks/useNetworkEnablement/useNetworkEnablement'; import Engine from '../../../core/Engine'; // Create mock functions that we can spy on @@ -147,16 +148,40 @@ jest.mock('../../hooks/useNetworksByNamespace/useNetworksByNamespace', () => ({ }, })); +const mockEnableNetwork = jest.fn(); + jest.mock('../../hooks/useNetworkEnablement/useNetworkEnablement', () => ({ - useNetworkEnablement: () => ({ + useNetworkEnablement: jest.fn(() => ({ disableNetwork: mockDisableNetwork, + enableNetwork: mockEnableNetwork, enabledNetworksByNamespace: { eip155: { '0x1': true, '0x89': true, }, }, - }), + })), +})); + +jest.mock('../../hooks/useNetworksToUse/useNetworksToUse', () => ({ + useNetworksToUse: jest.fn(() => ({ + networksToUse: [ + { + id: 'eip155:1', + name: 'Ethereum Mainnet', + caipChainId: 'eip155:1', + isSelected: true, + imageSource: { uri: 'ethereum.png' }, + }, + { + id: 'eip155:137', + name: 'Polygon Mainnet', + caipChainId: 'eip155:137', + isSelected: false, + imageSource: { uri: 'polygon.png' }, + }, + ], + })), })); jest.mock('../../../util/device', () => ({ @@ -1011,4 +1036,193 @@ describe('NetworkManager Component', () => { }); }); }); + + describe('Enabled Networks Processing', () => { + const mockUseNetworkEnablement = + useNetworkEnablement as jest.MockedFunction; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should correctly process enabled networks from flat structure', () => { + mockUseNetworkEnablement.mockReturnValue({ + disableNetwork: mockDisableNetwork, + enableNetwork: mockEnableNetwork, + enabledNetworksByNamespace: { + eip155: { + '0x1': true, + '0x89': true, + '0xa': false, + }, + }, + namespace: 'eip155', + enabledNetworksForCurrentNamespace: {}, + networkEnablementController: {} as never, + isNetworkEnabled: jest.fn(), + hasOneEnabledNetwork: false, + enableAllPopularNetworks: jest.fn(), + tryEnableEvmNetwork: jest.fn(), + enabledNetworksForAllNamespaces: { + '0x1': true, + '0x89': true, + '0xa': false, + }, + }); + + // The component internally processes enabledNetworksByNamespace + // We verify it renders without errors and has correct tab state + const { getByTestId } = renderComponent(); + + // Verify component renders successfully with processed networks + expect(getByTestId('main-bottom-sheet')).toBeOnTheScreen(); + }); + + it('should correctly process enabled networks from nested structure', () => { + mockUseNetworkEnablement.mockReturnValue({ + disableNetwork: mockDisableNetwork, + enableNetwork: mockEnableNetwork, + enabledNetworksByNamespace: { + eip155: { + '0x1': true, + '0x89': false, + }, + bip122: { + '0x1': true, + }, + } as never, + namespace: 'eip155', + enabledNetworksForCurrentNamespace: {}, + networkEnablementController: {} as never, + isNetworkEnabled: jest.fn(), + hasOneEnabledNetwork: false, + enableAllPopularNetworks: jest.fn(), + tryEnableEvmNetwork: jest.fn(), + enabledNetworksForAllNamespaces: { + '0x1': true, + '0x89': false, + '0xa': false, + }, + }); + + // The component should handle nested namespace structures + const { getByTestId } = renderComponent(); + + expect(getByTestId('main-bottom-sheet')).toBeOnTheScreen(); + }); + + it('should handle empty enabled networks gracefully', () => { + mockUseNetworkEnablement.mockReturnValue({ + disableNetwork: mockDisableNetwork, + enableNetwork: mockEnableNetwork, + enabledNetworksByNamespace: {}, + namespace: 'eip155', + enabledNetworksForCurrentNamespace: {}, + networkEnablementController: {} as never, + isNetworkEnabled: jest.fn(), + hasOneEnabledNetwork: false, + enableAllPopularNetworks: jest.fn(), + tryEnableEvmNetwork: jest.fn(), + enabledNetworksForAllNamespaces: {}, + }); + + const { getByTestId } = renderComponent(); + + expect(getByTestId('main-bottom-sheet')).toBeOnTheScreen(); + }); + + it('should filter out disabled networks correctly', () => { + mockUseNetworkEnablement.mockReturnValue({ + disableNetwork: mockDisableNetwork, + enableNetwork: mockEnableNetwork, + enabledNetworksByNamespace: { + eip155: { + '0x1': true, + '0x89': false, + '0xa': false, + '0xa4b1': true, + }, + }, + namespace: 'eip155', + enabledNetworksForCurrentNamespace: {}, + networkEnablementController: {} as never, + isNetworkEnabled: jest.fn(), + hasOneEnabledNetwork: false, + enableAllPopularNetworks: jest.fn(), + tryEnableEvmNetwork: jest.fn(), + enabledNetworksForAllNamespaces: { + '0x1': true, + '0x89': false, + '0xa': false, + '0xa4b1': true, + }, + }); + + // Component should only include enabled (true) networks + const { getByTestId } = renderComponent(); + + expect(getByTestId('main-bottom-sheet')).toBeOnTheScreen(); + }); + + it('should enable and disable networks correctly during deletion', async () => { + const otherNetwork = { + id: 'eip155:137', + name: 'Polygon Mainnet', + caipChainId: 'eip155:137', + isSelected: false, + imageSource: { uri: 'polygon.png' }, + }; + + (useNetworksByNamespace as jest.Mock).mockImplementation((args) => { + if (args.networkType === 'custom') { + return { + networks: [otherNetwork], + areAllNetworksSelected: false, + }; + } + return { selectedCount: 2 }; + }); + + mockUseNetworkEnablement.mockReturnValue({ + disableNetwork: mockDisableNetwork, + enableNetwork: mockEnableNetwork, + enabledNetworksByNamespace: { + eip155: { + '0x1': true, + '0x89': true, + }, + }, + namespace: 'eip155', + enabledNetworksForCurrentNamespace: {}, + networkEnablementController: {} as never, + isNetworkEnabled: jest.fn(), + hasOneEnabledNetwork: false, + enableAllPopularNetworks: jest.fn(), + tryEnableEvmNetwork: jest.fn(), + enabledNetworksForAllNamespaces: { + '0x1': true, + '0x89': true, + }, + }); + + const { getByTestId } = renderComponent(); + + // Open modal and trigger delete confirmation + const openModalButton = getByTestId('open-modal-button'); + fireEvent.press(openModalButton); + + await waitFor(() => { + const deleteButton = getByTestId('account-action-app_settings.delete'); + fireEvent.press(deleteButton); + }); + + // Confirm deletion + const confirmButton = getByTestId('footer-button-app_settings.delete'); + fireEvent.press(confirmButton); + + // Should enable the other network and disable the deleted one + expect(mockEnableNetwork).toHaveBeenCalledWith('eip155:137'); + expect(mockDisableNetwork).toHaveBeenCalledWith('eip155:1'); + }); + }); }); diff --git a/app/components/UI/NetworkManager/index.tsx b/app/components/UI/NetworkManager/index.tsx index a2bc9f345bb..1605b75f3d9 100644 --- a/app/components/UI/NetworkManager/index.tsx +++ b/app/components/UI/NetworkManager/index.tsx @@ -60,6 +60,7 @@ import { POPULAR_NETWORK_CHAIN_IDS } from '../../../constants/popular-networks'; import RpcSelectionModal from '../../Views/NetworkSelector/RpcSelectionModal/RpcSelectionModal'; import { isNonEvmChainId } from '../../../core/Multichain/utils'; import { NetworkConfiguration } from '@metamask/network-controller'; +import { useNetworksToUse } from '../../hooks/useNetworksToUse/useNetworksToUse'; export const createNetworkManagerNavDetails = createNavigationDetails( Routes.MODAL.ROOT_MODAL_FLOW, @@ -94,7 +95,16 @@ const NetworkManager = () => { const { selectedCount } = useNetworksByNamespace({ networkType: NetworkType.Popular, }); - const { disableNetwork, enabledNetworksByNamespace } = useNetworkEnablement(); + const { networks, areAllNetworksSelected } = useNetworksByNamespace({ + networkType: NetworkType.Custom, + }); + const { disableNetwork, enableNetwork, enabledNetworksByNamespace } = + useNetworkEnablement(); + const { networksToUse } = useNetworksToUse({ + networks, + networkType: NetworkType.Custom, + areAllNetworksSelected, + }); const isMultichainAccountsState2Enabled = useSelector( selectMultichainAccountsState2Enabled, @@ -304,17 +314,24 @@ const NetworkManager = () => { const { NetworkController } = Engine.context; const rawChainId = parseCaipChainId(caipChainId).reference; const chainId = toHex(rawChainId); + const otherNetwork = networksToUse.find( + (network) => network.caipChainId !== caipChainId, + ); + // Remove the network from controller and disable it NetworkController.removeNetwork(chainId); - disableNetwork(showConfirmDeleteModal.caipChainId); + if (otherNetwork?.caipChainId) { + enableNetwork(otherNetwork.caipChainId); + } + disableNetwork(showConfirmDeleteModal.caipChainId); MetaMetrics.getInstance().addTraitsToUser( removeItemFromChainIdList(chainId), ); setShowConfirmDeleteModal(initialShowConfirmDeleteModal); } - }, [showConfirmDeleteModal, disableNetwork]); + }, [showConfirmDeleteModal, disableNetwork, networksToUse, enableNetwork]); const cancelButtonProps: ButtonProps = useMemo( () => ({ diff --git a/app/components/UI/NetworkMultiSelector/NetworkMultiSelector.test.tsx b/app/components/UI/NetworkMultiSelector/NetworkMultiSelector.test.tsx index 8719db72717..7d536536796 100644 --- a/app/components/UI/NetworkMultiSelector/NetworkMultiSelector.test.tsx +++ b/app/components/UI/NetworkMultiSelector/NetworkMultiSelector.test.tsx @@ -230,6 +230,7 @@ describe('NetworkMultiSelector', () => { isNetworkEnabled: jest.fn(), hasOneEnabledNetwork: false, tryEnableEvmNetwork: jest.fn(), + enabledNetworksForAllNamespaces: mockEnabledNetworks, }); mockUseNetworksByNamespace.mockReturnValue({ @@ -399,6 +400,7 @@ describe('NetworkMultiSelector', () => { isNetworkEnabled: jest.fn(), hasOneEnabledNetwork: false, tryEnableEvmNetwork: jest.fn(), + enabledNetworksForAllNamespaces: {}, }); const { getByTestId } = renderWithProvider( @@ -436,6 +438,7 @@ describe('NetworkMultiSelector', () => { isNetworkEnabled: jest.fn(), hasOneEnabledNetwork: false, tryEnableEvmNetwork: jest.fn(), + enabledNetworksForAllNamespaces: {}, }); const { getByTestId } = renderWithProvider( @@ -521,6 +524,7 @@ describe('NetworkMultiSelector', () => { isNetworkEnabled: jest.fn(), hasOneEnabledNetwork: false, tryEnableEvmNetwork: jest.fn(), + enabledNetworksForAllNamespaces: mockEnabledNetworks, }); mockUseNetworksByNamespace.mockReturnValue({ @@ -576,6 +580,7 @@ describe('NetworkMultiSelector', () => { isNetworkEnabled: jest.fn(), hasOneEnabledNetwork: false, tryEnableEvmNetwork: jest.fn(), + enabledNetworksForAllNamespaces: mockEnabledNetworks, }); mockUseNetworksByNamespace.mockReturnValue({ @@ -658,6 +663,7 @@ describe('NetworkMultiSelector', () => { isNetworkEnabled: jest.fn(), hasOneEnabledNetwork: false, tryEnableEvmNetwork: jest.fn(), + enabledNetworksForAllNamespaces: mockEnabledNetworks, }); mockUseNetworksByNamespace.mockReturnValue({ @@ -739,6 +745,7 @@ describe('NetworkMultiSelector', () => { isNetworkEnabled: jest.fn(), hasOneEnabledNetwork: false, tryEnableEvmNetwork: jest.fn(), + enabledNetworksForAllNamespaces: mockEnabledNetworks, }); mockUseNetworksByNamespace.mockReturnValue({ @@ -812,6 +819,7 @@ describe('NetworkMultiSelector', () => { isNetworkEnabled: jest.fn(), hasOneEnabledNetwork: false, tryEnableEvmNetwork: jest.fn(), + enabledNetworksForAllNamespaces: mockEnabledNetworks, }); mockUseNetworksByNamespace.mockReturnValue({ @@ -1068,6 +1076,7 @@ describe('NetworkMultiSelector', () => { isNetworkEnabled: jest.fn(), hasOneEnabledNetwork: false, tryEnableEvmNetwork: jest.fn(), + enabledNetworksForAllNamespaces: mockEnabledNetworks, }); mockUseSelector diff --git a/app/components/UI/Predict/components/PredictPosition/PredictPosition.test.tsx b/app/components/UI/Predict/components/PredictPosition/PredictPosition.test.tsx index c85595a6927..b0f59f9b382 100644 --- a/app/components/UI/Predict/components/PredictPosition/PredictPosition.test.tsx +++ b/app/components/UI/Predict/components/PredictPosition/PredictPosition.test.tsx @@ -1,11 +1,18 @@ import React from 'react'; -import { render, screen, fireEvent } from '@testing-library/react-native'; +import { + render, + screen, + fireEvent, + act, + waitFor, +} from '@testing-library/react-native'; import PredictPosition from './PredictPosition'; import { PredictPositionStatus, type PredictPosition as PredictPositionType, } from '../../types'; import { PredictPositionSelectorsIDs } from '../../../../../../e2e/selectors/Predict/Predict.selectors'; +import { usePredictPositions } from '../../hooks/usePredictPositions'; jest.mock('../../../../../../locales/i18n', () => ({ strings: jest.fn((key: string, vars?: Record) => { @@ -16,6 +23,17 @@ jest.mock('../../../../../../locales/i18n', () => ({ }), })); +const mockLoadPositions = jest.fn(); +jest.mock('../../hooks/usePredictPositions', () => ({ + usePredictPositions: jest.fn(() => ({ + positions: [], + loadPositions: mockLoadPositions, + isLoading: false, + isRefreshing: false, + error: null, + })), +})); + const basePosition: PredictPositionType = { id: 'pos-1', providerId: 'polymarket', @@ -51,6 +69,27 @@ const renderComponent = ( }; describe('PredictPosition', () => { + const mockUsePredictPositions = usePredictPositions as jest.MockedFunction< + typeof usePredictPositions + >; + + beforeEach(() => { + jest.useFakeTimers(); + mockLoadPositions.mockClear(); + mockUsePredictPositions.mockReturnValue({ + positions: [], + loadPositions: mockLoadPositions, + isLoading: false, + isRefreshing: false, + error: null, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.useRealTimers(); + }); + it('renders primary position info', () => { renderComponent(); @@ -237,4 +276,205 @@ describe('PredictPosition', () => { expect(screen.getByText('$123.45 on Yes to win $10')).toBeOnTheScreen(); }); }); + + describe('optimistic position auto-refresh', () => { + it('starts auto-refresh immediately when position is optimistic', async () => { + mockLoadPositions.mockResolvedValue(undefined); + renderComponent({ optimistic: true }); + + await waitFor(() => { + expect(mockLoadPositions).toHaveBeenCalledWith({ isRefresh: true }); + }); + }); + + it('does not start auto-refresh when position is not optimistic', async () => { + renderComponent({ optimistic: false }); + + await act(async () => { + await jest.advanceTimersByTimeAsync(2000); + }); + + expect(mockLoadPositions).not.toHaveBeenCalled(); + }); + + it('continues auto-refresh at 2-second intervals after each load completes', async () => { + mockLoadPositions.mockResolvedValue(undefined); + renderComponent({ optimistic: true }); + + // First load happens immediately + await waitFor(() => { + expect(mockLoadPositions).toHaveBeenCalledTimes(1); + }); + + // Second load happens 2 seconds after first completes + await act(async () => { + await jest.advanceTimersByTimeAsync(2000); + }); + + expect(mockLoadPositions).toHaveBeenCalledTimes(2); + + // Third load happens 2 seconds after second completes + await act(async () => { + await jest.advanceTimersByTimeAsync(2000); + }); + + expect(mockLoadPositions).toHaveBeenCalledTimes(3); + }); + + it('stops auto-refresh when position becomes non-optimistic', async () => { + const optimisticPosition = { ...basePosition, optimistic: true }; + const resolvedPosition = { ...basePosition, optimistic: false }; + + mockLoadPositions.mockResolvedValue(undefined); + mockUsePredictPositions.mockReturnValue({ + positions: [optimisticPosition], + loadPositions: mockLoadPositions, + isLoading: false, + isRefreshing: false, + error: null, + }); + + const { rerender } = renderComponent({ optimistic: true }); + + // First load happens immediately + await waitFor(() => { + expect(mockLoadPositions).toHaveBeenCalledTimes(1); + }); + + mockLoadPositions.mockClear(); + + mockUsePredictPositions.mockReturnValue({ + positions: [resolvedPosition], + loadPositions: mockLoadPositions, + isLoading: false, + isRefreshing: false, + error: null, + }); + + rerender(); + + await waitFor(() => { + expect(screen.getByText('$2,345.67')).toBeOnTheScreen(); + }); + + await act(async () => { + await jest.advanceTimersByTimeAsync(2000); + }); + + expect(mockLoadPositions).not.toHaveBeenCalled(); + }); + + it('cleans up auto-refresh on unmount', async () => { + mockLoadPositions.mockResolvedValue(undefined); + const { unmount } = renderComponent({ optimistic: true }); + + // First load happens immediately + await waitFor(() => { + expect(mockLoadPositions).toHaveBeenCalledTimes(1); + }); + + mockLoadPositions.mockClear(); + + unmount(); + + await act(async () => { + await jest.advanceTimersByTimeAsync(2000); + }); + + expect(mockLoadPositions).not.toHaveBeenCalled(); + }); + + it('updates displayed position when positions hook returns new data', async () => { + const optimisticPosition = { + ...basePosition, + optimistic: true, + currentValue: 100, + percentPnl: 0, + }; + const resolvedPosition = { + ...basePosition, + optimistic: false, + currentValue: 2500, + percentPnl: 10.5, + }; + + mockUsePredictPositions.mockReturnValue({ + positions: [optimisticPosition], + loadPositions: mockLoadPositions, + isLoading: false, + isRefreshing: false, + error: null, + }); + + const { rerender } = renderComponent({ optimistic: true }); + + expect(screen.queryByText('$2,500')).toBeNull(); + + mockUsePredictPositions.mockReturnValue({ + positions: [resolvedPosition], + loadPositions: mockLoadPositions, + isLoading: false, + isRefreshing: false, + error: null, + }); + + rerender(); + + await waitFor(() => { + expect(screen.getByText('$2,500')).toBeOnTheScreen(); + expect(screen.getByText('10.5%')).toBeOnTheScreen(); + }); + }); + + it('calls onPress with updated position after refresh', async () => { + const mockOnPress = jest.fn(); + const optimisticPosition = { + ...basePosition, + optimistic: true, + currentValue: 100, + }; + const resolvedPosition = { + ...basePosition, + optimistic: false, + currentValue: 2500, + }; + + mockUsePredictPositions.mockReturnValue({ + positions: [optimisticPosition], + loadPositions: mockLoadPositions, + isLoading: false, + isRefreshing: false, + error: null, + }); + + const { rerender } = renderComponent({ optimistic: true }, mockOnPress); + + mockUsePredictPositions.mockReturnValue({ + positions: [resolvedPosition], + loadPositions: mockLoadPositions, + isLoading: false, + isRefreshing: false, + error: null, + }); + + rerender( + , + ); + + await waitFor(() => { + expect(screen.getByText('$2,500')).toBeOnTheScreen(); + }); + + fireEvent.press( + screen.getByTestId(PredictPositionSelectorsIDs.CURRENT_POSITION_CARD), + ); + + expect(mockOnPress).toHaveBeenCalledWith( + expect.objectContaining({ + currentValue: 2500, + optimistic: false, + }), + ); + }); + }); }); diff --git a/app/components/UI/Predict/components/PredictPosition/PredictPosition.tsx b/app/components/UI/Predict/components/PredictPosition/PredictPosition.tsx index 4797299833e..bff90461563 100644 --- a/app/components/UI/Predict/components/PredictPosition/PredictPosition.tsx +++ b/app/components/UI/Predict/components/PredictPosition/PredictPosition.tsx @@ -11,6 +11,7 @@ import styleSheet from './PredictPosition.styles'; import { PredictPositionSelectorsIDs } from '../../../../../../e2e/selectors/Predict/Predict.selectors'; import { strings } from '../../../../../../locales/i18n'; import { Skeleton } from '../../../../../component-library/components/Skeleton'; +import { usePredictOptimisticPositionRefresh } from '../../hooks/usePredictOptimisticPositionRefresh'; interface PredictPositionProps { position: PredictPositionType; @@ -21,6 +22,12 @@ const PredictPosition: React.FC = ({ position, onPress, }: PredictPositionProps) => { + const { styles } = useStyles(styleSheet, {}); + + const currentPosition = usePredictOptimisticPositionRefresh({ + position, + }); + const { icon, title, @@ -30,14 +37,13 @@ const PredictPosition: React.FC = ({ currentValue, size, optimistic, - } = position; - const { styles } = useStyles(styleSheet, {}); + } = currentPosition; return ( onPress?.(position)} + onPress={() => onPress?.(currentPosition)} > diff --git a/app/components/UI/Predict/components/PredictPositionDetail/PredictPositionDetail.test.tsx b/app/components/UI/Predict/components/PredictPositionDetail/PredictPositionDetail.test.tsx index d20652b1031..998157db6a4 100644 --- a/app/components/UI/Predict/components/PredictPositionDetail/PredictPositionDetail.test.tsx +++ b/app/components/UI/Predict/components/PredictPositionDetail/PredictPositionDetail.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { screen, fireEvent } from '@testing-library/react-native'; +import { screen, fireEvent, act, waitFor } from '@testing-library/react-native'; import renderWithProvider from '../../../../../util/test/renderWithProvider'; import { backgroundState } from '../../../../../util/test/initial-root-state'; import PredictPositionDetail from './PredictPositionDetail'; @@ -10,6 +10,9 @@ import { PredictPositionStatus, Recurrence, } from '../../types'; +import { usePredictPositions } from '../../hooks/usePredictPositions'; +import { PredictMarketDetailsSelectorsIDs } from '../../../../../../e2e/selectors/Predict/Predict.selectors'; +import Routes from '../../../../../constants/navigation/Routes'; declare global { // eslint-disable-next-line no-var @@ -44,30 +47,6 @@ jest.mock('@react-navigation/native', () => { }; }); -jest.mock('../../../../../constants/navigation/Routes', () => ({ - __esModule: true, - default: { - PREDICT: { - MODALS: { - ROOT: 'PREDICT_MODALS_ROOT', - SELL_PREVIEW: 'PREDICT_SELL_PREVIEW', - }, - }, - }, -})); - -jest.mock('@metamask/design-system-twrnc-preset', () => { - const mockTw = Object.assign( - jest.fn(() => ({})), - { - style: jest.fn(() => ({})), - }, - ); - return { - useTailwind: () => mockTw, - }; -}); - const mockExecuteGuardedAction = jest.fn(async (action) => await action()); jest.mock('../../hooks/usePredictActionGuard', () => ({ usePredictActionGuard: () => ({ @@ -77,6 +56,17 @@ jest.mock('../../hooks/usePredictActionGuard', () => ({ }), })); +const mockLoadPositions = jest.fn(); +jest.mock('../../hooks/usePredictPositions', () => ({ + usePredictPositions: jest.fn(() => ({ + positions: [], + loadPositions: mockLoadPositions, + isLoading: false, + isRefreshing: false, + error: null, + })), +})); + const basePosition: PredictPositionType = { id: 'pos-1', providerId: 'polymarket', @@ -172,16 +162,30 @@ const renderComponent = ( }; describe('PredictPositionDetail', () => { + const mockUsePredictPositions = usePredictPositions as jest.MockedFunction< + typeof usePredictPositions + >; + beforeEach(() => { + jest.useFakeTimers(); global.__mockNavigate.mockClear(); mockExecuteGuardedAction.mockClear(); + mockLoadPositions.mockClear(); mockExecuteGuardedAction.mockImplementation( async (action) => await action(), ); + mockUsePredictPositions.mockReturnValue({ + positions: [], + loadPositions: mockLoadPositions, + isLoading: false, + isRefreshing: false, + error: null, + }); }); afterEach(() => { jest.clearAllMocks(); + jest.useRealTimers(); }); it('renders open position with current value, percent change and cash out', () => { @@ -245,7 +249,7 @@ describe('PredictPositionDetail', () => { fireEvent.press(screen.getByText('Cash out')); expect(global.__mockNavigate).toHaveBeenCalledWith( - 'PREDICT_SELL_PREVIEW', + Routes.PREDICT.MODALS.SELL_PREVIEW, expect.objectContaining({ position: expect.objectContaining({ id: 'pos-1' }), outcome: expect.objectContaining({ id: 'outcome-1' }), @@ -274,4 +278,176 @@ describe('PredictPositionDetail', () => { ).toBeOnTheScreen(); }); }); + + describe('optimistic position auto-refresh', () => { + it('starts auto-refresh immediately when position is optimistic', async () => { + mockLoadPositions.mockResolvedValue(undefined); + renderComponent({ optimistic: true }); + + await waitFor(() => { + expect(mockLoadPositions).toHaveBeenCalledWith({ isRefresh: true }); + }); + }); + + it('does not start auto-refresh when position is not optimistic', async () => { + renderComponent({ optimistic: false }); + + await act(async () => { + await jest.advanceTimersByTimeAsync(2000); + }); + + expect(mockLoadPositions).not.toHaveBeenCalled(); + }); + + it('continues auto-refresh at 2-second intervals after each load completes', async () => { + mockLoadPositions.mockResolvedValue(undefined); + renderComponent({ optimistic: true }); + + // First load happens immediately + await waitFor(() => { + expect(mockLoadPositions).toHaveBeenCalledTimes(1); + }); + + // Second load happens 2 seconds after first completes + await act(async () => { + await jest.advanceTimersByTimeAsync(2000); + }); + + expect(mockLoadPositions).toHaveBeenCalledTimes(2); + + // Third load happens 2 seconds after second completes + await act(async () => { + await jest.advanceTimersByTimeAsync(2000); + }); + + expect(mockLoadPositions).toHaveBeenCalledTimes(3); + }); + + it('stops auto-refresh when position becomes non-optimistic', async () => { + const optimisticPosition = { ...basePosition, optimistic: true }; + const resolvedPosition = { ...basePosition, optimistic: false }; + + mockLoadPositions.mockResolvedValue(undefined); + mockUsePredictPositions.mockReturnValue({ + positions: [optimisticPosition], + loadPositions: mockLoadPositions, + isLoading: false, + isRefreshing: false, + error: null, + }); + + const { rerender } = renderComponent({ optimistic: true }); + + // First load happens immediately + await waitFor(() => { + expect(mockLoadPositions).toHaveBeenCalledTimes(1); + }); + + mockLoadPositions.mockClear(); + + mockUsePredictPositions.mockReturnValue({ + positions: [resolvedPosition], + loadPositions: mockLoadPositions, + isLoading: false, + isRefreshing: false, + error: null, + }); + + rerender( + , + ); + + await waitFor(() => { + expect(screen.getByText('$2,345.67')).toBeOnTheScreen(); + }); + + await act(async () => { + await jest.advanceTimersByTimeAsync(2000); + }); + + expect(mockLoadPositions).not.toHaveBeenCalled(); + }); + + it('cleans up auto-refresh on unmount', async () => { + mockLoadPositions.mockResolvedValue(undefined); + const { unmount } = renderComponent({ optimistic: true }); + + // First load happens immediately + await waitFor(() => { + expect(mockLoadPositions).toHaveBeenCalledTimes(1); + }); + + mockLoadPositions.mockClear(); + + unmount(); + + await act(async () => { + await jest.advanceTimersByTimeAsync(2000); + }); + + expect(mockLoadPositions).not.toHaveBeenCalled(); + }); + + it('updates displayed position when positions hook returns new data', async () => { + const optimisticPosition = { + ...basePosition, + optimistic: true, + currentValue: 100, + percentPnl: 0, + }; + const resolvedPosition = { + ...basePosition, + optimistic: false, + currentValue: 2500, + percentPnl: 10.5, + }; + + mockUsePredictPositions.mockReturnValue({ + positions: [optimisticPosition], + loadPositions: mockLoadPositions, + isLoading: false, + isRefreshing: false, + error: null, + }); + + const { rerender } = renderComponent({ optimistic: true }); + + expect(screen.queryByText('$2,500')).toBeNull(); + + mockUsePredictPositions.mockReturnValue({ + positions: [resolvedPosition], + loadPositions: mockLoadPositions, + isLoading: false, + isRefreshing: false, + error: null, + }); + + rerender( + , + ); + + await waitFor(() => { + expect(screen.getByText('$2,500')).toBeOnTheScreen(); + expect(screen.getByText('10.5%')).toBeOnTheScreen(); + }); + }); + + it('disables cash out button when position is optimistic', () => { + renderComponent({ optimistic: true }); + + const cashOutButton = screen.getByTestId( + PredictMarketDetailsSelectorsIDs.MARKET_DETAILS_CASH_OUT_BUTTON, + ); + + expect(cashOutButton).toHaveProp('disabled', true); + }); + }); }); diff --git a/app/components/UI/Predict/components/PredictPositionDetail/PredictPositionDetail.tsx b/app/components/UI/Predict/components/PredictPositionDetail/PredictPositionDetail.tsx index 7ec5e379a26..fdf9207fb57 100644 --- a/app/components/UI/Predict/components/PredictPositionDetail/PredictPositionDetail.tsx +++ b/app/components/UI/Predict/components/PredictPositionDetail/PredictPositionDetail.tsx @@ -25,6 +25,7 @@ import { } from '../../types'; import { PredictNavigationParamList } from '../../types/navigation'; import { formatPercentage, formatPrice } from '../../utils/format'; +import { usePredictOptimisticPositionRefresh } from '../../hooks/usePredictOptimisticPositionRefresh'; interface PredictPositionProps { position: PredictPositionType; @@ -39,6 +40,10 @@ const PredictPosition: React.FC = ({ }: PredictPositionProps) => { const tw = useTailwind(); + const currentPosition = usePredictOptimisticPositionRefresh({ + position, + }); + const { icon, initialValue, @@ -48,28 +53,28 @@ const PredictPosition: React.FC = ({ title, optimistic, size, - } = position; + } = currentPosition; const navigation = useNavigation>(); const { navigate } = navigation; const { executeGuardedAction } = usePredictActionGuard({ - providerId: position.providerId, + providerId: currentPosition.providerId, navigation, }); const groupItemTitle = market?.outcomes.find( - (o) => o.id === position.outcomeId && o.groupItemTitle, + (o) => o.id === currentPosition.outcomeId && o.groupItemTitle, )?.groupItemTitle; const onCashOut = () => { executeGuardedAction( () => { const _outcome = market?.outcomes.find( - (o) => o.id === position.outcomeId, + (o) => o.id === currentPosition.outcomeId, ); navigate(Routes.PREDICT.MODALS.SELL_PREVIEW, { market, - position, + position: currentPosition, outcome: _outcome, entryPoint: PredictEventValues.ENTRY_POINT.PREDICT_MARKET_DETAILS, }); diff --git a/app/components/UI/Predict/hooks/usePredictOptimisticPositionRefresh.test.ts b/app/components/UI/Predict/hooks/usePredictOptimisticPositionRefresh.test.ts new file mode 100644 index 00000000000..f95d959509f --- /dev/null +++ b/app/components/UI/Predict/hooks/usePredictOptimisticPositionRefresh.test.ts @@ -0,0 +1,351 @@ +import { renderHook, act, waitFor } from '@testing-library/react-native'; +import { usePredictOptimisticPositionRefresh } from './usePredictOptimisticPositionRefresh'; +import { PredictPosition, PredictPositionStatus } from '../types'; +import { usePredictPositions } from './usePredictPositions'; + +jest.mock('./usePredictPositions'); + +const basePosition: PredictPosition = { + id: 'pos-1', + providerId: 'polymarket', + marketId: 'market-1', + outcomeId: 'outcome-1', + outcome: 'Yes', + outcomeTokenId: 'token-1', + title: 'Test Position', + icon: 'https://example.com/icon.png', + size: 100, + amount: 100, + price: 0.5, + outcomeIndex: 0, + avgPrice: 0.5, + initialValue: 1000, + currentValue: 1200, + percentPnl: 20, + cashPnl: 200, + claimable: false, + endDate: '2025-12-31T23:59:59Z', + optimistic: false, + status: PredictPositionStatus.OPEN, +}; + +const mockLoadPositions = jest.fn(); +const mockUsePredictPositions = usePredictPositions as jest.MockedFunction< + typeof usePredictPositions +>; + +describe('usePredictOptimisticPositionRefresh', () => { + beforeEach(() => { + jest.useFakeTimers(); + mockLoadPositions.mockClear(); + mockLoadPositions.mockResolvedValue(undefined); + mockUsePredictPositions.mockReturnValue({ + positions: [], + loadPositions: mockLoadPositions, + isLoading: false, + isRefreshing: false, + error: null, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.useRealTimers(); + }); + + it('returns the initial position', () => { + const { result } = renderHook(() => + usePredictOptimisticPositionRefresh({ + position: basePosition, + }), + ); + + expect(result.current).toEqual(basePosition); + }); + + it('updates position when positions array changes', async () => { + const updatedPosition = { + ...basePosition, + currentValue: 1500, + percentPnl: 50, + }; + mockUsePredictPositions.mockReturnValue({ + positions: [], + loadPositions: mockLoadPositions, + isLoading: false, + isRefreshing: false, + error: null, + }); + + const { result, rerender } = renderHook(() => + usePredictOptimisticPositionRefresh({ + position: basePosition, + }), + ); + + expect(result.current.currentValue).toBe(1200); + + mockUsePredictPositions.mockReturnValue({ + positions: [updatedPosition], + loadPositions: mockLoadPositions, + isLoading: false, + isRefreshing: false, + error: null, + }); + + await act(async () => { + // @ts-expect-error - rerender doesn't need args when hook has no props + rerender(); + }); + + expect(result.current.currentValue).toBe(1500); + expect(result.current.percentPnl).toBe(50); + }); + + it('starts auto-refresh immediately when position is optimistic', async () => { + const optimisticPosition = { ...basePosition, optimistic: true }; + + renderHook(() => + usePredictOptimisticPositionRefresh({ + position: optimisticPosition, + }), + ); + + await waitFor(() => { + expect(mockLoadPositions).toHaveBeenCalledWith({ isRefresh: true }); + }); + }); + + it('does not start auto-refresh when position is not optimistic', async () => { + renderHook(() => + usePredictOptimisticPositionRefresh({ + position: basePosition, + }), + ); + + await act(async () => { + await jest.advanceTimersByTimeAsync(2000); + }); + + expect(mockLoadPositions).not.toHaveBeenCalled(); + }); + + it('continues auto-refresh at specified intervals after each load completes', async () => { + const optimisticPosition = { ...basePosition, optimistic: true }; + + renderHook(() => + usePredictOptimisticPositionRefresh({ + position: optimisticPosition, + pollingInterval: 1000, + }), + ); + + await waitFor(() => { + expect(mockLoadPositions).toHaveBeenCalledTimes(1); + }); + await act(async () => { + await jest.advanceTimersByTimeAsync(1000); + }); + + expect(mockLoadPositions).toHaveBeenCalledTimes(2); + + await act(async () => { + await jest.advanceTimersByTimeAsync(1000); + }); + + expect(mockLoadPositions).toHaveBeenCalledTimes(3); + }); + + it('uses default polling interval of 2000ms when not specified', async () => { + const optimisticPosition = { ...basePosition, optimistic: true }; + + renderHook(() => + usePredictOptimisticPositionRefresh({ + position: optimisticPosition, + }), + ); + + await waitFor(() => { + expect(mockLoadPositions).toHaveBeenCalledTimes(1); + }); + await act(async () => { + await jest.advanceTimersByTimeAsync(1999); + }); + + expect(mockLoadPositions).toHaveBeenCalledTimes(1); + + await act(async () => { + await jest.advanceTimersByTimeAsync(1); + }); + + expect(mockLoadPositions).toHaveBeenCalledTimes(2); + }); + + it('stops auto-refresh when position becomes non-optimistic', async () => { + const optimisticPosition = { ...basePosition, optimistic: true }; + const resolvedPosition = { ...basePosition, optimistic: false }; + mockUsePredictPositions.mockReturnValue({ + positions: [], + loadPositions: mockLoadPositions, + isLoading: false, + isRefreshing: false, + error: null, + }); + + const { rerender } = renderHook( + ({ position }) => + usePredictOptimisticPositionRefresh({ + position, + }), + { initialProps: { position: optimisticPosition } }, + ); + + await waitFor(() => { + expect(mockLoadPositions).toHaveBeenCalledTimes(1); + }); + mockLoadPositions.mockClear(); + mockUsePredictPositions.mockReturnValue({ + positions: [resolvedPosition], + loadPositions: mockLoadPositions, + isLoading: false, + isRefreshing: false, + error: null, + }); + rerender({ position: resolvedPosition }); + await act(async () => { + await jest.advanceTimersByTimeAsync(2000); + }); + + expect(mockLoadPositions).not.toHaveBeenCalled(); + }); + + it('cleans up auto-refresh on unmount', async () => { + const optimisticPosition = { ...basePosition, optimistic: true }; + + const { unmount } = renderHook(() => + usePredictOptimisticPositionRefresh({ + position: optimisticPosition, + }), + ); + + await waitFor(() => { + expect(mockLoadPositions).toHaveBeenCalledTimes(1); + }); + mockLoadPositions.mockClear(); + unmount(); + await act(async () => { + await jest.advanceTimersByTimeAsync(2000); + }); + + expect(mockLoadPositions).not.toHaveBeenCalled(); + }); + + it('finds and updates position from positions array by marketId and outcomeId', async () => { + const position1 = { ...basePosition, outcomeId: 'outcome-1' }; + const position2 = { + ...basePosition, + id: 'pos-2', + outcomeId: 'outcome-2', + currentValue: 1500, + }; + const updatedPosition1 = { + ...position1, + currentValue: 1800, + percentPnl: 80, + }; + mockUsePredictPositions.mockReturnValue({ + positions: [], + loadPositions: mockLoadPositions, + isLoading: false, + isRefreshing: false, + error: null, + }); + + const { result, rerender } = renderHook(() => + usePredictOptimisticPositionRefresh({ + position: position1, + }), + ); + + expect(result.current.currentValue).toBe(1200); + + mockUsePredictPositions.mockReturnValue({ + positions: [position2, updatedPosition1], + loadPositions: mockLoadPositions, + isLoading: false, + isRefreshing: false, + error: null, + }); + + await act(async () => { + // @ts-expect-error - rerender doesn't need args when hook has no props + rerender(); + }); + + expect(result.current.currentValue).toBe(1800); + expect(result.current.percentPnl).toBe(80); + }); + + it('handles slow network by waiting for each request to complete', async () => { + const optimisticPosition = { ...basePosition, optimistic: true }; + let resolveLoad: (() => void) | null = null; + mockLoadPositions.mockImplementation( + () => + new Promise((resolve) => { + resolveLoad = resolve; + }), + ); + + renderHook(() => + usePredictOptimisticPositionRefresh({ + position: optimisticPosition, + pollingInterval: 1000, + }), + ); + + await waitFor(() => { + expect(mockLoadPositions).toHaveBeenCalledTimes(1); + }); + await act(async () => { + await jest.advanceTimersByTimeAsync(1000); + }); + + expect(mockLoadPositions).toHaveBeenCalledTimes(1); + + await act(async () => { + resolveLoad?.(); + await jest.advanceTimersByTimeAsync(0); + }); + await act(async () => { + await jest.advanceTimersByTimeAsync(1000); + }); + + expect(mockLoadPositions).toHaveBeenCalledTimes(2); + }); + + it('handles loadPositions errors gracefully', async () => { + const optimisticPosition = { ...basePosition, optimistic: true }; + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + mockLoadPositions.mockRejectedValue(new Error('Network error')); + + renderHook(() => + usePredictOptimisticPositionRefresh({ + position: optimisticPosition, + pollingInterval: 1000, + }), + ); + + await waitFor(() => { + expect(mockLoadPositions).toHaveBeenCalledTimes(1); + }); + await act(async () => { + await jest.advanceTimersByTimeAsync(1000); + }); + + expect(mockLoadPositions).toHaveBeenCalledTimes(2); + + consoleErrorSpy.mockRestore(); + }); +}); diff --git a/app/components/UI/Predict/hooks/usePredictOptimisticPositionRefresh.ts b/app/components/UI/Predict/hooks/usePredictOptimisticPositionRefresh.ts new file mode 100644 index 00000000000..ab57cfc7512 --- /dev/null +++ b/app/components/UI/Predict/hooks/usePredictOptimisticPositionRefresh.ts @@ -0,0 +1,87 @@ +import { useEffect, useRef, useState } from 'react'; +import { PredictPosition } from '../types'; +import { usePredictPositions } from './usePredictPositions'; + +interface UsePredictOptimisticPositionRefreshParams { + position: PredictPosition; + pollingInterval?: number; +} + +/** + * Hook to handle auto-refresh of optimistic positions + * + * When a position is marked as optimistic, this hook: + * 1. Immediately loads positions to check for updates + * 2. After each load completes, waits for the polling interval + * 3. Loads again if the position is still optimistic + * 4. Stops when the position is no longer optimistic or component unmounts + * + * This prevents request pileup on slow connections by waiting for each + * request to complete before scheduling the next one. + */ +export const usePredictOptimisticPositionRefresh = ({ + position, + pollingInterval = 2000, +}: UsePredictOptimisticPositionRefreshParams): PredictPosition => { + const [currentPosition, setCurrentPosition] = + useState(position); + + const { positions, loadPositions } = usePredictPositions({ + marketId: position.marketId, + loadOnMount: false, + refreshOnFocus: false, + }); + + // Store loadPositions in a ref to avoid effect restarts when its identity changes + const loadPositionsRef = useRef(loadPositions); + loadPositionsRef.current = loadPositions; + + // Update current position when positions from the hook change + useEffect(() => { + const updatedPosition = positions.find( + (p) => + p.marketId === position.marketId && p.outcomeId === position.outcomeId, + ); + + if (updatedPosition) { + setCurrentPosition(updatedPosition); + } + }, [positions, position.marketId, position.outcomeId]); + + // Auto-refresh for optimistic positions - sequential loading pattern + useEffect(() => { + if (!currentPosition.optimistic) return; + + let shouldContinue = true; + let timeoutId: NodeJS.Timeout | null = null; + + const pollPositions = async () => { + if (!shouldContinue) return; + + try { + await loadPositionsRef.current({ isRefresh: true }); + } catch (error) { + // Continue polling even if an individual request fails + // This ensures we keep trying to get updated position data + } + + // After the response (or error), schedule next poll if still active + if (shouldContinue) { + timeoutId = setTimeout(() => { + pollPositions(); + }, pollingInterval); + } + }; + + pollPositions(); + + return () => { + shouldContinue = false; + if (timeoutId) { + clearTimeout(timeoutId); + } + }; + }, [currentPosition.optimistic, pollingInterval]); + + return currentPosition; +}; diff --git a/app/components/UI/Predict/hooks/usePredictWithdrawToasts.ts b/app/components/UI/Predict/hooks/usePredictWithdrawToasts.ts index 30da25529f5..8ab30d985e6 100644 --- a/app/components/UI/Predict/hooks/usePredictWithdrawToasts.ts +++ b/app/components/UI/Predict/hooks/usePredictWithdrawToasts.ts @@ -6,6 +6,7 @@ import { usePredictToasts } from './usePredictToasts'; import { PredictWithdrawStatus } from '../types'; import { useEffect } from 'react'; import { usePredictBalance } from './usePredictBalance'; +import { formatPrice } from '../utils/format'; export const usePredictWithdrawToasts = () => { const { loadBalance } = usePredictBalance(); @@ -21,7 +22,8 @@ export const usePredictWithdrawToasts = () => { description: strings('predict.withdraw.withdraw_completed_subtitle', { amount: '{amount}', }), - getAmount: () => withdrawTransaction?.amount.toString() ?? '0', + getAmount: () => + formatPrice(withdrawTransaction?.amount.toString() ?? '0'), }, errorToastConfig: { title: strings('predict.withdraw.error_title'), @@ -36,14 +38,9 @@ export const usePredictWithdrawToasts = () => { useEffect(() => { if (withdrawTransaction?.status === PredictWithdrawStatus.PENDING) { showPendingToast({ - amount: withdrawTransaction?.amount.toString() ?? '0', config: { - title: strings('predict.withdraw.withdrawing', { - amount: '{amount}', - }), - description: strings('predict.withdraw.withdrawing_subtitle', { - time: 30, - }), + title: strings('predict.withdraw.withdrawing'), + description: strings('predict.withdraw.withdrawing_subtitle'), }, }); } diff --git a/app/components/UI/Ramp/hooks/useRampsSmartRouting.ts b/app/components/UI/Ramp/hooks/useRampsSmartRouting.ts index cd4c57b2a2a..a1e6eb10f52 100644 --- a/app/components/UI/Ramp/hooks/useRampsSmartRouting.ts +++ b/app/components/UI/Ramp/hooks/useRampsSmartRouting.ts @@ -96,7 +96,7 @@ export default function useRampsSmartRouting() { return; } - const [lastCompletedOrder] = completedOrders.sort( + const [lastCompletedOrder] = [...completedOrders].sort( (a, b) => b.createdAt - a.createdAt, ); diff --git a/app/components/UI/Swaps/index.js b/app/components/UI/Swaps/index.js index c4433d3517d..3c8813f91d4 100644 --- a/app/components/UI/Swaps/index.js +++ b/app/components/UI/Swaps/index.js @@ -27,6 +27,7 @@ import { safeNumberToBN, } from '../../../util/number'; import { areAddressesEqual, toFormattedAddress } from '../../../util/address'; +import { NATIVE_SWAPS_TOKEN_ADDRESS } from '../../../constants/bridge'; import { swapsUtils } from '@metamask/swaps-controller'; import { MetaMetricsEvents } from '../../../core/Analytics'; @@ -189,7 +190,7 @@ const createStyles = (colors) => }, }); -const SWAPS_NATIVE_ADDRESS = swapsUtils.NATIVE_SWAPS_TOKEN_ADDRESS; +const SWAPS_NATIVE_ADDRESS = NATIVE_SWAPS_TOKEN_ADDRESS; const TOKEN_MINIMUM_SOURCES = 1; const MAX_TOP_ASSETS = 20; diff --git a/app/components/UI/Swaps/useStablecoinsDefaultSlippage.test.tsx b/app/components/UI/Swaps/useStablecoinsDefaultSlippage.test.tsx index 1aa3158bedd..87e02062e80 100644 --- a/app/components/UI/Swaps/useStablecoinsDefaultSlippage.test.tsx +++ b/app/components/UI/Swaps/useStablecoinsDefaultSlippage.test.tsx @@ -4,7 +4,7 @@ import { handleEvmStablecoinSlippage, } from './useStablecoinsDefaultSlippage'; import { Hex } from '@metamask/utils'; -import { swapsUtils } from '@metamask/swaps-controller'; +import { CHAIN_IDS } from '@metamask/transaction-controller'; import AppConstants from '../../../core/AppConstants'; describe('useStablecoinsDefaultSlippage', () => { @@ -40,7 +40,7 @@ describe('useStablecoinsDefaultSlippage', () => { useStablecoinsDefaultSlippage({ sourceTokenAddress: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', // USDC destTokenAddress: '0xdac17f958d2ee523a2206206994597c13d831ec7', // USDT - chainId: swapsUtils.ETH_CHAIN_ID as Hex, + chainId: CHAIN_IDS.MAINNET, setSlippage: mockSetSlippage, }), { state: initialState }, @@ -55,7 +55,7 @@ describe('useStablecoinsDefaultSlippage', () => { useStablecoinsDefaultSlippage({ sourceTokenAddress: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', // USDC (checksum) destTokenAddress: '0xdAC17F958D2ee523a2206206994597C13D831ec7', // USDT (checksum) - chainId: swapsUtils.ETH_CHAIN_ID as Hex, + chainId: CHAIN_IDS.MAINNET as Hex, setSlippage: mockSetSlippage, }), { state: initialState }, @@ -70,7 +70,7 @@ describe('useStablecoinsDefaultSlippage', () => { useStablecoinsDefaultSlippage({ sourceTokenAddress: '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359', // USDC destTokenAddress: '0xc2132d05d31c914a87c6611c10748aeb04b58e8f', // USDT - chainId: swapsUtils.POLYGON_CHAIN_ID as Hex, + chainId: CHAIN_IDS.POLYGON as Hex, setSlippage: mockSetSlippage, }), { state: initialState }, @@ -85,7 +85,7 @@ describe('useStablecoinsDefaultSlippage', () => { useStablecoinsDefaultSlippage({ sourceTokenAddress: '0x123', // Non-stablecoin destTokenAddress: '0xdac17f958d2ee523a2206206994597c13d831ec7', // USDT - chainId: swapsUtils.ETH_CHAIN_ID as Hex, + chainId: CHAIN_IDS.MAINNET as Hex, setSlippage: mockSetSlippage, }), { state: initialState }, @@ -100,7 +100,7 @@ describe('useStablecoinsDefaultSlippage', () => { useStablecoinsDefaultSlippage({ sourceTokenAddress: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', // USDC destTokenAddress: '0x123', // Non-stablecoin - chainId: swapsUtils.ETH_CHAIN_ID as Hex, + chainId: CHAIN_IDS.MAINNET as Hex, setSlippage: mockSetSlippage, }), { state: initialState }, @@ -129,7 +129,7 @@ describe('useStablecoinsDefaultSlippage', () => { () => useStablecoinsDefaultSlippage({ destTokenAddress: '0xdac17f958d2ee523a2206206994597c13d831ec7', // USDT - chainId: swapsUtils.ETH_CHAIN_ID as Hex, + chainId: CHAIN_IDS.MAINNET, setSlippage: mockSetSlippage, }), { state: initialState }, @@ -143,7 +143,7 @@ describe('useStablecoinsDefaultSlippage', () => { () => useStablecoinsDefaultSlippage({ sourceTokenAddress: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', // USDC - chainId: swapsUtils.ETH_CHAIN_ID as Hex, + chainId: CHAIN_IDS.MAINNET, setSlippage: mockSetSlippage, }), { state: initialState }, @@ -164,7 +164,7 @@ describe('handleStablecoinSlippage', () => { handleEvmStablecoinSlippage({ sourceTokenAddress: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', // USDC destTokenAddress: '0xdac17f958d2ee523a2206206994597c13d831ec7', // USDT - chainId: swapsUtils.ETH_CHAIN_ID as Hex, + chainId: CHAIN_IDS.MAINNET, setSlippage: mockSetSlippage, }); @@ -177,7 +177,7 @@ describe('handleStablecoinSlippage', () => { handleEvmStablecoinSlippage({ sourceTokenAddress: '0x123', // Non-stablecoin destTokenAddress: '0xdac17f958d2ee523a2206206994597c13d831ec7', // USDT - chainId: swapsUtils.ETH_CHAIN_ID as Hex, + chainId: CHAIN_IDS.MAINNET, setSlippage: mockSetSlippage, }); @@ -188,7 +188,7 @@ describe('handleStablecoinSlippage', () => { handleEvmStablecoinSlippage({ sourceTokenAddress: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', // USDC destTokenAddress: '0x123', // Non-stablecoin - chainId: swapsUtils.ETH_CHAIN_ID as Hex, + chainId: CHAIN_IDS.MAINNET, setSlippage: mockSetSlippage, }); @@ -209,7 +209,7 @@ describe('handleStablecoinSlippage', () => { it('does not set slippage when source token address is missing', () => { handleEvmStablecoinSlippage({ destTokenAddress: '0xdac17f958d2ee523a2206206994597c13d831ec7', // USDT - chainId: swapsUtils.ETH_CHAIN_ID as Hex, + chainId: CHAIN_IDS.MAINNET, setSlippage: mockSetSlippage, }); @@ -219,7 +219,7 @@ describe('handleStablecoinSlippage', () => { it('does not set slippage when destination token address is missing', () => { handleEvmStablecoinSlippage({ sourceTokenAddress: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', // USDC - chainId: swapsUtils.ETH_CHAIN_ID as Hex, + chainId: CHAIN_IDS.MAINNET, setSlippage: mockSetSlippage, }); @@ -230,7 +230,7 @@ describe('handleStablecoinSlippage', () => { handleEvmStablecoinSlippage({ sourceTokenAddress: '0x123', // Non-stablecoin destTokenAddress: '0x456', // Non-stablecoin - chainId: swapsUtils.ETH_CHAIN_ID as Hex, + chainId: CHAIN_IDS.MAINNET, setSlippage: mockSetSlippage, prevSourceTokenAddress: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', // USDC prevDestTokenAddress: '0xdac17f958d2ee523a2206206994597c13d831ec7', // USDT @@ -245,7 +245,7 @@ describe('handleStablecoinSlippage', () => { handleEvmStablecoinSlippage({ sourceTokenAddress: '0x6b175474e89094c44da98b954eedeac495271d0f', // DAI destTokenAddress: '0x2260fac5e5542a773aa44fbcfedf7c193bc2c599', // WBTC - chainId: swapsUtils.ETH_CHAIN_ID as Hex, + chainId: CHAIN_IDS.MAINNET, setSlippage: mockSetSlippage, prevSourceTokenAddress: '0x123', // Non-stablecoin prevDestTokenAddress: '0xdac17f958d2ee523a2206206994597c13d831ec7', // USDT @@ -258,7 +258,7 @@ describe('handleStablecoinSlippage', () => { handleEvmStablecoinSlippage({ sourceTokenAddress: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', // USDC destTokenAddress: '0xdac17f958d2ee523a2206206994597c13d831ec7', // USDT - chainId: swapsUtils.ETH_CHAIN_ID as Hex, + chainId: CHAIN_IDS.MAINNET, setSlippage: mockSetSlippage, prevSourceTokenAddress: '0x123', // Non-stablecoin prevDestTokenAddress: '0x2260fac5e5542a773aa44fbcfedf7c193bc2c599', // WBTC @@ -273,7 +273,7 @@ describe('handleStablecoinSlippage', () => { handleEvmStablecoinSlippage({ sourceTokenAddress: undefined, destTokenAddress: undefined, - chainId: swapsUtils.ETH_CHAIN_ID as Hex, + chainId: CHAIN_IDS.MAINNET, setSlippage: mockSetSlippage, prevSourceTokenAddress: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', // USDC prevDestTokenAddress: '0xdac17f958d2ee523a2206206994597c13d831ec7', // USDT @@ -286,7 +286,7 @@ describe('handleStablecoinSlippage', () => { handleEvmStablecoinSlippage({ sourceTokenAddress: '0x123', // Non-stablecoin destTokenAddress: '0x456', // Non-stablecoin - chainId: swapsUtils.ETH_CHAIN_ID as Hex, + chainId: CHAIN_IDS.MAINNET, setSlippage: mockSetSlippage, prevSourceTokenAddress: undefined, prevDestTokenAddress: undefined, diff --git a/app/components/UI/Swaps/useStablecoinsDefaultSlippage.ts b/app/components/UI/Swaps/useStablecoinsDefaultSlippage.ts index cc060b061cc..5a6c121eb65 100644 --- a/app/components/UI/Swaps/useStablecoinsDefaultSlippage.ts +++ b/app/components/UI/Swaps/useStablecoinsDefaultSlippage.ts @@ -1,50 +1,50 @@ import { useEffect } from 'react'; import AppConstants from '../../../core/AppConstants'; import { Hex } from '@metamask/utils'; -import { swapsUtils } from '@metamask/swaps-controller'; import { toChecksumHexAddress } from '@metamask/controller-utils'; import usePrevious from '../../hooks/usePrevious'; import { NETWORKS_CHAIN_ID } from '../../../constants/network'; +import { CHAIN_IDS } from '@metamask/transaction-controller'; // USDC and USDT for now const StablecoinsByChainId: Partial>> = { - [swapsUtils.ETH_CHAIN_ID]: new Set([ + [CHAIN_IDS.MAINNET]: new Set([ '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', // USDC '0xdac17f958d2ee523a2206206994597c13d831ec7', // USDT ]), - [swapsUtils.LINEA_CHAIN_ID]: new Set([ + [CHAIN_IDS.LINEA_MAINNET]: new Set([ '0x176211869cA2b568f2A7D4EE941E073a821EE1ff', // USDC '0xA219439258ca9da29E9Cc4cE5596924745e12B93', // USDT ]), - [swapsUtils.POLYGON_CHAIN_ID]: new Set([ + [CHAIN_IDS.POLYGON]: new Set([ '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359', // USDC '0x2791bca1f2de4661ed88a30c99a7a9449aa84174', // USDC.e '0xc2132d05d31c914a87c6611c10748aeb04b58e8f', // USDT ]), - [swapsUtils.ARBITRUM_CHAIN_ID]: new Set([ + [CHAIN_IDS.ARBITRUM]: new Set([ '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', // USDC '0xFF970A61A04b1cA14834A43f5dE4533eBDDB5CC8', // USDC.e '0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9', // USDT ]), - [swapsUtils.BASE_CHAIN_ID]: new Set([ + [CHAIN_IDS.BASE]: new Set([ '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', // USDC ]), - [swapsUtils.OPTIMISM_CHAIN_ID]: new Set([ + [CHAIN_IDS.OPTIMISM]: new Set([ '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', // USDC '0x7F5c764cBc14f9669B88837ca1490cCa17c31607', // USDC.e '0x94b008aA00579c1307B0EF2c499aD98a8ce58e58', // USDT ]), - [swapsUtils.BSC_CHAIN_ID]: new Set([ + [CHAIN_IDS.BSC]: new Set([ '0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d', // USDC '0x55d398326f99059ff775485246999027b3197955', // USDT ]), - [swapsUtils.AVALANCHE_CHAIN_ID]: new Set([ + [CHAIN_IDS.AVALANCHE]: new Set([ '0xb97ef9ef8734c71904d8002f8b6bc66dd9c48a6e', // USDC '0xa7d7079b0fead91f3e65f86e8915cb59c1a4c664', // USDC.e '0x9702230a8ea53601f5cd2dc00fdbc13d4df4a8c7', // USDT '0xc7198437980c041c805a1edcba50c1ce5db95118', // USDT.e ]), - [swapsUtils.ZKSYNC_ERA_CHAIN_ID]: new Set([ + [CHAIN_IDS.ZKSYNC_ERA]: new Set([ '0x1d17CBcF0D6D143135aE902365D2E5e2A16538D4', // USDC '0x3355df6D4c9C3035724Fd0e3914dE96A5a83aaf4', // USDC.e '0x493257fD37EDB34451f62EDf8D2a0C418852bA4C', // USDT diff --git a/app/components/UI/Swaps/utils/index.js b/app/components/UI/Swaps/utils/index.js index 7edeeb581a3..8813f3968be 100644 --- a/app/components/UI/Swaps/utils/index.js +++ b/app/components/UI/Swaps/utils/index.js @@ -6,32 +6,22 @@ import AppConstants from '../../../../core/AppConstants'; import { NETWORKS_CHAIN_ID } from '../../../../constants/network'; import { SolScope, BtcScope, TrxScope } from '@metamask/keyring-api'; import { CHAIN_IDS } from '@metamask/transaction-controller'; - -const { - ETH_CHAIN_ID, - BSC_CHAIN_ID, +import { + NATIVE_SWAPS_TOKEN_ADDRESS, SWAPS_TESTNET_CHAIN_ID, - POLYGON_CHAIN_ID, - AVALANCHE_CHAIN_ID, - ARBITRUM_CHAIN_ID, - OPTIMISM_CHAIN_ID, - ZKSYNC_ERA_CHAIN_ID, - LINEA_CHAIN_ID, - BASE_CHAIN_ID, - SEI_CHAIN_ID, -} = swapsUtils; +} from '../../../../constants/bridge'; const allowedChainIds = [ - ETH_CHAIN_ID, - BSC_CHAIN_ID, - POLYGON_CHAIN_ID, - AVALANCHE_CHAIN_ID, - ARBITRUM_CHAIN_ID, - OPTIMISM_CHAIN_ID, - ZKSYNC_ERA_CHAIN_ID, - LINEA_CHAIN_ID, - BASE_CHAIN_ID, - SEI_CHAIN_ID, + CHAIN_IDS.MAINNET, + CHAIN_IDS.BSC, + CHAIN_IDS.POLYGON, + CHAIN_IDS.AVALANCHE, + CHAIN_IDS.ARBITRUM, + CHAIN_IDS.OPTIMISM, + CHAIN_IDS.ZKSYNC_ERA, + CHAIN_IDS.LINEA_MAINNET, + CHAIN_IDS.BASE, + CHAIN_IDS.SEI, CHAIN_IDS.MONAD, SWAPS_TESTNET_CHAIN_ID, ]; @@ -65,9 +55,7 @@ export function isSwapsAllowed(chainId) { } export function isSwapsNativeAsset(token) { - return ( - Boolean(token) && token?.address === swapsUtils.NATIVE_SWAPS_TOKEN_ADDRESS - ); + return Boolean(token) && token?.address === NATIVE_SWAPS_TOKEN_ADDRESS; } export function isDynamicToken(token) { diff --git a/app/components/UI/Swaps/utils/index.test.js b/app/components/UI/Swaps/utils/index.test.js index c0cc955c3ec..c1d9023f3b3 100644 --- a/app/components/UI/Swaps/utils/index.test.js +++ b/app/components/UI/Swaps/utils/index.test.js @@ -3,8 +3,8 @@ import { shouldShowMaxBalanceLink, isSwapsAllowed, } from './index'; -import { swapsUtils } from '@metamask/swaps-controller'; -import { SolScope } from '@metamask/keyring-api'; +import { CHAIN_IDS } from '@metamask/transaction-controller'; +import { SWAPS_TESTNET_CHAIN_ID } from '../../../../constants/bridge'; // Mock AppConstants const mockSwapsConstantsGetter = jest.fn(() => ({ @@ -17,18 +17,15 @@ jest.mock('../../../../core/AppConstants', () => ({ }, })); -const { - ETH_CHAIN_ID, - BSC_CHAIN_ID, - SWAPS_TESTNET_CHAIN_ID, - POLYGON_CHAIN_ID, - AVALANCHE_CHAIN_ID, - ARBITRUM_CHAIN_ID, - OPTIMISM_CHAIN_ID, - ZKSYNC_ERA_CHAIN_ID, - LINEA_CHAIN_ID, - BASE_CHAIN_ID, -} = swapsUtils; +const ETH_CHAIN_ID = CHAIN_IDS.MAINNET; +const BSC_CHAIN_ID = CHAIN_IDS.BSC; +const POLYGON_CHAIN_ID = CHAIN_IDS.POLYGON; +const AVALANCHE_CHAIN_ID = CHAIN_IDS.AVALANCHE; +const ARBITRUM_CHAIN_ID = CHAIN_IDS.ARBITRUM; +const OPTIMISM_CHAIN_ID = CHAIN_IDS.OPTIMISM; +const ZKSYNC_ERA_CHAIN_ID = CHAIN_IDS.ZKSYNC_ERA; +const LINEA_CHAIN_ID = CHAIN_IDS.LINEA_MAINNET; +const BASE_CHAIN_ID = CHAIN_IDS.BASE; describe('getFetchParams', () => { const mockSourceToken = { diff --git a/app/components/Views/AddAsset/AddAsset.test.tsx b/app/components/Views/AddAsset/AddAsset.test.tsx index 93f25d04364..fbf5d1fdc3c 100644 --- a/app/components/Views/AddAsset/AddAsset.test.tsx +++ b/app/components/Views/AddAsset/AddAsset.test.tsx @@ -100,6 +100,36 @@ jest.mock('../../../core/Engine', () => ({ }, })); +const mockEnableNetworkEnablement = { + enabledNetworksForCurrentNamespace: { + '0x1': true, + '0x89': false, + '0xa': true, + }, + enabledNetworksForAllNamespaces: { + // EVM networks + '0x1': true, + '0x89': true, + '0xa': true, + '0xa4b1': true, + '0x38': true, + // Solana networks + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': true, + 'solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z': false, + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1': false, + // Bitcoin networks + 'bip122:000000000019d6689c085ae165831e93': true, + 'bip122:000000000933ea01ad0ee984209779ba': false, + // Tron networks + 'tron:728126428': true, + 'tron:2494104990': false, + }, +}; + +jest.mock('../../hooks/useNetworkEnablement/useNetworkEnablement', () => ({ + useNetworkEnablement: jest.fn(() => mockEnableNetworkEnablement), +})); + const initialState = { engine: { backgroundState: { @@ -400,4 +430,101 @@ describe('AddAsset component', () => { expect(mockIsNonEvmChainId).toHaveBeenCalled(); }); }); + + describe('Enabled Network Selection', () => { + it('should select first enabled network from enabledNetworksForCurrentNamespace', () => { + mockUseParamsValues.assetType = 'token'; + + // The component should select 0x1 as the first enabled network + const { getByTestId } = renderComponent(); + + expect(getByTestId('add-token-screen')).toBeOnTheScreen(); + expect(getByTestId('add-asset-network-selector')).toBeOnTheScreen(); + }); + + it('should handle only one enabled network correctly', () => { + mockUseParamsValues.assetType = 'token'; + + mockEnableNetworkEnablement.enabledNetworksForCurrentNamespace = { + '0x1': true, + '0x89': false, + '0xa': false, + }; + + const { getByTestId } = renderComponent(); + + expect(getByTestId('add-token-screen')).toBeOnTheScreen(); + }); + + it('should handle multiple enabled networks and pick the first one', () => { + mockUseParamsValues.assetType = 'token'; + + mockEnableNetworkEnablement.enabledNetworksForCurrentNamespace = { + '0x1': true, + '0x89': true, + '0xa': true, + }; + + const { getByTestId } = renderComponent(); + + expect(getByTestId('add-token-screen')).toBeOnTheScreen(); + // Network selector should be present + expect(getByTestId('add-asset-network-selector')).toBeOnTheScreen(); + }); + + it('should handle no enabled networks gracefully', () => { + mockUseParamsValues.assetType = 'token'; + + mockEnableNetworkEnablement.enabledNetworksForCurrentNamespace = { + '0x1': false, + '0x89': false, + '0xa': false, + }; + + const { getByTestId } = renderComponent(); + + expect(getByTestId('add-token-screen')).toBeOnTheScreen(); + }); + + it('should filter out disabled networks when finding enabled chain ID', () => { + mockUseParamsValues.assetType = 'token'; + + mockEnableNetworkEnablement.enabledNetworksForCurrentNamespace = { + '0x1': false, + '0x89': false, + '0xa': true, + }; + + // Should select 0xa (first enabled network) + const { getByTestId } = renderComponent(); + + expect(getByTestId('add-token-screen')).toBeOnTheScreen(); + }); + + it('should handle empty enabledNetworksForCurrentNamespace object', () => { + mockUseParamsValues.assetType = 'token'; + + // @ts-expect-error - mockEnableNetworkEnablement.enabledNetworksForCurrentNamespace is not defined + mockEnableNetworkEnablement.enabledNetworksForCurrentNamespace = {}; + + const { getByTestId } = renderComponent(); + + expect(getByTestId('add-token-screen')).toBeOnTheScreen(); + }); + + it('should only consider networks with value === true as enabled', () => { + mockUseParamsValues.assetType = 'token'; + + mockEnableNetworkEnablement.enabledNetworksForCurrentNamespace = { + '0x1': false, + '0x89': true, + '0xa': false, + }; + + // Should only select 0x89 as it's the only one with true value + const { getByTestId } = renderComponent(); + + expect(getByTestId('add-token-screen')).toBeOnTheScreen(); + }); + }); }); diff --git a/app/components/Views/AddAsset/AddAsset.tsx b/app/components/Views/AddAsset/AddAsset.tsx index 408816ca761..4a9fb119f46 100644 --- a/app/components/Views/AddAsset/AddAsset.tsx +++ b/app/components/Views/AddAsset/AddAsset.tsx @@ -1,10 +1,4 @@ -import React, { - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; +import React, { useCallback, useMemo, useRef, useState } from 'react'; import { ActivityIndicator, SafeAreaView, View } from 'react-native'; import { useSelector } from 'react-redux'; import TabBar from '../../../component-library/components-temp/TabBar/TabBar'; @@ -16,7 +10,6 @@ import ScrollableTabView, { import { strings } from '../../../../locales/i18n'; import AddCustomCollectible from '../../UI/AddCustomCollectible'; import { - selectChainId, selectNetworkConfigurations, selectProviderConfig, } from '../../../selectors/networkController'; @@ -56,6 +49,7 @@ import { ImportTokenViewSelectorsIDs } from '../../../../e2e/selectors/wallet/Im import { SupportedCaipChainId } from '@metamask/multichain-network-controller'; import { isNonEvmChainId } from '../../../core/Multichain/utils'; import { useTopTokens } from '../../UI/Bridge/hooks/useTopTokens'; +import { useNetworkEnablement } from '../../hooks/useNetworkEnablement/useNetworkEnablement'; export enum FilterOption { AllNetworks, @@ -72,26 +66,26 @@ const AddAsset = () => { } = useStyles(styleSheet, {}); const providerConfig = useSelector(selectProviderConfig); - const chainId = useSelector(selectChainId); const displayNftMedia = useSelector(selectDisplayNftMedia); const networkConfigurations = useSelector(selectNetworkConfigurations); const [openNetworkSelector, setOpenNetworkSelector] = useState(false); + const { enabledNetworksForAllNamespaces } = useNetworkEnablement(); + const enabledChainId = useMemo( + () => + Object.keys(enabledNetworksForAllNamespaces).find( + (chainId) => enabledNetworksForAllNamespaces[chainId as Hex] === true, + ) ?? '0x1', // Fallback to Ethereum Mainnet if no networks are enabled + [enabledNetworksForAllNamespaces], + ); const [selectedNetwork, setSelectedNetwork] = useState< SupportedCaipChainId | Hex | null - >(chainId); + >(enabledChainId as Hex); const sheetRef = useRef(null); const { topTokens, remainingTokens, pending } = useTopTokens({ chainId: selectedNetwork ?? undefined, }); - // Update selectedNetwork when chainId changes (MultichainNetworkController active network) - useEffect(() => { - if (!selectedNetwork) { - setSelectedNetwork(chainId); - } - }, [chainId, selectedNetwork]); - const networkName = useSelector(selectEvmNetworkName); const goToSecuritySettings = () => { @@ -221,7 +215,10 @@ const AddAsset = () => { ) : ( - + {allTokens && allTokens.length > 0 && ( { + const mockCheckIfNetworkExists = jest.fn(); + const mockCheckIfRpcUrlExists = jest.fn(); + const mockOnValidationChange = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('does not show an error when a valid RPC URL is pasted', async () => { + mockCheckIfNetworkExists.mockResolvedValue([]); + mockCheckIfRpcUrlExists.mockResolvedValue([]); + + const { getByTestId, queryByText } = render( + , + ); + + const input = getByTestId('rpc-url-input'); + const validRpcUrl = 'https://mainnet.infura.io/v3/123'; + + // Simulate user pasting the URL + await userEvent.paste(input, validRpcUrl); + + // Check that the error message is not present + await waitFor(() => + expect( + queryByText(strings('app_settings.invalid_rpc_url')), + ).not.toBeOnTheScreen(), + ); + }); + + it.each([ + { + description: 'is invalid', + rpcUrl: 'invalid-url', + errorMessage: strings('app_settings.invalid_rpc_prefix'), + existingNetworks: [], + existingRPCs: [], + }, + { + description: 'already exists', + rpcUrl: 'https://mainnet.infura.io/v3/123', + errorMessage: strings('app_settings.url_associated_to_another_chain_id'), + existingNetworks: [{ network: 'mainnet' }], + existingRPCs: [], + }, + { + description: 'is a duplicate', + rpcUrl: 'https://mainnet.infura.io/v3/123', + errorMessage: strings('app_settings.invalid_rpc_url'), + existingNetworks: [], + existingRPCs: [{ network: 'mainnet' }], + }, + ])( + 'shows an error when the RPC URL $description', + async ({ rpcUrl, errorMessage, existingNetworks, existingRPCs }) => { + mockCheckIfNetworkExists.mockResolvedValue(existingNetworks); + mockCheckIfRpcUrlExists.mockResolvedValue(existingRPCs); + + const { getByTestId, findByText } = render( + , + ); + + const input = getByTestId('rpc-url-input'); + + // Simulate user pasting the URL + await userEvent.paste(input, rpcUrl); + + // Check that the error message is present + expect(await findByText(errorMessage)).toBeOnTheScreen(); + }, + ); +}); diff --git a/app/components/Views/Settings/NetworksSettings/NetworkSettings/RpcUrlInput.tsx b/app/components/Views/Settings/NetworksSettings/NetworkSettings/RpcUrlInput.tsx new file mode 100644 index 00000000000..f1d4d30ecfe --- /dev/null +++ b/app/components/Views/Settings/NetworksSettings/NetworkSettings/RpcUrlInput.tsx @@ -0,0 +1,122 @@ +import React, { useState, useCallback, RefObject } from 'react'; +import { View, TextInput, TextInputProps, TextStyle } from 'react-native'; +import URLParse from 'url-parse'; +import { isWebUri } from 'valid-url'; +import { strings } from '../../../../../../locales/i18n'; +import { isPrivateConnection } from '../../../../../util/networks'; +import Text from '../../../../../component-library/components/Texts/Text'; +import { NetworksViewSelectorsIDs } from '../../../../../../e2e/selectors/Settings/NetworksView.selectors'; + +type InputProps = Pick< + TextInputProps, + | 'autoCapitalize' + | 'autoCorrect' + | 'editable' + | 'keyboardAppearance' + | 'onBlur' + | 'onFocus' + | 'onChangeText' + | 'onSubmitEditing' + | 'placeholder' + | 'placeholderTextColor' + | 'style' + | 'testID' + | 'value' +> & { + ref?: RefObject; +}; + +interface RpcUrlInputProps extends InputProps { + checkIfNetworkExists: (rpcUrl: string) => Promise; + checkIfRpcUrlExists: (rpcUrl: string) => Promise; + onValidationSuccess?: () => void; + onValidationChange: (isValid: boolean) => void; + warningStyle?: TextStyle; +} + +const RpcUrlInput: React.FC = (props) => { + const { + checkIfNetworkExists, + checkIfRpcUrlExists, + onValidationSuccess, + onValidationChange, + warningStyle, + ...inputProps + } = props; + const { onChangeText } = inputProps ?? {}; + + const [warningRpcUrl, setWarningRpcUrl] = useState( + undefined, + ); + + const validateRpcUrl = useCallback( + async (rpcUrl: string) => { + const isNetworkExists = await checkIfNetworkExists(rpcUrl); + const isRpcExists = await checkIfRpcUrlExists(rpcUrl); + if (!isWebUri(rpcUrl)) { + const appendedRpc = `http://${rpcUrl}`; + if (isWebUri(appendedRpc)) { + setWarningRpcUrl(strings('app_settings.invalid_rpc_prefix')); + } else { + setWarningRpcUrl(strings('app_settings.invalid_rpc_url')); + } + onValidationChange(false); + return false; + } + if (isRpcExists.length > 0) { + setWarningRpcUrl(strings('app_settings.invalid_rpc_url')); + onValidationChange(false); + return; + } + + if (isNetworkExists.length > 0) { + setWarningRpcUrl( + strings('app_settings.url_associated_to_another_chain_id'), + ); + onValidationChange(false); + return; + } + + const url = new URLParse(rpcUrl); + const privateConnection = isPrivateConnection(url.hostname); + if (!privateConnection && url.protocol === 'http:') { + setWarningRpcUrl(strings('app_settings.invalid_rpc_prefix')); + onValidationChange(false); + return false; + } + setWarningRpcUrl(undefined); + if (onValidationSuccess) { + onValidationSuccess(); + } + onValidationChange(true); + return true; + }, + [ + checkIfNetworkExists, + checkIfRpcUrlExists, + onValidationChange, + onValidationSuccess, + ], + ); + + const handleRpcUrlChange = useCallback( + (url: string) => { + onChangeText?.(url); + validateRpcUrl(url); + }, + [onChangeText, validateRpcUrl], + ); + + return ( + <> + + {warningRpcUrl && ( + + {warningRpcUrl} + + )} + + ); +}; + +export default RpcUrlInput; diff --git a/app/components/Views/Settings/NetworksSettings/NetworkSettings/__snapshots__/index.test.tsx.snap b/app/components/Views/Settings/NetworksSettings/NetworkSettings/__snapshots__/index.test.tsx.snap index ba068aa9447..66324fe4431 100644 --- a/app/components/Views/Settings/NetworksSettings/NetworkSettings/__snapshots__/index.test.tsx.snap +++ b/app/components/Views/Settings/NetworksSettings/NetworkSettings/__snapshots__/index.test.tsx.snap @@ -31,38 +31,7 @@ exports[`NetworkSettings should render correctly 1`] = ` `; -exports[`NetworkSettings should render the component correctly when isNetworkUiRedesignEnabled is false 1`] = ` - - - -`; - -exports[`NetworkSettings should render the component correctly when isNetworkUiRedesignEnabled is true 1`] = ` +exports[`NetworkSettings should render the component correctly 1`] = ` { @@ -510,13 +503,6 @@ export class NetworkSettings extends PureComponent { ).filter((item) => item.rpcUrl === rpcUrl); if (checkCustomNetworks.length > 0) { - if (!isNetworkUiRedesignEnabled()) { - this.setState({ - warningRpcUrl: strings('app_settings.network_exists'), - }); - return checkCustomNetworks; - } - return checkCustomNetworks; } const defaultNetworks = getAllNetworks().map((item) => Networks[item]); @@ -637,14 +623,9 @@ export class NetworkSettings extends PureComponent { } // Conditionally check existence of network (Only check in Add Mode) - let isNetworkExists; - if (isNetworkUiRedesignEnabled()) { - isNetworkExists = addMode - ? await this.checkIfNetworkNotExistsByChainId(stateChainId) - : []; - } else { - isNetworkExists = editable ? [] : await this.checkIfNetworkExists(rpcUrl); - } + const isNetworkExists = addMode + ? await this.checkIfNetworkNotExistsByChainId(stateChainId) + : []; const isOnboarded = getIsNetworkOnboarded( stateChainId, @@ -710,58 +691,6 @@ export class NetworkSettings extends PureComponent { * Validates rpc url, setting a warningRpcUrl if is invalid * It also changes validatedRpcURL to true, indicating that was validated */ - validateRpcUrl = async (rpcUrl) => { - const isNetworkExists = await this.checkIfNetworkExists(rpcUrl); - const isRpcExists = await this.checkIfRpcUrlExists(rpcUrl); - - if (!isWebUri(rpcUrl)) { - const appendedRpc = `http://${rpcUrl}`; - if (isWebUri(appendedRpc)) { - this.setState({ - warningRpcUrl: strings('app_settings.invalid_rpc_prefix'), - }); - } else { - this.setState({ - warningRpcUrl: strings('app_settings.invalid_rpc_url'), - }); - } - return false; - } - - if (isRpcExists.length > 0) { - return this.setState({ - warningRpcUrl: strings('app_settings.invalid_rpc_url'), - }); - } - - if (isNetworkExists.length > 0) { - if (isNetworkUiRedesignEnabled()) { - return this.setState({ - validatedRpcURL: false, - warningRpcUrl: strings( - 'app_settings.url_associated_to_another_chain_id', - ), - }); - } - return this.setState({ - validatedRpcURL: true, - warningRpcUrl: strings('app_settings.network_exists'), - }); - } - const url = new URL(rpcUrl); - const privateConnection = isPrivateConnection(url.hostname); - if (!privateConnection && url.protocol === 'http:') { - this.setState({ - warningRpcUrl: strings('app_settings.invalid_rpc_prefix'), - }); - return false; - } - this.setState({ validatedRpcURL: true, warningRpcUrl: undefined }); - - this.validateRpcAndChainId(); - - return true; - }; /** * Validates that chain id is a valid integer number, setting a warningChainId if is invalid @@ -771,12 +700,7 @@ export class NetworkSettings extends PureComponent { const isChainIdExists = await this.checkIfChainIdExists(chainId); const isNetworkExists = await this.checkIfNetworkExists(rpcUrl); - if ( - isChainIdExists && - isNetworkExists.length > 0 && - isNetworkUiRedesignEnabled() && - !editable - ) { + if (isChainIdExists && isNetworkExists.length > 0 && !editable) { return this.setState({ validateChainId: true, warningChainId: strings( @@ -785,12 +709,7 @@ export class NetworkSettings extends PureComponent { }); } - if ( - isChainIdExists && - isNetworkExists.length === 0 && - isNetworkUiRedesignEnabled() && - !editable - ) { + if (isChainIdExists && isNetworkExists.length === 0 && !editable) { return this.setState({ validateChainId: true, warningChainId: strings('app_settings.network_already_exist'), @@ -848,10 +767,7 @@ export class NetworkSettings extends PureComponent { providerError = err; } - if ( - (providerError || typeof endpointChainId !== 'string') && - isNetworkUiRedesignEnabled() - ) { + if (providerError || typeof endpointChainId !== 'string') { return this.setState({ validatedRpcURL: false, warningRpcUrl: strings('app_settings.unMatched_chain'), @@ -859,15 +775,13 @@ export class NetworkSettings extends PureComponent { } if (endpointChainId !== toHex(chainId)) { - if (isNetworkUiRedesignEnabled()) { - return this.setState({ - warningRpcUrl: strings( - 'app_settings.url_associated_to_another_chain_id', - ), - validatedRpcURL: false, - warningChainId: strings('app_settings.unMatched_chain_name'), - }); - } + return this.setState({ + warningRpcUrl: strings( + 'app_settings.url_associated_to_another_chain_id', + ), + validatedRpcURL: false, + warningChainId: strings('app_settings.unMatched_chain_name'), + }); } this.validateRpcAndChainId(); @@ -980,14 +894,10 @@ export class NetworkSettings extends PureComponent { disabledByChainId = () => { const { chainId, validatedChainId, warningChainId } = this.state; - if (isNetworkUiRedesignEnabled()) { - return ( - !chainId || - (chainId && (!validatedChainId || warningChainId !== undefined)) - ); - } - if (!chainId) return true; - return validatedChainId && !!warningChainId; + return ( + !chainId || + (chainId && (!validatedChainId || warningChainId !== undefined)) + ); }; /** @@ -1011,7 +921,6 @@ export class NetworkSettings extends PureComponent { warningSymbol: undefined, warningName: undefined, }); - this.validateRpcUrl(this.state.rpcUrlForm); }; onRpcNameAdd = async (name) => { @@ -1096,6 +1005,10 @@ export class NetworkSettings extends PureComponent { this.getCurrentState(); }; + onRpcUrlValidationChange = (isValid) => { + this.setState({ validatedRpcURL: isValid }); + }; + onRpcUrlChangeWithName = async (url, failoverUrls, name, type) => { const nameToUse = name ?? type; const { addMode } = this.state; @@ -1468,9 +1381,7 @@ export class NetworkSettings extends PureComponent { const renderWarningChainId = () => { const CHAIN_LIST_URL = 'https://chainid.network/'; - const containerStyle = isNetworkUiRedesignEnabled() - ? styles.newWarningContainer - : styles.warningContainer; + const containerStyle = styles.newWarningContainer; if (warningChainId) { if (warningChainId === strings('app_settings.unMatched_chain_name')) { @@ -1568,66 +1479,22 @@ export class NetworkSettings extends PureComponent { }; const renderButtons = () => { - if (isNetworkUiRedesignEnabled()) { - return ( - - -