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": {