diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5b712170248b..8278033bab5d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -347,7 +347,6 @@ jobs: check-workflows, js-bundle-size-check, sonar-cloud-quality-gate-status, - e2e-smoke-tests-android, ] outputs: ALL_JOBS_PASSED: ${{ steps.jobs-passed-status.outputs.ALL_JOBS_PASSED }} diff --git a/.storybook/storybook.requires.js b/.storybook/storybook.requires.js index 0f8d893730aa..85db4fe7fd7c 100644 --- a/.storybook/storybook.requires.js +++ b/.storybook/storybook.requires.js @@ -129,6 +129,7 @@ const getStories = () => { "./app/components/Views/AssetDetails/AssetDetailsActions/AssetDetailsActions.stories.tsx": require("../app/components/Views/AssetDetails/AssetDetailsActions/AssetDetailsActions.stories.tsx"), "./app/components/Views/confirmations/components/deposit-keyboard/deposit-keyboard.stories.tsx": require("../app/components/Views/confirmations/components/deposit-keyboard/deposit-keyboard.stories.tsx"), "./app/components/Views/confirmations/components/edit-amount-keyboard/edit-amount-keyboard.stories.tsx": require("../app/components/Views/confirmations/components/edit-amount-keyboard/edit-amount-keyboard.stories.tsx"), + "./app/components/Views/MultichainAccounts/MultichainAccountConnect/MultichainAccountConnect.stories.tsx": require("../app/components/Views/MultichainAccounts/MultichainAccountConnect/MultichainAccountConnect.stories.tsx"), "./app/components/Views/MultichainAccounts/MultichainAccountConnect/MultichainAccountConnectMultiSelector/MultichainAccountConnectMultiSelector.stories.tsx": require("../app/components/Views/MultichainAccounts/MultichainAccountConnect/MultichainAccountConnectMultiSelector/MultichainAccountConnectMultiSelector.stories.tsx"), "./app/components/Views/MultichainAccounts/MultichainAccountsConnectedList/MultichainAccountsConnectedList.stories.tsx": require("../app/components/Views/MultichainAccounts/MultichainAccountsConnectedList/MultichainAccountsConnectedList.stories.tsx"), "./app/components/Views/MultichainAccounts/MultichainPermissionsSummary/MultichainPermissionsSummary.stories.tsx": require("../app/components/Views/MultichainAccounts/MultichainPermissionsSummary/MultichainPermissionsSummary.stories.tsx"), diff --git a/app/components/Nav/App/App.tsx b/app/components/Nav/App/App.tsx index ce72f06a8be6..56c61be77a70 100644 --- a/app/components/Nav/App/App.tsx +++ b/app/components/Nav/App/App.tsx @@ -41,7 +41,6 @@ import { TokenSortBottomSheet } from '../../../components/UI/Tokens/TokensBottom import ProfilerManager from '../../../components/UI/ProfilerManager'; import { TokenFilterBottomSheet } from '../../../components/UI/Tokens/TokensBottomSheet/TokenFilterBottomSheet'; import NetworkManager from '../../../components/UI/NetworkManager'; -import AccountConnect from '../../../components/Views/AccountConnect'; import AccountPermissions from '../../../components/Views/AccountPermissions'; import { AccountPermissionsScreens } from '../../../components/Views/AccountPermissions/AccountPermissions.types'; import AccountPermissionsConfirmRevokeAll from '../../../components/Views/AccountPermissions/AccountPermissionsConfirmRevokeAll'; @@ -152,6 +151,7 @@ import { SmartAccountUpdateModal } from '../../Views/confirmations/components/sm import { PayWithModal } from '../../Views/confirmations/components/modals/pay-with-modal/pay-with-modal'; import { PayWithNetworkModal } from '../../Views/confirmations/components/modals/pay-with-network-modal/pay-with-network-modal'; import { useMetrics } from '../../hooks/useMetrics'; +import { State2AccountConnectWrapper } from '../../Views/MultichainAccounts/MultichainAccountConnect/State2AccountConnectWrapper'; import { SmartAccountModal } from '../../Views/MultichainAccounts/AccountDetails/components/SmartAccountModal/SmartAccountModal'; const clearStackNavigatorOptions = { @@ -408,7 +408,7 @@ const RootModalFlow = (props: RootModalFlowProps) => ( /> { const nativeAsset = getNativeAssetForChainId(chainId); @@ -63,12 +63,7 @@ export const useInitialSourceToken = ( domainIsConnectedDapp, networkName: selectedEvmNetworkName, } = useNetworkInfo(); - const { - onSetRpcTarget, - ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) - onNonEvmNetworkChange, - ///: END:ONLY_INCLUDE_IF - } = useSwitchNetworks({ + const { onSetRpcTarget, onNonEvmNetworkChange } = useSwitchNetworks({ domainIsConnectedDapp, selectedChainId: selectedEvmChainId, selectedNetworkName: selectedEvmNetworkName, @@ -101,14 +96,19 @@ export const useInitialSourceToken = ( } // Change network if necessary - if (initialSourceToken?.chainId && initialSourceToken?.chainId !== chainId) { - ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) - if (initialSourceToken?.chainId === SolScope.Mainnet) { - onNonEvmNetworkChange(initialSourceToken.chainId); - return; - } - ///: END:ONLY_INCLUDE_IF + if (initialSourceToken?.chainId) { + // Convert both chain IDs to CAIP format for accurate comparison + const sourceCaipChainId = formatChainIdToCaip(initialSourceToken.chainId); + const currentCaipChainId = formatChainIdToCaip(chainId); - onSetRpcTarget(evmNetworkConfigurations[initialSourceToken.chainId as Hex]); + if (sourceCaipChainId !== currentCaipChainId) { + if (sourceCaipChainId === SolScope.Mainnet) { + onNonEvmNetworkChange(SolScope.Mainnet); + return; + } + + const hexChainId = formatChainIdToHex(sourceCaipChainId); + onSetRpcTarget(evmNetworkConfigurations[hexChainId]); + } } }; diff --git a/app/components/UI/Bridge/hooks/useSwapBridgeNavigation/index.ts b/app/components/UI/Bridge/hooks/useSwapBridgeNavigation/index.ts index 88a6998f8e23..ec0cfbce2a68 100644 --- a/app/components/UI/Bridge/hooks/useSwapBridgeNavigation/index.ts +++ b/app/components/UI/Bridge/hooks/useSwapBridgeNavigation/index.ts @@ -2,16 +2,18 @@ import { useCallback } from 'react'; import { useNavigation } from '@react-navigation/native'; import useGoToPortfolioBridge from '../useGoToPortfolioBridge'; import Routes from '../../../../../constants/navigation/Routes'; -import { Hex } from '@metamask/utils'; +import { Hex, CaipChainId } from '@metamask/utils'; import Engine from '../../../../../core/Engine'; import { useSelector } from 'react-redux'; import { selectChainId } from '../../../../../selectors/networkController'; import { BridgeToken, BridgeViewMode } from '../../types'; -import { getNativeAssetForChainId } from '@metamask/bridge-controller'; +import { + formatChainIdToHex, + getNativeAssetForChainId, + isSolanaChainId, +} from '@metamask/bridge-controller'; import { BridgeRouteParams } from '../../Views/BridgeView'; -///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) -import { SolScope } from '@metamask/keyring-api'; -///: END:ONLY_INCLUDE_IF +import { SolScope, EthScope } from '@metamask/keyring-api'; import { ethers } from 'ethers'; import { MetaMetricsEvents, useMetrics } from '../../../../hooks/useMetrics'; import { getDecimalChainId } from '../../../../../util/networks'; @@ -25,6 +27,7 @@ import { } from '../../../../../core/redux/slices/bridge'; import { RootState } from '../../../../../reducers'; import { trace, TraceName } from '../../../../../util/trace'; +import { useCurrentNetworkInfo } from '../../../../hooks/useCurrentNetworkInfo'; export enum SwapBridgeNavigationLocation { TabBar = 'TabBar', @@ -55,14 +58,37 @@ export const useSwapBridgeNavigation = ({ selectIsBridgeEnabledSource(state, selectedChainId), ); const isUnifiedSwapsEnabled = useSelector(selectIsUnifiedSwapsEnabled); + const currentNetworkInfo = useCurrentNetworkInfo(); // Bridge const goToNativeBridge = useCallback( (bridgeViewMode: BridgeViewMode) => { + // Determine effective chain ID - use home page filter network when no sourceToken provided + const getEffectiveChainId = (): CaipChainId | Hex => { + if (tokenBase) { + // If specific token provided, use its chainId + return tokenBase.chainId; + } + + // No token provided - check home page filter network + const homePageFilterNetwork = currentNetworkInfo.getNetworkInfo(0); + if ( + !homePageFilterNetwork?.caipChainId || + currentNetworkInfo.enabledNetworks.length > 1 + ) { + // Fall back to mainnet if no filter or multiple networks + return EthScope.Mainnet; + } + + return homePageFilterNetwork.caipChainId as CaipChainId; + }; + + const effectiveChainId = getEffectiveChainId(); + let bridgeSourceNativeAsset; try { if (!tokenBase) { - bridgeSourceNativeAsset = getNativeAssetForChainId(selectedChainId); + bridgeSourceNativeAsset = getNativeAssetForChainId(effectiveChainId); } } catch (error) { // Suppress error as it's expected when the chain is not supported @@ -76,7 +102,9 @@ export const useSwapBridgeNavigation = ({ symbol: bridgeSourceNativeAsset.symbol, image: bridgeSourceNativeAsset.iconUrl ?? '', decimals: bridgeSourceNativeAsset.decimals, - chainId: selectedChainId, + chainId: isSolanaChainId(effectiveChainId) // TODO: refactor for other non-evm chains + ? effectiveChainId + : formatChainIdToHex(effectiveChainId), // Use hex format for balance fetching compatibility, unless it's a Solana chain } : undefined; @@ -118,13 +146,13 @@ export const useSwapBridgeNavigation = ({ }, [ navigation, - selectedChainId, tokenBase, sourcePage, trackEvent, createEventBuilder, location, isBridgeEnabledSource, + currentNetworkInfo, ], ); diff --git a/app/components/UI/Bridge/hooks/useSwapBridgeNavigation/useSwapBridgeNavigation.test.ts b/app/components/UI/Bridge/hooks/useSwapBridgeNavigation/useSwapBridgeNavigation.test.ts index 375bcde7e05f..a4a88f68dc70 100644 --- a/app/components/UI/Bridge/hooks/useSwapBridgeNavigation/useSwapBridgeNavigation.test.ts +++ b/app/components/UI/Bridge/hooks/useSwapBridgeNavigation/useSwapBridgeNavigation.test.ts @@ -4,7 +4,7 @@ import { SwapBridgeNavigationLocation, useSwapBridgeNavigation } from '.'; import { waitFor } from '@testing-library/react-native'; import { BridgeToken, BridgeViewMode } from '../../types'; import { Hex } from '@metamask/utils'; -import { SolScope } from '@metamask/keyring-api'; +import { EthScope, SolScope } from '@metamask/keyring-api'; import Engine from '../../../../../core/Engine'; import Routes from '../../../../../constants/navigation/Routes'; import { selectChainId } from '../../../../../selectors/networkController'; @@ -64,6 +64,24 @@ jest.mock('../../../../../core/Engine', () => ({ }, })); +// Mock useCurrentNetworkInfo hook +const mockUseCurrentNetworkInfo = jest.fn(); +jest.mock('../../../../hooks/useCurrentNetworkInfo', () => ({ + useCurrentNetworkInfo: () => mockUseCurrentNetworkInfo(), +})); + +// Mock bridge controller functions +import { + getNativeAssetForChainId, + isSolanaChainId, +} from '@metamask/bridge-controller'; + +jest.mock('@metamask/bridge-controller', () => ({ + ...jest.requireActual('@metamask/bridge-controller'), + getNativeAssetForChainId: jest.fn(), + isSolanaChainId: jest.fn(), +})); + describe('useSwapBridgeNavigation', () => { const mockChainId = '0x1' as Hex; const mockLocation = SwapBridgeNavigationLocation.TabBar; @@ -79,6 +97,28 @@ describe('useSwapBridgeNavigation', () => { beforeEach(() => { jest.clearAllMocks(); + + // Setup default mocks for Ethereum + mockUseCurrentNetworkInfo.mockReturnValue({ + enabledNetworks: [{ chainId: '1', enabled: true }], + getNetworkInfo: jest.fn().mockReturnValue({ + caipChainId: EthScope.Mainnet, + networkName: 'Ethereum Mainnet', + }), + isDisabled: false, + hasEnabledNetworks: true, + }); + + (isSolanaChainId as jest.Mock).mockReturnValue(false); + (getNativeAssetForChainId as jest.Mock).mockReturnValue({ + address: '0x0000000000000000000000000000000000000000', + name: 'Ether', + symbol: 'ETH', + decimals: 18, + }); + + // Reset selectChainId mock to default + (selectChainId as unknown as jest.Mock).mockReturnValue(mockChainId); }); it('uses native token when no token is provided', () => { @@ -266,6 +306,96 @@ describe('useSwapBridgeNavigation', () => { }); }); + it('uses home page filter network when no token is provided', () => { + // Mock home page filter network as Polygon + mockUseCurrentNetworkInfo.mockReturnValue({ + enabledNetworks: [{ chainId: '137', enabled: true }], + getNetworkInfo: jest.fn().mockReturnValue({ + caipChainId: 'eip155:137', + networkName: 'Polygon Mainnet', + }), + isDisabled: false, + hasEnabledNetworks: true, + }); + + (getNativeAssetForChainId as jest.Mock).mockReturnValue({ + address: '0x0000000000000000000000000000000000000000', + name: 'Polygon', + symbol: 'MATIC', + decimals: 18, + }); + + const { result } = renderHookWithProvider( + () => + useSwapBridgeNavigation({ + location: mockLocation, + sourcePage: mockSourcePage, + }), + { state: initialState }, + ); + + result.current.goToBridge(); + + expect(mockNavigate).toHaveBeenCalledWith('Bridge', { + screen: 'BridgeView', + params: { + sourceToken: { + address: '0x0000000000000000000000000000000000000000', + name: 'Polygon', + symbol: 'MATIC', + image: '', + decimals: 18, + chainId: '0x89', // Should be converted to hex format + }, + sourcePage: mockSourcePage, + bridgeViewMode: BridgeViewMode.Bridge, + }, + }); + }); + + it('falls back to Ethereum mainnet when multiple networks enabled', () => { + // Mock multiple networks enabled + mockUseCurrentNetworkInfo.mockReturnValue({ + enabledNetworks: [ + { chainId: '1', enabled: true }, + { chainId: '137', enabled: true }, + ], + getNetworkInfo: jest.fn().mockReturnValue({ + caipChainId: 'eip155:1', + networkName: 'Ethereum Mainnet', + }), + isDisabled: false, + hasEnabledNetworks: true, + }); + + const { result } = renderHookWithProvider( + () => + useSwapBridgeNavigation({ + location: mockLocation, + sourcePage: mockSourcePage, + }), + { state: initialState }, + ); + + result.current.goToBridge(); + + expect(mockNavigate).toHaveBeenCalledWith('Bridge', { + screen: 'BridgeView', + params: { + sourceToken: { + address: '0x0000000000000000000000000000000000000000', + name: 'Ether', + symbol: 'ETH', + image: '', + decimals: 18, + chainId: '0x1', // Should use mainnet fallback + }, + sourcePage: mockSourcePage, + bridgeViewMode: BridgeViewMode.Bridge, + }, + }); + }); + describe('Unified', () => { it('navigates to Bridge when goToSwaps is called and unified swaps is enabled', () => { (selectIsUnifiedSwapsEnabled as unknown as jest.Mock).mockReturnValueOnce( @@ -295,11 +425,78 @@ describe('useSwapBridgeNavigation', () => { }); describe('Solana', () => { + it('keeps Solana chain ID in CAIP format for Bridge', () => { + // Mock home page filter network as Solana + mockUseCurrentNetworkInfo.mockReturnValue({ + enabledNetworks: [{ chainId: SolScope.Mainnet, enabled: true }], + getNetworkInfo: jest.fn().mockReturnValue({ + caipChainId: SolScope.Mainnet, + networkName: 'Solana Mainnet', + }), + isDisabled: false, + hasEnabledNetworks: true, + }); + + (isSolanaChainId as jest.Mock).mockReturnValue(true); + (getNativeAssetForChainId as jest.Mock).mockReturnValue({ + address: ethers.constants.AddressZero, + name: 'Solana', + symbol: 'SOL', + decimals: 9, + }); + + const { result } = renderHookWithProvider( + () => + useSwapBridgeNavigation({ + location: mockLocation, + sourcePage: mockSourcePage, + }), + { state: initialState }, + ); + + result.current.goToBridge(); + + expect(mockNavigate).toHaveBeenCalledWith('Bridge', { + screen: 'BridgeView', + params: { + sourceToken: { + address: ethers.constants.AddressZero, + name: 'Solana', + symbol: 'SOL', + image: '', + decimals: 9, + chainId: SolScope.Mainnet, // Should keep CAIP format for Solana + }, + sourcePage: mockSourcePage, + bridgeViewMode: BridgeViewMode.Bridge, + }, + }); + }); + it('navigates to Bridge when goToSwaps is called and token chainId is Solana', () => { (selectChainId as unknown as jest.Mock).mockReturnValueOnce( SolScope.Mainnet, ); + // Mock home page filter network as Solana + mockUseCurrentNetworkInfo.mockReturnValue({ + enabledNetworks: [{ chainId: SolScope.Mainnet, enabled: true }], + getNetworkInfo: jest.fn().mockReturnValue({ + caipChainId: SolScope.Mainnet, + networkName: 'Solana Mainnet', + }), + isDisabled: false, + hasEnabledNetworks: true, + }); + + (isSolanaChainId as jest.Mock).mockReturnValue(true); + (getNativeAssetForChainId as jest.Mock).mockReturnValue({ + address: ethers.constants.AddressZero, + name: 'Solana', + symbol: 'SOL', + decimals: 9, + }); + const { result } = renderHookWithProvider( () => useSwapBridgeNavigation({ @@ -333,6 +530,25 @@ describe('useSwapBridgeNavigation', () => { SolScope.Mainnet, ); + // Mock home page filter network as Solana + mockUseCurrentNetworkInfo.mockReturnValue({ + enabledNetworks: [{ chainId: SolScope.Mainnet, enabled: true }], + getNetworkInfo: jest.fn().mockReturnValue({ + caipChainId: SolScope.Mainnet, + networkName: 'Solana Mainnet', + }), + isDisabled: false, + hasEnabledNetworks: true, + }); + + (isSolanaChainId as jest.Mock).mockReturnValue(true); + (getNativeAssetForChainId as jest.Mock).mockReturnValue({ + address: ethers.constants.AddressZero, + name: 'Solana', + symbol: 'SOL', + decimals: 9, + }); + const { result } = renderHookWithProvider( () => useSwapBridgeNavigation({ diff --git a/app/components/UI/Bridge/utils/tokenUtils.test.ts b/app/components/UI/Bridge/utils/tokenUtils.test.ts index febf48c3c315..8be47603ed1a 100644 --- a/app/components/UI/Bridge/utils/tokenUtils.test.ts +++ b/app/components/UI/Bridge/utils/tokenUtils.test.ts @@ -1,9 +1,11 @@ import { constants } from 'ethers'; -import { getNativeSourceToken } from './tokenUtils'; +import { getNativeSourceToken, getDefaultDestToken } from './tokenUtils'; import { getNativeAssetForChainId, isSolanaChainId, } from '@metamask/bridge-controller'; +import { CHAIN_IDS } from '@metamask/transaction-controller'; +import { DefaultSwapDestTokens } from '../constants/default-swap-dest-tokens'; // Mock dependencies jest.mock('@metamask/utils', () => ({ @@ -132,4 +134,120 @@ describe('tokenUtils', () => { }); }); }); + + describe('getDefaultDestToken', () => { + it('returns token for direct hex chainId lookup', () => { + const result = getDefaultDestToken(CHAIN_IDS.MAINNET); + + expect(result).toEqual(DefaultSwapDestTokens[CHAIN_IDS.MAINNET]); + expect(result?.chainId).toBe(CHAIN_IDS.MAINNET); + }); + + it('returns token for another valid hex chainId', () => { + const result = getDefaultDestToken(CHAIN_IDS.OPTIMISM); + + expect(result).toEqual(DefaultSwapDestTokens[CHAIN_IDS.OPTIMISM]); + expect(result?.chainId).toBe(CHAIN_IDS.OPTIMISM); + }); + + it('returns undefined for hex chainId not in mapping', () => { + const nonExistentChainId = '0x999999' as const; + const result = getDefaultDestToken(nonExistentChainId); + + expect(result).toBeUndefined(); + }); + + it('converts CAIP format to hex and returns matching token', () => { + // eip155:1 should convert to 0x1 (MAINNET) + const caipChainId = 'eip155:1'; + const result = getDefaultDestToken(caipChainId); + + expect(result).toBeDefined(); + expect(result?.symbol).toBe('USDC'); + expect(result?.name).toBe('USD Coin'); + expect(result?.chainId).toBe(caipChainId); // Should return with original CAIP format + }); + + it('converts CAIP format for Optimism and returns matching token', () => { + // eip155:10 should convert to 0xa (OPTIMISM) + const caipChainId = 'eip155:10'; + const result = getDefaultDestToken(caipChainId); + + expect(result).toBeDefined(); + expect(result?.symbol).toBe('USDC'); + expect(result?.chainId).toBe(caipChainId); // Should return with original CAIP format + }); + + it('converts CAIP format for BSC and returns matching token', () => { + // eip155:56 should convert to 0x38 (BSC) + const caipChainId = 'eip155:56'; + const result = getDefaultDestToken(caipChainId); + + expect(result).toBeDefined(); + expect(result?.symbol).toBe('USDT'); + expect(result?.chainId).toBe(caipChainId); // Should return with original CAIP format + }); + + it('returns undefined for CAIP format with no corresponding hex mapping', () => { + // eip155:999999 should convert to 0xf423f but this won't exist in mapping + const caipChainId = 'eip155:999999'; + const result = getDefaultDestToken(caipChainId); + + expect(result).toBeUndefined(); + }); + + it('returns undefined for malformed CAIP format', () => { + const malformedCaip = 'invalid:format:extra'; + const result = getDefaultDestToken(malformedCaip); + + expect(result).toBeUndefined(); + }); + + it('returns undefined for CAIP format with non-numeric chain ID', () => { + const invalidCaip = 'eip155:abc'; + const result = getDefaultDestToken(invalidCaip); + + expect(result).toBeUndefined(); + }); + + it('returns undefined for empty string', () => { + // @ts-expect-error - Testing edge case with invalid input + const result = getDefaultDestToken(''); + + expect(result).toBeUndefined(); + }); + + it('returns undefined for string without colon (not CAIP format)', () => { + const nonCaipString = 'notacaipformat'; + // @ts-expect-error - Testing edge case with invalid input + const result = getDefaultDestToken(nonCaipString); + + expect(result).toBeUndefined(); + }); + + it('handles edge case of CAIP format with zero chain ID', () => { + // eip155:0 should convert to 0x0 + const caipChainId = 'eip155:0'; + const result = getDefaultDestToken(caipChainId); + + // This should return undefined since 0x0 is not in our mapping + expect(result).toBeUndefined(); + }); + + it('preserves token properties when converting CAIP to hex', () => { + const caipChainId = 'eip155:1'; + const result = getDefaultDestToken(caipChainId); + const originalToken = DefaultSwapDestTokens[CHAIN_IDS.MAINNET]; + + expect(result).toBeDefined(); + expect(result?.address).toBe(originalToken.address); + expect(result?.symbol).toBe(originalToken.symbol); + expect(result?.name).toBe(originalToken.name); + expect(result?.decimals).toBe(originalToken.decimals); + expect(result?.image).toBe(originalToken.image); + // Only chainId should be different (CAIP format instead of hex) + expect(result?.chainId).toBe(caipChainId); + expect(result?.chainId).not.toBe(originalToken.chainId); + }); + }); }); diff --git a/app/components/UI/Bridge/utils/tokenUtils.ts b/app/components/UI/Bridge/utils/tokenUtils.ts index d33b78e3a9cd..98a637f6e638 100644 --- a/app/components/UI/Bridge/utils/tokenUtils.ts +++ b/app/components/UI/Bridge/utils/tokenUtils.ts @@ -4,6 +4,7 @@ import { isSolanaChainId, } from '@metamask/bridge-controller'; import { BridgeToken } from '../types'; +import { DefaultSwapDestTokens } from '../constants/default-swap-dest-tokens'; /** * Creates a formatted native token object for the given chain ID @@ -28,3 +29,27 @@ export const getNativeSourceToken = ( chainId, }; }; + +/** + * Helper function to get default destination token, handling both hex and CAIP format chain IDs + */ +export const getDefaultDestToken = ( + chainId: Hex | CaipChainId, +): BridgeToken | undefined => { + // Try direct lookup first + let token = DefaultSwapDestTokens[chainId]; + if (token) return token; + + // If chainId is CAIP format (e.g., "eip155:1"), convert to hex and try again + if (typeof chainId === 'string' && chainId.includes(':')) { + const chainIdFromCaip = chainId.split(':')[1]; + const hexChainId = `0x${parseInt(chainIdFromCaip, 10).toString(16)}` as Hex; + token = DefaultSwapDestTokens[hexChainId]; + if (token) { + // Return token with CAIP chainId to match the request format + return { ...token, chainId }; + } + } + + return undefined; +}; diff --git a/app/components/UI/Navbar/index.js b/app/components/UI/Navbar/index.js index 2cde25de52b2..1d0851fb3d21 100644 --- a/app/components/UI/Navbar/index.js +++ b/app/components/UI/Navbar/index.js @@ -972,6 +972,9 @@ export function getWalletNavbarOptions( left: 12, right: 12, }, + accountPickerStyle: { + marginRight: 16, + }, }); const onScanSuccess = (data, content) => { @@ -1081,11 +1084,7 @@ export function getWalletNavbarOptions( header: () => ( ), diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx index ddcbf8a0c603..a3a594e1fc0c 100644 --- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx @@ -57,6 +57,7 @@ import { } from '../../hooks'; import { usePerpsLiveOrders } from '../../hooks/stream'; import PerpsMarketTabs from '../../components/PerpsMarketTabs/PerpsMarketTabs'; +import type { PerpsTabId } from '../../components/PerpsMarketTabs/PerpsMarketTabs.types'; import PerpsNotificationTooltip from '../../components/PerpsNotificationTooltip'; import { isNotificationsFeatureEnabled } from '../../../../../util/notifications'; import { PERPS_NOTIFICATIONS_FEATURE_ENABLED } from '../../constants/perpsConfig'; @@ -75,6 +76,7 @@ import ButtonSemantic, { interface MarketDetailsRouteParams { market: PerpsMarketData; + initialTab?: PerpsTabId; isNavigationFromOrderSuccess?: boolean; } @@ -82,7 +84,8 @@ const PerpsMarketDetailsView: React.FC = () => { const navigation = useNavigation>(); const route = useRoute>(); - const { market, isNavigationFromOrderSuccess } = route.params || {}; + const { market, initialTab, isNavigationFromOrderSuccess } = + route.params || {}; const { track } = usePerpsEventTracking(); const [isEligibilityModalVisible, setIsEligibilityModalVisible] = @@ -388,6 +391,7 @@ const PerpsMarketDetailsView: React.FC = () => { position={existingPosition} isLoadingPosition={isLoadingPosition} unfilledOrders={openOrders} + initialTab={initialTab} nextFundingTime={market?.nextFundingTime} fundingIntervalHours={market?.fundingIntervalHours} /> diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx index f5e93f7067e7..e368ed6eb98d 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx @@ -1,5 +1,6 @@ import { useNavigation, useRoute } from '@react-navigation/native'; import { + act, fireEvent, render, screen, @@ -48,11 +49,17 @@ import { usePerpsMarketData, usePerpsNetwork, usePerpsOrderExecution, + usePerpsOrderForm, usePerpsOrderValidation, usePerpsPrices, usePerpsTrading, + usePerpsToasts, } from '../../hooks'; -import { PerpsStreamProvider } from '../../providers/PerpsStreamManager'; +import { + PerpsStreamManager, + PerpsStreamProvider, +} from '../../providers/PerpsStreamManager'; +import { usePerpsOrderContext } from '../../contexts/PerpsOrderContext'; import PerpsOrderView from './PerpsOrderView'; // Mock dependencies @@ -61,6 +68,15 @@ jest.mock('@react-navigation/native', () => ({ useRoute: jest.fn(), })); +// Mock the context +jest.mock('../../contexts/PerpsOrderContext', () => { + const actual = jest.requireActual('../../contexts/PerpsOrderContext'); + return { + ...actual, + usePerpsOrderContext: jest.fn(), + }; +}); + // Mock the hooks module - these will be overridden in beforeEach jest.mock('../../hooks', () => ({ usePerpsAccount: jest.fn(), @@ -95,13 +111,23 @@ jest.mock('../../hooks', () => ({ amount: '11', leverage: 3, direction: 'long', - orderType: 'market', + type: 'market', limitPrice: undefined, takeProfitPrice: undefined, stopLossPrice: undefined, }, - updateOrderForm: jest.fn(), - resetOrderForm: jest.fn(), + setAmount: jest.fn(), + setLeverage: jest.fn(), + setTakeProfitPrice: jest.fn(), + setStopLossPrice: jest.fn(), + setLimitPrice: jest.fn(), + setOrderType: jest.fn(), + handlePercentageAmount: jest.fn(), + handleMaxAmount: jest.fn(), + calculations: { + marginRequired: 11, + positionSize: 0.0037, + }, })), usePerpsOrderValidation: jest.fn(() => ({ isValid: true, @@ -155,6 +181,36 @@ jest.mock('../../hooks', () => ({ measure: jest.fn(), measureAsync: jest.fn(), })), + usePerpsToasts: jest.fn(() => ({ + showToast: jest.fn(), + PerpsToastOptions: { + formValidation: { + orderForm: { + limitPriceRequired: {}, + validationError: jest.fn(), + }, + }, + orderManagement: { + market: { + submitted: jest.fn(), + confirmed: jest.fn(), + creationFailed: jest.fn(), + }, + limit: { + submitted: jest.fn(), + confirmed: jest.fn(), + creationFailed: jest.fn(), + }, + }, + dataFetching: { + market: { + error: { + marketDataUnavailable: jest.fn(), + }, + }, + }, + }, + })), })); // Mock Redux selectors @@ -388,9 +444,64 @@ const defaultMockHooks = { ], }; +// Mock stream manager for tests +const createMockStreamManager = () => { + // Using Map to track subscribers for potential cleanup + const subscribers = new Map void>(); + + return { + prices: { + subscribeToSymbols: ({ + symbols, + callback, + }: { + symbols: string[]; + callback: (data: unknown) => void; + }) => { + const id = Math.random().toString(); + subscribers.set(id, callback); + // Immediately provide mock price data + const mockPrices: Record = {}; + symbols.forEach((symbol: string) => { + mockPrices[symbol] = { + price: '3000', + percentChange24h: '2.5', + }; + }); + callback(mockPrices); + return () => { + subscribers.delete(id); + }; + }, + }, + orders: { + subscribe: jest.fn(() => jest.fn()), + }, + positions: { + subscribe: jest.fn(() => jest.fn()), + }, + fills: { + subscribe: jest.fn(() => jest.fn()), + }, + account: { + subscribe: jest.fn(() => jest.fn()), + }, + marketData: { + subscribe: jest.fn(() => jest.fn()), + getMarkets: jest.fn(), + }, + }; +}; + // Wrapper component for tests const TestWrapper = ({ children }: { children: React.ReactNode }) => ( - {children} + + {children} + ); describe('PerpsOrderView', () => { @@ -436,6 +547,34 @@ describe('PerpsOrderView', () => { isCalculating: false, error: null, }); + + // Mock the context with default values + (usePerpsOrderContext as jest.Mock).mockReturnValue({ + orderForm: { + asset: 'ETH', + amount: '11', + leverage: 3, + direction: 'long', + type: 'market', + limitPrice: undefined, + takeProfitPrice: undefined, + stopLossPrice: undefined, + balancePercent: 10, + }, + setAmount: jest.fn(), + setLeverage: jest.fn(), + setTakeProfitPrice: jest.fn(), + setStopLossPrice: jest.fn(), + setLimitPrice: jest.fn(), + setOrderType: jest.fn(), + handlePercentageAmount: jest.fn(), + handleMaxAmount: jest.fn(), + handleMinAmount: jest.fn(), + calculations: { + marginRequired: '11', + positionSize: '0.0037', + }, + }); }); it('renders the order view', async () => { @@ -503,6 +642,34 @@ describe('PerpsOrderView', () => { }, }); + // Override the context mock to show the correct leverage + (usePerpsOrderContext as jest.Mock).mockReturnValue({ + orderForm: { + asset: 'ETH', + amount: '11', + leverage: 10, + direction: 'long', + type: 'market', + limitPrice: undefined, + takeProfitPrice: undefined, + stopLossPrice: undefined, + balancePercent: 10, + }, + setAmount: jest.fn(), + setLeverage: jest.fn(), + setTakeProfitPrice: jest.fn(), + setStopLossPrice: jest.fn(), + setLimitPrice: jest.fn(), + setOrderType: jest.fn(), + handlePercentageAmount: jest.fn(), + handleMaxAmount: jest.fn(), + handleMinAmount: jest.fn(), + calculations: { + marginRequired: '11', + positionSize: '0.0037', + }, + }); + render(, { wrapper: TestWrapper }); // Find leverage text @@ -678,6 +845,34 @@ describe('PerpsOrderView', () => { }, }); + // Override the default mock for this specific test + (usePerpsOrderContext as jest.Mock).mockReturnValue({ + orderForm: { + asset: 'BTC', + amount: '11', + leverage: 3, + direction: 'short', + type: 'market', + limitPrice: undefined, + takeProfitPrice: undefined, + stopLossPrice: undefined, + balancePercent: 10, + }, + setAmount: jest.fn(), + setLeverage: jest.fn(), + setTakeProfitPrice: jest.fn(), + setStopLossPrice: jest.fn(), + setLimitPrice: jest.fn(), + setOrderType: jest.fn(), + handlePercentageAmount: jest.fn(), + handleMaxAmount: jest.fn(), + handleMinAmount: jest.fn(), + calculations: { + marginRequired: '11', + positionSize: '0.0037', + }, + }); + render(, { wrapper: TestWrapper }); // Find all elements with 'Short BTC' text (header and button) @@ -694,6 +889,34 @@ describe('PerpsOrderView', () => { }, }); + // Override the context mock to show the correct leverage + (usePerpsOrderContext as jest.Mock).mockReturnValue({ + orderForm: { + asset: 'SOL', + amount: '11', + leverage: 10, + direction: 'long', + type: 'market', + limitPrice: undefined, + takeProfitPrice: undefined, + stopLossPrice: undefined, + balancePercent: 10, + }, + setAmount: jest.fn(), + setLeverage: jest.fn(), + setTakeProfitPrice: jest.fn(), + setStopLossPrice: jest.fn(), + setLimitPrice: jest.fn(), + setOrderType: jest.fn(), + handlePercentageAmount: jest.fn(), + handleMaxAmount: jest.fn(), + handleMinAmount: jest.fn(), + calculations: { + marginRequired: '11', + positionSize: '0.0037', + }, + }); + render(, { wrapper: TestWrapper }); await waitFor(() => { @@ -969,4 +1192,220 @@ describe('PerpsOrderView', () => { expect(placeOrderButton.props.accessibilityState?.disabled).toBeFalsy(); }); }); + + describe('TP/SL limit price validation', () => { + it('shows toast and prevents TP/SL bottom sheet from opening on limit order without limit price', async () => { + // Clear all mocks to ensure clean state + jest.clearAllMocks(); + + // Create a mock showToast function that we can spy on + const mockShowToast = jest.fn(); + const mockLimitPriceRequiredToast = + 'mock-limit-price-required-toast-object'; + + // Mock the context to provide order form data + (usePerpsOrderContext as jest.Mock).mockImplementation(() => ({ + orderForm: { + asset: 'ETH', + amount: '100', + leverage: 3, + direction: 'long', + type: 'limit', // This is a limit order + limitPrice: undefined, // But no limit price is set + takeProfitPrice: undefined, + stopLossPrice: undefined, + balancePercent: 10, + }, + setAmount: jest.fn(), + setLeverage: jest.fn(), + setTakeProfitPrice: jest.fn(), + setStopLossPrice: jest.fn(), + setLimitPrice: jest.fn(), + setOrderType: jest.fn(), + handlePercentageAmount: jest.fn(), + handleMaxAmount: jest.fn(), + handleMinAmount: jest.fn(), + calculations: { + marginRequired: '33.33', + positionSize: '0.0333', + }, + })); + + // Mock usePerpsOrderForm to match the context + (usePerpsOrderForm as jest.Mock).mockImplementation(() => ({ + orderForm: { + asset: 'ETH', + amount: '100', + leverage: 3, + direction: 'long', + type: 'limit', + limitPrice: undefined, + takeProfitPrice: undefined, + stopLossPrice: undefined, + balancePercent: 10, + }, + setAmount: jest.fn(), + setLeverage: jest.fn(), + setTakeProfitPrice: jest.fn(), + setStopLossPrice: jest.fn(), + setLimitPrice: jest.fn(), + setOrderType: jest.fn(), + handlePercentageAmount: jest.fn(), + handleMaxAmount: jest.fn(), + handleMinAmount: jest.fn(), + calculations: { + marginRequired: '33.33', + positionSize: '0.0333', + }, + })); + + // Mock the usePerpsToasts hook + (usePerpsToasts as jest.Mock).mockImplementation(() => ({ + showToast: mockShowToast, + PerpsToastOptions: { + formValidation: { + orderForm: { + limitPriceRequired: mockLimitPriceRequiredToast, + validationError: jest.fn(), + }, + }, + orderManagement: { + market: { + submitted: jest.fn(), + confirmed: jest.fn(), + creationFailed: jest.fn(), + }, + limit: { + submitted: jest.fn(), + confirmed: jest.fn(), + creationFailed: jest.fn(), + }, + }, + dataFetching: { + market: { + error: { + marketDataUnavailable: jest.fn(), + }, + }, + }, + }, + })); + + // Set up route params + (useRoute as jest.Mock).mockReturnValue({ + params: { + asset: 'ETH', + direction: 'long', + }, + }); + + render(, { wrapper: TestWrapper }); + + // Wait for the TP/SL button to be rendered + const tpSlButton = await screen.findByTestId( + PerpsOrderViewSelectorsIDs.STOP_LOSS_BUTTON, + ); + + // Press the TP/SL button + fireEvent.press(tpSlButton); + + // Give the event handler time to execute + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + + // Verify that showToast was called with the correct argument + expect(mockShowToast).toHaveBeenCalledTimes(1); + expect(mockShowToast).toHaveBeenCalledWith(mockLimitPriceRequiredToast); + + // Verify that the TP/SL bottom sheet was NOT opened + expect(screen.queryByTestId('tpsl-bottom-sheet')).toBeNull(); + }); + + it('opens TP/SL bottom sheet on limit order with limit price', async () => { + // Set up route params + (useRoute as jest.Mock).mockReturnValue({ + params: { + asset: 'ETH', + direction: 'long', + }, + }); + + // Override the context mock for a limit order WITH price + (usePerpsOrderContext as jest.Mock).mockReturnValue({ + orderForm: { + asset: 'ETH', + amount: '100', + leverage: 3, + direction: 'long', + type: 'limit', + limitPrice: '3100', // Has a limit price + takeProfitPrice: undefined, + stopLossPrice: undefined, + balancePercent: 10, + }, + setAmount: jest.fn(), + setLeverage: jest.fn(), + setTakeProfitPrice: jest.fn(), + setStopLossPrice: jest.fn(), + setLimitPrice: jest.fn(), + setOrderType: jest.fn(), + handlePercentageAmount: jest.fn(), + handleMaxAmount: jest.fn(), + handleMinAmount: jest.fn(), + calculations: { + marginRequired: '33.33', + positionSize: '0.0333', + }, + }); + + // Also update the order form mock to match + (usePerpsOrderForm as jest.Mock).mockImplementation(() => ({ + orderForm: { + asset: 'ETH', + amount: '100', + leverage: 3, + direction: 'long', + type: 'limit', + limitPrice: '3100', + takeProfitPrice: undefined, + stopLossPrice: undefined, + balancePercent: 10, + }, + setAmount: jest.fn(), + setLeverage: jest.fn(), + setTakeProfitPrice: jest.fn(), + setStopLossPrice: jest.fn(), + setLimitPrice: jest.fn(), + setOrderType: jest.fn(), + handlePercentageAmount: jest.fn(), + handleMaxAmount: jest.fn(), + handleMinAmount: jest.fn(), + calculations: { + marginRequired: '33.33', + positionSize: '0.0333', + }, + })); + + render(, { wrapper: TestWrapper }); + + // Wait for component to be ready + await waitFor(() => { + expect( + screen.getByTestId(PerpsOrderViewSelectorsIDs.STOP_LOSS_BUTTON), + ).toBeDefined(); + }); + + // Find and press the TP/SL button + const tpSlButton = screen.getByTestId( + PerpsOrderViewSelectorsIDs.STOP_LOSS_BUTTON, + ); + fireEvent.press(tpSlButton); + + // Verify that TP/SL bottom sheet IS shown + await waitFor(() => { + expect(screen.getByTestId('tpsl-bottom-sheet')).toBeDefined(); + }); + }); + }); }); diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx index 5ed9d67aeb93..1f6e594ea2c1 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx @@ -77,6 +77,7 @@ import { usePerpsOrderFees, usePerpsOrderValidation, usePerpsPerformance, + usePerpsToasts, } from '../../hooks'; import { usePerpsLivePrices } from '../../hooks/stream'; import { usePerpsEventTracking } from '../../hooks/usePerpsEventTracking'; @@ -85,7 +86,6 @@ import { formatPrice } from '../../utils/formatUtils'; import { calculatePositionSize } from '../../utils/orderCalculations'; import { calculateRoEForPrice } from '../../utils/tpslValidation'; import createStyles from './PerpsOrderView.styles'; -import usePerpsToasts from '../../hooks/usePerpsToasts'; // Navigation params interface interface OrderRouteParams { @@ -532,6 +532,21 @@ const PerpsOrderViewContentBase: React.FC = () => { ]); // Handlers + const handleTPSLPress = useCallback(() => { + if (orderForm.type === 'limit' && !orderForm.limitPrice) { + // We need to set a limit price for limit orders before TP/SL can be set + showToast(PerpsToastOptions.formValidation.orderForm.limitPriceRequired); + return; + } + + setIsTPSLVisible(true); + }, [ + PerpsToastOptions.formValidation.orderForm.limitPriceRequired, + orderForm.limitPrice, + orderForm.type, + showToast, + ]); + const handleAmountPress = () => { setIsInputFocused(true); }; @@ -818,7 +833,7 @@ const PerpsOrderViewContentBase: React.FC = () => { {/* Combined TP/SL row */} setIsTPSLVisible(true)} + onPress={handleTPSLPress} testID={PerpsOrderViewSelectorsIDs.STOP_LOSS_BUTTON} > @@ -1018,8 +1033,6 @@ const PerpsOrderViewContentBase: React.FC = () => { setTakeProfitPrice(takeProfitPrice); setStopLossPrice(stopLossPrice); setIsTPSLVisible(false); - - // TP/SL set events are tracked in the bottom sheet component }} asset={orderForm.asset} currentPrice={assetData.price} diff --git a/app/components/UI/Perps/components/PerpsCard/PerpsCard.test.tsx b/app/components/UI/Perps/components/PerpsCard/PerpsCard.test.tsx new file mode 100644 index 000000000000..2e2966385204 --- /dev/null +++ b/app/components/UI/Perps/components/PerpsCard/PerpsCard.test.tsx @@ -0,0 +1,293 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import PerpsCard from './PerpsCard'; +import Routes from '../../../../../constants/navigation/Routes'; +import { usePerpsMarkets } from '../../hooks/usePerpsMarkets'; +import { + defaultPerpsPositionMock, + defaultPerpsOrderMock, +} from '../../__mocks__/perpsHooksMocks'; + +const mockNavigate = jest.fn(); + +jest.mock('@react-navigation/native', () => { + const actualReactNavigation = jest.requireActual('@react-navigation/native'); + return { + ...actualReactNavigation, + useNavigation: () => ({ + navigate: mockNavigate, + }), + }; +}); + +// Mock dependencies +jest.mock('../../../../../component-library/hooks', () => ({ + useStyles: () => ({ + styles: { + card: {}, + cardContent: {}, + cardLeft: {}, + assetIcon: {}, + cardInfo: {}, + cardRight: {}, + }, + }), +})); + +jest.mock('../../../../../../locales/i18n', () => ({ + strings: (key: string) => key, +})); + +jest.mock('../../hooks/usePerpsMarkets', () => ({ + usePerpsMarkets: jest.fn(), +})); + +jest.mock('../PerpsTokenLogo', () => 'PerpsTokenLogo'); + +describe('PerpsCard', () => { + const mockPosition = { ...defaultPerpsPositionMock }; + const mockOrder = { ...defaultPerpsOrderMock }; + const mockUsePerpsMarkets = jest.mocked(usePerpsMarkets); + + beforeEach(() => { + jest.clearAllMocks(); + // Set up default mock return value + mockUsePerpsMarkets.mockReturnValue({ + markets: [ + { + symbol: 'ETH', + name: 'Ethereum', + maxLeverage: '50', + price: '$3,000.00', + change24h: '+$150.00', + change24hPercent: '+5.0%', + volume: '$1.2B', + }, + ], + isLoading: false, + error: null, + refresh: jest.fn(), + isRefreshing: false, + }); + }); + + describe('Navigation', () => { + it('navigates to position tab when position card is pressed', () => { + // Act + const { getByTestId } = render( + , + ); + + const card = getByTestId('test-position-card'); + fireEvent.press(card); + + // Assert + expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { + screen: Routes.PERPS.MARKET_DETAILS, + params: { + market: expect.objectContaining({ + symbol: 'ETH', + }), + initialTab: 'position', + }, + }); + }); + + it('navigates to orders tab when order card is pressed', () => { + // Act + const { getByTestId } = render( + , + ); + + const card = getByTestId('test-order-card'); + fireEvent.press(card); + + // Assert + expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { + screen: Routes.PERPS.MARKET_DETAILS, + params: { + market: expect.objectContaining({ + symbol: 'ETH', + }), + initialTab: 'orders', + }, + }); + }); + + it('calls custom onPress when provided', () => { + // Arrange + const customOnPress = jest.fn(); + + // Act + const { getByTestId } = render( + , + ); + + const card = getByTestId('test-card'); + fireEvent.press(card); + + // Assert + expect(customOnPress).toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('does not navigate when no market data is available', () => { + // Arrange + mockUsePerpsMarkets.mockReturnValue({ + markets: [], + isLoading: false, + error: null, + refresh: jest.fn(), + isRefreshing: false, + }); + + // Act + const { getByTestId } = render( + , + ); + + const card = getByTestId('test-card'); + fireEvent.press(card); + + // Assert + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('does not navigate when market symbol does not match', () => { + // Arrange + mockUsePerpsMarkets.mockReturnValue({ + markets: [ + { + symbol: 'BTC', // Different symbol from position.coin (ETH) + name: 'Bitcoin', + maxLeverage: '25', + price: '$50,000.00', + change24h: '+$1,250.00', + change24hPercent: '+2.5%', + volume: '$2.1B', + }, + ], + isLoading: false, + error: null, + refresh: jest.fn(), + isRefreshing: false, + }); + + // Act + const { getByTestId } = render( + , + ); + + const card = getByTestId('test-card'); + fireEvent.press(card); + + // Assert + expect(mockNavigate).not.toHaveBeenCalled(); + }); + }); + + describe('Rendering', () => { + it('renders position card with correct content', () => { + // Arrange & Act + const { getByText } = render( + , + ); + + // Assert + expect(getByText('ETH 3x long')).toBeDefined(); + expect(getByText('1.5 ETH')).toBeDefined(); + }); + + it('renders order card with correct content', () => { + // Arrange & Act + const { getByText } = render( + , + ); + + // Assert + expect(getByText('ETH long')).toBeDefined(); + expect(getByText('1.0 ETH')).toBeDefined(); + }); + + it('returns null when neither position nor order is provided', () => { + // Arrange & Act + const { queryByTestId } = render(); + + // Assert + expect(queryByTestId('test-card')).toBeNull(); + }); + }); + + describe('Data Display', () => { + it('displays correct PnL color for positive position', () => { + // Arrange + const positivePosition = { + ...mockPosition, + unrealizedPnl: '100.50', + returnOnEquity: '0.05', + }; + + // Act + const { getByText } = render( + , + ); + + // Assert + expect(getByText('+$100.50 (+5.0%)')).toBeDefined(); + }); + + it('displays correct PnL color for negative position', () => { + // Arrange + const negativePosition = { + ...mockPosition, + unrealizedPnl: '-50.25', + returnOnEquity: '-0.025', + }; + + // Act + const { getByText } = render( + , + ); + + // Assert + expect(getByText('-$50.25 (-2.5%)')).toBeDefined(); + }); + + it('displays short position correctly', () => { + // Arrange + const shortPosition = { + ...mockPosition, + size: '-1.5', + }; + + // Act + const { getByText } = render( + , + ); + + // Assert + expect(getByText('ETH 3x short')).toBeDefined(); + expect(getByText('1.5 ETH')).toBeDefined(); + }); + + it('displays order side correctly', () => { + // Arrange + const sellOrder = { + ...mockOrder, + side: 'sell' as const, + }; + + // Act + const { getByText } = render( + , + ); + + // Assert + expect(getByText('ETH short')).toBeDefined(); + }); + }); +}); diff --git a/app/components/UI/Perps/components/PerpsCard/PerpsCard.tsx b/app/components/UI/Perps/components/PerpsCard/PerpsCard.tsx index 970a59f6309b..6e5d51508c30 100644 --- a/app/components/UI/Perps/components/PerpsCard/PerpsCard.tsx +++ b/app/components/UI/Perps/components/PerpsCard/PerpsCard.tsx @@ -77,17 +77,19 @@ const PerpsCard: React.FC = ({ // Find the market data for this symbol const market = markets.find((m) => m.symbol === symbol); if (market) { + const initialTab = order ? 'orders' : position ? 'position' : undefined; // Navigate to market details with the full market data // When navigating from a tab, we need to navigate through the root stack navigation.navigate(Routes.PERPS.ROOT, { screen: Routes.PERPS.MARKET_DETAILS, params: { market, + initialTab, }, }); } } - }, [onPress, markets, symbol, navigation]); + }, [onPress, markets, symbol, navigation, order, position]); if (!position && !order) { return null; diff --git a/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.test.tsx b/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.test.tsx index d7d3b9bec0b0..4a6e10d67555 100644 --- a/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.test.tsx +++ b/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.test.tsx @@ -252,4 +252,226 @@ describe('PerpsMarketTabs', () => { expect(getByText('perps.market.statistics')).toBeDefined(); }); }); + + describe('Initial Tab Selection', () => { + it('sets initial tab to position when initialTab is position', () => { + // Arrange + const onActiveTabChange = jest.fn(); + + // Act + const { getByTestId } = render( + , + ); + + // Assert - Position content should be visible + expect( + getByTestId(PerpsMarketTabsSelectorsIDs.POSITION_CONTENT), + ).toBeDefined(); + expect(onActiveTabChange).toHaveBeenCalledWith('position'); + }); + + it('sets initial tab to orders when initialTab is orders', () => { + // Arrange + const onActiveTabChange = jest.fn(); + + // Act + const { getByTestId } = render( + , + ); + + // Assert - Orders content should be visible + expect( + getByTestId(PerpsMarketTabsSelectorsIDs.ORDERS_CONTENT), + ).toBeDefined(); + expect(onActiveTabChange).toHaveBeenCalledWith('orders'); + }); + + it('sets initial tab to statistics when initialTab is statistics', () => { + // Arrange + const onActiveTabChange = jest.fn(); + + // Act + const { getByTestId } = render( + , + ); + + // Assert - Statistics-only title should be visible when no positions/orders + expect( + getByTestId(PerpsMarketTabsSelectorsIDs.STATISTICS_ONLY_TITLE), + ).toBeDefined(); + expect(onActiveTabChange).toHaveBeenCalledWith('statistics'); + }); + + it('defaults to statistics when initialTab is undefined', () => { + // Arrange + const onActiveTabChange = jest.fn(); + + // Act + const { getByTestId } = render( + , + ); + + // Assert - Statistics-only title should be visible by default when no positions/orders + expect( + getByTestId(PerpsMarketTabsSelectorsIDs.STATISTICS_ONLY_TITLE), + ).toBeDefined(); + }); + + it('ignores initialTab when tab is not available', () => { + // Arrange + const onActiveTabChange = jest.fn(); + + // Act + const { getByTestId } = render( + , + ); + + // Assert - Should fall back to statistics-only title since orders tab is not available + expect( + getByTestId(PerpsMarketTabsSelectorsIDs.STATISTICS_ONLY_TITLE), + ).toBeDefined(); + }); + + it('respects initialTab over auto-selection when both position and orders exist', () => { + // Arrange + const onActiveTabChange = jest.fn(); + + // Act + const { getByTestId } = render( + , + ); + + // Assert - Orders content should be visible despite position existing + expect( + getByTestId(PerpsMarketTabsSelectorsIDs.ORDERS_CONTENT), + ).toBeDefined(); + expect(onActiveTabChange).toHaveBeenCalledWith('orders'); + }); + + it('does not override user interaction with initialTab', () => { + // Arrange + const onActiveTabChange = jest.fn(); + const { getByText, getByTestId } = render( + , + ); + + // Act - User clicks on position tab + const positionTab = getByText('perps.market.position'); + fireEvent.press(positionTab); + + // Assert - Should show position content, not orders + expect( + getByTestId(PerpsMarketTabsSelectorsIDs.POSITION_CONTENT), + ).toBeDefined(); + expect(onActiveTabChange).toHaveBeenLastCalledWith('position'); + }); + + it('handles initialTab when data loads after component mount', async () => { + // Arrange + const onActiveTabChange = jest.fn(); + const { rerender, getByTestId } = render( + , + ); + + // Act - Simulate data loading completion + rerender( + , + ); + + // Assert - Should now show position content + expect( + getByTestId(PerpsMarketTabsSelectorsIDs.POSITION_CONTENT), + ).toBeDefined(); + expect(onActiveTabChange).toHaveBeenCalledWith('position'); + }); + }); }); diff --git a/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.tsx b/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.tsx index d5db0714b8ef..2975ca5e4638 100644 --- a/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.tsx +++ b/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.tsx @@ -12,7 +12,7 @@ import { strings } from '../../../../../../locales/i18n'; import { useStyles } from '../../../../hooks/useStyles'; import PerpsMarketStatisticsCard from '../PerpsMarketStatisticsCard'; import PerpsPositionCard from '../PerpsPositionCard'; -import { PerpsMarketTabsProps } from './PerpsMarketTabs.types'; +import { PerpsMarketTabsProps, PerpsTabId } from './PerpsMarketTabs.types'; import styleSheet from './PerpsMarketTabs.styles'; import type { PerpsTooltipContentKey } from '../PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.types'; import PerpsBottomSheetTooltip from '../PerpsBottomSheetTooltip'; @@ -36,12 +36,14 @@ const PerpsMarketTabs: React.FC = ({ isLoadingPosition, unfilledOrders = [], onActiveTabChange, + initialTab, nextFundingTime, fundingIntervalHours, }) => { const { styles } = useStyles(styleSheet, {}); const fadeAnim = useRef(new Animated.Value(0)).current; const hasUserInteracted = useRef(false); + const hasSetInitialTab = useRef(false); const { showToast, PerpsToastOptions } = usePerpsToasts(); @@ -87,8 +89,22 @@ const PerpsMarketTabs: React.FC = ({ return dynamicTabs; }, [position, unfilledOrders.length]); - // Initialize with statistics by default - const [activeTabId, setActiveTabId] = useState('statistics'); + // Initialize with initialTab or statistics by default + const [activeTabId, setActiveTabId] = useState( + initialTab || 'statistics', + ); + + // Handle initialTab when it becomes available after data loads + useEffect(() => { + if (initialTab && !hasUserInteracted.current && !hasSetInitialTab.current) { + const availableTabs = tabs.map((t) => t.id); + if (availableTabs.includes(initialTab)) { + setActiveTabId(initialTab as PerpsTabId); + onActiveTabChange?.(initialTab); + hasSetInitialTab.current = true; + } + } + }, [initialTab, tabs, onActiveTabChange]); // Set initial tab based on data availability // Now we can properly distinguish between loading and empty states @@ -98,6 +114,11 @@ const PerpsMarketTabs: React.FC = ({ return; } + // If we've already set the initial tab from props, don't override it + if (hasSetInitialTab.current) { + return; + } + // Wait until position data has loaded // isLoadingPosition will be true until first WebSocket update if (isLoadingPosition) { @@ -125,7 +146,7 @@ const PerpsMarketTabs: React.FC = ({ previousTab: activeTabId, isLoadingPosition, }); - setActiveTabId(targetTabId); + setActiveTabId(targetTabId as PerpsTabId); onActiveTabChange?.(targetTabId); } }, [ @@ -142,7 +163,7 @@ const PerpsMarketTabs: React.FC = ({ if (!tabIds.includes(activeTabId)) { // Switch to first available tab if current tab is hidden const newTabId = tabs[0]?.id || 'statistics'; - setActiveTabId(newTabId); + setActiveTabId(newTabId as PerpsTabId); onActiveTabChange?.(newTabId); } }, [tabs, activeTabId, onActiveTabChange]); @@ -151,7 +172,7 @@ const PerpsMarketTabs: React.FC = ({ const handleTabChange = useCallback( (tabId: string) => { hasUserInteracted.current = true; // Mark that user has interacted - setActiveTabId(tabId); + setActiveTabId(tabId as PerpsTabId); onActiveTabChange?.(tabId); }, [onActiveTabChange], diff --git a/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.types.ts b/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.types.ts index a90c9ed29ebf..7576927be674 100644 --- a/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.types.ts +++ b/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.types.ts @@ -1,6 +1,8 @@ import type { Position, Order } from '../../controllers/types'; import { usePerpsMarketStats } from '../../hooks'; +export type PerpsTabId = 'position' | 'orders' | 'statistics'; + export interface TabViewProps { tabLabel: string; } @@ -16,6 +18,10 @@ export interface PerpsMarketTabsProps { unfilledOrders: Order[]; onActiveTabChange?: (tabId: string) => void; activeTabId?: string; + /** + * Initial tab to select when component mounts + */ + initialTab?: PerpsTabId; /** * Next funding time in milliseconds since epoch (optional, market-specific) */ diff --git a/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.test.tsx b/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.test.tsx index bca509d5ac0e..c53c86a59f2d 100644 --- a/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.test.tsx +++ b/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.test.tsx @@ -342,6 +342,7 @@ describe('PerpsPositionCard', () => { screen: Routes.PERPS.MARKET_DETAILS, params: { market: expect.any(Object), + initialTab: 'position', }, }); }); diff --git a/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.tsx b/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.tsx index 29fe9f2010f8..ffb2398ae90b 100644 --- a/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.tsx +++ b/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.tsx @@ -95,6 +95,7 @@ const PerpsPositionCard: React.FC = ({ screen: Routes.PERPS.MARKET_DETAILS, params: { market: marketData, + initialTab: 'position', }, }); }; diff --git a/app/components/UI/Perps/components/PerpsTPSLBottomSheet/PerpsTPSLBottomSheet.tsx b/app/components/UI/Perps/components/PerpsTPSLBottomSheet/PerpsTPSLBottomSheet.tsx index a890691c37cf..5016bb6c1ab6 100644 --- a/app/components/UI/Perps/components/PerpsTPSLBottomSheet/PerpsTPSLBottomSheet.tsx +++ b/app/components/UI/Perps/components/PerpsTPSLBottomSheet/PerpsTPSLBottomSheet.tsx @@ -1,6 +1,7 @@ import React, { memo, useCallback, useEffect, useRef } from 'react'; import { ActivityIndicator, + ScrollView, TextInput, TouchableOpacity, View, @@ -260,7 +261,7 @@ const PerpsTPSLBottomSheet: React.FC = ({ - + {showOverlay && ( @@ -507,7 +508,7 @@ const PerpsTPSLBottomSheet: React.FC = ({ )} - + { expect(result.success).toBe(true); expect(result.orderId).toBe('123'); + + // Verify market orders use FrontendMarket TIF (TAT-1447 fix) + expect(mockClientService.getExchangeClient().order).toHaveBeenCalledWith( + expect.objectContaining({ + orders: [ + expect.objectContaining({ + t: { limit: { tif: 'FrontendMarket' } }, + }), + ], + }), + ); }); it('should place a limit order successfully', async () => { @@ -403,6 +414,42 @@ describe('HyperLiquidProvider', () => { const result = await provider.placeOrder(orderParams); expect(result.success).toBe(true); + + // Verify limit orders use Gtc TIF (regression test for TAT-1447) + expect(mockClientService.getExchangeClient().order).toHaveBeenCalledWith( + expect.objectContaining({ + orders: [ + expect.objectContaining({ + t: { limit: { tif: 'Gtc' } }, + }), + ], + }), + ); + }); + + it('should use Gtc TIF for limit orders (regression test)', async () => { + const orderParams: OrderParams = { + coin: 'BTC', + isBuy: true, + size: '0.1', + price: '51000', + orderType: 'limit', + }; + + await provider.placeOrder(orderParams); + + // Verify that the order was called with Gtc TIF for limit orders + expect(mockClientService.getExchangeClient().order).toHaveBeenCalledWith( + expect.objectContaining({ + orders: [ + expect.objectContaining({ + a: 0, // BTC asset ID + b: true, // isBuy + t: { limit: { tif: 'Gtc' } }, // Limit orders use Gtc TIF + }), + ], + }), + ); }); it('should track performance measurements when placing order', async () => { diff --git a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts index bff21edf06d3..0b76c32340e4 100644 --- a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts +++ b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts @@ -421,10 +421,22 @@ export class HyperLiquidProvider implements IPerpsProvider { p: formattedPrice, s: formattedSize, r: params.reduceOnly || false, + /** + * HyperLiquid Time-In-Force (TIF) options: + * - 'Gtc' (Good Till Canceled): Standard limit orders that remain active until filled or canceled + * - 'Ioc' (Immediate or Cancel): Limit orders that fill immediately or cancel unfilled portion + * - 'FrontendMarket': True market orders as used in HyperLiquid UI - USE THIS FOR MARKET ORDERS + * - 'Alo' (Add Liquidity Only): Maker-only orders that add liquidity to order book + * - 'LiquidationMarket': Similar to IoC, used for liquidation orders + * + * IMPORTANT: Use 'FrontendMarket' for market orders, NOT 'Ioc' + * Using 'Ioc' causes market orders to be treated as limit orders by HyperLiquid, + * leading to incorrect order type display in transaction history (TAT-1447) + */ t: params.orderType === 'limit' - ? { limit: { tif: 'Gtc' } } - : { limit: { tif: 'Ioc' } }, + ? { limit: { tif: 'Gtc' } } // Standard limit order + : { limit: { tif: 'FrontendMarket' } }, // True market order c: params.clientOrderId ? (params.clientOrderId as Hex) : undefined, }; orders.push(mainOrder); @@ -587,10 +599,11 @@ export class HyperLiquidProvider implements IPerpsProvider { p: formattedPrice, s: formattedSize, r: params.newOrder.reduceOnly || false, + // Same TIF logic as placeOrder - see documentation above for details t: params.newOrder.orderType === 'limit' - ? { limit: { tif: 'Gtc' } } - : { limit: { tif: 'Ioc' } }, + ? { limit: { tif: 'Gtc' } } // Standard limit order + : { limit: { tif: 'FrontendMarket' } }, // True market order c: params.newOrder.clientOrderId ? (params.newOrder.clientOrderId as Hex) : undefined, diff --git a/app/components/UI/Perps/hooks/index.ts b/app/components/UI/Perps/hooks/index.ts index 2d94b02bbcd6..9d25d3d0944d 100644 --- a/app/components/UI/Perps/hooks/index.ts +++ b/app/components/UI/Perps/hooks/index.ts @@ -49,6 +49,7 @@ export { usePerpsClosePositionValidation } from './usePerpsClosePositionValidati export { usePerpsOrderExecution } from './usePerpsOrderExecution'; export { usePerpsFirstTimeUser } from './usePerpsFirstTimeUser'; export { usePerpsTPSLForm } from './usePerpsTPSLForm'; +export { default as usePerpsToasts } from './usePerpsToasts'; // Transaction data hooks export { usePerpsOrderFills } from './usePerpsOrderFills'; diff --git a/app/components/UI/Perps/hooks/usePerpsMarkets.test.ts b/app/components/UI/Perps/hooks/usePerpsMarkets.test.ts index 2d5a0411bb8e..fd3d542a90c2 100644 --- a/app/components/UI/Perps/hooks/usePerpsMarkets.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsMarkets.test.ts @@ -1,7 +1,7 @@ import { renderHook, act } from '@testing-library/react-hooks'; import { waitFor } from '@testing-library/react-native'; import DevLogger from '../../../../core/SDKConnect/utils/DevLogger'; -import { usePerpsMarkets } from './usePerpsMarkets'; +import { usePerpsMarkets, parseVolume } from './usePerpsMarkets'; import type { PerpsMarketData } from '../controllers/types'; // Mock dependencies @@ -166,8 +166,13 @@ describe('usePerpsMarkets', () => { subscriberCallback(newMarketData); }); - // Assert - expect(result.current.markets).toEqual(newMarketData); + expect(result.current.markets).toEqual([ + { + ...newMarketData[0], + // used during sorting + volumeNumber: 100000000, + }, + ]); }); it('handles empty market data', async () => { @@ -496,4 +501,107 @@ describe('usePerpsMarkets', () => { expect(unsubscribeFn).toHaveBeenCalled(); }); }); + + describe('parseVolume', () => { + describe('handles undefined and special cases', () => { + it('returns -1 for undefined input', () => { + const result = parseVolume(undefined); + expect(result).toBe(-1); + }); + + it('returns -1 for fallback price display', () => { + const result = parseVolume('—'); + expect(result).toBe(-1); + }); + + it('returns 0.5 for very small volume indicator', () => { + const result = parseVolume('$<1'); + expect(result).toBe(0.5); + }); + }); + + describe('parses suffixed volume values', () => { + it('parses thousand suffix correctly', () => { + expect(parseVolume('$100K')).toBe(100000); + expect(parseVolume('$1.5K')).toBe(1500); + expect(parseVolume('$999K')).toBe(999000); + }); + + it('parses million suffix correctly', () => { + expect(parseVolume('$1M')).toBe(1000000); + expect(parseVolume('$2.5M')).toBe(2500000); + expect(parseVolume('$900M')).toBe(900000000); + }); + + it('parses billion suffix correctly', () => { + expect(parseVolume('$1B')).toBe(1000000000); + expect(parseVolume('$1.2B')).toBe(1200000000); + expect(parseVolume('$50B')).toBe(50000000000); + }); + + it('parses trillion suffix correctly', () => { + expect(parseVolume('$1T')).toBe(1000000000000); + expect(parseVolume('$2.5T')).toBe(2500000000000); + }); + + it('handles values without dollar sign', () => { + expect(parseVolume('100K')).toBe(100000); + expect(parseVolume('1.5M')).toBe(1500000); + expect(parseVolume('2B')).toBe(2000000000); + }); + + it('handles comma-separated numbers with suffixes', () => { + expect(parseVolume('$1,500K')).toBe(1500000); + expect(parseVolume('$2,300M')).toBe(2300000000); + expect(parseVolume('$10,000B')).toBe(10000000000000); + }); + }); + + describe('parses regular numeric values', () => { + it('parses whole numbers without suffix', () => { + expect(parseVolume('$1000')).toBe(1000); + expect(parseVolume('$500000')).toBe(500000); + expect(parseVolume('1000')).toBe(1000); + }); + + it('parses decimal numbers without suffix', () => { + expect(parseVolume('$1234.56')).toBe(1234.56); + expect(parseVolume('$0.01')).toBe(0.01); + expect(parseVolume('999.99')).toBe(999.99); + }); + + it('handles comma-separated numbers without suffix', () => { + expect(parseVolume('$1,234')).toBe(1234); + expect(parseVolume('$1,234,567')).toBe(1234567); + expect(parseVolume('$1,234.56')).toBe(1234.56); + }); + }); + + describe('handles invalid inputs', () => { + it('returns -1 for invalid numeric strings', () => { + expect(parseVolume('invalid')).toBe(-1); + expect(parseVolume('$abc')).toBe(-1); + expect(parseVolume('$K')).toBe(-1); + expect(parseVolume('')).toBe(-1); + }); + + it('returns -1 for NaN results', () => { + expect(parseVolume('$NaN')).toBe(-1); + expect(parseVolume('$...')).toBe(-1); + }); + }); + + describe('edge cases', () => { + it('handles zero values', () => { + expect(parseVolume('$0')).toBe(0); + expect(parseVolume('$0.00')).toBe(0); + expect(parseVolume('0K')).toBe(0); + }); + + it('handles very large numbers', () => { + expect(parseVolume('$999.99T')).toBe(999990000000000); + expect(parseVolume('$1,000T')).toBe(1000000000000000); + }); + }); + }); }); diff --git a/app/components/UI/Perps/hooks/usePerpsMarkets.ts b/app/components/UI/Perps/hooks/usePerpsMarkets.ts index 7d7d7d1fe30a..7c62ffed4e3c 100644 --- a/app/components/UI/Perps/hooks/usePerpsMarkets.ts +++ b/app/components/UI/Perps/hooks/usePerpsMarkets.ts @@ -5,11 +5,15 @@ import { PERPS_CONSTANTS } from '../constants/perpsConfig'; import { usePerpsStream } from '../providers/PerpsStreamManager'; import { parseCurrencyString } from '../utils/formatUtils'; +type PerpsMarketDataWithVolumeNumber = PerpsMarketData & { + volumeNumber: number; +}; + export interface UsePerpsMarketsResult { /** * Transformed market data ready for UI consumption */ - markets: PerpsMarketData[]; + markets: PerpsMarketDataWithVolumeNumber[]; /** * Loading state for initial data fetch */ @@ -46,6 +50,49 @@ export interface UsePerpsMarketsOptions { skipInitialFetch?: boolean; } +const multipliers: Record = { + K: 1e3, + M: 1e6, + B: 1e9, + T: 1e12, +} as const; + +// Pre-compiled regex for better performance - avoids regex compilation on every call +const VOLUME_SUFFIX_REGEX = /\$?([\d.,]+)([KMBT])?/; + +// Helper function to remove commas using for loop (~2x faster than regex for short strings) +const removeCommas = (str: string): string => { + let result = ''; + // eslint-disable-next-line @typescript-eslint/prefer-for-of + for (let i = 0; i < str.length; i++) { + const char = str[i]; + if (char !== ',') result += char; + } + return result; +}; + +export const parseVolume = (volumeStr: string | undefined): number => { + if (!volumeStr) return -1; // Put undefined at the end + + // Handle special cases + if (volumeStr === PERPS_CONSTANTS.FALLBACK_PRICE_DISPLAY) return -1; + if (volumeStr === '$<1') return 0.5; // Treat as very small but not zero + + // Handle suffixed values (e.g., "$1.5M", "$2.3B", "$500K") + const suffixMatch = volumeStr.match(VOLUME_SUFFIX_REGEX); + if (suffixMatch) { + const [, numberPart, suffix] = suffixMatch; + const baseValue = parseFloat(removeCommas(numberPart)); + + if (isNaN(baseValue)) return -1; + + return suffix ? baseValue * multipliers[suffix] : baseValue; + } + + // Fallback to currency parser for regular values + return parseCurrencyString(volumeStr) || -1; +}; + /** * Custom hook to fetch and manage Perps market data from the active provider * Uses the StreamManager's marketData channel for caching and deduplication @@ -60,49 +107,22 @@ export const usePerpsMarkets = ( } = options; const streamManager = usePerpsStream(); - const [markets, setMarkets] = useState([]); + const [markets, setMarkets] = useState([]); const [isLoading, setIsLoading] = useState(!skipInitialFetch); const [isRefreshing, setIsRefreshing] = useState(false); const [error, setError] = useState(null); // Helper function to sort markets by volume const sortMarketsByVolume = useCallback( - (marketData: PerpsMarketData[]): PerpsMarketData[] => { - const parseVolume = (volumeStr: string | undefined): number => { - if (!volumeStr) return -1; // Put undefined at the end - - // Handle special cases - if (volumeStr === PERPS_CONSTANTS.FALLBACK_PRICE_DISPLAY) return -1; - if (volumeStr === '$<1') return 0.5; // Treat as very small but not zero - - // Handle suffixed values (e.g., "$1.5M", "$2.3B", "$500K") - const suffixMatch = volumeStr.match(/\$?([\d.,]+)([KMBT])?/); - if (suffixMatch) { - const [, numberPart, suffix] = suffixMatch; - const baseValue = parseFloat(numberPart.replace(/,/g, '')); - - if (isNaN(baseValue)) return -1; - - const multipliers: Record = { - K: 1e3, - M: 1e6, - B: 1e9, - T: 1e12, - }; - - return suffix ? baseValue * multipliers[suffix] : baseValue; - } - - // Fallback to currency parser for regular values - return parseCurrencyString(volumeStr) || -1; - }; - - return [...marketData].sort((a, b) => { - const volumeA = parseVolume(a.volume); - const volumeB = parseVolume(b.volume); - return volumeB - volumeA; // Descending order - }); - }, + (marketData: PerpsMarketData[]): PerpsMarketDataWithVolumeNumber[] => + marketData + // pregenerate volumeNumber for sorting to avoid recalculating it on every sort + .map((item) => ({ ...item, volumeNumber: parseVolume(item.volume) })) + .sort((a, b) => { + const volumeA = a.volumeNumber; + const volumeB = b.volumeNumber; + return volumeB - volumeA; + }), [], ); diff --git a/app/components/UI/Perps/hooks/usePerpsToasts.ts b/app/components/UI/Perps/hooks/usePerpsToasts.ts index 1f6217864db8..c40c3b7d786b 100644 --- a/app/components/UI/Perps/hooks/usePerpsToasts.ts +++ b/app/components/UI/Perps/hooks/usePerpsToasts.ts @@ -116,6 +116,7 @@ export interface PerpsToastOptionsConfig { formValidation: { orderForm: { validationError: (error: string) => PerpsToastOptions; + limitPriceRequired: PerpsToastOptions; }; }; dataFetching: { @@ -545,6 +546,15 @@ const usePerpsToasts = (): { error, ), }), + limitPriceRequired: { + ...perpsBaseToastOptions.error, + labelOptions: getPerpsToastLabels( + strings('perps.order.validation.please_set_a_limit_price'), + strings( + 'perps.order.validation.limit_price_must_be_set_before_configuing_tpsl', + ), + ), + }, }, }, dataFetching: { diff --git a/app/components/UI/Perps/utils/formatUtils.ts b/app/components/UI/Perps/utils/formatUtils.ts index fcee19df5f7a..ddbbac695fef 100644 --- a/app/components/UI/Perps/utils/formatUtils.ts +++ b/app/components/UI/Perps/utils/formatUtils.ts @@ -3,6 +3,10 @@ */ import { formatWithThreshold } from '../../../../util/assets'; import { FUNDING_RATE_CONFIG } from '../constants/perpsConfig'; +import { + getIntlNumberFormatter, + getIntlDateTimeFormatter, +} from '../../../../util/intl'; /** * Formats a balance value as USD currency with appropriate decimal places @@ -83,7 +87,7 @@ export const formatPnl = (pnl: string | number): string => { return '$0.00'; } - const formatted = new Intl.NumberFormat('en-US', { + const formatted = getIntlNumberFormatter('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 2, @@ -363,12 +367,12 @@ export const parsePercentageString = (formattedValue: string): number => { */ export const formatTransactionDate = (timestamp: number): string => { const date = new Date(timestamp); - const dateStr = new Intl.DateTimeFormat('en-US', { + const dateStr = getIntlDateTimeFormatter('en-US', { year: 'numeric', month: 'long', day: 'numeric', }).format(date); - const timeStr = new Intl.DateTimeFormat('en-US', { + const timeStr = getIntlDateTimeFormatter('en-US', { hour: 'numeric', minute: '2-digit', hour12: true, @@ -406,10 +410,10 @@ export const formatDateSection = (timestamp: number): string => { return 'Yesterday'; } - const month = new Intl.DateTimeFormat('en-US', { + const month = getIntlDateTimeFormatter('en-US', { month: 'short', }).format(new Date(timestamp)); - const day = new Intl.DateTimeFormat('en-US', { + const day = getIntlDateTimeFormatter('en-US', { day: 'numeric', }).format(new Date(timestamp)); diff --git a/app/components/UI/Perps/utils/marketDataTransform.ts b/app/components/UI/Perps/utils/marketDataTransform.ts index 6377e37c5c4a..097fa3e02600 100644 --- a/app/components/UI/Perps/utils/marketDataTransform.ts +++ b/app/components/UI/Perps/utils/marketDataTransform.ts @@ -7,6 +7,7 @@ import type { import { PERPS_CONSTANTS } from '../constants/perpsConfig'; import type { PerpsMarketData } from '../controllers/types'; import { formatVolume } from './formatUtils'; +import { getIntlNumberFormatter } from '../../../../util/intl'; /** * HyperLiquid-specific market data structure @@ -139,7 +140,7 @@ export function formatPrice(price: number): string { const absPrice = Math.abs(price); if (absPrice >= 1000) { - return new Intl.NumberFormat('en-US', { + return getIntlNumberFormatter('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 2, @@ -147,7 +148,7 @@ export function formatPrice(price: number): string { }).format(price); } if (absPrice >= 1) { - return new Intl.NumberFormat('en-US', { + return getIntlNumberFormatter('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 2, @@ -155,14 +156,14 @@ export function formatPrice(price: number): string { }).format(price); } if (absPrice >= 0.01) { - return new Intl.NumberFormat('en-US', { + return getIntlNumberFormatter('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 4, maximumFractionDigits: 4, }).format(price); } - return new Intl.NumberFormat('en-US', { + return getIntlNumberFormatter('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 6, @@ -182,7 +183,7 @@ export function formatChange(change: number): string { const absChange = Math.abs(change); const decimalPlaces = absChange >= 1 ? 2 : absChange >= 0.01 ? 4 : 6; - const formatted = new Intl.NumberFormat('en-US', { + const formatted = getIntlNumberFormatter('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: decimalPlaces, @@ -199,7 +200,7 @@ export function formatPercentage(percent: number): string { if (isNaN(percent) || !isFinite(percent)) return '0.00%'; if (percent === 0) return '0.00%'; - const formatted = new Intl.NumberFormat('en-US', { + const formatted = getIntlNumberFormatter('en-US', { style: 'percent', minimumFractionDigits: 2, maximumFractionDigits: 2, diff --git a/app/components/UI/UrlAutocomplete/index.test.tsx b/app/components/UI/UrlAutocomplete/index.test.tsx index 91a682df4fd6..5718d46cd604 100644 --- a/app/components/UI/UrlAutocomplete/index.test.tsx +++ b/app/components/UI/UrlAutocomplete/index.test.tsx @@ -3,25 +3,61 @@ import React from 'react'; import UrlAutocomplete, { UrlAutocompleteRef } from './'; import { deleteFavoriteTestId } from '../../../../wdio/screen-objects/testIDs/BrowserScreen/UrlAutocomplete.testIds'; import { act, fireEvent, screen } from '@testing-library/react-native'; -import renderWithProvider from '../../../util/test/renderWithProvider'; +import renderWithProvider, { + DeepPartial, +} from '../../../util/test/renderWithProvider'; import { removeBookmark } from '../../../actions/bookmarks'; import { noop } from 'lodash'; import { createStackNavigator } from '@react-navigation/stack'; import { TokenSearchResponseItem } from '@metamask/token-search-discovery-controller'; +import { RpcEndpointType } from '@metamask/network-controller'; +import { RootState } from '../../../reducers'; -const defaultState = { +const defaultState: DeepPartial = { browser: { history: [{ url: 'https://www.google.com', name: 'Google' }] }, bookmarks: [{ url: 'https://www.bookmark.com', name: 'MyBookmark' }], engine: { backgroundState: { PreferencesController: { isIpfsGatewayEnabled: false, + tokenNetworkFilter: { + '0x1': 'true', + }, }, CurrencyRateController: { currentCurrency: 'USD', }, MultichainNetworkController: { isEvmSelected: true, + multichainNetworkConfigurationsByChainId: {}, + }, + NetworkController: { + selectedNetworkClientId: 'mainnet', + networksMetadata: { + mainnet: { + EIPS: { + 1559: true, + }, + }, + }, + networkConfigurationsByChainId: { + '0x1': { + chainId: '0x1' as `0x${string}`, + rpcEndpoints: [ + { + name: 'Ethereum Mainnet', + networkClientId: 'mainnet' as const, + url: 'https://mainnet.infura.io/v3/{infuraProjectId}', + type: RpcEndpointType.Infura, + }, + ], + defaultRpcEndpointIndex: 0, + nativeCurrency: 'ETH', + name: 'Ethereum Mainnet', + blockExplorerUrls: ['https://etherscan.io'], + defaultBlockExplorerUrlIndex: 0, + }, + }, }, }, }, diff --git a/app/components/Views/MultichainAccounts/AccountGroupDetails/AccountGroupDetails.tsx b/app/components/Views/MultichainAccounts/AccountGroupDetails/AccountGroupDetails.tsx index ab601f14e79b..d5d05557ca1a 100644 --- a/app/components/Views/MultichainAccounts/AccountGroupDetails/AccountGroupDetails.tsx +++ b/app/components/Views/MultichainAccounts/AccountGroupDetails/AccountGroupDetails.tsx @@ -109,11 +109,14 @@ export const AccountGroupDetails = (props: AccountGroupDetailsProps) => { ); const navigateToAddressList = useCallback(() => { - // Start the trace before navigating to the address list so that the - // navigation and render time are included in the trace. + // Start the trace before navigating to the address list to include the + // navigation and render times in the trace. trace({ name: TraceName.ShowAccountAddressList, op: TraceOperation.AccountUi, + tags: { + screen: 'account.details', + }, }); navigation.navigate( diff --git a/app/components/Views/MultichainAccounts/MultichainAccountConnect/MultichainAccountConnect.stories.tsx b/app/components/Views/MultichainAccounts/MultichainAccountConnect/MultichainAccountConnect.stories.tsx new file mode 100644 index 000000000000..0d770b8144f4 --- /dev/null +++ b/app/components/Views/MultichainAccounts/MultichainAccountConnect/MultichainAccountConnect.stories.tsx @@ -0,0 +1,287 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import { configureStore } from '@reduxjs/toolkit'; +import { AccountGroupType, AccountGroupId } from '@metamask/account-api'; +import { Box } from '@metamask/design-system-react-native'; +import { NavigationContainer } from '@react-navigation/native'; +import { createStackNavigator } from '@react-navigation/stack'; +import { + Caip25EndowmentPermissionName, + Caip25CaveatValue, + Caip25CaveatType, +} from '@metamask/chain-agnostic-permission'; + +import MultichainAccountConnect from './MultichainAccountConnect'; +import { AccountGroupWithInternalAccounts } from '../../../../selectors/multichainAccounts/accounts.type'; +import { AccountConnectProps } from '../../AccountConnect/AccountConnect.types'; +import { ToastContextWrapper } from '../../../../component-library/components/Toast'; +import { createMockInternalAccount } from '../../../../util/test/accountsControllerTestUtils'; + +const Stack = createStackNavigator(); + +const createMockAccountGroupWithInternalAccounts = ( + id: string, + name: string, + _accountId: string, + address: string, +): AccountGroupWithInternalAccounts => ({ + id: id as AccountGroupId, + type: AccountGroupType.SingleAccount, + metadata: { + name, + pinned: false, + hidden: false, + }, + accounts: [createMockInternalAccount(address, name)], +}); + +const mockAccountGroup1 = createMockAccountGroupWithInternalAccounts( + 'test-group1', + 'Account 1', + 'account-id-1', + '0x1234567890123456789012345678901234567890', +); + +const mockAccountGroup2 = createMockAccountGroupWithInternalAccounts( + 'test-group2', + 'Account 2', + 'account-id-2', + '0x2345678901234567890123456789012345678901', +); + +const mockAccountGroup3 = createMockAccountGroupWithInternalAccounts( + 'test-group3', + 'Account 3', + 'account-id-3', + '0x3456789012345678901234567890123456789012', +); + +const mockAccountGroups: AccountGroupWithInternalAccounts[] = [ + mockAccountGroup1, + mockAccountGroup2, + mockAccountGroup3, +]; + +const mockNetworkConfigurations = { + 'eip155:1': { + chainId: '0x1', + name: 'Ethereum Mainnet', + nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + rpcUrls: ['https://url'], + blockExplorerUrls: ['https://url'], + }, + 'eip155:137': { + chainId: '0x89', + name: 'Polygon', + nativeCurrency: { name: 'MATIC', symbol: 'MATIC', decimals: 18 }, + rpcUrls: ['https://polygon-rpc.com'], + blockExplorerUrls: ['https://polygonscan.com'], + }, +} as const; + +const createMockHostInfo = (origin: string, isEip1193Request = false) => ({ + metadata: { + origin, + id: 'test-dapp-id', + isEip1193Request, + }, + permissions: { + [Caip25EndowmentPermissionName]: { + parentCapability: Caip25EndowmentPermissionName, + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + methods: ['eth_sendTransaction', 'personal_sign'], + notifications: ['accountsChanged', 'chainChanged'], + accounts: [], + }, + 'eip155:137': { + methods: ['eth_sendTransaction', 'personal_sign'], + notifications: ['accountsChanged', 'chainChanged'], + accounts: [], + }, + }, + sessionProperties: {}, + isMultichainOrigin: true, + } as Caip25CaveatValue, + }, + ], + }, + }, +}); + +const createMockStore = (accountGroups = mockAccountGroups) => + configureStore({ + reducer: { + engine: () => ({ + backgroundState: { + AccountTreeController: { + accountTree: { + wallets: { + 'wallet-1': { + id: 'wallet-1', + metadata: { name: 'MetaMask Wallet' }, + groups: Object.fromEntries( + accountGroups.map((group) => [group.id, group]), + ), + }, + }, + }, + }, + AccountsController: { + internalAccounts: { + accounts: Object.fromEntries( + accountGroups.flatMap((group) => + group.accounts.map((account) => [account.id, account]), + ), + ), + selectedAccount: + accountGroups[0]?.accounts[0]?.id || 'account-id-1', + }, + }, + AccountTrackerController: { + accounts: Object.fromEntries( + accountGroups.flatMap((group) => + group.accounts.map((account) => [ + account.address, + { balance: '0x1bc16d674ec80000' }, // 2 ETH + ]), + ), + ), + }, + NetworkController: { + networkConfigurationsByChainId: mockNetworkConfigurations, + }, + PermissionController: { + subjects: {}, + }, + RemoteFeatureFlagController: { + remoteFeatureFlags: { + enableMultichainAccounts: { + enabled: true, + featureVersion: '2', + minimumVersion: '0.0.0', + }, + }, + }, + }, + }), + sdk: () => ({ + wc2Metadata: { + id: 'test-wc-id', + url: 'https://example.com', + lastVerifiedUrl: 'https://example.com', + }, + }), + }, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware({ + serializableCheck: false, + immutableCheck: false, + }), + }); + +const MockNavigationWrapper = ({ children }: { children: React.ReactNode }) => ( + + + children} + /> + + +); + +const MultichainAccountConnectMeta = { + title: + 'Component Library / Views / MultichainAccounts / MultichainAccountConnect', + component: MultichainAccountConnect, + decorators: [ + ( + Story: React.ComponentType, + { + args, + }: { args: { accountGroups?: AccountGroupWithInternalAccounts[] } }, + ) => ( + + + + + + + + + + ), + ], + argTypes: { + hostInfo: { + control: { type: 'object' }, + description: 'Host information for the dApp requesting connection', + }, + permissionRequestId: { + control: { type: 'text' }, + description: 'Unique identifier for the permission request', + }, + accountGroups: { + control: { type: 'object' }, + description: 'Available account groups for connection', + }, + }, + parameters: { + docs: { + description: { + component: + 'A comprehensive component for managing multichain account connections to dApps. Handles permission requests, account selection, network selection, and phishing protection.', + }, + }, + }, +}; + +export default MultichainAccountConnectMeta; + +const createStoryArgs = ( + origin: string, + isEip1193Request = false, + accountGroups = mockAccountGroups, +) => ({ + route: { + params: { + hostInfo: createMockHostInfo(origin, isEip1193Request), + permissionRequestId: 'test-permission-request-id', + }, + } as AccountConnectProps['route'], + accountGroups, +}); + +export const Default = { + args: createStoryArgs('https://fake-url'), +}; + +export const SingleAccount = { + args: createStoryArgs('https://fake-rul', false, [mockAccountGroup1]), + parameters: { + docs: { + description: { + story: + 'Shows the component with only one account available for connection.', + }, + }, + }, +}; + +export const MultipleAccounts = { + args: createStoryArgs('https://fake-url'), + parameters: { + docs: { + description: { + story: + 'Shows the component with multiple accounts available for selection.', + }, + }, + }, +}; diff --git a/app/components/Views/MultichainAccounts/MultichainAccountConnect/MultichainAccountConnect.styles.ts b/app/components/Views/MultichainAccounts/MultichainAccountConnect/MultichainAccountConnect.styles.ts new file mode 100644 index 000000000000..dc8e40dcc098 --- /dev/null +++ b/app/components/Views/MultichainAccounts/MultichainAccountConnect/MultichainAccountConnect.styles.ts @@ -0,0 +1,23 @@ +// Third party dependencies. +import { StyleSheet, Platform, StatusBar } from 'react-native'; + +// External dependencies. +import { Theme } from '../../../../util/theme/models'; + +/** + * Style sheet function for MultichainAccountConnect screen. + * @returns StyleSheet object. + */ +const styleSheet = (params: { theme: Theme }) => { + const { colors } = params.theme; + + return StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background.default, + paddingTop: Platform.OS === 'android' ? StatusBar.currentHeight : 0, + }, + }); +}; + +export default styleSheet; diff --git a/app/components/Views/MultichainAccounts/MultichainAccountConnect/MultichainAccountConnect.test.tsx b/app/components/Views/MultichainAccounts/MultichainAccountConnect/MultichainAccountConnect.test.tsx new file mode 100644 index 000000000000..0062c80a64f2 --- /dev/null +++ b/app/components/Views/MultichainAccounts/MultichainAccountConnect/MultichainAccountConnect.test.tsx @@ -0,0 +1,2021 @@ +import React from 'react'; +import { fireEvent, waitFor } from '@testing-library/react-native'; +import { + Caip25EndowmentPermissionName, + Caip25CaveatType, + Caip25CaveatValue, +} from '@metamask/chain-agnostic-permission'; +import renderWithProvider, { + DeepPartial, +} from '../../../../util/test/renderWithProvider'; +import MultichainAccountConnect from './MultichainAccountConnect'; +import { backgroundState } from '../../../../util/test/initial-root-state'; +import { RootState } from '../../../../reducers'; +import Engine from '../../../../core/Engine'; +import { CommonSelectorsIDs } from '../../../../../e2e/selectors/Common.selectors'; +import { ConnectedAccountsSelectorsIDs } from '../../../../../e2e/selectors/Browser/ConnectedAccountModal.selectors'; +import { AccountListBottomSheetSelectorsIDs } from '../../../../../e2e/selectors/wallet/AccountListBottomSheet.selectors'; +import { ConnectAccountBottomSheetSelectorsIDs } from '../../../../../e2e/selectors/Browser/ConnectAccountBottomSheet.selectors'; +import { + createMockAccountsControllerState, + MOCK_ADDRESS_1, + MOCK_ADDRESS_2, +} from '../../../../util/test/accountsControllerTestUtils'; +import { KeyringTypes } from '@metamask/keyring-controller'; +import { PermissionDoesNotExistError } from '@metamask/permission-controller'; + +const mockNavigate = jest.fn(); +const mockGoBack = jest.fn(); +const mockTrackEvent = jest.fn(); +const mockCreateEventBuilder = jest.fn().mockReturnValue({ + addProperties: jest.fn().mockReturnValue({ + build: jest.fn(), + }), +}); +const mockGetNextAvailableAccountName = jest.fn().mockReturnValue('Account 3'); + +jest.mock('@react-navigation/native', () => { + const actualNav = jest.requireActual('@react-navigation/native'); + return { + ...actualNav, + useNavigation: () => ({ + navigate: mockNavigate, + goBack: mockGoBack, + }), + }; +}); + +jest.mock('../../../hooks/useMetrics', () => ({ + useMetrics: () => ({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }), +})); + +jest.mock('react-native-scrollable-tab-view', () => ({ + __esModule: true, + default: ({ children }: { children: React.ReactNode }) => <>{children}, + DefaultTabBar: ({ children }: { children: React.ReactNode }) => ( + <>{children} + ), +})); + +jest.mock('react-native-safe-area-context', () => { + const inset = { top: 0, right: 0, bottom: 0, left: 0 }; + const frame = { width: 0, height: 0, x: 0, y: 0 }; + const { View } = jest.requireActual('react-native'); + return { + SafeAreaProvider: jest.fn().mockImplementation(({ children }) => children), + SafeAreaConsumer: jest + .fn() + .mockImplementation(({ children }) => children(inset)), + SafeAreaView: View, + useSafeAreaInsets: jest.fn().mockImplementation(() => inset), + useSafeAreaFrame: jest.fn().mockImplementation(() => frame), + }; +}); + +const mockRejectPermissionsRequest = jest.fn(); +const mockAcceptPermissionsRequest = jest.fn().mockResolvedValue(undefined); +const mockRemoveChannel = jest.fn(); +const mockGetConnection = jest.fn(); + +jest.mock('../../../../core/Engine', () => { + const { + createMockAccountsControllerState: createMockAccountsControllerStateUtil, + MOCK_ADDRESS_1: mockAddress1, + MOCK_ADDRESS_2: mockAddress2, + // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires + } = require('../../../../util/test/accountsControllerTestUtils'); + const mockAccountsState = createMockAccountsControllerStateUtil( + [mockAddress1, mockAddress2], + mockAddress1, + ); + // eslint-disable-next-line @typescript-eslint/no-shadow + const { KeyringTypes } = jest.requireActual('@metamask/keyring-controller'); + + return { + context: { + PhishingController: { + maybeUpdateState: jest.fn(), + test: jest.fn((url: string) => { + if (url === 'phishing.com') return { result: true }; + return { result: false }; + }), + scanUrl: jest.fn(async (url: string) => { + if (url === 'https://phishing.com') { + return { recommendedAction: 'BLOCK' }; + } + return { recommendedAction: 'NONE' }; + }), + }, + PermissionController: { + rejectPermissionsRequest: mockRejectPermissionsRequest, + getCaveat: jest.fn(), + acceptPermissionsRequest: mockAcceptPermissionsRequest, + updateCaveat: jest.fn(), + grantPermissionsIncremental: jest.fn(), + hasCaveat: jest.fn().mockReturnValue(false), + }, + AccountsController: { + state: mockAccountsState, + getAccountByAddress: jest.fn(), + getNextAvailableAccountName: () => mockGetNextAvailableAccountName(), + }, + KeyringController: { + state: { + keyrings: [ + { + type: KeyringTypes.hd, + accounts: [mockAddress1, mockAddress2], + metadata: { + id: '01JNG71B7GTWH0J1TSJY9891S0', + name: '', + }, + }, + ], + }, + }, + }, + }; +}); + +jest.mock('../../../../core/SDKConnect/SDKConnect', () => ({ + getInstance: () => ({ + removeChannel: mockRemoveChannel, + getConnection: mockGetConnection, + }), +})); + +jest.mock('../../../../core/SDKConnect/utils/isUUID', () => ({ + isUUID: jest.fn(() => false), +})); + +const { isUUID: mockIsUUID } = jest.requireMock( + '../../../../core/SDKConnect/utils/isUUID', +); + +jest.mock('../../../../util/phishingDetection', () => ({ + getPhishingTestResultAsync: jest.fn().mockResolvedValue({ result: false }), + isProductSafetyDappScanningEnabled: jest.fn().mockReturnValue(false), +})); + +jest.mock('../../../../util/metrics', () => ({ + trackDappViewedEvent: jest.fn(), +})); + +jest.mock('../../../hooks/useFavicon/useFavicon', () => + jest.fn(() => 'favicon-url'), +); + +jest.mock('../../../hooks/useOriginSource', () => jest.fn(() => 'test-source')); + +jest.mock( + '../../../../selectors/multichainAccounts/accountTreeController', + () => ({ + ...jest.requireActual( + '../../../../selectors/multichainAccounts/accountTreeController', + ), + selectAccountGroups: jest.fn(() => [ + { + id: 'entropy:01JKAF3DSGM3AB87EM9N0K41AJ/0', + accounts: ['01JKAF3DSGM3AB87EM9N0K41AJ'], + metadata: { name: 'Account 1' }, + }, + { + id: 'entropy:01JKAF3DSGM3AB87EM9N0K41AJ/1', + accounts: ['01JKAF3DSGM3AB87EM9N0K41AJ'], + metadata: { name: 'Account 2' }, + }, + ]), + selectAccountGroupsByWallet: jest.fn(() => [ + { + title: 'Test Wallet', + wallet: { + id: 'entropy:01JKAF3DSGM3AB87EM9N0K41AJ', + metadata: { name: 'Test Wallet' }, + }, + data: [ + { + id: 'entropy:01JKAF3DSGM3AB87EM9N0K41AJ/0', + accounts: ['01JKAF3DSGM3AB87EM9N0K41AJ'], + metadata: { name: 'Account 1' }, + }, + { + id: 'entropy:01JKAF3DSGM3AB87EM9N0K41AJ/1', + accounts: ['01JKAF3DSGM3AB87EM9N0K41AJ'], + metadata: { name: 'Account 2' }, + }, + ], + }, + ]), + selectWalletsMap: jest.fn(() => ({ + 'entropy:01JKAF3DSGM3AB87EM9N0K41AJ': { + id: 'entropy:01JKAF3DSGM3AB87EM9N0K41AJ', + metadata: { name: 'Test Wallet' }, + groups: { + 'entropy:01JKAF3DSGM3AB87EM9N0K41AJ/0': { + id: 'entropy:01JKAF3DSGM3AB87EM9N0K41AJ/0', + accounts: ['01JKAF3DSGM3AB87EM9N0K41AJ'], + metadata: { name: 'Account 1' }, + }, + 'entropy:01JKAF3DSGM3AB87EM9N0K41AJ/1': { + id: 'entropy:01JKAF3DSGM3AB87EM9N0K41AJ/1', + accounts: ['01JKAF3DSGM3AB87EM9N0K41AJ'], + metadata: { name: 'Account 2' }, + }, + }, + }, + })), + }), +); + +// Mock feature flag selector +jest.mock( + '../../../../selectors/featureFlagController/multichainAccounts/enabledMultichainAccounts', + () => ({ + selectMultichainAccountsState1Enabled: jest.fn(() => true), + }), +); + +jest.mock('../../../../selectors/accountsController', () => ({ + ...jest.requireActual('../../../../selectors/accountsController'), + selectInternalAccountsById: jest.fn(() => ({ + '01JKAF3DSGM3AB87EM9N0K41AJ': { + id: '01JKAF3DSGM3AB87EM9N0K41AJ', + address: MOCK_ADDRESS_1, + metadata: { name: 'Account 1' }, + scopes: ['eip155:1'], + }, + })), +})); + +jest.mock('../../../../selectors/assets/balances', () => ({ + ...jest.requireActual('../../../../selectors/assets/balances'), + selectBalanceByAccountGroup: jest.fn(() => () => ({ + totalBalanceInUserCurrency: 100.5, + userCurrency: 'usd', + })), +})); + +// Mock useAccountGroupsForPermissions hook +jest.mock( + '../../../hooks/useAccountGroupsForPermissions/useAccountGroupsForPermissions', + () => ({ + useAccountGroupsForPermissions: jest.fn(() => ({ + supportedAccountGroups: [ + { + id: 'entropy:01JKAF3DSGM3AB87EM9N0K41AJ/0', + accounts: ['01JKAF3DSGM3AB87EM9N0K41AJ'], + metadata: { name: 'Account 1' }, + }, + { + id: 'entropy:01JKAF3DSGM3AB87EM9N0K41AJ/1', + accounts: ['01JKAF3DSGM3AB87EM9N0K41AJ'], + metadata: { name: 'Account 2' }, + }, + ], + connectedAccountGroups: [], + })), + }), +); + +// Mock useWalletInfo hook +jest.mock( + '../../../../components/Views/MultichainAccounts/WalletDetails/hooks/useWalletInfo', + () => ({ + useWalletInfo: jest.fn(() => ({ + keyringId: 'test-keyring-id', + walletType: 'entropy', + })), + }), +); + +const createMockCaip25Permission = ( + optionalScopes: Record, +) => ({ + [Caip25EndowmentPermissionName]: { + parentCapability: Caip25EndowmentPermissionName, + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes, + isMultichainOrigin: false, + sessionProperties: {}, + }, + }, + ] as [{ type: string; value: Caip25CaveatValue }], + }, +}); + +const createMockState = (): DeepPartial => ({ + settings: {}, + engine: { + backgroundState: { + ...backgroundState, + AccountsController: createMockAccountsControllerState( + [MOCK_ADDRESS_1, MOCK_ADDRESS_2], + MOCK_ADDRESS_1, + ), + AccountTreeController: { + accountTree: { + wallets: { + 'entropy:01JKAF3DSGM3AB87EM9N0K41AJ': { + id: 'entropy:01JKAF3DSGM3AB87EM9N0K41AJ', + metadata: { name: 'Test Wallet' }, + groups: { + 'entropy:01JKAF3DSGM3AB87EM9N0K41AJ/0': { + id: 'entropy:01JKAF3DSGM3AB87EM9N0K41AJ/0', + accounts: ['01JKAF3DSGM3AB87EM9N0K41AJ'], + metadata: { name: 'Account 1' }, + }, + 'entropy:01JKAF3DSGM3AB87EM9N0K41AJ/1': { + id: 'entropy:01JKAF3DSGM3AB87EM9N0K41AJ/1', + accounts: ['01JKAF3DSGM3AB87EM9N0K41AJ'], + metadata: { name: 'Account 2' }, + }, + }, + }, + }, + selectedAccountGroup: 'entropy:01JKAF3DSGM3AB87EM9N0K41AJ/0', + }, + }, + NetworkController: { + networkConfigurationsByChainId: { + '0x1': { + chainId: '0x1', + name: 'Ethereum', + rpcEndpoints: [{ url: 'https://mainnet.infura.io/v3/test' }], + blockExplorerUrls: ['https://etherscan.io'], + nativeCurrency: 'ETH', + }, + }, + selectedNetworkClientId: '1', + }, + NetworkEnablementController: { + enabledNetworkMap: { + eip155: { + '0x1': true, + }, + }, + }, + KeyringController: { + keyrings: [ + { + type: KeyringTypes.hd, + accounts: [MOCK_ADDRESS_1, MOCK_ADDRESS_2], + metadata: { + id: '01JNG71B7GTWH0J1TSJY9891S0', + name: '', + }, + }, + ], + }, + }, + }, +}); + +mockGetConnection.mockReturnValue(undefined); +mockIsUUID.mockReturnValue(false); + +describe('MultichainAccountConnect', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders correctly with base request when there is no existing CAIP endowment', () => { + ( + Engine.context.PermissionController.getCaveat as jest.Mock + ).mockImplementation(() => { + throw new PermissionDoesNotExistError( + 'Permission does not exist', + Caip25EndowmentPermissionName, + ); + }); + const { getByTestId } = renderWithProvider( + , + { state: createMockState() }, + ); + + expect(getByTestId(CommonSelectorsIDs.CONNECT_BUTTON)).toBeTruthy(); + expect(getByTestId(CommonSelectorsIDs.CANCEL_BUTTON)).toBeTruthy(); + }); + + it('renders correctly with request including chains and accounts', () => { + const { getByTestId } = renderWithProvider( + , + { state: createMockState() }, + ); + + expect(getByTestId(CommonSelectorsIDs.CONNECT_BUTTON)).toBeTruthy(); + expect(getByTestId(CommonSelectorsIDs.CANCEL_BUTTON)).toBeTruthy(); + }); + + it('renders correctly with request including only chains', () => { + const { getByTestId } = renderWithProvider( + , + { state: createMockState() }, + ); + + expect(getByTestId(CommonSelectorsIDs.CONNECT_BUTTON)).toBeTruthy(); + expect(getByTestId(CommonSelectorsIDs.CANCEL_BUTTON)).toBeTruthy(); + }); + + it('handles cancel button press correctly', () => { + Engine.context.PermissionController.rejectPermissionsRequest = + mockRejectPermissionsRequest; + const { getByTestId } = renderWithProvider( + , + { state: createMockState() }, + ); + + const cancelButton = getByTestId(CommonSelectorsIDs.CANCEL_BUTTON); + fireEvent.press(cancelButton); + + expect(mockTrackEvent).toHaveBeenCalled(); + expect(mockRejectPermissionsRequest).toHaveBeenCalledWith('test'); + expect(mockRemoveChannel).toHaveBeenCalledWith({ + channelId: 'mockOrigin', + sendTerminate: true, + }); + expect(mockCreateEventBuilder).toHaveBeenCalled(); + }); + + it('handles confirm button press correctly', async () => { + const mockAcceptPermissionsRequestLocal = jest + .fn() + .mockResolvedValue(undefined); + const mockUpdateCaveat = jest.fn(); + const mockGrantPermissionsIncremental = jest.fn(); + + Engine.context.PermissionController.acceptPermissionsRequest = + mockAcceptPermissionsRequestLocal; + Engine.context.PermissionController.updateCaveat = mockUpdateCaveat; + Engine.context.PermissionController.grantPermissionsIncremental = + mockGrantPermissionsIncremental; + + const { getByTestId } = renderWithProvider( + , + { state: createMockState() }, + ); + + const confirmButton = getByTestId(CommonSelectorsIDs.CONNECT_BUTTON); + fireEvent.press(confirmButton); + + await waitFor(() => { + expect(mockAcceptPermissionsRequestLocal).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ + origin: 'https://example.com', + id: 'mockId', + isEip1193Request: true, + }), + permissions: expect.objectContaining({ + [Caip25EndowmentPermissionName]: expect.any(Object), + }), + }), + ); + }); + }); + + describe('Phishing detection', () => { + describe('dapp scanning is enabled', () => { + it('does not show phishing modal for safe URLs', async () => { + const { queryByText } = renderWithProvider( + , + { state: createMockState() }, + ); + + const warningText = queryByText( + `MetaMask flagged the site you're trying to visit as potentially deceptive.`, + ); + expect(warningText).toBeNull(); + }); + }); + }); + + describe('Domain title and hostname logic', () => { + beforeEach(() => { + mockGetConnection.mockReset(); + mockGetConnection.mockReturnValue(undefined); + mockIsUUID.mockReset(); + mockIsUUID.mockReturnValue(false); + jest.clearAllMocks(); + }); + + it('handles MMSDK remote connection origin correctly', () => { + const mockOrigin = 'https://example-dapp.com'; + const mockChannelId = `metamask-sdk://connect?redirect=${mockOrigin}`; + + mockGetConnection.mockReturnValue({ + originatorInfo: { url: 'https://test.com' }, + }); + + mockIsUUID.mockReturnValue(false); + + const mockStateWithoutWC2 = { + ...createMockState(), + sdk: { + wc2Metadata: { id: '' }, // Empty to avoid WalletConnect branch + }, + }; + + const { getByTestId } = renderWithProvider( + , + { state: mockStateWithoutWC2 }, + ); + + const connectButton = getByTestId(CommonSelectorsIDs.CONNECT_BUTTON); + expect(connectButton).toBeDefined(); + expect(mockGetConnection).toHaveBeenCalledWith({ + channelId: mockChannelId, + }); + }); + + it('handles WalletConnect origin correctly', () => { + const mockChannelId = 'walletconnect-origin.com'; + + mockGetConnection.mockReturnValue(undefined); + + mockIsUUID.mockReturnValue(false); + + const mockStateWithWC2 = { + ...createMockState(), + sdk: { + wc2Metadata: { id: 'mock-wc2-id' }, + }, + }; + + const { getByTestId } = renderWithProvider( + , + { state: mockStateWithWC2 }, + ); + + const connectButton = getByTestId(CommonSelectorsIDs.CONNECT_BUTTON); + expect(connectButton).toBeDefined(); + expect(mockGetConnection).toHaveBeenCalledWith({ + channelId: mockChannelId, + }); + }); + }); + + it('handles permission request rejection gracefully', async () => { + const mockAcceptPermissionsRequestError = jest + .fn() + .mockRejectedValue(new Error('Permission denied')); + + Engine.context.PermissionController.acceptPermissionsRequest = + mockAcceptPermissionsRequestError; + + const { getByTestId } = renderWithProvider( + , + { state: createMockState() }, + ); + + const confirmButton = getByTestId(CommonSelectorsIDs.CONNECT_BUTTON); + fireEvent.press(confirmButton); + + await waitFor(() => { + expect(mockAcceptPermissionsRequestError).toHaveBeenCalled(); + }); + }); + + it('handles network controller errors during connection', async () => { + const mockAcceptPermissionsRequestNetworkError = jest + .fn() + .mockRejectedValue(new Error('Network error')); + + Engine.context.PermissionController.acceptPermissionsRequest = + mockAcceptPermissionsRequestNetworkError; + + const { getByTestId } = renderWithProvider( + , + { state: createMockState() }, + ); + + const confirmButton = getByTestId(CommonSelectorsIDs.CONNECT_BUTTON); + fireEvent.press(confirmButton); + + await waitFor(() => { + expect(mockAcceptPermissionsRequestNetworkError).toHaveBeenCalled(); + }); + }); + + describe('Account selection and multi-selector', () => { + it('renders multi-selector screen when editing accounts', () => { + const { getByTestId } = renderWithProvider( + , + { state: createMockState() }, + ); + + // Should render the connect button initially + expect(getByTestId(CommonSelectorsIDs.CONNECT_BUTTON)).toBeTruthy(); + }); + + it('handles account group selection correctly', () => { + const { getByTestId } = renderWithProvider( + , + { state: createMockState() }, + ); + + expect(getByTestId(CommonSelectorsIDs.CONNECT_BUTTON)).toBeTruthy(); + }); + + it('handles empty account selection', () => { + const { getByTestId } = renderWithProvider( + , + { state: createMockState() }, + ); + + const connectButton = getByTestId(CommonSelectorsIDs.CONNECT_BUTTON); + expect(connectButton).toBeTruthy(); + }); + }); + + describe('Network selection and chain handling', () => { + it('handles multiple chain requests correctly', () => { + const { getByTestId } = renderWithProvider( + , + { state: createMockState() }, + ); + + expect(getByTestId(CommonSelectorsIDs.CONNECT_BUTTON)).toBeTruthy(); + expect(getByTestId(CommonSelectorsIDs.CANCEL_BUTTON)).toBeTruthy(); + }); + + it('handles unsupported chain requests', () => { + const { getByTestId } = renderWithProvider( + , + { state: createMockState() }, + ); + + expect(getByTestId(CommonSelectorsIDs.CONNECT_BUTTON)).toBeTruthy(); + }); + + it('handles network switching scenarios', () => { + const { getByTestId } = renderWithProvider( + , + { state: createMockState() }, + ); + + expect(getByTestId(CommonSelectorsIDs.CONNECT_BUTTON)).toBeTruthy(); + }); + }); + + describe('Permissions summary screen', () => { + it('renders permissions summary with correct information', () => { + const { getByTestId } = renderWithProvider( + , + { state: createMockState() }, + ); + + expect(getByTestId(CommonSelectorsIDs.CONNECT_BUTTON)).toBeTruthy(); + expect(getByTestId(CommonSelectorsIDs.CANCEL_BUTTON)).toBeTruthy(); + }); + + it('handles edit accounts action from permissions summary', () => { + const { getByTestId } = renderWithProvider( + , + { state: createMockState() }, + ); + + expect(getByTestId(CommonSelectorsIDs.CONNECT_BUTTON)).toBeTruthy(); + }); + + it('handles edit networks action from permissions summary', () => { + const { getByTestId } = renderWithProvider( + , + { state: createMockState() }, + ); + + expect(getByTestId(CommonSelectorsIDs.CONNECT_BUTTON)).toBeTruthy(); + }); + }); + + it('handles empty permissions gracefully', () => { + const { getByTestId } = renderWithProvider( + , + { state: createMockState() }, + ); + + expect(getByTestId(CommonSelectorsIDs.CONNECT_BUTTON)).toBeTruthy(); + expect(getByTestId(CommonSelectorsIDs.CANCEL_BUTTON)).toBeTruthy(); + }); + + it('handles invalid origin URLs', () => { + const { getByTestId } = renderWithProvider( + , + { state: createMockState() }, + ); + + expect(getByTestId(CommonSelectorsIDs.CONNECT_BUTTON)).toBeTruthy(); + }); + + it('handles missing metadata gracefully', () => { + const { getByTestId } = renderWithProvider( + , + { state: createMockState() }, + ); + + expect(getByTestId(CommonSelectorsIDs.CONNECT_BUTTON)).toBeTruthy(); + }); + + it('handles malformed CAIP account IDs', () => { + const { getByTestId } = renderWithProvider( + , + { state: createMockState() }, + ); + + expect(getByTestId(CommonSelectorsIDs.CONNECT_BUTTON)).toBeTruthy(); + }); + + describe('Account group selection logic', () => { + it('selects first supported account group when no connected account groups exist', () => { + const mockStateWithMultipleAccounts = { + ...createMockState(), + engine: { + ...createMockState().engine, + backgroundState: { + ...createMockState().engine?.backgroundState, + AccountsController: createMockAccountsControllerState( + [MOCK_ADDRESS_1, MOCK_ADDRESS_2], + MOCK_ADDRESS_1, + ), + }, + }, + }; + + const { getByTestId } = renderWithProvider( + , + { state: mockStateWithMultipleAccounts }, + ); + + expect(getByTestId(CommonSelectorsIDs.CONNECT_BUTTON)).toBeTruthy(); + expect(getByTestId(CommonSelectorsIDs.CANCEL_BUTTON)).toBeTruthy(); + }); + + it('handles scenario with no supported account groups', () => { + const mockStateWithMinimalAccounts = { + ...createMockState(), + engine: { + ...createMockState().engine, + backgroundState: { + ...createMockState().engine?.backgroundState, + AccountsController: createMockAccountsControllerState( + [MOCK_ADDRESS_1], // At least one account required by utility + MOCK_ADDRESS_1, + ), + }, + }, + }; + + const { getByTestId } = renderWithProvider( + , + { state: mockStateWithMinimalAccounts }, + ); + + expect(getByTestId(CommonSelectorsIDs.CONNECT_BUTTON)).toBeTruthy(); + expect(getByTestId(CommonSelectorsIDs.CANCEL_BUTTON)).toBeTruthy(); + }); + + it('uses connected account groups when they exist', () => { + ( + Engine.context.PermissionController.getCaveat as jest.Mock + ).mockReturnValue({ + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + accounts: [`eip155:1:${MOCK_ADDRESS_1}`], + }, + }, + isMultichainOrigin: false, + sessionProperties: {}, + }, + }); + + const { getByTestId } = renderWithProvider( + , + { state: createMockState() }, + ); + + expect(getByTestId(CommonSelectorsIDs.CONNECT_BUTTON)).toBeTruthy(); + expect(getByTestId(CommonSelectorsIDs.CANCEL_BUTTON)).toBeTruthy(); + }); + }); + + describe('Phishing modal navigation functions coverage', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('creates phishing navigation callback functions', () => { + const { getByTestId } = renderWithProvider( + , + { state: createMockState() }, + ); + + expect(getByTestId(CommonSelectorsIDs.CONNECT_BUTTON)).toBeTruthy(); + expect(getByTestId(CommonSelectorsIDs.CANCEL_BUTTON)).toBeTruthy(); + }); + + it('handles different origin formats for phishing detection setup', () => { + const { getByTestId } = renderWithProvider( + , + { state: createMockState() }, + ); + + expect(getByTestId(CommonSelectorsIDs.CONNECT_BUTTON)).toBeTruthy(); + expect(getByTestId(CommonSelectorsIDs.CANCEL_BUTTON)).toBeTruthy(); + }); + + it('sets up phishing modal callbacks with proper dependencies', () => { + const { getByTestId } = renderWithProvider( + , + { state: createMockState() }, + ); + + expect(getByTestId(CommonSelectorsIDs.CONNECT_BUTTON)).toBeTruthy(); + expect(getByTestId(CommonSelectorsIDs.CANCEL_BUTTON)).toBeTruthy(); + }); + }); + + describe('handleNetworksSelected function and network tab functionality', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('navigates to network selector screen when editing networks', async () => { + const { getByTestId, findByTestId } = renderWithProvider( + , + { state: createMockState() }, + ); + + // Find and click the edit networks button to trigger network selector + const editNetworksButton = getByTestId( + ConnectedAccountsSelectorsIDs.NAVIGATE_TO_EDIT_NETWORKS_PERMISSIONS_BUTTON, + ); + fireEvent.press(editNetworksButton); + + // Verify that the network selector screen is shown by checking for its elements + const networkSelectorElement = await findByTestId( + 'multiconnect-connect-network-button', + ); + expect(networkSelectorElement).toBeTruthy(); + }); + + it('returns to single connect screen after network selection', async () => { + const mockStateWithMultipleNetworks = { + ...createMockState(), + engine: { + ...createMockState().engine, + backgroundState: { + ...createMockState().engine?.backgroundState, + NetworkController: { + networkConfigurationsByChainId: { + '0x1': { + chainId: '0x1' as `0x${string}`, + name: 'Ethereum', + rpcEndpoints: [{ url: 'https://mainnet.infura.io/v3/test' }], + blockExplorerUrls: ['https://etherscan.io'], + nativeCurrency: 'ETH', + defaultRpcEndpointIndex: 0, + }, + '0x89': { + chainId: '0x89' as `0x${string}`, + name: 'Polygon', + rpcEndpoints: [{ url: 'https://polygon-rpc.com' }], + blockExplorerUrls: ['https://polygonscan.com'], + nativeCurrency: 'MATIC', + defaultRpcEndpointIndex: 0, + }, + }, + selectedNetworkClientId: '1', + }, + }, + }, + }; + + const { getByTestId, findByTestId } = renderWithProvider( + , + { state: mockStateWithMultipleNetworks }, + ); + + const editNetworksButton = getByTestId( + ConnectedAccountsSelectorsIDs.NAVIGATE_TO_EDIT_NETWORKS_PERMISSIONS_BUTTON, + ); + fireEvent.press(editNetworksButton); + + const networkSelectorButton = await findByTestId( + 'multiconnect-connect-network-button', + ); + expect(networkSelectorButton).toBeTruthy(); + + fireEvent.press(networkSelectorButton); + + expect( + await findByTestId(CommonSelectorsIDs.CONNECT_BUTTON), + ).toBeTruthy(); + }); + + it('correctly updates selectedChainIds state when networks are selected', async () => { + const mockStateWithNetworks = { + ...createMockState(), + engine: { + ...createMockState().engine, + backgroundState: { + ...createMockState().engine?.backgroundState, + NetworkController: { + networkConfigurationsByChainId: { + '0x1': { + chainId: '0x1' as `0x${string}`, + name: 'Ethereum', + rpcEndpoints: [{ url: 'https://mainnet.infura.io/v3/test' }], + blockExplorerUrls: ['https://etherscan.io'], + nativeCurrency: 'ETH', + defaultRpcEndpointIndex: 0, + }, + '0x89': { + chainId: '0x89' as `0x${string}`, + name: 'Polygon', + rpcEndpoints: [{ url: 'https://polygon-rpc.com' }], + blockExplorerUrls: ['https://polygonscan.com'], + nativeCurrency: 'MATIC', + defaultRpcEndpointIndex: 0, + }, + '0xa': { + chainId: '0xa' as `0x${string}`, + name: 'Optimism', + rpcEndpoints: [{ url: 'https://optimism-rpc.com' }], + blockExplorerUrls: ['https://optimistic.etherscan.io'], + nativeCurrency: 'ETH', + defaultRpcEndpointIndex: 0, + }, + }, + selectedNetworkClientId: '1', + }, + }, + }, + }; + + const { getByTestId, findByTestId } = renderWithProvider( + , + { state: mockStateWithNetworks }, + ); + + // Navigate to network selector + const editNetworksButton = getByTestId( + ConnectedAccountsSelectorsIDs.NAVIGATE_TO_EDIT_NETWORKS_PERMISSIONS_BUTTON, + ); + fireEvent.press(editNetworksButton); + + // Wait for network selector to appear + const networkSelector = await findByTestId( + 'multiconnect-connect-network-button', + ); + expect(networkSelector).toBeTruthy(); + + // Try to find and select additional networks + // Note: Network selection would happen here in a real test scenario + + // Click update to confirm selection + fireEvent.press(networkSelector); + + // Verify we return to the main screen + expect( + await findByTestId(CommonSelectorsIDs.CONNECT_BUTTON), + ).toBeTruthy(); + }); + + it('verifies networks appear correctly selected in network selector', async () => { + const mockStateWithNetworks = { + ...createMockState(), + engine: { + ...createMockState().engine, + backgroundState: { + ...createMockState().engine?.backgroundState, + NetworkController: { + networkConfigurationsByChainId: { + '0x1': { + chainId: '0x1' as `0x${string}`, + name: 'Ethereum', + rpcEndpoints: [{ url: 'https://mainnet.infura.io/v3/test' }], + blockExplorerUrls: ['https://etherscan.io'], + nativeCurrency: 'ETH', + defaultRpcEndpointIndex: 0, + }, + '0x89': { + chainId: '0x89' as `0x${string}`, + name: 'Polygon', + rpcEndpoints: [{ url: 'https://polygon-rpc.com' }], + blockExplorerUrls: ['https://polygonscan.com'], + nativeCurrency: 'MATIC', + defaultRpcEndpointIndex: 0, + }, + }, + selectedNetworkClientId: '1', + }, + }, + }, + }; + + const { getByTestId, findByTestId } = renderWithProvider( + , + { state: mockStateWithNetworks }, + ); + + const editNetworksButton = getByTestId( + ConnectedAccountsSelectorsIDs.NAVIGATE_TO_EDIT_NETWORKS_PERMISSIONS_BUTTON, + ); + fireEvent.press(editNetworksButton); + + const networkSelectorButton = await findByTestId( + 'multiconnect-connect-network-button', + ); + expect(networkSelectorButton).toBeTruthy(); + + const ethereumSelected = await findByTestId('Ethereum-selected'); + expect(ethereumSelected).toBeTruthy(); + + const polygonSelected = await findByTestId('Polygon-selected'); + expect(polygonSelected).toBeTruthy(); + }); + + it('verifies individual network selection toggles correctly', async () => { + const mockStateWithNetworks = { + ...createMockState(), + engine: { + ...createMockState().engine, + backgroundState: { + ...createMockState().engine?.backgroundState, + NetworkController: { + networkConfigurationsByChainId: { + '0x1': { + chainId: '0x1' as `0x${string}`, + name: 'Ethereum', + rpcEndpoints: [{ url: 'https://mainnet.infura.io/v3/test' }], + blockExplorerUrls: ['https://etherscan.io'], + nativeCurrency: 'ETH', + defaultRpcEndpointIndex: 0, + }, + '0x89': { + chainId: '0x89' as `0x${string}`, + name: 'Polygon', + rpcEndpoints: [{ url: 'https://polygon-rpc.com' }], + blockExplorerUrls: ['https://polygonscan.com'], + nativeCurrency: 'MATIC', + defaultRpcEndpointIndex: 0, + }, + }, + selectedNetworkClientId: '1', + }, + }, + }, + }; + + const { getByTestId, findByTestId, queryByTestId } = renderWithProvider( + , + { state: mockStateWithNetworks }, + ); + + const editNetworksButton = getByTestId( + ConnectedAccountsSelectorsIDs.NAVIGATE_TO_EDIT_NETWORKS_PERMISSIONS_BUTTON, + ); + fireEvent.press(editNetworksButton); + + const networkSelectorButton = await findByTestId( + 'multiconnect-connect-network-button', + ); + expect(networkSelectorButton).toBeTruthy(); + + const ethereumSelected = queryByTestId('Ethereum-selected'); + + expect(ethereumSelected).toBeTruthy(); + + const polygonSelected = queryByTestId('Polygon-selected'); + + expect(polygonSelected).toBeTruthy(); + }); + }); + + describe('handleAccountGroupsSelected function tests', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('verifies handleAccountGroupsSelected updates component state after account selection', async () => { + const mockAcceptPermissions = jest.fn().mockResolvedValue(undefined); + Engine.context.PermissionController.acceptPermissionsRequest = + mockAcceptPermissions; + + const { getByTestId } = renderWithProvider( + , + { state: createMockState() }, + ); + + const connectButton = getByTestId(CommonSelectorsIDs.CONNECT_BUTTON); + fireEvent.press(connectButton); + + await waitFor(() => { + expect(mockAcceptPermissions).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ + origin: 'https://example.com', + }), + permissions: expect.objectContaining({ + [Caip25EndowmentPermissionName]: expect.any(Object), + }), + }), + ); + }); + + const permissionRequest = mockAcceptPermissions.mock.calls[0][0]; + expect( + permissionRequest.permissions[Caip25EndowmentPermissionName], + ).toBeDefined(); + + expect(mockAcceptPermissions).toHaveBeenCalledTimes(1); + }); + + it('displays selected accounts correctly in permissions summary', () => { + const { getByTestId } = renderWithProvider( + , + { state: createMockState() }, + ); + + expect(getByTestId('account-list-bottom-sheet')).toBeTruthy(); + expect(getByTestId(CommonSelectorsIDs.CONNECT_BUTTON)).toBeTruthy(); + expect(getByTestId(CommonSelectorsIDs.CANCEL_BUTTON)).toBeTruthy(); + }); + + it('verifies handleAccountGroupsSelected updates CAIP25 account IDs correctly', async () => { + const mockAcceptPermissionsRequestLocal = jest + .fn() + .mockResolvedValue(undefined); + + Engine.context.PermissionController.acceptPermissionsRequest = + mockAcceptPermissionsRequestLocal; + + const { getByTestId } = renderWithProvider( + , + { state: createMockState() }, + ); + + const connectButton = getByTestId(CommonSelectorsIDs.CONNECT_BUTTON); + fireEvent.press(connectButton); + + await waitFor(() => { + expect(mockAcceptPermissionsRequestLocal).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ + origin: 'https://example.com', + }), + permissions: expect.objectContaining({ + [Caip25EndowmentPermissionName]: expect.any(Object), + }), + }), + ); + }); + + const callArgs = mockAcceptPermissionsRequestLocal.mock.calls[0][0]; + expect(callArgs.permissions[Caip25EndowmentPermissionName]).toBeDefined(); + expect( + callArgs.permissions[Caip25EndowmentPermissionName].caveats, + ).toBeDefined(); + }); + + it('handles multiple account selections correctly', () => { + const { getByTestId } = renderWithProvider( + , + { state: createMockState() }, + ); + + expect(getByTestId('account-list-bottom-sheet')).toBeTruthy(); + + expect(getByTestId(CommonSelectorsIDs.CONNECT_BUTTON)).toBeTruthy(); + expect(getByTestId(CommonSelectorsIDs.CANCEL_BUTTON)).toBeTruthy(); + }); + + it('verifies handleAccountGroupsSelected handles multi-chain scenarios', () => { + const { getByTestId, getAllByTestId } = renderWithProvider( + , + { state: createMockState() }, + ); + + const avatarGroups = getAllByTestId('avatar-group-container'); + expect(avatarGroups.length).toBe(2); + + expect(getByTestId('account-list-bottom-sheet')).toBeTruthy(); + + expect(getByTestId(CommonSelectorsIDs.CONNECT_BUTTON)).toBeTruthy(); + }); + + it('verifies handleAccountGroupsSelected function behavior is testable', () => { + const { getByTestId } = renderWithProvider( + , + { state: createMockState() }, + ); + + expect(getByTestId('account-list-bottom-sheet')).toBeTruthy(); + expect(getByTestId(CommonSelectorsIDs.CONNECT_BUTTON)).toBeTruthy(); + expect(getByTestId(CommonSelectorsIDs.CANCEL_BUTTON)).toBeTruthy(); + + expect(getByTestId('permission-summary-container')).toBeTruthy(); + }); + + it('selects Account 2 through edit accounts flow and MultichainAccountConnectMultiSelector', async () => { + const mockStateWithAccountGroups = { + ...createMockState(), + engine: { + ...createMockState().engine, + backgroundState: { + ...createMockState().engine?.backgroundState, + AccountsController: createMockAccountsControllerState( + [MOCK_ADDRESS_1, MOCK_ADDRESS_2], + MOCK_ADDRESS_1, + ), + }, + }, + }; + + const { getByTestId, findByTestId } = renderWithProvider( + , + { state: mockStateWithAccountGroups }, + ); + + expect(getByTestId('permission-summary-account-text')).toHaveTextContent( + 'Requesting for Account 1', + ); + + const editAccountsButton = getByTestId('permission-summary-container'); + fireEvent.press(editAccountsButton); + + const accountSelectorList = await findByTestId( + AccountListBottomSheetSelectorsIDs.ACCOUNT_LIST_ID, + ); + expect(accountSelectorList).toBeTruthy(); + + const accountList = await findByTestId('account-list'); + expect(accountList).toBeTruthy(); + + const account1Text = getByTestId('account-list').findByProps({ + children: 'Account 1', + }); + expect(account1Text).toBeTruthy(); + + const account2Text = getByTestId('account-list').findByProps({ + children: 'Account 2', + }); + expect(account2Text).toBeTruthy(); + + const account2Cell = account2Text.parent?.parent?.parent?.parent; + expect(account2Cell).toBeTruthy(); + if (account2Cell) { + fireEvent.press(account2Cell); + } + + const updateButtonAfterSelect = await findByTestId( + ConnectAccountBottomSheetSelectorsIDs.SELECT_MULTI_BUTTON, + ); + expect(updateButtonAfterSelect).toBeTruthy(); + + fireEvent.press(updateButtonAfterSelect); + + const connectButton = await findByTestId( + CommonSelectorsIDs.CONNECT_BUTTON, + ); + expect(connectButton).toBeTruthy(); + + await waitFor(() => { + expect( + getByTestId('permission-summary-account-text'), + ).toHaveTextContent('Requesting for 2 accounts'); + }); + }); + }); +}); diff --git a/app/components/Views/MultichainAccounts/MultichainAccountConnect/MultichainAccountConnect.tsx b/app/components/Views/MultichainAccounts/MultichainAccountConnect/MultichainAccountConnect.tsx new file mode 100644 index 000000000000..12316b4c1569 --- /dev/null +++ b/app/components/Views/MultichainAccounts/MultichainAccountConnect/MultichainAccountConnect.tsx @@ -0,0 +1,752 @@ +// Third party dependencies. +import { useNavigation } from '@react-navigation/native'; +import React, { + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import Modal from 'react-native-modal'; +import { useSelector } from 'react-redux'; +import { NON_EVM_TESTNET_IDS } from '@metamask/multichain-network-controller'; + +// External dependencies. +import { strings } from '../../../../../locales/i18n.js'; +import { + ToastContext, + ToastVariants, +} from '../../../../component-library/components/Toast/index.ts'; +import { ToastOptions } from '../../../../component-library/components/Toast/Toast.types.ts'; +import { USER_INTENT } from '../../../../constants/permissions.ts'; +import { MetaMetricsEvents } from '../../../../core/Analytics/index.ts'; +import Engine from '../../../../core/Engine/index.ts'; +import { selectAccountsLength } from '../../../../selectors/accountTrackerController.ts'; +import Logger from '../../../../util/Logger/index.ts'; +import { + getHost, + getUrlObj, + prefixUrlWithProtocol, +} from '../../../../util/browser/index.ts'; + +// Internal dependencies. +import { PermissionsRequest } from '@metamask/permission-controller'; +import PhishingModal from '../../../UI/PhishingModal/index.js'; +import { useMetrics } from '../../../hooks/useMetrics/index.ts'; +import Routes from '../../../../constants/navigation/Routes.ts'; +import { + MM_BLOCKLIST_ISSUE_URL, + MM_ETHERSCAN_URL, + MM_PHISH_DETECT_URL, +} from '../../../../constants/urls.ts'; +import AppConstants from '../../../../core/AppConstants.ts'; +import SDKConnect from '../../../../core/SDKConnect/SDKConnect.ts'; +import DevLogger from '../../../../core/SDKConnect/utils/DevLogger.ts'; +import { RootState } from '../../../../reducers/index.ts'; +import { trackDappViewedEvent } from '../../../../util/metrics/index.ts'; +import { useTheme } from '../../../../util/theme/index.ts'; +import useFavicon from '../../../hooks/useFavicon/useFavicon.ts'; +import { + AccountConnectProps, + AccountConnectScreens, +} from '../../AccountConnect/AccountConnect.types.ts'; +import { getNetworkImageSource } from '../../../../util/networks/index.js'; +import { + AvatarSize, + AvatarVariant, +} from '../../../../component-library/components/Avatars/Avatar/index.ts'; +import { selectNetworkConfigurationsByCaipChainId } from '../../../../selectors/networkController.ts'; +import { isUUID } from '../../../../core/SDKConnect/utils/isUUID.ts'; +import useOriginSource from '../../../hooks/useOriginSource.ts'; +import { + getCaip25PermissionsResponse, + getRequestedCaip25CaveatValue, + getDefaultSelectedChainIds, +} from '../../AccountConnect/utils.ts'; +import { + getPhishingTestResultAsync, + isProductSafetyDappScanningEnabled, +} from '../../../../util/phishingDetection.ts'; +import { CaipAccountId, CaipChainId } from '@metamask/utils'; +import { + Caip25EndowmentPermissionName, + getAllNamespacesFromCaip25CaveatValue, + getAllScopesFromCaip25CaveatValue, + getAllScopesFromPermission, + getCaipAccountIdsFromCaip25CaveatValue, +} from '@metamask/chain-agnostic-permission'; +import styleSheet from './MultichainAccountConnect.styles.ts'; +import { useStyles } from '../../../../component-library/hooks/index.ts'; +import { getApiAnalyticsProperties } from '../../../../util/metrics/MultichainAPI/getApiAnalyticsProperties.ts'; +import { AccountGroupWithInternalAccounts } from '../../../../selectors/multichainAccounts/accounts.type.ts'; +import { AccountGroupId } from '@metamask/account-api'; +import { getCaip25AccountFromAccountGroupAndScope } from '../../../../util/multichain/getCaip25AccountFromAccountGroupAndScope.ts'; +import MultichainPermissionsSummary, { + MultichainPermissionsSummaryProps, +} from '../MultichainPermissionsSummary/MultichainPermissionsSummary.tsx'; +import MultichainAccountConnectMultiSelector from './MultichainAccountConnectMultiSelector/MultichainAccountConnectMultiSelector.tsx'; +import { getPermissions } from '../../../../selectors/snaps/index.ts'; +import { useAccountGroupsForPermissions } from '../../../hooks/useAccountGroupsForPermissions/useAccountGroupsForPermissions.ts'; +import NetworkConnectMultiSelector from '../../NetworkConnect/NetworkConnectMultiSelector/index.ts'; +import { Box } from '@metamask/design-system-react-native'; + +const MultichainAccountConnect = (props: AccountConnectProps) => { + const { colors } = useTheme(); + const { styles } = useStyles(styleSheet, {}); + const { hostInfo, permissionRequestId } = props.route.params; + const [isLoading, setIsLoading] = useState(false); + const [tabIndex, setTabIndex] = useState(0); + const previousIdentitiesListSize = useRef(); + const navigation = useNavigation(); + const { trackEvent, createEventBuilder } = useMetrics(); + + const [blockedUrl, setBlockedUrl] = useState(''); + + const existingPermissionsForHost = useSelector((state: RootState) => + getPermissions(state, hostInfo?.metadata?.origin), + ); + + const existingPermissionsCaip25CaveatValue = useMemo( + () => + getRequestedCaip25CaveatValue( + existingPermissionsForHost, + hostInfo?.metadata?.origin, + ), + [existingPermissionsForHost, hostInfo?.metadata?.origin], + ); + + const requestedCaip25CaveatValue = useMemo( + () => + getRequestedCaip25CaveatValue( + hostInfo.permissions, + hostInfo.metadata.origin, + ), + [hostInfo.permissions, hostInfo.metadata.origin], + ); + + const requestedCaipAccountIds = useMemo( + () => getCaipAccountIdsFromCaip25CaveatValue(requestedCaip25CaveatValue), + [requestedCaip25CaveatValue], + ); + + const requestedCaipChainIds = useMemo( + () => getAllScopesFromCaip25CaveatValue(requestedCaip25CaveatValue), + [requestedCaip25CaveatValue], + ); + + const requestedNamespaces = useMemo( + () => getAllNamespacesFromCaip25CaveatValue(requestedCaip25CaveatValue), + [requestedCaip25CaveatValue], + ); + + const networkConfigurations = useSelector( + selectNetworkConfigurationsByCaipChainId, + ); + const allNetworksList = useMemo( + () => Object.keys(networkConfigurations) as CaipChainId[], + [networkConfigurations], + ); + + const { wc2Metadata } = useSelector((state: RootState) => state.sdk); + + const { origin: channelIdOrHostname, isEip1193Request } = hostInfo.metadata; + + const isChannelId = isUUID(channelIdOrHostname); + + const sdkConnection = SDKConnect.getInstance().getConnection({ + channelId: channelIdOrHostname, + }); + + const isOriginMMSDKRemoteConn = sdkConnection !== undefined; + + const isOriginWalletConnect = + !isOriginMMSDKRemoteConn && wc2Metadata?.id && wc2Metadata?.id.length > 0; + + const defaultSelectedChainIds = useMemo( + () => + getDefaultSelectedChainIds({ + isEip1193Request: Boolean(isEip1193Request), + isOriginWalletConnect: Boolean(isOriginWalletConnect), + isOriginMMSDKRemoteConn: Boolean(isOriginMMSDKRemoteConn), + origin: channelIdOrHostname, + allNetworksList, + supportedRequestedCaipChainIds: allNetworksList, + requestedNamespaces, + }), + [ + isEip1193Request, + isOriginWalletConnect, + isOriginMMSDKRemoteConn, + channelIdOrHostname, + allNetworksList, + requestedNamespaces, + ], + ); + + const requestedCaipChainIdsWithDefaultSelectedChainIds = useMemo( + () => + Array.from( + new Set([...requestedCaipChainIds, ...defaultSelectedChainIds]), + ), + [requestedCaipChainIds, defaultSelectedChainIds], + ); + + const { + connectedAccountGroups, + supportedAccountGroups, + existingConnectedCaipAccountIds, + } = useAccountGroupsForPermissions( + existingPermissionsCaip25CaveatValue, + requestedCaipAccountIds, + requestedCaipChainIdsWithDefaultSelectedChainIds, + requestedNamespaces, + ); + + const [selectedChainIds, setSelectedChainIds] = useState( + requestedCaipChainIdsWithDefaultSelectedChainIds, + ); + + const selectedNetworkAvatars = useMemo( + () => + selectedChainIds + .filter( + (selectedChainId) => !NON_EVM_TESTNET_IDS.includes(selectedChainId), + ) + .map((selectedChainId) => ({ + size: AvatarSize.Xs, + name: networkConfigurations[selectedChainId]?.name || '', + imageSource: getNetworkImageSource({ chainId: selectedChainId }), + variant: AvatarVariant.Network, + caipChainId: selectedChainId, + })), + [networkConfigurations, selectedChainIds], + ); + + const { suggestedAccountGroups, suggestedCaipAccountIds } = useMemo(() => { + if (connectedAccountGroups.length > 0) { + return { + suggestedAccountGroups: connectedAccountGroups, + suggestedCaipAccountIds: existingConnectedCaipAccountIds, + }; + } + + if (supportedAccountGroups.length === 0) { + return { + suggestedAccountGroups: [], + suggestedCaipAccountIds: [], + }; + } + + // if there are no connected account groups, show the first supported account group + const [firstSupportedAccountGroup] = supportedAccountGroups; + + return { + suggestedAccountGroups: [firstSupportedAccountGroup], + suggestedCaipAccountIds: getCaip25AccountFromAccountGroupAndScope( + [firstSupportedAccountGroup], + requestedCaipChainIdsWithDefaultSelectedChainIds, + ), + }; + }, [ + connectedAccountGroups, + supportedAccountGroups, + requestedCaipChainIdsWithDefaultSelectedChainIds, + existingConnectedCaipAccountIds, + ]); + + const [selectedAccountGroupIds, setSelectedAccountGroupIds] = useState< + AccountGroupId[] + >( + suggestedAccountGroups.map( + (group: AccountGroupWithInternalAccounts) => group.id, + ), + ); + + const [selectedCaipAccountIds, setSelectedCaipAccountIds] = useState< + CaipAccountId[] + >(suggestedCaipAccountIds); + + const [screen, setScreen] = useState( + AccountConnectScreens.SingleConnect, + ); + const [showPhishingModal, setShowPhishingModal] = useState(false); + const [userIntent, setUserIntent] = useState(USER_INTENT.None); + const isMountedRef = useRef(true); + + const { toastRef } = useContext(ToastContext); + + const accountsLength = useSelector(selectAccountsLength); + + const dappUrl = sdkConnection?.originatorInfo?.url ?? ''; + + const { domainTitle, hostname } = useMemo(() => { + let title = strings('sdk.unknown'); + let dappHostname = dappUrl || channelIdOrHostname; + if ( + isOriginMMSDKRemoteConn && + channelIdOrHostname.startsWith(AppConstants.MM_SDK.SDK_REMOTE_ORIGIN) + ) { + title = getUrlObj( + channelIdOrHostname.replace(AppConstants.MM_SDK.SDK_REMOTE_ORIGIN, ''), + ).origin; + } else if (isOriginWalletConnect) { + title = + wc2Metadata?.lastVerifiedUrl ?? wc2Metadata?.url ?? channelIdOrHostname; + dappHostname = title; + } else if (!isChannelId && (dappUrl || channelIdOrHostname)) { + title = prefixUrlWithProtocol(dappUrl || channelIdOrHostname); + dappHostname = channelIdOrHostname; + } + return { domainTitle: title, hostname: dappHostname }; + }, [ + isOriginWalletConnect, + isOriginMMSDKRemoteConn, + isChannelId, + dappUrl, + channelIdOrHostname, + wc2Metadata?.lastVerifiedUrl, + wc2Metadata?.url, + ]); + + const urlWithProtocol = + hostname && !isUUID(hostname) + ? prefixUrlWithProtocol(getHost(hostname)) + : domainTitle; + + const { hostname: hostnameFromUrlObj } = getUrlObj(urlWithProtocol); + + useEffect(() => { + let url = dappUrl || channelIdOrHostname || ''; + + const checkOrigin = async () => { + if (isProductSafetyDappScanningEnabled()) { + url = prefixUrlWithProtocol(url); + } + const scanResult = await getPhishingTestResultAsync(url); + if (scanResult.result && isMountedRef.current) { + setBlockedUrl(dappUrl); + setShowPhishingModal(true); + } + }; + checkOrigin(); + return () => { + isMountedRef.current = false; + }; + }, [dappUrl, channelIdOrHostname]); + + const faviconSource = useFavicon( + channelIdOrHostname || (!isChannelId ? channelIdOrHostname : ''), + ); + + const eventSource = useOriginSource({ origin: channelIdOrHostname }); + + const suggestedAccountGroupIds = useMemo( + () => + suggestedAccountGroups.map( + (group: AccountGroupWithInternalAccounts) => group.id, + ), + [suggestedAccountGroups], + ); + + useEffect(() => { + const currentLength = suggestedAccountGroupIds.length; + + if (previousIdentitiesListSize.current !== currentLength) { + setSelectedAccountGroupIds(suggestedAccountGroupIds); + setSelectedCaipAccountIds(suggestedCaipAccountIds); + previousIdentitiesListSize.current = currentLength; + } + }, [suggestedAccountGroupIds, suggestedCaipAccountIds]); + + const cancelPermissionRequest = useCallback( + (requestId: string) => { + DevLogger.log( + `AccountConnect::cancelPermissionRequest requestId=${requestId} channelIdOrHostname=${channelIdOrHostname} accountsLength=${accountsLength}`, + ); + Engine.context.PermissionController.rejectPermissionsRequest(requestId); + if (channelIdOrHostname && accountsLength === 0) { + // Remove Potential SDK connection + SDKConnect.getInstance().removeChannel({ + channelId: channelIdOrHostname, + sendTerminate: true, + }); + } + + const chainIds = getAllScopesFromPermission( + hostInfo.permissions[Caip25EndowmentPermissionName] ?? { + caveats: [], + }, + ); + + const isMultichainRequest = !hostInfo.metadata.isEip1193Request; + + trackEvent( + createEventBuilder(MetaMetricsEvents.CONNECT_REQUEST_CANCELLED) + .addProperties({ + number_of_accounts: accountsLength, + source: eventSource, + chain_id_list: chainIds, + referrer: channelIdOrHostname, + ...getApiAnalyticsProperties(isMultichainRequest), + }) + .build(), + ); + }, + [ + accountsLength, + channelIdOrHostname, + trackEvent, + createEventBuilder, + eventSource, + hostInfo.metadata.isEip1193Request, + hostInfo.permissions, + ], + ); + + const navigateToUrlInEthPhishingModal = useCallback( + (url: string | null) => { + setShowPhishingModal(false); + cancelPermissionRequest(permissionRequestId); + navigation.goBack(); + setIsLoading(false); + + if (url !== null) { + navigation.navigate(Routes.BROWSER.HOME, { + screen: Routes.BROWSER.VIEW, + params: { + newTabUrl: url, + timestamp: Date.now(), + }, + }); + } + }, + [cancelPermissionRequest, navigation, permissionRequestId], + ); + + const continueToPhishingSite = useCallback(() => { + setShowPhishingModal(false); + }, []); + + const goToETHPhishingDetector = useCallback(() => { + navigateToUrlInEthPhishingModal(MM_PHISH_DETECT_URL); + }, [navigateToUrlInEthPhishingModal]); + + const goToFilePhishingIssue = useCallback(() => { + navigateToUrlInEthPhishingModal(MM_BLOCKLIST_ISSUE_URL); + }, [navigateToUrlInEthPhishingModal]); + + const goToEtherscam = useCallback(() => { + navigateToUrlInEthPhishingModal(MM_ETHERSCAN_URL); + }, [navigateToUrlInEthPhishingModal]); + + const goBackToSafety = useCallback(() => { + navigateToUrlInEthPhishingModal(null); // No URL means just go back to safety without navigating to a new page + }, [navigateToUrlInEthPhishingModal]); + + const triggerDappViewedEvent = useCallback( + (numberOfConnectedAccounts: number) => + // Track dapp viewed event + trackDappViewedEvent({ + hostname: hostnameFromUrlObj, + numberOfConnectedAccounts, + }), + [hostnameFromUrlObj], + ); + + const handleConnect = useCallback(async () => { + const request: PermissionsRequest = { + ...hostInfo, + metadata: { + ...hostInfo.metadata, + origin: channelIdOrHostname, + }, + permissions: { + ...hostInfo.permissions, + ...getCaip25PermissionsResponse( + requestedCaip25CaveatValue, + selectedCaipAccountIds, + selectedChainIds, + ), + }, + }; + + const connectedAccountLength = selectedAccountGroupIds.length; + const isMultichainRequest = !hostInfo.metadata.isEip1193Request; + + try { + setIsLoading(true); + await Engine.context.PermissionController.acceptPermissionsRequest( + request, + ); + + triggerDappViewedEvent(connectedAccountLength); + + trackEvent( + createEventBuilder(MetaMetricsEvents.CONNECT_REQUEST_COMPLETED) + .addProperties({ + number_of_accounts: accountsLength, + number_of_accounts_connected: connectedAccountLength, + // TODO: Fix this. Not accurate + account_type: 'multichain', + source: eventSource, + chain_id_list: selectedChainIds, + referrer: request.metadata.origin, + ...getApiAnalyticsProperties(isMultichainRequest), + }) + .build(), + ); + + const labelOptions: ToastOptions['labelOptions'] = + connectedAccountLength >= 1 + ? [{ label: strings('toast.permissions_updated') }] + : []; + + toastRef?.current?.showToast({ + variant: ToastVariants.Network, + labelOptions, + networkImageSource: faviconSource, + hasNoTimeout: false, + }); + } catch (e) { + if (e instanceof Error) { + Logger.error(e, 'Error while trying to connect to a dApp.'); + } + } finally { + setIsLoading(false); + } + }, [ + hostInfo, + channelIdOrHostname, + requestedCaip25CaveatValue, + selectedCaipAccountIds, + selectedChainIds, + selectedAccountGroupIds.length, + triggerDappViewedEvent, + trackEvent, + createEventBuilder, + accountsLength, + eventSource, + toastRef, + faviconSource, + ]); + + const handleAccountGroupsSelected = useCallback( + (newSelectedAccountGroupIds: AccountGroupId[]) => { + const updatedSelectedChains = [...selectedChainIds]; + + // Create lookup sets for selected account group IDs + const selectedGroupIds = new Set(newSelectedAccountGroupIds); + + // Filter to only selected account groups + const selectedAccountGroups = supportedAccountGroups.filter( + (group: AccountGroupWithInternalAccounts) => + selectedGroupIds.has(group.id), + ); + + const caip25AccountIds = getCaip25AccountFromAccountGroupAndScope( + selectedAccountGroups, + updatedSelectedChains, + ); + + setSelectedChainIds(updatedSelectedChains); + setSelectedAccountGroupIds( + selectedAccountGroups.map( + (group: AccountGroupWithInternalAccounts) => group.id, + ), + ); + setSelectedCaipAccountIds(caip25AccountIds); + setScreen(AccountConnectScreens.SingleConnect); + }, + [selectedChainIds, supportedAccountGroups], + ); + + const handleNetworksSelected = useCallback( + (newSelectedChainIds: CaipChainId[]) => { + setSelectedChainIds(newSelectedChainIds); + setScreen(AccountConnectScreens.SingleConnect); + }, + [setScreen, setSelectedChainIds], + ); + + const handleConfirm = useCallback(async () => { + await handleConnect(); + navigation.goBack(); + }, [handleConnect, navigation]); + + /** + * User intent is set on AccountConnectSingle, + * AccountConnectSingleSelector & AccountConnectMultiSelector. + * + * We need to know where the user clicks to decide what + * should happen to the Permission Request Promise. + * We then trigger the corresponding side effects & + * control the Bottom Sheet visibility. + */ + useEffect(() => { + if (userIntent === USER_INTENT.None) return; + + const handleUserActions = (action: USER_INTENT) => { + switch (action) { + case USER_INTENT.Confirm: { + handleConfirm(); + break; + } + case USER_INTENT.Cancel: { + cancelPermissionRequest(permissionRequestId); + navigation.goBack(); + break; + } + } + }; + + handleUserActions(userIntent); + + setUserIntent(USER_INTENT.None); + }, [ + navigation, + userIntent, + cancelPermissionRequest, + permissionRequestId, + handleConfirm, + ]); + + const permissionsSummaryProps = useMemo( + (): MultichainPermissionsSummaryProps => ({ + currentPageInformation: { + currentEnsName: '', + icon: typeof faviconSource === 'string' ? faviconSource : '', + url: urlWithProtocol, + }, + onEdit: () => setScreen(AccountConnectScreens.MultiConnectSelector), + onEditNetworks: () => + setScreen(AccountConnectScreens.MultiConnectNetworkSelector), + onConfirm: handleConfirm, + onCancel: () => { + cancelPermissionRequest(permissionRequestId); + navigation.goBack(); + }, + isAlreadyConnected: false, + selectedAccountGroupIds, + networkAvatars: selectedNetworkAvatars, + setTabIndex, + tabIndex, + }), + [ + faviconSource, + urlWithProtocol, + handleConfirm, + selectedAccountGroupIds, + selectedNetworkAvatars, + tabIndex, + cancelPermissionRequest, + permissionRequestId, + navigation, + ], + ); + + const renderPermissionsSummaryScreen = useCallback( + () => , + [permissionsSummaryProps], + ); + + const renderMultiConnectSelectorScreen = useCallback( + () => ( + { + setScreen(AccountConnectScreens.SingleConnect); + }} + connection={sdkConnection} + hostname={hostnameFromUrlObj} + screenTitle={strings('accounts.edit_accounts_title')} + onUserAction={setUserIntent} + isRenderedAsBottomSheet={false} + showDisconnectAllButton={false} + /> + ), + [ + supportedAccountGroups, + selectedAccountGroupIds, + handleAccountGroupsSelected, + isLoading, + sdkConnection, + hostnameFromUrlObj, + ], + ); + + const renderMultiConnectNetworkSelectorScreen = useCallback( + () => ( + setScreen(AccountConnectScreens.SingleConnect)} + defaultSelectedChainIds={selectedChainIds} + /> + ), + [isLoading, handleNetworksSelected, hostnameFromUrlObj, selectedChainIds], + ); + + const renderPhishingModal = useCallback( + () => ( + + + + ), + [ + blockedUrl, + colors.error.default, + continueToPhishingSite, + goBackToSafety, + goToETHPhishingDetector, + goToEtherscam, + goToFilePhishingIssue, + showPhishingModal, + ], + ); + + const renderConnectScreens = useCallback(() => { + switch (screen) { + case AccountConnectScreens.SingleConnect: + return renderPermissionsSummaryScreen(); + case AccountConnectScreens.MultiConnectSelector: + return renderMultiConnectSelectorScreen(); + case AccountConnectScreens.MultiConnectNetworkSelector: + return renderMultiConnectNetworkSelectorScreen(); + } + }, [ + screen, + renderPermissionsSummaryScreen, + renderMultiConnectSelectorScreen, + renderMultiConnectNetworkSelectorScreen, + ]); + + return ( + + {renderConnectScreens()} + {renderPhishingModal()} + + ); +}; + +export default MultichainAccountConnect; diff --git a/app/components/Views/MultichainAccounts/MultichainAccountConnect/State2AccountConnectWrapper.test.tsx b/app/components/Views/MultichainAccounts/MultichainAccountConnect/State2AccountConnectWrapper.test.tsx index a27c793bc1fa..05fe93b86f4c 100644 --- a/app/components/Views/MultichainAccounts/MultichainAccountConnect/State2AccountConnectWrapper.test.tsx +++ b/app/components/Views/MultichainAccounts/MultichainAccountConnect/State2AccountConnectWrapper.test.tsx @@ -30,6 +30,19 @@ jest.mock('../../AccountConnect/AccountConnect', () => { return MockAccountConnect; }); +jest.mock('./MultichainAccountConnect', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires + const actualReact = require('react'); + const MockMultichainAccountConnect = ({ route }: AccountConnectProps) => + actualReact.createElement( + 'div', + { testID: TEST_IDS.MULTICHAIN_ACCOUNT_CONNECT_COMPONENT }, + ['MultichainAccountConnect - ', route.params.permissionRequestId], + ); + MockMultichainAccountConnect.displayName = 'MultichainAccountConnect'; + return MockMultichainAccountConnect; +}); + const createMockCaip25Permission = ( optionalScopes: Record, ) => ({ @@ -102,13 +115,15 @@ describe('State2AccountConnectWrapper', () => { const isState2Enabled = true; const mockState = createMockState(isState2Enabled); - const { getByTestId } = renderWithProvider( + const { getByTestId, queryByTestId } = renderWithProvider( , { state: mockState }, ); - // TODO: replace with MultichainAccountConnect in subsequent PR - expect(getByTestId(TEST_IDS.ACCOUNT_CONNECT_COMPONENT)).toBeTruthy(); + expect( + getByTestId(TEST_IDS.MULTICHAIN_ACCOUNT_CONNECT_COMPONENT), + ).toBeTruthy(); + expect(queryByTestId(TEST_IDS.ACCOUNT_CONNECT_COMPONENT)).toBeNull(); }); it('forwards all props to MultichainAccountConnect', () => { @@ -139,9 +154,8 @@ describe('State2AccountConnectWrapper', () => { { state: mockState }, ); - // TODO: replace with multichain component in subsequent PR const multichainComponent = getByTestId( - TEST_IDS.ACCOUNT_CONNECT_COMPONENT, + TEST_IDS.MULTICHAIN_ACCOUNT_CONNECT_COMPONENT, ); expect(multichainComponent).toBeTruthy(); expect(multichainComponent.props.children).toContain( @@ -213,9 +227,8 @@ describe('State2AccountConnectWrapper', () => { { state: state2MockState }, ); - // TODO expect( - getByTestIdState2(TEST_IDS.ACCOUNT_CONNECT_COMPONENT), + getByTestIdState2(TEST_IDS.MULTICHAIN_ACCOUNT_CONNECT_COMPONENT), ).toBeTruthy(); const state1MockState = createMockState(false); @@ -255,8 +268,9 @@ describe('State2AccountConnectWrapper', () => { { state: mockState }, ); - // TODO: replace with MultichainAccountConnect in subsequent PR - expect(getByTestId(TEST_IDS.ACCOUNT_CONNECT_COMPONENT)).toBeTruthy(); + expect( + getByTestId(TEST_IDS.MULTICHAIN_ACCOUNT_CONNECT_COMPONENT), + ).toBeTruthy(); }); it('handles props with complex permissions when state 2 is disabled', () => { diff --git a/app/components/Views/MultichainAccounts/MultichainAccountConnect/State2AccountConnectWrapper.tsx b/app/components/Views/MultichainAccounts/MultichainAccountConnect/State2AccountConnectWrapper.tsx index c556a775bdf2..87f8ca48fa0d 100644 --- a/app/components/Views/MultichainAccounts/MultichainAccountConnect/State2AccountConnectWrapper.tsx +++ b/app/components/Views/MultichainAccounts/MultichainAccountConnect/State2AccountConnectWrapper.tsx @@ -1,5 +1,6 @@ import React from 'react'; import AccountConnect from '../../AccountConnect/AccountConnect'; +import MultichainAccountConnect from './MultichainAccountConnect'; import { useSelector } from 'react-redux'; import { AccountConnectProps } from '../../AccountConnect/AccountConnect.types'; import { selectMultichainAccountsState2Enabled } from '../../../../selectors/featureFlagController/multichainAccounts'; @@ -10,8 +11,7 @@ export const State2AccountConnectWrapper = (props: AccountConnectProps) => { ); return isMultichainAccountsState2Enabled ? ( - // TODO: replace with MultichainAccountConnect in subsequent PR - + ) : ( ); diff --git a/app/components/Views/MultichainAccounts/MultichainAccountsConnectedList/MultichainAccountsConnectedList.styles.ts b/app/components/Views/MultichainAccounts/MultichainAccountsConnectedList/MultichainAccountsConnectedList.styles.ts index fddac55d8e6a..f921e115bca8 100644 --- a/app/components/Views/MultichainAccounts/MultichainAccountsConnectedList/MultichainAccountsConnectedList.styles.ts +++ b/app/components/Views/MultichainAccounts/MultichainAccountsConnectedList/MultichainAccountsConnectedList.styles.ts @@ -3,10 +3,7 @@ import { StyleSheet } from 'react-native'; // External dependencies. import { Theme } from '../../../../util/theme/models'; -import { - ACCOUNTS_CONNECTED_LIST_ITEM_HEIGHT, - MAX_VISIBLE_ITEMS, -} from '../../../UI/PermissionsSummary/PermissionSummary.constants'; +import { ACCOUNTS_CONNECTED_LIST_ITEM_HEIGHT } from '../../../UI/PermissionsSummary/PermissionSummary.constants'; interface MultichainAccountsConnectedListStyleSheetVars { itemHeight: number; @@ -23,12 +20,11 @@ const styleSheet = (params: { }) => { const { theme } = params; const { colors } = theme; - const { numOfAccounts } = params.vars; return StyleSheet.create({ // Account List Item container: { - maxHeight: ACCOUNTS_CONNECTED_LIST_ITEM_HEIGHT * MAX_VISIBLE_ITEMS, + flex: 1, }, accountListItem: { borderWidth: 0, @@ -38,8 +34,7 @@ const styleSheet = (params: { accountsConnectedContainer: { backgroundColor: colors.background.default, marginTop: 8, - overflow: 'hidden', - minHeight: ACCOUNTS_CONNECTED_LIST_ITEM_HEIGHT * numOfAccounts, + flex: 1, }, // Balances Container balancesContainer: { @@ -51,7 +46,7 @@ const styleSheet = (params: { }, // Edit Accounts editAccountsContainer: { - marginTop: 8, + marginTop: 16, marginLeft: 16, flexDirection: 'row', justifyContent: 'flex-start', diff --git a/app/components/Views/MultichainAccounts/MultichainAccountsConnectedList/MultichainAccountsConnectedList.test.tsx b/app/components/Views/MultichainAccounts/MultichainAccountsConnectedList/MultichainAccountsConnectedList.test.tsx index 4cddacd5c285..31334e7a3366 100644 --- a/app/components/Views/MultichainAccounts/MultichainAccountsConnectedList/MultichainAccountsConnectedList.test.tsx +++ b/app/components/Views/MultichainAccounts/MultichainAccountsConnectedList/MultichainAccountsConnectedList.test.tsx @@ -84,45 +84,14 @@ const renderMultichainAccountsConnectedList = (propOverrides = {}) => { ); }; -const renderWithMultipleAccounts = () => - renderMultichainAccountsConnectedList({ - selectedAccountGroups: MOCK_MULTICHAIN_ACCOUNT_GROUPS, - }); - -const renderWithEmptyAccountGroups = () => - renderMultichainAccountsConnectedList({ - selectedAccountGroups: [], - }); - describe('MultichainAccountsConnectedList', () => { beforeEach(() => { jest.clearAllMocks(); }); - describe('Component Rendering', () => { - it('renders the component with account groups', () => { - const { getByText } = renderMultichainAccountsConnectedList(); - - expect(getByText('Edit accounts')).toBeTruthy(); - }); - - it('renders with empty account groups list', () => { - const { getByText } = renderWithEmptyAccountGroups(); - - expect(getByText('Edit accounts')).toBeTruthy(); - }); - - it('renders with multiple account groups', () => { - const { getByText } = renderWithMultipleAccounts(); - - expect(getByText('Edit accounts')).toBeTruthy(); - }); - }); - - it('renders component with selected account groups', () => { - const { getByText } = renderMultichainAccountsConnectedList(); - - expect(getByText('Edit accounts')).toBeTruthy(); + it('renders component with different account group configurations', () => { + const { toJSON } = renderMultichainAccountsConnectedList(); + expect(toJSON()).toMatchSnapshot(); }); it('calls handleEditAccountsButtonPress when edit button is pressed', () => { @@ -169,4 +138,136 @@ describe('MultichainAccountsConnectedList', () => { expect(() => fireEvent.press(editButton)).not.toThrow(); }); + + describe('ListFooterComponent - Edit Accounts Button', () => { + it('renders edit accounts button with correct structure', () => { + const { getByTestId, getByText } = + renderMultichainAccountsConnectedList(); + + const editButton = getByTestId( + ConnectedAccountsSelectorsIDs.ACCOUNT_LIST_BOTTOM_SHEET, + ); + expect(editButton).toBeTruthy(); + + expect(getByText('Edit accounts')).toBeTruthy(); + }); + + it('calls handleEditAccountsButtonPress when button is pressed', () => { + const mockHandleEdit = jest.fn(); + const { getByTestId } = renderMultichainAccountsConnectedList({ + handleEditAccountsButtonPress: mockHandleEdit, + }); + + const editButton = getByTestId( + ConnectedAccountsSelectorsIDs.ACCOUNT_LIST_BOTTOM_SHEET, + ); + + fireEvent.press(editButton); + + expect(mockHandleEdit).toHaveBeenCalledTimes(1); + expect(mockHandleEdit).toHaveBeenCalledWith(); + }); + + it('calls handleEditAccountsButtonPress multiple times when pressed multiple times', () => { + const mockHandleEdit = jest.fn(); + const { getByTestId } = renderMultichainAccountsConnectedList({ + handleEditAccountsButtonPress: mockHandleEdit, + }); + + const editButton = getByTestId( + ConnectedAccountsSelectorsIDs.ACCOUNT_LIST_BOTTOM_SHEET, + ); + + fireEvent.press(editButton); + fireEvent.press(editButton); + fireEvent.press(editButton); + + expect(mockHandleEdit).toHaveBeenCalledTimes(3); + }); + + it('maintains button functionality with different account group configurations', () => { + const mockHandleEdit = jest.fn(); + + const { getByTestId: getByTestIdEmpty } = + renderMultichainAccountsConnectedList({ + selectedAccountGroups: [], + handleEditAccountsButtonPress: mockHandleEdit, + }); + + const editButtonEmpty = getByTestIdEmpty( + ConnectedAccountsSelectorsIDs.ACCOUNT_LIST_BOTTOM_SHEET, + ); + fireEvent.press(editButtonEmpty); + + expect(mockHandleEdit).toHaveBeenCalledTimes(1); + + const { getByTestId: getByTestIdMultiple } = + renderMultichainAccountsConnectedList({ + selectedAccountGroups: MOCK_MULTICHAIN_ACCOUNT_GROUPS, + handleEditAccountsButtonPress: mockHandleEdit, + }); + + const editButtonMultiple = getByTestIdMultiple( + ConnectedAccountsSelectorsIDs.ACCOUNT_LIST_BOTTOM_SHEET, + ); + fireEvent.press(editButtonMultiple); + + expect(mockHandleEdit).toHaveBeenCalledTimes(2); + }); + + it('renders consistently with privacy mode enabled', () => { + const { getByTestId, getByText } = renderMultichainAccountsConnectedList({ + privacyMode: true, + }); + + const editButton = getByTestId( + ConnectedAccountsSelectorsIDs.ACCOUNT_LIST_BOTTOM_SHEET, + ); + expect(editButton).toBeTruthy(); + expect(getByText('Edit accounts')).toBeTruthy(); + }); + + it('renders consistently with privacy mode disabled', () => { + const { getByTestId, getByText } = renderMultichainAccountsConnectedList({ + privacyMode: false, + }); + + const editButton = getByTestId( + ConnectedAccountsSelectorsIDs.ACCOUNT_LIST_BOTTOM_SHEET, + ); + expect(editButton).toBeTruthy(); + expect(getByText('Edit accounts')).toBeTruthy(); + }); + }); + + describe('ListFooterComponent - Error Handling', () => { + it('handles undefined handleEditAccountsButtonPress', () => { + const { getByTestId } = renderMultichainAccountsConnectedList({ + handleEditAccountsButtonPress: undefined, + }); + + const editButton = getByTestId( + ConnectedAccountsSelectorsIDs.ACCOUNT_LIST_BOTTOM_SHEET, + ); + + expect(() => fireEvent.press(editButton)).not.toThrow(); + }); + + it('handles function that throws error', () => { + const mockHandleEditWithError = jest.fn(() => { + throw new Error('Test error'); + }); + + const { getByTestId } = renderMultichainAccountsConnectedList({ + handleEditAccountsButtonPress: mockHandleEditWithError, + }); + + const editButton = getByTestId( + ConnectedAccountsSelectorsIDs.ACCOUNT_LIST_BOTTOM_SHEET, + ); + + expect(() => fireEvent.press(editButton)).toThrow('Test error'); + expect(mockHandleEditWithError).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/app/components/Views/MultichainAccounts/MultichainAccountsConnectedList/MultichainAccountsConnectedList.tsx b/app/components/Views/MultichainAccounts/MultichainAccountsConnectedList/MultichainAccountsConnectedList.tsx index fceb32c636b7..f08654fd1537 100644 --- a/app/components/Views/MultichainAccounts/MultichainAccountsConnectedList/MultichainAccountsConnectedList.tsx +++ b/app/components/Views/MultichainAccounts/MultichainAccountsConnectedList/MultichainAccountsConnectedList.tsx @@ -58,25 +58,29 @@ const MultichainAccountsConnectedList = ({ data={selectedAccountGroups} renderItem={renderItem} showsVerticalScrollIndicator={false} + keyExtractor={(item, index) => `${item.id || index}`} + removeClippedSubviews={false} + ListFooterComponent={ + + + + {strings('accounts.edit_accounts_title')} + + + } /> - - - - {strings('accounts.edit_accounts_title')} - - ); }; diff --git a/app/components/Views/MultichainAccounts/MultichainAccountsConnectedList/__snapshots__/MultichainAccountsConnectedList.test.tsx.snap b/app/components/Views/MultichainAccounts/MultichainAccountsConnectedList/__snapshots__/MultichainAccountsConnectedList.test.tsx.snap new file mode 100644 index 000000000000..7968a1143e56 --- /dev/null +++ b/app/components/Views/MultichainAccounts/MultichainAccountsConnectedList/__snapshots__/MultichainAccountsConnectedList.test.tsx.snap @@ -0,0 +1,468 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MultichainAccountsConnectedList renders component with different account group configurations 1`] = ` + + + + + + + + + + + + + + + Account 1 + + + + + + $0.00 + + + + + + + + + + + + + + + Account 2 + + + + + + $0.00 + + + + + + + + + + + + + + + + Edit accounts + + + + + + + + +`; diff --git a/app/components/Views/MultichainAccounts/sheets/MultichainAccountActions/MultichainAccountActions.test.tsx b/app/components/Views/MultichainAccounts/sheets/MultichainAccountActions/MultichainAccountActions.test.tsx index a1860c37fff1..a564a7d75d35 100644 --- a/app/components/Views/MultichainAccounts/sheets/MultichainAccountActions/MultichainAccountActions.test.tsx +++ b/app/components/Views/MultichainAccounts/sheets/MultichainAccountActions/MultichainAccountActions.test.tsx @@ -12,6 +12,7 @@ import { MULTICHAIN_ACCOUNT_ACTIONS_EDIT_NAME, MULTICHAIN_ACCOUNT_ACTIONS_ADDRESSES, } from './MultichainAccountActions.testIds'; +import { TraceName, TraceOperation } from '../../../../../util/trace'; const mockAccountGroup: AccountGroupObject = { type: AccountGroupType.SingleAccount, @@ -43,6 +44,10 @@ const mockInternalAccount: InternalAccount = { const mockNavigate = jest.fn(); const mockGoBack = jest.fn(); +// Mock trace +const mockTrace = jest.fn(); +const mockEndTrace = jest.fn(); + jest.mock('@react-navigation/native', () => ({ ...jest.requireActual('@react-navigation/native'), useNavigation: () => ({ navigate: mockNavigate, goBack: mockGoBack }), @@ -53,6 +58,13 @@ jest.mock('@react-navigation/native', () => ({ }), })); +// Mock trace functions +jest.mock('../../../../../util/trace', () => ({ + ...jest.requireActual('../../../../../util/trace'), + trace: (options: unknown) => mockTrace(options), + endTrace: (options: unknown) => mockEndTrace(options), +})); + // Mock Engine jest.mock('../../../../../core/Engine', () => ({ context: { @@ -161,8 +173,37 @@ describe('MultichainAccountActions', () => { { groupId: mockAccountGroup.id, title: `Addresses / ${mockAccountGroup.metadata.name}`, + onLoad: expect.any(Function), }, ); + + expect(mockTrace).toHaveBeenCalledWith({ + name: TraceName.ShowAccountAddressList, + op: TraceOperation.AccountUi, + tags: { + screen: 'account.actions', + }, + }); + }); + + it('calls endTrace when onLoad callback is invoked', () => { + const { getByTestId } = renderWithProvider(); + + const addressesButton = getByTestId(MULTICHAIN_ACCOUNT_ACTIONS_ADDRESSES); + addressesButton.props.onPress(); + + // Get the onLoad callback from the navigation call + const navigationCallArgs = mockNavigate.mock.calls[0]; + const navigationParams = navigationCallArgs[1]; + const onLoadCallback = navigationParams.onLoad; + + // Invoke the onLoad callback + onLoadCallback(); + + // Verify endTrace was called with correct parameters + expect(mockEndTrace).toHaveBeenCalledWith({ + name: TraceName.ShowAccountAddressList, + }); }); it('navigates to edit account name when rename account button is pressed', () => { diff --git a/app/components/Views/MultichainAccounts/sheets/MultichainAccountActions/MultichainAccountActions.tsx b/app/components/Views/MultichainAccounts/sheets/MultichainAccountActions/MultichainAccountActions.tsx index 7850a72fd148..d8dcfe05cc96 100644 --- a/app/components/Views/MultichainAccounts/sheets/MultichainAccountActions/MultichainAccountActions.tsx +++ b/app/components/Views/MultichainAccounts/sheets/MultichainAccountActions/MultichainAccountActions.tsx @@ -29,6 +29,12 @@ import { } from './MultichainAccountActions.testIds'; import { createAddressListNavigationDetails } from '../../AddressList/AddressList'; import { createNavigationDetails } from '../../../../../util/navigation/navUtils'; +import { + endTrace, + trace, + TraceName, + TraceOperation, +} from '../../../../../util/trace'; export const createAccountGroupDetailsNavigationDetails = createNavigationDetails<{ @@ -88,6 +94,16 @@ const MultichainAccountActions = () => { }, [navigate, accountGroup]); const goToAddresses = useCallback(() => { + // Start the trace before navigating to the address list to include the + // navigation and render times in the trace. + trace({ + name: TraceName.ShowAccountAddressList, + op: TraceOperation.AccountUi, + tags: { + screen: 'account.actions', + }, + }); + // Close the modal and navigate to address list goBack(); navigate( @@ -96,6 +112,9 @@ const MultichainAccountActions = () => { title: `${strings('multichain_accounts.address_list.addresses')} / ${ accountGroup.metadata.name }`, + onLoad: () => { + endTrace({ name: TraceName.ShowAccountAddressList }); + }, }), ); }, [accountGroup.id, accountGroup.metadata.name, navigate, goBack]); diff --git a/app/components/Views/Settings/NetworksSettings/index.js b/app/components/Views/Settings/NetworksSettings/index.js index e4949efe869a..d31bff445cbd 100644 --- a/app/components/Views/Settings/NetworksSettings/index.js +++ b/app/components/Views/Settings/NetworksSettings/index.js @@ -263,6 +263,11 @@ class NetworksSettings extends PureComponent { networkElement(name, image, i, networkTypeOrRpcUrl, isCustomRPC, color) { const colors = this.context.colors || mockTheme.colors; const styles = createStyles(colors); + const { NetworkController } = Engine.context; + const selectedNetworkClientId = + NetworkController.state.selectedNetworkClientId; + const isSelectedNetwork = networkTypeOrRpcUrl === selectedNetworkClientId; + return ( {isMainnetNetwork(networkTypeOrRpcUrl) ? ( @@ -271,11 +276,18 @@ class NetworksSettings extends PureComponent { this.onNetworkPress(networkTypeOrRpcUrl)} - onLongPress={() => - isCustomRPC && this.showRemoveMenu(networkTypeOrRpcUrl) - } + onLongPress={() => { + if (isCustomRPC && !isSelectedNetwork) { + this.showRemoveMenu(networkTypeOrRpcUrl); + } + }} > - + {isCustomRPC ? ( ))} {name} - {!isCustomRPC && ( + {(!isCustomRPC || isSelectedNetwork) && ( { const renderHookWithStore = ( existingPermission: Caip25CaveatValue, - requestedChainIds: CaipChainId[], - requestedNamespaces: CaipNamespace[], + requestedCaipAccountIds: CaipAccountId[], + requestedCaipChainIds: CaipChainId[], + requestedNamespacesWithoutWallet: CaipNamespace[], stateOverrides = {}, accountOverrides = {}, ) => { @@ -199,8 +200,9 @@ const renderHookWithStore = ( () => useAccountGroupsForPermissions( existingPermission, - requestedChainIds, - requestedNamespaces, + requestedCaipAccountIds, + requestedCaipChainIds, + requestedNamespacesWithoutWallet, ), { state: state as DeepPartial }, ); @@ -210,13 +212,15 @@ describe('useAccountGroupsForPermissions', () => { describe('when no existing permissions', () => { it('returns empty connected account groups with available supported groups', () => { const emptyPermission = createEmptyPermission(); - const requestedChainIds: CaipChainId[] = ['eip155:1' as CaipChainId]; - const requestedNamespaces: CaipNamespace[] = []; + const requestedCaipAccountIds: CaipAccountId[] = []; + const requestedCaipChainIds: CaipChainId[] = ['eip155:1' as CaipChainId]; + const requestedNamespacesWithoutWallet: CaipNamespace[] = []; const { result } = renderHookWithStore( emptyPermission, - requestedChainIds, - requestedNamespaces, + requestedCaipAccountIds, + requestedCaipChainIds, + requestedNamespacesWithoutWallet, ); expect(result.current.connectedAccountGroups).toEqual([]); @@ -230,13 +234,15 @@ describe('useAccountGroupsForPermissions', () => { const existingPermission = createPermissionWithEvmAccounts([ mockEvmAccount1.address, ]); - const requestedChainIds: CaipChainId[] = ['eip155:1' as CaipChainId]; - const requestedNamespaces: CaipNamespace[] = []; + const requestedCaipAccountIds: CaipAccountId[] = []; + const requestedCaipChainIds: CaipChainId[] = ['eip155:1' as CaipChainId]; + const requestedNamespacesWithoutWallet: CaipNamespace[] = []; const { result } = renderHookWithStore( existingPermission, - requestedChainIds, - requestedNamespaces, + requestedCaipAccountIds, + requestedCaipChainIds, + requestedNamespacesWithoutWallet, ); expect(result.current.connectedAccountGroups).toHaveLength(1); @@ -251,17 +257,19 @@ describe('useAccountGroupsForPermissions', () => { describe('EVM wildcard handling', () => { it('converts EVM chain IDs to wildcard format for deduplication', () => { const emptyPermission = createEmptyPermission(); - const requestedChainIds: CaipChainId[] = [ + const requestedCaipAccountIds: CaipAccountId[] = []; + const requestedCaipChainIds: CaipChainId[] = [ 'eip155:1' as CaipChainId, 'eip155:137' as CaipChainId, 'eip155:10' as CaipChainId, ]; - const requestedNamespaces: CaipNamespace[] = []; + const requestedNamespacesWithoutWallet: CaipNamespace[] = []; const { result } = renderHookWithStore( emptyPermission, - requestedChainIds, - requestedNamespaces, + requestedCaipAccountIds, + requestedCaipChainIds, + requestedNamespacesWithoutWallet, ); expect(result.current.supportedAccountGroups).toHaveLength(2); @@ -271,13 +279,15 @@ describe('useAccountGroupsForPermissions', () => { describe('supportedAccountGroups when no chain IDs provided', () => { it('returns empty array when no namespaces are requested', () => { const emptyPermission = createEmptyPermission(); - const requestedChainIds: CaipChainId[] = []; - const requestedNamespaces: CaipNamespace[] = []; + const requestedCaipAccountIds: CaipAccountId[] = []; + const requestedCaipChainIds: CaipChainId[] = []; + const requestedNamespacesWithoutWallet: CaipNamespace[] = []; const { result } = renderHookWithStore( emptyPermission, - requestedChainIds, - requestedNamespaces, + requestedCaipAccountIds, + requestedCaipChainIds, + requestedNamespacesWithoutWallet, ); expect(result.current.supportedAccountGroups).toEqual([]); @@ -285,13 +295,17 @@ describe('useAccountGroupsForPermissions', () => { it('filters account groups by requested namespaces when no chain IDs provided', () => { const emptyPermission = createEmptyPermission(); - const requestedChainIds: CaipChainId[] = []; - const requestedNamespaces: CaipNamespace[] = ['solana' as CaipNamespace]; + const requestedCaipAccountIds: CaipAccountId[] = []; + const requestedCaipChainIds: CaipChainId[] = []; + const requestedNamespacesWithoutWallet: CaipNamespace[] = [ + 'solana' as CaipNamespace, + ]; const { result } = renderHookWithStore( emptyPermission, - requestedChainIds, - requestedNamespaces, + requestedCaipAccountIds, + requestedCaipChainIds, + requestedNamespacesWithoutWallet, ); expect(result.current.supportedAccountGroups).toHaveLength(2); @@ -299,16 +313,18 @@ describe('useAccountGroupsForPermissions', () => { it('handles multiple matching namespaces', () => { const emptyPermission = createEmptyPermission(); - const requestedChainIds: CaipChainId[] = []; - const requestedNamespaces: CaipNamespace[] = [ + const requestedCaipAccountIds: CaipAccountId[] = []; + const requestedCaipChainIds: CaipChainId[] = []; + const requestedNamespacesWithoutWallet: CaipNamespace[] = [ 'eip155' as CaipNamespace, 'solana' as CaipNamespace, ]; const { result } = renderHookWithStore( emptyPermission, - requestedChainIds, - requestedNamespaces, + requestedCaipAccountIds, + requestedCaipChainIds, + requestedNamespacesWithoutWallet, ); expect(result.current.supportedAccountGroups).toHaveLength(2); @@ -331,13 +347,15 @@ describe('useAccountGroupsForPermissions', () => { isMultichainOrigin: false, }; - const requestedChainIds: CaipChainId[] = ['eip155:1' as CaipChainId]; - const requestedNamespaces: CaipNamespace[] = []; + const requestedCaipAccountIds: CaipAccountId[] = []; + const requestedCaipChainIds: CaipChainId[] = ['eip155:1' as CaipChainId]; + const requestedNamespacesWithoutWallet: CaipNamespace[] = []; const { result } = renderHookWithStore( malformedPermission, - requestedChainIds, - requestedNamespaces, + requestedCaipAccountIds, + requestedCaipChainIds, + requestedNamespacesWithoutWallet, ); expect(result.current.existingConnectedCaipAccountIds).toEqual([ @@ -357,13 +375,15 @@ describe('useAccountGroupsForPermissions', () => { const existingPermission = createPermissionWithEvmAccounts([ mockEvmAccount1.address, ]); - const requestedChainIds: CaipChainId[] = ['eip155:1' as CaipChainId]; - const requestedNamespaces: CaipNamespace[] = []; + const requestedCaipAccountIds: CaipAccountId[] = []; + const requestedCaipChainIds: CaipChainId[] = ['eip155:1' as CaipChainId]; + const requestedNamespacesWithoutWallet: CaipNamespace[] = []; const { result } = renderHookWithStore( existingPermission, - requestedChainIds, - requestedNamespaces, + requestedCaipAccountIds, + requestedCaipChainIds, + requestedNamespacesWithoutWallet, stateOverrides, ); @@ -374,18 +394,20 @@ describe('useAccountGroupsForPermissions', () => { describe('mixed namespace and chain scenarios', () => { it('handles mixed EVM and non-EVM chain requests', () => { const emptyPermission = createEmptyPermission(); - const requestedChainIds: CaipChainId[] = [ + const requestedCaipAccountIds: CaipAccountId[] = []; + const requestedCaipChainIds: CaipChainId[] = [ 'eip155:1' as CaipChainId, 'eip155:137' as CaipChainId, 'solana:mainnet' as CaipChainId, 'bip122:000000000019d6689c085ae165831e93' as CaipChainId, ]; - const requestedNamespaces: CaipNamespace[] = []; + const requestedNamespacesWithoutWallet: CaipNamespace[] = []; const { result } = renderHookWithStore( emptyPermission, - requestedChainIds, - requestedNamespaces, + requestedCaipAccountIds, + requestedCaipChainIds, + requestedNamespacesWithoutWallet, ); expect(result.current.supportedAccountGroups).toHaveLength(2); @@ -405,15 +427,17 @@ describe('useAccountGroupsForPermissions', () => { isMultichainOrigin: false, }; - const requestedChainIds: CaipChainId[] = [ + const requestedCaipAccountIds: CaipAccountId[] = []; + const requestedCaipChainIds: CaipChainId[] = [ MOCK_SOLANA_CHAIN_ID as CaipChainId, ]; - const requestedNamespaces: CaipNamespace[] = []; + const requestedNamespacesWithoutWallet: CaipNamespace[] = []; const { result } = renderHookWithStore( solPermission, - requestedChainIds, - requestedNamespaces, + requestedCaipAccountIds, + requestedCaipChainIds, + requestedNamespacesWithoutWallet, ); expect(result.current.connectedAccountGroups).toHaveLength(1); @@ -426,13 +450,15 @@ describe('useAccountGroupsForPermissions', () => { it('handles empty permission scopes', () => { const emptyPermission = createEmptyPermission(); - const requestedChainIds: CaipChainId[] = []; - const requestedNamespaces: CaipNamespace[] = []; + const requestedCaipAccountIds: CaipAccountId[] = []; + const requestedCaipChainIds: CaipChainId[] = []; + const requestedNamespacesWithoutWallet: CaipNamespace[] = []; const { result } = renderHookWithStore( emptyPermission, - requestedChainIds, - requestedNamespaces, + requestedCaipAccountIds, + requestedCaipChainIds, + requestedNamespacesWithoutWallet, ); expect(result.current.connectedAccountGroups).toEqual([]); @@ -452,13 +478,15 @@ describe('useAccountGroupsForPermissions', () => { isMultichainOrigin: false, }; - const requestedChainIds: CaipChainId[] = ['eip155:1' as CaipChainId]; - const requestedNamespaces: CaipNamespace[] = []; + const requestedCaipAccountIds: CaipAccountId[] = []; + const requestedCaipChainIds: CaipChainId[] = ['eip155:1' as CaipChainId]; + const requestedNamespacesWithoutWallet: CaipNamespace[] = []; const { result } = renderHookWithStore( emptyAccountsPermission, - requestedChainIds, - requestedNamespaces, + requestedCaipAccountIds, + requestedCaipChainIds, + requestedNamespacesWithoutWallet, ); expect(result.current.connectedAccountGroups).toEqual([]); @@ -486,17 +514,113 @@ describe('useAccountGroupsForPermissions', () => { }; const emptyPermission = createEmptyPermission(); - const requestedChainIds: CaipChainId[] = []; - const requestedNamespaces: CaipNamespace[] = ['eip155' as CaipNamespace]; + const requestedCaipAccountIds: CaipAccountId[] = []; + const requestedCaipChainIds: CaipChainId[] = []; + const requestedNamespacesWithoutWallet: CaipNamespace[] = [ + 'eip155' as CaipNamespace, + ]; const { result } = renderHookWithStore( emptyPermission, - requestedChainIds, - requestedNamespaces, + requestedCaipAccountIds, + requestedCaipChainIds, + requestedNamespacesWithoutWallet, {}, accountOverrides, ); expect(result.current.supportedAccountGroups).toEqual([]); }); + + describe('requestedCaipAccountIds prioritization', () => { + it('prioritizes account groups that fulfill requested account IDs in connected groups', () => { + const existingPermission = createPermissionWithEvmAccounts([ + mockEvmAccount1.address, + mockEvmAccount2.address, + ]); + const requestedCaipAccountIds: CaipAccountId[] = [ + `eip155:1:${mockEvmAccount2.address}` as CaipAccountId, + ]; + const requestedCaipChainIds: CaipChainId[] = []; + const requestedNamespacesWithoutWallet: CaipNamespace[] = []; + + const { result } = renderHookWithStore( + existingPermission, + requestedCaipAccountIds, + requestedCaipChainIds, + requestedNamespacesWithoutWallet, + ); + + expect(result.current.connectedAccountGroups).toHaveLength(2); + // Group 2 should be first because it fulfills the requested account ID + expect(result.current.connectedAccountGroups[0].id).toBe(MOCK_GROUP_ID_2); + expect(result.current.connectedAccountGroups[1].id).toBe(MOCK_GROUP_ID_1); + }); + + it('prioritizes account groups that fulfill requested account IDs in supported groups', () => { + const emptyPermission = createEmptyPermission(); + const requestedCaipAccountIds: CaipAccountId[] = [ + `eip155:1:${mockEvmAccount2.address}` as CaipAccountId, + ]; + const requestedCaipChainIds: CaipChainId[] = ['eip155:1' as CaipChainId]; + const requestedNamespacesWithoutWallet: CaipNamespace[] = []; + + const { result } = renderHookWithStore( + emptyPermission, + requestedCaipAccountIds, + requestedCaipChainIds, + requestedNamespacesWithoutWallet, + ); + + expect(result.current.supportedAccountGroups).toHaveLength(2); + // Group 2 should be first because it fulfills the requested account ID + expect(result.current.supportedAccountGroups[0].id).toBe(MOCK_GROUP_ID_2); + expect(result.current.supportedAccountGroups[1].id).toBe(MOCK_GROUP_ID_1); + }); + + it('includes groups with requested account IDs even if they do not support requested chains', () => { + const emptyPermission = createEmptyPermission(); + const requestedCaipAccountIds: CaipAccountId[] = [ + `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:${mockSolAccount1.address}` as CaipAccountId, + ]; + const requestedCaipChainIds: CaipChainId[] = ['eip155:1' as CaipChainId]; + const requestedNamespacesWithoutWallet: CaipNamespace[] = []; + + const { result } = renderHookWithStore( + emptyPermission, + requestedCaipAccountIds, + requestedCaipChainIds, + requestedNamespacesWithoutWallet, + ); + + expect(result.current.supportedAccountGroups).toHaveLength(2); + // Group 1 should be first because it fulfills the requested account ID (even though it doesn't support eip155:1) + expect(result.current.supportedAccountGroups[0].id).toBe(MOCK_GROUP_ID_1); + }); + + it('handles multiple requested account IDs from different groups', () => { + const emptyPermission = createEmptyPermission(); + const requestedCaipAccountIds: CaipAccountId[] = [ + `eip155:1:${mockEvmAccount1.address}` as CaipAccountId, + `eip155:1:${mockEvmAccount2.address}` as CaipAccountId, + ]; + const requestedCaipChainIds: CaipChainId[] = ['eip155:1' as CaipChainId]; + const requestedNamespacesWithoutWallet: CaipNamespace[] = []; + + const { result } = renderHookWithStore( + emptyPermission, + requestedCaipAccountIds, + requestedCaipChainIds, + requestedNamespacesWithoutWallet, + ); + + expect(result.current.supportedAccountGroups).toHaveLength(2); + // Both groups should be present since both fulfill requested account IDs + const groupIds = result.current.supportedAccountGroups.map( + (group) => group.id, + ); + expect(groupIds).toContain(MOCK_GROUP_ID_1); + expect(groupIds).toContain(MOCK_GROUP_ID_2); + }); + }); }); diff --git a/app/components/hooks/useAccountGroupsForPermissions/useAccountGroupsForPermissions.ts b/app/components/hooks/useAccountGroupsForPermissions/useAccountGroupsForPermissions.ts index 66a109964fe9..a69cb9327b18 100644 --- a/app/components/hooks/useAccountGroupsForPermissions/useAccountGroupsForPermissions.ts +++ b/app/components/hooks/useAccountGroupsForPermissions/useAccountGroupsForPermissions.ts @@ -21,7 +21,8 @@ const hasConnectedAccounts = ( accountGroup: AccountGroupWithInternalAccounts, connectedAddresses: CaipAccountId[], ): boolean => { - if (!connectedAddresses.length || accountGroup.accounts.length === 0) { + // Early return if no connected addresses or no accounts to check + if (connectedAddresses.length === 0 || accountGroup.accounts.length === 0) { return false; } @@ -38,107 +39,150 @@ const hasConnectedAccounts = ( }; /** - * Checks if an account group supports the requested chains or namespaces + * Checks if an account group supports the requested chain IDs * - * @param accountGroup - Account group to check for scope support + * @param accountGroup - Account group to check for chain ID support * @param requestedChainIds - Array of requested chain IDs to match against - * @param requestedNamespaces - Set of requested namespaces to match against - * @returns True if any account in the group supports the requested scopes + * @returns True if any account in the group supports the requested chain IDs */ -const hasSupportedScopes = ( +const supportsChainIds = ( accountGroup: AccountGroupWithInternalAccounts, requestedChainIds: CaipChainId[], +): boolean => + accountGroup.accounts.some((account) => + hasChainIdSupport(account.scopes, requestedChainIds), + ); + +/** + * Checks if an account group supports the requested namespaces + * + * @param accountGroup - Account group to check for namespace support + * @param requestedNamespaces - Set of requested namespaces to match against + * @returns True if any account in the group supports the requested namespaces + */ +const supportsNamespaces = ( + accountGroup: AccountGroupWithInternalAccounts, requestedNamespaces: Set, -): boolean => { - if (accountGroup.accounts.length === 0) { - return false; - } - return accountGroup.accounts.some((account) => { - if (requestedChainIds.length > 0) { - return hasChainIdSupport(account.scopes, requestedChainIds); +): boolean => + accountGroup.accounts.some((account) => + hasNamespaceSupport(account.scopes, requestedNamespaces), + ); + +/** + * Checks if an account group contains any of the requested account IDs + * + * @param accountGroup - Account group to check for requested account IDs + * @param requestedAccountIds - Array of requested account IDs to match against + * @returns True if any account in the group matches the requested account IDs + */ +const hasRequestedAccountIds = ( + accountGroup: AccountGroupWithInternalAccounts, + requestedAccountIds: CaipAccountId[], +): boolean => + accountGroup.accounts.some((account) => { + try { + return isInternalAccountInPermittedAccountIds( + account, + requestedAccountIds, + ); + } catch { + return false; } - return hasNamespaceSupport(account.scopes, requestedNamespaces); }); -}; /** * Hook that manages account groups for CAIP-25 permissions, providing both connected - * and supported account groups based on existing permissions and requested chains/namespaces. + * and supported account groups based on existing permissions, requested chains/namespaces, + * and specific account IDs with prioritization support. * * This hook handles the complex logic of: * - Filtering account groups that support requested chains/namespaces + * - Prioritizing account groups that fulfill specific requested account IDs * - Mapping existing CAIP-25 permissions to account groups * - Converting between different account ID formats + * - Preventing duplicate account groups in results * * @param existingPermission - The current CAIP-25 caveat value containing existing permissions + * @param requestedCaipAccountIds - Array of specific CAIP account IDs being requested (prioritized in results) * @param requestedCaipChainIds - Array of CAIP chain IDs being requested for permission * @param requestedNamespacesWithoutWallet - Array of CAIP namespaces being requested (excluding wallet namespace) - * @returns Object containing connected account groups, supported account groups, and existing connected CAIP account IDs + * @returns Object containing connected account groups, supported account groups, and existing connected CAIP account IDs. + * Account groups that fulfill requestedCaipAccountIds appear first in both arrays. */ export const useAccountGroupsForPermissions = ( existingPermission: Caip25CaveatValue, + requestedCaipAccountIds: CaipAccountId[], requestedCaipChainIds: CaipChainId[], requestedNamespacesWithoutWallet: CaipNamespace[], ) => { const accountGroups = useSelector(selectAccountGroupWithInternalAccounts); - const { - supportedAccountGroups, - connectedAccountGroups, - connectedCaipAccountIds, - } = useMemo(() => { + const result = useMemo(() => { const connectedAccountIds = getCaipAccountIdsFromCaip25CaveatValue(existingPermission); const requestedNamespaceSet = new Set(requestedNamespacesWithoutWallet); - const { filteredConnectedAccountGroups, filteredSupportedAccountGroups } = - accountGroups.reduce( - (acc, accountGroup) => { - const isConnected = hasConnectedAccounts( - accountGroup, - connectedAccountIds, - ); - const isSupported = hasSupportedScopes( - accountGroup, - requestedCaipChainIds, - requestedNamespaceSet, - ); + const connectedAccountGroups: AccountGroupWithInternalAccounts[] = []; + const supportedAccountGroups: AccountGroupWithInternalAccounts[] = []; + // Priority groups are groups that fulfill the requested account IDs and should be shown first + const priorityConnectedGroups: AccountGroupWithInternalAccounts[] = []; + const prioritySupportedGroups: AccountGroupWithInternalAccounts[] = []; - if (isConnected) { - acc.filteredConnectedAccountGroups.push(accountGroup); - } - if (isSupported) { - acc.filteredSupportedAccountGroups.push(accountGroup); - } - - return acc; - }, - { - filteredConnectedAccountGroups: - [] as AccountGroupWithInternalAccounts[], - filteredSupportedAccountGroups: - [] as AccountGroupWithInternalAccounts[], - }, + accountGroups.forEach((accountGroup) => { + const isConnected = hasConnectedAccounts( + accountGroup, + connectedAccountIds, + ); + const isSupported = + requestedCaipChainIds.length > 0 + ? supportsChainIds(accountGroup, requestedCaipChainIds) + : supportsNamespaces(accountGroup, requestedNamespaceSet); + const fulfillsRequestedAccounts = hasRequestedAccountIds( + accountGroup, + requestedCaipAccountIds, ); + if (isConnected) { + if (fulfillsRequestedAccounts) { + priorityConnectedGroups.push(accountGroup); + } else { + connectedAccountGroups.push(accountGroup); + } + } + if (isSupported || fulfillsRequestedAccounts) { + if (fulfillsRequestedAccounts) { + prioritySupportedGroups.push(accountGroup); + } else if (isSupported) { + supportedAccountGroups.push(accountGroup); + } + } + }); + return { - supportedAccountGroups: filteredSupportedAccountGroups, - connectedAccountGroups: filteredConnectedAccountGroups, + supportedAccountGroups: [ + ...prioritySupportedGroups, + ...supportedAccountGroups, + ], + connectedAccountGroups: [ + ...priorityConnectedGroups, + ...connectedAccountGroups, + ], connectedCaipAccountIds: connectedAccountIds, }; }, [ existingPermission, accountGroups, + requestedCaipAccountIds, requestedCaipChainIds, requestedNamespacesWithoutWallet, ]); return { /** Account groups that are currently connected via existing permissions */ - connectedAccountGroups, + connectedAccountGroups: result.connectedAccountGroups, /** Account groups that support the requested chains/namespaces */ - supportedAccountGroups, + supportedAccountGroups: result.supportedAccountGroups, /** CAIP account IDs that are already connected via existing permissions */ - existingConnectedCaipAccountIds: connectedCaipAccountIds, + existingConnectedCaipAccountIds: result.connectedCaipAccountIds, }; }; diff --git a/e2e/api-mocking/mock-responses/feature-flags-mocks.ts b/e2e/api-mocking/mock-responses/feature-flags-mocks.ts index 531e7ede5722..f5d90e685399 100644 --- a/e2e/api-mocking/mock-responses/feature-flags-mocks.ts +++ b/e2e/api-mocking/mock-responses/feature-flags-mocks.ts @@ -120,6 +120,16 @@ export const remoteFeatureMultichainAccountsAccountDetails = ( }, }); +export const remoteFeatureMultichainAccountsAccountDetailsV2 = ( + enabled = true, +) => ({ + enableMultichainAccounts: { + enabled, + featureVersion: '2', + minimumVersion: '7.46.0', + }, +}); + export const remoteFeatureFlagSendRedesignDisabled = { urlEndpoint: 'https://client-config.api.cx.metamask.io/v1/flags?client=mobile&distribution=main&environment=dev', diff --git a/e2e/specs/multichain-accounts/common.ts b/e2e/specs/multichain-accounts/common.ts index 6cd446eb2ee8..d0630cbe23ca 100644 --- a/e2e/specs/multichain-accounts/common.ts +++ b/e2e/specs/multichain-accounts/common.ts @@ -6,7 +6,10 @@ import { withFixtures } from '../../framework/fixtures/FixtureHelper'; import AccountListBottomSheet from '../../pages/wallet/AccountListBottomSheet'; import WalletView from '../../pages/wallet/WalletView'; import { loginToApp } from '../../viewHelper'; -import { remoteFeatureMultichainAccountsAccountDetails } from '../../api-mocking/mock-responses/feature-flags-mocks'; +import { + remoteFeatureMultichainAccountsAccountDetails, + remoteFeatureMultichainAccountsAccountDetailsV2, +} from '../../api-mocking/mock-responses/feature-flags-mocks'; import { setupRemoteFeatureFlagsMock } from '../../api-mocking/helpers/remoteFeatureFlagsHelper'; export interface Account { @@ -55,3 +58,28 @@ export const withMultichainAccountDetailsEnabled = async ( }, ); }; + +export const withMultichainAccountDetailsV2Enabled = async ( + testFn: () => Promise, +) => { + const testSpecificMock = async (mockServer: Mockttp) => { + await setupRemoteFeatureFlagsMock( + mockServer, + remoteFeatureMultichainAccountsAccountDetailsV2(), + ); + }; + return await withFixtures( + { + fixture: new FixtureBuilder() + .withImportedHdKeyringAndTwoDefaultAccountsOneImportedHdAccountOneQrAccountOneSimpleKeyPairAccount() + .build(), + restartDevice: true, + testSpecificMock, + }, + async () => { + await loginToApp(); + await WalletView.tapIdenticon(); + await testFn(); + }, + ); +}; diff --git a/locales/languages/en.json b/locales/languages/en.json index 6c3016f3f8fd..8dd798eae096 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -1001,6 +1001,8 @@ "invalid_stop_loss": "Stop loss must be {{direction}} current price for {{positionType}} positions", "liquidation_warning": "Position is close to liquidation price", "limit_price_required": "Please set a limit price for limit orders", + "please_set_a_limit_price": "Please set a limit price", + "limit_price_must_be_set_before_configuing_tpsl": "Limit price must be set before configuring TP/SL", "only_hyperliquid_usdc": "Only USDC on Hyperliquid is currently supported for payment" }, "error": {