diff --git a/.yarn/patches/@metamask-network-enablement-controller-npm-3.1.0-1c0cfefdc3.patch b/.yarn/patches/@metamask-network-enablement-controller-npm-3.1.0-1c0cfefdc3.patch index 7d1746545fc..542897a3e9f 100644 --- a/.yarn/patches/@metamask-network-enablement-controller-npm-3.1.0-1c0cfefdc3.patch +++ b/.yarn/patches/@metamask-network-enablement-controller-npm-3.1.0-1c0cfefdc3.patch @@ -14,3 +14,15 @@ index d4a40bea9e4ed3c28e347d96e309efe1ff889e81..fab280760de6bd5cdfdbecf01495c2d5 }, [utils_1.KnownCaipNamespace.Solana]: { [keyring_api_1.SolScope.Mainnet]: true, +diff --git a/dist/constants.cjs b/dist/constants.cjs +index d45d861dd20777a9c767ef6a4272d0b4fd53f895..145d00f5deec1d79b145bdab8a940e4a6c71230e 100644 +--- a/dist/constants.cjs ++++ b/dist/constants.cjs +@@ -15,5 +15,6 @@ exports.POPULAR_NETWORKS = [ + '0x2a15c308d', + '0x3e7', + '0x8f', // Monad (143) ++ '0x10e6', // MegaETH (4326) + ]; + //# sourceMappingURL=constants.cjs.map +\ No newline at end of file diff --git a/app/__mocks__/react-native-vision-camera.ts b/app/__mocks__/react-native-vision-camera.ts index a6f99e6f204..26af9113a84 100644 --- a/app/__mocks__/react-native-vision-camera.ts +++ b/app/__mocks__/react-native-vision-camera.ts @@ -24,15 +24,53 @@ const mockPermission = { requestPermission: jest.fn().mockResolvedValue('granted'), }; -const mockCodeScanner = { - codeTypes: ['qr'], - onCodeScanned: jest.fn(), +let capturedOnCodeScanned: + | ((codes: { value: string; type: string }[]) => Promise | void) + | null = null; +let capturedOnError: ((error: Error) => Promise | void) | null = null; + +export const resetCapturedCallbacks = () => { + capturedOnCodeScanned = null; + capturedOnError = null; }; -const Camera = React.forwardRef(() => null); +export const getCapturedCallbacks = () => ({ + onCodeScanned: capturedOnCodeScanned, + onError: capturedOnError, +}); + +const Camera = React.forwardRef( + ( + props: { + onError?: (error: Error) => Promise | void; + codeScanner?: { + onCodeScanned: ( + codes: { value: string; type: string }[], + ) => Promise | void; + }; + }, + _ref: unknown, + ) => { + if (props.onError) { + capturedOnError = props.onError; + } + if (props.codeScanner?.onCodeScanned) { + capturedOnCodeScanned = props.codeScanner.onCodeScanned; + } + return React.createElement('View', { testID: 'camera-mock' }); + }, +); const useCameraDevice = jest.fn(() => mockDevice); const useCameraPermission = jest.fn(() => mockPermission); -const useCodeScanner = jest.fn(() => mockCodeScanner); +const useCodeScanner = jest.fn((config) => { + if (config?.onCodeScanned) { + capturedOnCodeScanned = config.onCodeScanned; + } + return { + codeTypes: ['qr'], + onCodeScanned: config?.onCodeScanned || jest.fn(), + }; +}); export { Camera, useCameraDevice, useCameraPermission, useCodeScanner }; diff --git a/app/component-library/components-temp/MultichainAccounts/MultichainAddWalletActions/MultichainAddWalletActions.test.tsx b/app/component-library/components-temp/MultichainAccounts/MultichainAddWalletActions/MultichainAddWalletActions.test.tsx index 892c2a6b4fb..2742ccc84c7 100644 --- a/app/component-library/components-temp/MultichainAccounts/MultichainAddWalletActions/MultichainAddWalletActions.test.tsx +++ b/app/component-library/components-temp/MultichainAccounts/MultichainAddWalletActions/MultichainAddWalletActions.test.tsx @@ -6,6 +6,7 @@ import MultichainAddWalletActions from './MultichainAddWalletActions'; import { MOCK_ACCOUNTS_CONTROLLER_STATE } from '../../../../util/test/accountsControllerTestUtils'; import { MOCK_KEYRING_CONTROLLER } from '../../../../selectors/keyringController/testUtils'; import Routes from '../../../../constants/navigation/Routes'; +import { MetaMetricsEvents } from '../../../../core/Analytics'; const mockedNavigate = jest.fn(); jest.mock('@react-navigation/native', () => { @@ -18,6 +19,18 @@ jest.mock('@react-navigation/native', () => { }; }); +const mockTrackEvent = jest.fn(); +const mockCreateEventBuilder = jest.fn((event) => ({ + build: jest.fn(() => event), +})); + +jest.mock('../../../../components/hooks/useMetrics', () => ({ + useMetrics: () => ({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }), +})); + const mockInitialState = { engine: { backgroundState: { @@ -147,4 +160,78 @@ describe('MultichainAddWalletActions', () => { expect(mockedNavigate).toHaveBeenCalledWith(Routes.MULTI_SRP.IMPORT); expect(mockProps.onBack).toHaveBeenCalled(); }); + + describe('Analytics', () => { + it('tracks event when import wallet button is pressed', () => { + renderScreen( + () => , + { + name: 'MultichainAddWalletActions', + }, + { + state: mockInitialState, + }, + ); + + const importWalletButton = screen.getByTestId( + AddAccountBottomSheetSelectorsIDs.IMPORT_SRP_BUTTON, + ); + fireEvent.press(importWalletButton); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.IMPORT_SECRET_RECOVERY_PHRASE_CLICKED, + ); + expect(mockTrackEvent).toHaveBeenCalledWith( + MetaMetricsEvents.IMPORT_SECRET_RECOVERY_PHRASE_CLICKED, + ); + }); + + it('tracks event when import account button is pressed', () => { + renderScreen( + () => , + { + name: 'MultichainAddWalletActions', + }, + { + state: mockInitialState, + }, + ); + + const importAccountButton = screen.getByTestId( + AddAccountBottomSheetSelectorsIDs.IMPORT_ACCOUNT_BUTTON, + ); + fireEvent.press(importAccountButton); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.ACCOUNTS_IMPORTED_NEW_ACCOUNT, + ); + expect(mockTrackEvent).toHaveBeenCalledWith( + MetaMetricsEvents.ACCOUNTS_IMPORTED_NEW_ACCOUNT, + ); + }); + + it('tracks event when hardware wallet button is pressed', () => { + renderScreen( + () => , + { + name: 'MultichainAddWalletActions', + }, + { + state: mockInitialState, + }, + ); + + const hardwareWalletButton = screen.getByTestId( + AddAccountBottomSheetSelectorsIDs.ADD_HARDWARE_WALLET_BUTTON, + ); + fireEvent.press(hardwareWalletButton); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.ADD_HARDWARE_WALLET, + ); + expect(mockTrackEvent).toHaveBeenCalledWith( + MetaMetricsEvents.ADD_HARDWARE_WALLET, + ); + }); + }); }); diff --git a/app/component-library/components-temp/MultichainAccounts/MultichainAddWalletActions/MultichainAddWalletActions.tsx b/app/component-library/components-temp/MultichainAccounts/MultichainAddWalletActions/MultichainAddWalletActions.tsx index 4bc474be387..5f5f9d61a3d 100644 --- a/app/component-library/components-temp/MultichainAccounts/MultichainAddWalletActions/MultichainAddWalletActions.tsx +++ b/app/component-library/components-temp/MultichainAccounts/MultichainAddWalletActions/MultichainAddWalletActions.tsx @@ -74,7 +74,7 @@ const MultichainAddWalletActions = ({ iconName: IconName.Usb, testID: AddAccountBottomSheetSelectorsIDs.ADD_HARDWARE_WALLET_BUTTON, isVisible: true, - analyticsEvent: MetaMetricsEvents.CONNECT_HARDWARE_WALLET, + analyticsEvent: MetaMetricsEvents.ADD_HARDWARE_WALLET, navigationAction: () => { navigate(Routes.HW.CONNECT); onBack(); diff --git a/app/components/UI/Bridge/components/BridgeDestNetworkSelector/BridgeDestNetworkSelector.test.tsx b/app/components/UI/Bridge/components/BridgeDestNetworkSelector/BridgeDestNetworkSelector.test.tsx index f08859cc7e5..56deaf15d1c 100644 --- a/app/components/UI/Bridge/components/BridgeDestNetworkSelector/BridgeDestNetworkSelector.test.tsx +++ b/app/components/UI/Bridge/components/BridgeDestNetworkSelector/BridgeDestNetworkSelector.test.tsx @@ -251,6 +251,6 @@ describe('BridgeDestNetworkSelector - ChainPopularity fallback', () => { // Optimism (popularity 10) should appear before Palm and zkSync Era (both Infinity) expect(getByText('Optimism')).toBeTruthy(); expect(getByText('Palm')).toBeTruthy(); - expect(getByText('zkSync Era')).toBeTruthy(); + expect(getByText('zkSync')).toBeTruthy(); }); }); diff --git a/app/components/UI/Bridge/components/BridgeDestNetworkSelector/index.tsx b/app/components/UI/Bridge/components/BridgeDestNetworkSelector/index.tsx index be92828a3bc..822513f4fc3 100644 --- a/app/components/UI/Bridge/components/BridgeDestNetworkSelector/index.tsx +++ b/app/components/UI/Bridge/components/BridgeDestNetworkSelector/index.tsx @@ -18,6 +18,7 @@ import Routes from '../../../../../constants/navigation/Routes'; import { selectChainId } from '../../../../../selectors/networkController'; import { BridgeViewMode } from '../../types'; import { ChainPopularity } from '../BridgeDestNetworksBar'; +import { NETWORK_TO_SHORT_NETWORK_NAME_MAP } from '../../../../../constants/bridge'; export interface BridgeDestNetworkSelectorRouteParams { shouldGoToTokens?: boolean; @@ -80,7 +81,12 @@ export const BridgeDestNetworkSelector: React.FC = () => { onPress={() => handleChainSelect(chain.chainId)} > - + )), diff --git a/app/components/UI/Bridge/components/BridgeDestNetworksBar.tsx b/app/components/UI/Bridge/components/BridgeDestNetworksBar.tsx index 6498301a3a4..00ee2dcce22 100644 --- a/app/components/UI/Bridge/components/BridgeDestNetworksBar.tsx +++ b/app/components/UI/Bridge/components/BridgeDestNetworksBar.tsx @@ -20,6 +20,7 @@ import { import { CHAIN_IDS } from '@metamask/transaction-controller'; import { NETWORKS_CHAIN_ID } from '../../../../constants/network'; import { CaipChainId, Hex } from '@metamask/utils'; +import { NETWORK_TO_SHORT_NETWORK_NAME_MAP } from '../../../../constants/bridge'; import { Box } from '../../Box/Box'; import { getNetworkImageSource } from '../../../../util/networks'; import { AlignItems, FlexDirection } from '../../Box/box.types'; @@ -80,10 +81,6 @@ export const ChainPopularity: Record = { [NETWORKS_CHAIN_ID.MONAD]: 13, }; -const ShortChainNames: Record = { - [CHAIN_IDS.MAINNET]: 'Ethereum', -}; - export const BridgeDestNetworksBar = () => { const navigation = useNavigation(); const dispatch = useDispatch(); @@ -140,7 +137,10 @@ export const BridgeDestNetworksBar = () => { size={AvatarSize.Xs} /> ) : null} - {ShortChainNames[chain.chainId] ?? chain.name} + + {NETWORK_TO_SHORT_NETWORK_NAME_MAP[chain.chainId] ?? + chain.name} + } style={ diff --git a/app/components/UI/Bridge/components/BridgeDestTokenSelector/BridgeDestTokenSelector.test.tsx b/app/components/UI/Bridge/components/BridgeDestTokenSelector/BridgeDestTokenSelector.test.tsx index 78ef0495b16..2d7affb0677 100644 --- a/app/components/UI/Bridge/components/BridgeDestTokenSelector/BridgeDestTokenSelector.test.tsx +++ b/app/components/UI/Bridge/components/BridgeDestTokenSelector/BridgeDestTokenSelector.test.tsx @@ -20,8 +20,6 @@ import { ARBITRUM_DISPLAY_NAME, AVALANCHE_DISPLAY_NAME, BASE_DISPLAY_NAME, - BNB_DISPLAY_NAME, - OPTIMISM_DISPLAY_NAME, } from '../../../../../core/Engine/constants'; const mockNavigate = jest.fn(); @@ -104,7 +102,7 @@ jest.mock('../../../../../util/trace', () => ({ })); describe('getNetworkName', () => { - it('returns network name from network configurations when available', () => { + it('returns short name from NETWORK_TO_SHORT_NETWORK_NAME_MAP when available', () => { const chainId = toHex('1') as Hex; const networkConfigurations: Record< string, @@ -121,8 +119,9 @@ describe('getNetworkName', () => { } as MultichainNetworkConfiguration, }; + // NETWORK_TO_SHORT_NETWORK_NAME_MAP takes priority, returning 'Ethereum' const result = getNetworkName(chainId, networkConfigurations); - expect(result).toBe('Ethereum Mainnet'); + expect(result).toBe('Ethereum'); }); it('returns nickname from PopularList when network not in configurations', () => { @@ -147,15 +146,16 @@ describe('getNetworkName', () => { expect(result).toBe(ARBITRUM_DISPLAY_NAME); }); - it('returns nickname from PopularList for BNB Smart Chain', () => { + it('returns short name from NETWORK_TO_SHORT_NETWORK_NAME_MAP for BNB', () => { const chainId = toHex('56') as Hex; const networkConfigurations: Record< string, MultichainNetworkConfiguration > = {}; + // NETWORK_TO_SHORT_NETWORK_NAME_MAP returns 'BNB' for this chain const result = getNetworkName(chainId, networkConfigurations); - expect(result).toBe(BNB_DISPLAY_NAME); + expect(result).toBe('BNB'); }); it('returns nickname from PopularList for Base', () => { @@ -169,15 +169,16 @@ describe('getNetworkName', () => { expect(result).toBe(BASE_DISPLAY_NAME); }); - it('returns nickname from PopularList for OP', () => { + it('returns short name from NETWORK_TO_SHORT_NETWORK_NAME_MAP for Optimism', () => { const chainId = toHex('10') as Hex; const networkConfigurations: Record< string, MultichainNetworkConfiguration > = {}; + // NETWORK_TO_SHORT_NETWORK_NAME_MAP returns 'Optimism' for this chain const result = getNetworkName(chainId, networkConfigurations); - expect(result).toBe(OPTIMISM_DISPLAY_NAME); + expect(result).toBe('Optimism'); }); it('returns "Unknown Network" when network not found anywhere', () => { @@ -191,7 +192,7 @@ describe('getNetworkName', () => { expect(result).toBe('Unknown Network'); }); - it('prioritizes network configurations over PopularList', () => { + it('prioritizes NETWORK_TO_SHORT_NETWORK_NAME_MAP over network configurations', () => { const chainId = toHex('43114') as Hex; // Avalanche const networkConfigurations: Record< string, @@ -208,30 +209,33 @@ describe('getNetworkName', () => { } as MultichainNetworkConfiguration, }; + // NETWORK_TO_SHORT_NETWORK_NAME_MAP takes priority over network configurations const result = getNetworkName(chainId, networkConfigurations); - expect(result).toBe('Custom Avalanche Name'); + expect(result).toBe('Avalanche'); }); - it('handles undefined network configurations gracefully', () => { + it('returns short name when network configurations is undefined', () => { const chainId = toHex('1') as Hex; const networkConfigurations = undefined as unknown as Record< string, MultichainNetworkConfiguration >; + // NETWORK_TO_SHORT_NETWORK_NAME_MAP takes priority, returning 'Ethereum' const result = getNetworkName(chainId, networkConfigurations); - expect(result).toBe('Unknown Network'); + expect(result).toBe('Ethereum'); }); - it('handles null network configurations gracefully', () => { + it('returns short name when network configurations is null', () => { const chainId = toHex('1') as Hex; const networkConfigurations = null as unknown as Record< string, MultichainNetworkConfiguration >; + // NETWORK_TO_SHORT_NETWORK_NAME_MAP takes priority, returning 'Ethereum' const result = getNetworkName(chainId, networkConfigurations); - expect(result).toBe('Unknown Network'); + expect(result).toBe('Ethereum'); }); it('handles empty string chainId', () => { @@ -245,7 +249,7 @@ describe('getNetworkName', () => { expect(result).toBe('Unknown Network'); }); - it('handles network configuration without name property', () => { + it('returns short name when network configuration lacks name property', () => { const chainId = toHex('1') as Hex; const networkConfigurations = { [chainId]: { @@ -259,8 +263,9 @@ describe('getNetworkName', () => { } as unknown as MultichainNetworkConfiguration, }; + // NETWORK_TO_SHORT_NETWORK_NAME_MAP takes priority, returning 'Ethereum' const result = getNetworkName(chainId, networkConfigurations); - expect(result).toBe('Unknown Network'); + expect(result).toBe('Ethereum'); }); }); @@ -372,7 +377,7 @@ describe('BridgeDestTokenSelector', () => { symbol: 'HELLO', tokenFiatAmount: 200000, }), - networkName: 'Ethereum Mainnet', + networkName: 'Ethereum', }), }); }); @@ -522,7 +527,7 @@ describe('BridgeDestTokenSelector', () => { token_name: 'Hello Token', token_symbol: 'HELLO', token_contract: ethToken2Address, - chain_name: 'Ethereum Mainnet', + chain_name: 'Ethereum', chain_id: '0x1', }, ); diff --git a/app/components/UI/Bridge/components/BridgeDestTokenSelector/index.tsx b/app/components/UI/Bridge/components/BridgeDestTokenSelector/index.tsx index 85aebc57c18..b9054a8b70f 100644 --- a/app/components/UI/Bridge/components/BridgeDestTokenSelector/index.tsx +++ b/app/components/UI/Bridge/components/BridgeDestTokenSelector/index.tsx @@ -34,11 +34,13 @@ import Engine from '../../../../../core/Engine'; import { UnifiedSwapBridgeEventName } from '@metamask/bridge-controller'; import { MultichainNetworkConfiguration } from '@metamask/multichain-network-controller'; import Routes from '../../../../../constants/navigation/Routes'; +import { NETWORK_TO_SHORT_NETWORK_NAME_MAP } from '../../../../../constants/bridge'; export const getNetworkName = ( chainId: Hex, networkConfigurations: Record, ) => + NETWORK_TO_SHORT_NETWORK_NAME_MAP[chainId] ?? networkConfigurations?.[chainId as Hex]?.name ?? PopularList.find((network) => network.chainId === chainId)?.nickname ?? 'Unknown Network'; diff --git a/app/components/UI/Bridge/components/BridgeSourceNetworkSelector/BridgeSourceNetworkSelector.test.tsx b/app/components/UI/Bridge/components/BridgeSourceNetworkSelector/BridgeSourceNetworkSelector.test.tsx index 63dada4772d..f8a06b990e3 100644 --- a/app/components/UI/Bridge/components/BridgeSourceNetworkSelector/BridgeSourceNetworkSelector.test.tsx +++ b/app/components/UI/Bridge/components/BridgeSourceNetworkSelector/BridgeSourceNetworkSelector.test.tsx @@ -64,7 +64,7 @@ describe('BridgeSourceNetworkSelector', () => { // Networks should be visible with fiat values await waitFor(() => { - expect(getByText('Ethereum Mainnet')).toBeTruthy(); + expect(getByText('Ethereum')).toBeTruthy(); expect(getByText('Optimism')).toBeTruthy(); // Check for fiat values @@ -274,7 +274,7 @@ describe('BridgeSourceNetworkSelector', () => { ); await waitFor(() => { - expect(queryByText('Ethereum Mainnet')).toBeNull(); + expect(queryByText('Ethereum')).toBeNull(); expect(getByText('Optimism')).toBeTruthy(); }); }); @@ -304,7 +304,7 @@ describe('BridgeSourceNetworkSelector', () => { ); await waitFor(() => { - expect(getByText('Ethereum Mainnet')).toBeTruthy(); + expect(getByText('Ethereum')).toBeTruthy(); expect(getByText('Optimism')).toBeTruthy(); const labels = queryAllByText(strings('networks.no_network_fee')); @@ -333,7 +333,7 @@ describe('BridgeSourceNetworkSelector', () => { ); await waitFor(() => { - expect(getByText('Ethereum Mainnet')).toBeTruthy(); + expect(getByText('Ethereum')).toBeTruthy(); expect(getByText('Optimism')).toBeTruthy(); expect(queryByText(strings('networks.no_network_fee'))).toBeNull(); }); diff --git a/app/components/UI/Bridge/components/BridgeSourceNetworkSelector/__snapshots__/BridgeSourceNetworkSelector.test.tsx.snap b/app/components/UI/Bridge/components/BridgeSourceNetworkSelector/__snapshots__/BridgeSourceNetworkSelector.test.tsx.snap index aca4fccf242..2b10f897f80 100644 --- a/app/components/UI/Bridge/components/BridgeSourceNetworkSelector/__snapshots__/BridgeSourceNetworkSelector.test.tsx.snap +++ b/app/components/UI/Bridge/components/BridgeSourceNetworkSelector/__snapshots__/BridgeSourceNetworkSelector.test.tsx.snap @@ -745,7 +745,7 @@ exports[`BridgeSourceNetworkSelector renders with initial state and displays net } } > - Ethereum Mainnet + Ethereum diff --git a/app/components/UI/Bridge/components/BridgeSourceNetworkSelector/index.tsx b/app/components/UI/Bridge/components/BridgeSourceNetworkSelector/index.tsx index c9f724e2cd6..b2fb7741cc6 100644 --- a/app/components/UI/Bridge/components/BridgeSourceNetworkSelector/index.tsx +++ b/app/components/UI/Bridge/components/BridgeSourceNetworkSelector/index.tsx @@ -33,6 +33,7 @@ import { CaipChainId, Hex } from '@metamask/utils'; import { selectEvmNetworkConfigurationsByChainId } from '../../../../../selectors/networkController'; import { getNativeSourceToken } from '../../utils/tokenUtils'; import { getGasFeesSponsoredNetworkEnabled } from '../../../../../selectors/featureFlagController/gasFeesSponsored'; +import { NETWORK_TO_SHORT_NETWORK_NAME_MAP } from '../../../../../constants/bridge'; const createStyles = () => StyleSheet.create({ @@ -235,7 +236,9 @@ export const BridgeSourceNetworkSelector: React.FC< /> { const dispatch = useDispatch(); @@ -145,7 +146,9 @@ export const BridgeSourceTokenSelector: React.FC = React.memo(() => { return ; } - const networkName = allNetworkConfigurations[item.chainId]?.name; + const networkName = + NETWORK_TO_SHORT_NETWORK_NAME_MAP[item.chainId] ?? + allNetworkConfigurations[item.chainId]?.name; return ( ({ __esModule: true, @@ -47,9 +45,29 @@ jest.mock('@react-navigation/native', () => ({ jest.mock('../../../components/hooks/useMetrics'); const mockTrackEvent = jest.fn(); +const mockCreateEventBuilder = jest.fn(); describe('LedgerConfirmationModal', () => { beforeEach(() => { + jest.clearAllMocks(); + + // Mock event builder chain + const mockEventBuilder = { + addProperties: jest.fn().mockReturnThis(), + addSensitiveProperties: jest.fn().mockReturnThis(), + removeProperties: jest.fn().mockReturnThis(), + removeSensitiveProperties: jest.fn().mockReturnThis(), + setSaveDataRecording: jest.fn().mockReturnThis(), + build: jest.fn().mockReturnValue({ + name: 'test-event', + properties: {}, + sensitiveProperties: {}, + saveDataRecording: true, + }), + }; + + mockCreateEventBuilder.mockReturnValue(mockEventBuilder); + // Mock hook return value (useBluetoothPermissions as jest.Mock).mockReturnValue({ hasBluetoothPermissions: true, @@ -71,7 +89,7 @@ describe('LedgerConfirmationModal', () => { (useMetrics as jest.MockedFn).mockReturnValue({ trackEvent: mockTrackEvent, - createEventBuilder: MetricsEventBuilder.createEventBuilder, + createEventBuilder: mockCreateEventBuilder, enable: jest.fn(), addTraitsToUser: jest.fn(), createDataDeletionTask: jest.fn(), @@ -143,10 +161,10 @@ describe('LedgerConfirmationModal', () => { expect(toJSON()).toMatchSnapshot(); }); - it('logs LEDGER_HARDWARE_WALLET_ERROR event when the ledger error occurs', async () => { + it('logs HARDWARE_WALLET_ERROR event when the ledger error occurs', async () => { const onConfirmation = jest.fn(); - const ledgerLogicToRun = jest.fn(); + (useLedgerBluetooth as jest.Mock).mockReturnValue({ isSendingLedgerCommands: true, isAppLaunchConfirmationNeeded: false, @@ -166,22 +184,11 @@ describe('LedgerConfirmationModal', () => { />, ); - // eslint-disable-next-line no-empty-function - await act(async () => {}); - expect(onConfirmation).not.toHaveBeenCalled(); - - expect(mockTrackEvent).toHaveBeenNthCalledWith( - 1, - MetricsEventBuilder.createEventBuilder( - MetaMetricsEvents.LEDGER_HARDWARE_WALLET_ERROR, - ) - .addProperties({ - device_type: HardwareDeviceTypes.LEDGER, - error: 'LEDGER_ETH_APP_NOT_INSTALLED', - }) - .build(), + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.HARDWARE_WALLET_ERROR, ); + expect(mockTrackEvent).toHaveBeenCalled(); }); it('renders SearchingForDeviceStep when not sending ledger commands', () => { @@ -351,15 +358,14 @@ describe('LedgerConfirmationModal', () => { />, ); - // eslint-disable-next-line no-empty-function - await act(async () => {}); - expect(ledgerLogicToRun).toHaveBeenCalledTimes(1); const retryButton = getByTestId(RETRY_BUTTON); - fireEvent.press(retryButton); - //Retry will run connectLedger again + await act(async () => { + fireEvent.press(retryButton); + }); + expect(ledgerLogicToRun).toHaveBeenCalledTimes(2); }); @@ -380,13 +386,12 @@ describe('LedgerConfirmationModal', () => { />, ); - // eslint-disable-next-line no-empty-function - await act(async () => {}); - const retryButton = getByTestId(RETRY_BUTTON); - fireEvent.press(retryButton); - //Retry will run connectLedger again + await act(async () => { + fireEvent.press(retryButton); + }); + expect(checkPermissions).toHaveBeenCalledTimes(1); }); @@ -407,9 +412,6 @@ describe('LedgerConfirmationModal', () => { />, ); - // eslint-disable-next-line no-empty-function - await act(async () => {}); - expect(onConfirmation).toHaveBeenCalled(); }); diff --git a/app/components/UI/LedgerModals/LedgerConfirmationModal.tsx b/app/components/UI/LedgerModals/LedgerConfirmationModal.tsx index 33b7f55414b..dc98c68df55 100644 --- a/app/components/UI/LedgerModals/LedgerConfirmationModal.tsx +++ b/app/components/UI/LedgerModals/LedgerConfirmationModal.tsx @@ -79,7 +79,7 @@ const LedgerConfirmationModal = ({ // Handle a super edge case of the user starting a transaction with the device connected // After arriving to confirmation the ETH app is not installed anymore this causes a crash. trackEvent( - createEventBuilder(MetaMetricsEvents.LEDGER_HARDWARE_WALLET_ERROR) + createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_ERROR) .addProperties({ device_type: HardwareDeviceTypes.LEDGER, error: 'LEDGER_ETH_APP_NOT_INSTALLED', @@ -95,9 +95,7 @@ const LedgerConfirmationModal = ({ onRejection(); } finally { trackEvent( - createEventBuilder( - MetaMetricsEvents.LEDGER_HARDWARE_TRANSACTION_CANCELLED, - ) + createEventBuilder(MetaMetricsEvents.DAPP_TRANSACTION_CANCELLED) .addProperties({ device_type: HardwareDeviceTypes.LEDGER, }) @@ -209,7 +207,7 @@ const LedgerConfirmationModal = ({ } if (ledgerError !== LedgerCommunicationErrors.UserRefusedConfirmation) { trackEvent( - createEventBuilder(MetaMetricsEvents.LEDGER_HARDWARE_WALLET_ERROR) + createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_ERROR) .addProperties({ device_type: HardwareDeviceTypes.LEDGER, error: `${ledgerError}`, @@ -242,7 +240,7 @@ const LedgerConfirmationModal = ({ } setPermissionErrorShown(true); trackEvent( - createEventBuilder(MetaMetricsEvents.LEDGER_HARDWARE_WALLET_ERROR) + createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_ERROR) .addProperties({ device_type: HardwareDeviceTypes.LEDGER, error: 'LEDGER_BLUETOOTH_PERMISSION_ERR', @@ -258,7 +256,7 @@ const LedgerConfirmationModal = ({ }); trackEvent( - createEventBuilder(MetaMetricsEvents.LEDGER_HARDWARE_WALLET_ERROR) + createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_ERROR) .addProperties({ device_type: HardwareDeviceTypes.LEDGER, error: 'LEDGER_BLUETOOTH_CONNECTION_ERR', diff --git a/app/components/UI/Perps/utils/amountConversion.test.ts b/app/components/UI/Perps/utils/amountConversion.test.ts index b01c92e26db..76571445c33 100644 --- a/app/components/UI/Perps/utils/amountConversion.test.ts +++ b/app/components/UI/Perps/utils/amountConversion.test.ts @@ -8,8 +8,8 @@ describe('convertPerpsAmountToUSD', () => { }); it('handles USD strings correctly', () => { - expect(convertPerpsAmountToUSD('$10.32')).toBe('$10'); // Rounded down using Math.floor() - expect(convertPerpsAmountToUSD('$0.50')).toBe('$0'); // Rounded down using Math.floor() + expect(convertPerpsAmountToUSD('$10.32')).toBe('$10.32'); // Preserves decimals + expect(convertPerpsAmountToUSD('$0.50')).toBe('$0.50'); // Preserves decimals expect(convertPerpsAmountToUSD('$1000')).toBe('$1,000'); }); @@ -20,8 +20,8 @@ describe('convertPerpsAmountToUSD', () => { it('handles numeric strings correctly', () => { expect(convertPerpsAmountToUSD('100')).toBe('$100'); - expect(convertPerpsAmountToUSD('0.5')).toBe('$0'); // Rounded down using Math.floor() - expect(convertPerpsAmountToUSD('1234.56')).toBe('$1,234'); // Rounded down using Math.floor() + expect(convertPerpsAmountToUSD('0.5')).toBe('$0.50'); // Preserves decimals + expect(convertPerpsAmountToUSD('1234.56')).toBe('$1,234.56'); // Preserves decimals }); it('handles edge cases', () => { @@ -34,8 +34,8 @@ describe('convertPerpsAmountToUSD', () => { // Very small wei amount expect(convertPerpsAmountToUSD('0x1')).toBe('$0'); - // Very small decimal - gets threshold formatting - expect(convertPerpsAmountToUSD('0.001')).toBe('$0'); + // Very small decimal - gets threshold formatting from formatPerpsFiat + expect(convertPerpsAmountToUSD('0.001')).toBe('<$0.01'); }); it('handles very large amounts', () => { @@ -46,15 +46,17 @@ describe('convertPerpsAmountToUSD', () => { expect(convertPerpsAmountToUSD('$1000000')).toBe('$1,000,000'); }); - it('rounds down dollar amounts using Math.floor()', () => { - // Test various decimal values to ensure they round down correctly - expect(convertPerpsAmountToUSD('$10.02')).toBe('$10'); - expect(convertPerpsAmountToUSD('$10.99')).toBe('$10'); - expect(convertPerpsAmountToUSD('$0.99')).toBe('$0'); - expect(convertPerpsAmountToUSD('$999.99')).toBe('$999'); - expect(convertPerpsAmountToUSD('10.02')).toBe('$10'); - expect(convertPerpsAmountToUSD('10.99')).toBe('$10'); - expect(convertPerpsAmountToUSD('0.99')).toBe('$0'); - expect(convertPerpsAmountToUSD('999.99')).toBe('$999'); + it('preserves decimal amounts correctly', () => { + // Test various decimal values to ensure they preserve decimals + expect(convertPerpsAmountToUSD('$10.02')).toBe('$10.02'); + expect(convertPerpsAmountToUSD('$10.99')).toBe('$10.99'); + expect(convertPerpsAmountToUSD('$0.99')).toBe('$0.99'); + expect(convertPerpsAmountToUSD('$999.99')).toBe('$999.99'); + expect(convertPerpsAmountToUSD('10.02')).toBe('$10.02'); + expect(convertPerpsAmountToUSD('10.99')).toBe('$10.99'); + expect(convertPerpsAmountToUSD('0.99')).toBe('$0.99'); + expect(convertPerpsAmountToUSD('999.99')).toBe('$999.99'); + // Test the specific bug case: $2.30 withdrawal showing $1.30 + expect(convertPerpsAmountToUSD('1.30')).toBe('$1.30'); }); }); diff --git a/app/components/UI/Perps/utils/amountConversion.ts b/app/components/UI/Perps/utils/amountConversion.ts index 737aceaf3c1..32e535d51e9 100644 --- a/app/components/UI/Perps/utils/amountConversion.ts +++ b/app/components/UI/Perps/utils/amountConversion.ts @@ -20,8 +20,8 @@ export const convertPerpsAmountToUSD = (amount: string): string => { // If it's already a USD string (e.g., "$10.32"), extract numeric value if (amount.startsWith('$')) { const numericValue = parseFloat(amount.replace('$', '')); - const flooredValue = Math.floor(numericValue); - return formatPerpsFiat(flooredValue); + // Preserve decimals for USD amounts + return formatPerpsFiat(numericValue); } // Check if it's a hex value (starts with 0x) - treat as wei @@ -34,16 +34,15 @@ export const convertPerpsAmountToUSD = (amount: string): string => { // In a real implementation, this should come from a price feed const ethPriceUSD = 2000; // TODO: Replace with actual ETH price from price feed const usdValue = ethValue * ethPriceUSD; - const flooredValue = Math.floor(usdValue); - - return formatPerpsFiat(flooredValue); + // Preserve decimals for converted amounts + return formatPerpsFiat(usdValue); } - // Otherwise, treat as a direct USD amount (e.g., "1" = $1) + // Otherwise, treat as a direct USD amount (e.g., "1.30" = $1.30) const numericValue = parseFloat(amount); if (!isNaN(numericValue)) { - const flooredValue = Math.floor(numericValue); - return formatPerpsFiat(flooredValue); + // Preserve decimals for numeric USD amounts + return formatPerpsFiat(numericValue); } // Invalid input - return formatted zero diff --git a/app/components/UI/QRHardware/AnimatedQRScanner.test.tsx b/app/components/UI/QRHardware/AnimatedQRScanner.test.tsx new file mode 100644 index 00000000000..cda7df5f09a --- /dev/null +++ b/app/components/UI/QRHardware/AnimatedQRScanner.test.tsx @@ -0,0 +1,920 @@ +import React from 'react'; +import { render, waitFor, act } from '@testing-library/react-native'; +import AnimatedQRScannerModal from './AnimatedQRScanner'; +import { QrScanRequestType } from '@metamask/eth-qr-keyring'; +import { URRegistryDecoder } from '@keystonehq/ur-decoder'; +import { MetaMetricsEvents } from '../../../core/Analytics'; +import { SUPPORTED_UR_TYPE } from '../../../constants/qr'; +import { HardwareDeviceTypes } from '../../../constants/keyringTypes'; + +const mockTrackEvent = jest.fn(); +const mockCreateEventBuilder = jest.fn(); +const mockBuild = jest.fn(); +const mockAddProperties = jest.fn(() => ({ build: mockBuild })); + +import { + getCapturedCallbacks, + resetCapturedCallbacks, +} from '../../../__mocks__/react-native-vision-camera'; + +jest.mock('../../../components/hooks/useMetrics', () => { + const actualMetrics = jest.requireActual( + '../../../components/hooks/useMetrics', + ); + return { + ...actualMetrics, + useMetrics: jest.fn(() => ({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder.mockReturnValue({ + addProperties: mockAddProperties, + }), + })), + }; +}); + +jest.mock('../../../core/QrKeyring/QrKeyring', () => ({ + withQrKeyring: jest.fn(async (callback) => + callback({ + keyring: { getName: jest.fn().mockResolvedValue('MockDevice') }, + metadata: { id: 'mock-id' }, + }), + ), +})); + +jest.mock('@keystonehq/ur-decoder', () => ({ + URRegistryDecoder: jest.fn().mockImplementation(() => ({ + receivePart: jest.fn(), + getProgress: jest.fn(() => 0.5), + isError: jest.fn(() => false), + isSuccess: jest.fn(() => false), + resultError: jest.fn(() => 'Mock error'), + resultUR: jest.fn(() => ({ + type: 'crypto-hdkey', + cbor: Buffer.from([]), + })), + })), +})); + +jest.mock('../../../../locales/i18n', () => ({ + strings: jest.fn((key: string) => key), +})); + +jest.mock('react-native-vision-camera'); + +jest.mock('react-native-modal', () => { + const { View } = jest.requireActual('react-native'); + let previousVisible: boolean | undefined; + return jest.fn(({ children, isVisible, onModalWillShow, onModalHide }) => { + const wasVisible = previousVisible; + + if (isVisible && wasVisible !== isVisible && onModalWillShow) { + setImmediate(() => { + onModalWillShow(); + }); + } + if (!isVisible && wasVisible === true && onModalHide) { + setImmediate(() => { + onModalHide(); + }); + } + previousVisible = isVisible; + return isVisible ? {children} : null; + }); +}); + +const mockURRegistryDecoder = URRegistryDecoder as jest.MockedClass< + typeof URRegistryDecoder +>; + +describe('AnimatedQRScannerModal - Metrics', () => { + const mockOnScanSuccess = jest.fn(); + const mockOnScanError = jest.fn(); + const mockHideModal = jest.fn(); + const mockPauseQRCode = jest.fn(); + + const defaultProps = { + visible: true, + purpose: QrScanRequestType.PAIR, + onScanSuccess: mockOnScanSuccess, + onScanError: mockOnScanError, + hideModal: mockHideModal, + pauseQRCode: mockPauseQRCode, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockBuild.mockReturnValue({}); + resetCapturedCallbacks(); + const modalMock = jest.requireMock('react-native-modal'); + modalMock.mockClear(); + }); + + describe('Camera Error Metrics', () => { + it('tracks metrics when camera error occurs', async () => { + render(); + + await waitFor(() => { + const callbacks = getCapturedCallbacks(); + expect(callbacks.onError).not.toBeNull(); + }); + + const callbacks = getCapturedCallbacks(); + if (!callbacks.onError) { + throw new Error('onError callback is null'); + } + + const onError = callbacks.onError; + const mockError = new Error('Camera initialization failed'); + await act(async () => { + await onError(mockError); + }); + + await waitFor(() => { + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.HARDWARE_WALLET_ERROR, + ); + expect(mockAddProperties).toHaveBeenCalledWith({ + error: 'Camera initialization failed', + device_model: 'MockDevice', + device_type: HardwareDeviceTypes.QR, + }); + expect(mockTrackEvent).toHaveBeenCalledWith({}); + expect(mockOnScanError).toHaveBeenCalledWith( + 'Camera initialization failed', + ); + }); + }); + + it('does not track metrics when error is falsy', async () => { + render(); + + await waitFor(() => { + const callbacks = getCapturedCallbacks(); + expect(callbacks.onError).not.toBeNull(); + }); + + const callbacks = getCapturedCallbacks(); + if (!callbacks.onError) { + throw new Error('onError callback is null'); + } + + const onError = callbacks.onError; + await act(async () => { + await onError(null as unknown as Error); + }); + + await waitFor(() => { + expect(mockTrackEvent).not.toHaveBeenCalled(); + expect(mockCreateEventBuilder).not.toHaveBeenCalled(); + expect(mockOnScanError).not.toHaveBeenCalled(); + }); + }); + + it('does not track metrics when error is null or undefined', async () => { + render(); + + await waitFor(() => { + const callbacks = getCapturedCallbacks(); + expect(callbacks.onError).not.toBeNull(); + }); + + const callbacks = getCapturedCallbacks(); + if (!callbacks.onError) { + throw new Error('onError callback is null'); + } + + const onError = callbacks.onError; + await act(async () => { + await onError(null as unknown as Error); + }); + + await waitFor(() => { + expect(mockTrackEvent).not.toHaveBeenCalled(); + expect(mockCreateEventBuilder).not.toHaveBeenCalled(); + expect(mockOnScanError).not.toHaveBeenCalled(); + }); + }); + }); + + describe('QR Code Scanning Metrics', () => { + it('tracks metrics when UR decoder reports an error', async () => { + const mockDecoderInstance = { + receivePart: jest.fn(), + getProgress: jest.fn(() => 0.5), + isError: jest.fn(() => true), + isSuccess: jest.fn(() => false), + resultError: jest.fn(() => 'Invalid UR format'), + resultUR: jest.fn(), + }; + + mockURRegistryDecoder.mockImplementation( + () => mockDecoderInstance as unknown as URRegistryDecoder, + ); + + render(); + + await waitFor(() => { + const callbacks = getCapturedCallbacks(); + expect(callbacks.onCodeScanned).not.toBeNull(); + }); + + const callbacks = getCapturedCallbacks(); + if (!callbacks.onCodeScanned) { + throw new Error('onCodeScanned callback is null'); + } + + const onCodeScanned = callbacks.onCodeScanned; + await act(async () => { + await onCodeScanned([{ value: 'mock-qr-data', type: 'qr' }]); + }); + + await waitFor(() => { + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.HARDWARE_WALLET_ERROR, + ); + expect(mockAddProperties).toHaveBeenCalledWith({ + error: 'Invalid UR format', + device_model: 'MockDevice', + device_type: HardwareDeviceTypes.QR, + }); + expect(mockTrackEvent).toHaveBeenCalledWith({}); + expect(mockOnScanError).toHaveBeenCalledWith( + 'transaction.unknown_qr_code', + ); + }); + }); + + it('tracks metrics for invalid sync QR code during pairing', async () => { + const mockDecoderInstance = { + receivePart: jest.fn(), + getProgress: jest.fn(() => 1), + isError: jest.fn(() => false), + isSuccess: jest.fn(() => true), + resultError: jest.fn(), + resultUR: jest.fn(() => ({ + type: SUPPORTED_UR_TYPE.ETH_SIGNATURE, // Wrong type for pairing + cbor: Buffer.from([]), + })), + }; + + mockURRegistryDecoder.mockImplementation( + () => mockDecoderInstance as unknown as URRegistryDecoder, + ); + + render(); + + await waitFor(() => { + const callbacks = getCapturedCallbacks(); + expect(callbacks.onCodeScanned).not.toBeNull(); + }); + + const callbacks = getCapturedCallbacks(); + if (!callbacks.onCodeScanned) { + throw new Error('onCodeScanned callback is null'); + } + + const onCodeScanned = callbacks.onCodeScanned; + await act(async () => { + await onCodeScanned([{ value: 'mock-qr-data', type: 'qr' }]); + }); + + await waitFor(() => { + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.HARDWARE_WALLET_ERROR, + ); + expect(mockAddProperties).toHaveBeenCalledWith({ + received_ur_type: SUPPORTED_UR_TYPE.ETH_SIGNATURE, + error: 'invalid `sync` qr code', + device_model: 'MockDevice', + device_type: HardwareDeviceTypes.QR, + }); + expect(mockTrackEvent).toHaveBeenCalledWith({}); + expect(mockOnScanError).toHaveBeenCalledWith( + 'transaction.invalid_qr_code_sync', + ); + }); + }); + + it('tracks metrics for invalid sign QR code during signing', async () => { + const mockDecoderInstance = { + receivePart: jest.fn(), + getProgress: jest.fn(() => 1), + isError: jest.fn(() => false), + isSuccess: jest.fn(() => true), + resultError: jest.fn(), + resultUR: jest.fn(() => ({ + type: SUPPORTED_UR_TYPE.CRYPTO_HDKEY, // Wrong type for signing + cbor: Buffer.from([]), + })), + }; + + mockURRegistryDecoder.mockImplementation( + () => mockDecoderInstance as unknown as URRegistryDecoder, + ); + + render( + , + ); + + await waitFor(() => { + const callbacks = getCapturedCallbacks(); + expect(callbacks.onCodeScanned).not.toBeNull(); + }); + + const callbacks = getCapturedCallbacks(); + if (!callbacks.onCodeScanned) { + throw new Error('onCodeScanned callback is null'); + } + + const onCodeScanned = callbacks.onCodeScanned; + await act(async () => { + await onCodeScanned([{ value: 'mock-qr-data', type: 'qr' }]); + }); + + await waitFor(() => { + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.HARDWARE_WALLET_ERROR, + ); + expect(mockAddProperties).toHaveBeenCalledWith({ + error: 'invalid `sign` qr code', + device_model: 'MockDevice', + device_type: HardwareDeviceTypes.QR, + }); + expect(mockTrackEvent).toHaveBeenCalledWith({}); + expect(mockOnScanError).toHaveBeenCalledWith( + 'transaction.invalid_qr_code_sign', + ); + }); + }); + + it('tracks metrics for unknown QR code exception', async () => { + const mockDecoderInstance = { + receivePart: jest.fn(() => { + throw new Error('Unexpected decoding error'); + }), + getProgress: jest.fn(() => 0), + isError: jest.fn(() => false), + isSuccess: jest.fn(() => false), + resultError: jest.fn(), + resultUR: jest.fn(), + }; + + mockURRegistryDecoder.mockImplementation( + () => mockDecoderInstance as unknown as URRegistryDecoder, + ); + + render(); + + await waitFor(() => { + const callbacks = getCapturedCallbacks(); + expect(callbacks.onCodeScanned).not.toBeNull(); + }); + + const callbacks = getCapturedCallbacks(); + if (!callbacks.onCodeScanned) { + throw new Error('onCodeScanned callback is null'); + } + + const onCodeScanned = callbacks.onCodeScanned; + await act(async () => { + await onCodeScanned([{ value: 'mock-qr-data', type: 'qr' }]); + }); + + await waitFor(() => { + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.HARDWARE_WALLET_ERROR, + ); + expect(mockAddProperties).toHaveBeenCalledWith({ + error: 'transaction.unknown_qr_code', + device_model: 'MockDevice', + device_type: HardwareDeviceTypes.QR, + }); + expect(mockTrackEvent).toHaveBeenCalledWith({}); + expect(mockOnScanError).toHaveBeenCalledWith( + 'transaction.unknown_qr_code', + ); + }); + }); + + it('successfully scans valid QR code without tracking error metrics', async () => { + const mockDecoderInstance = { + receivePart: jest.fn(), + getProgress: jest.fn(() => 1), + isError: jest.fn(() => false), + isSuccess: jest.fn(() => true), + resultError: jest.fn(), + resultUR: jest.fn(() => ({ + type: SUPPORTED_UR_TYPE.CRYPTO_HDKEY, + cbor: Buffer.from([]), + })), + }; + + mockURRegistryDecoder.mockImplementation( + () => mockDecoderInstance as unknown as URRegistryDecoder, + ); + + render(); + + await waitFor(() => { + const callbacks = getCapturedCallbacks(); + expect(callbacks.onCodeScanned).not.toBeNull(); + }); + + const callbacks = getCapturedCallbacks(); + if (!callbacks.onCodeScanned) { + throw new Error('onCodeScanned callback is null'); + } + + const onCodeScanned = callbacks.onCodeScanned; + await act(async () => { + await onCodeScanned([{ value: 'mock-qr-data', type: 'qr' }]); + }); + + await waitFor(() => { + expect(mockOnScanSuccess).toHaveBeenCalledWith({ + type: SUPPORTED_UR_TYPE.CRYPTO_HDKEY, + cbor: Buffer.from([]), + }); + expect(mockTrackEvent).not.toHaveBeenCalled(); + expect(mockOnScanError).not.toHaveBeenCalled(); + }); + }); + + it('does not process QR code when modal is not visible', async () => { + const mockDecoderInstance = { + receivePart: jest.fn(), + getProgress: jest.fn(() => 0.5), + isError: jest.fn(() => false), + isSuccess: jest.fn(() => false), + resultError: jest.fn(), + resultUR: jest.fn(), + }; + + mockURRegistryDecoder.mockImplementation( + () => mockDecoderInstance as unknown as URRegistryDecoder, + ); + + render(); + + await waitFor(() => { + const callbacks = getCapturedCallbacks(); + expect(callbacks.onCodeScanned).not.toBeNull(); + }); + + const callbacks = getCapturedCallbacks(); + if (!callbacks.onCodeScanned) { + throw new Error('onCodeScanned callback is null'); + } + + const onCodeScanned = callbacks.onCodeScanned; + await act(async () => { + await onCodeScanned([{ value: 'mock-qr-data', type: 'qr' }]); + }); + + await waitFor(() => { + expect(mockDecoderInstance.receivePart).not.toHaveBeenCalled(); + expect(mockOnScanSuccess).not.toHaveBeenCalled(); + expect(mockOnScanError).not.toHaveBeenCalled(); + expect(mockTrackEvent).not.toHaveBeenCalled(); + }); + }); + + it('does not process QR code when codes array is empty', async () => { + const mockDecoderInstance = { + receivePart: jest.fn(), + getProgress: jest.fn(() => 0.5), + isError: jest.fn(() => false), + isSuccess: jest.fn(() => false), + resultError: jest.fn(), + resultUR: jest.fn(), + }; + + mockURRegistryDecoder.mockImplementation( + () => mockDecoderInstance as unknown as URRegistryDecoder, + ); + + render(); + + await waitFor(() => { + const callbacks = getCapturedCallbacks(); + expect(callbacks.onCodeScanned).not.toBeNull(); + }); + + const callbacks = getCapturedCallbacks(); + if (!callbacks.onCodeScanned) { + throw new Error('onCodeScanned callback is null'); + } + + const onCodeScanned = callbacks.onCodeScanned; + await act(async () => { + await onCodeScanned([]); + }); + + await waitFor(() => { + expect(mockDecoderInstance.receivePart).not.toHaveBeenCalled(); + expect(mockOnScanSuccess).not.toHaveBeenCalled(); + expect(mockOnScanError).not.toHaveBeenCalled(); + expect(mockTrackEvent).not.toHaveBeenCalled(); + }); + }); + + it('does not process QR code when code value is null or undefined', async () => { + const mockDecoderInstance = { + receivePart: jest.fn(), + getProgress: jest.fn(() => 0.5), + isError: jest.fn(() => false), + isSuccess: jest.fn(() => false), + resultError: jest.fn(), + resultUR: jest.fn(), + }; + + mockURRegistryDecoder.mockImplementation( + () => mockDecoderInstance as unknown as URRegistryDecoder, + ); + + render(); + + await waitFor(() => { + const callbacks = getCapturedCallbacks(); + expect(callbacks.onCodeScanned).not.toBeNull(); + }); + + const callbacks = getCapturedCallbacks(); + if (!callbacks.onCodeScanned) { + throw new Error('onCodeScanned callback is null'); + } + + const onCodeScanned = callbacks.onCodeScanned; + await act(async () => { + await onCodeScanned([{ value: null as unknown as string, type: 'qr' }]); + }); + + await waitFor(() => { + expect(mockDecoderInstance.receivePart).not.toHaveBeenCalled(); + expect(mockOnScanSuccess).not.toHaveBeenCalled(); + expect(mockOnScanError).not.toHaveBeenCalled(); + expect(mockTrackEvent).not.toHaveBeenCalled(); + }); + }); + + it('does not process QR code when code value is empty string', async () => { + const mockDecoderInstance = { + receivePart: jest.fn(), + getProgress: jest.fn(() => 0.5), + isError: jest.fn(() => false), + isSuccess: jest.fn(() => false), + resultError: jest.fn(), + resultUR: jest.fn(), + }; + + mockURRegistryDecoder.mockImplementation( + () => mockDecoderInstance as unknown as URRegistryDecoder, + ); + + render(); + + await waitFor(() => { + const callbacks = getCapturedCallbacks(); + expect(callbacks.onCodeScanned).not.toBeNull(); + }); + + const callbacks = getCapturedCallbacks(); + if (!callbacks.onCodeScanned) { + throw new Error('onCodeScanned callback is null'); + } + + const onCodeScanned = callbacks.onCodeScanned; + await act(async () => { + await onCodeScanned([{ value: '', type: 'qr' }]); + }); + + await waitFor(() => { + expect(mockDecoderInstance.receivePart).not.toHaveBeenCalled(); + expect(mockOnScanSuccess).not.toHaveBeenCalled(); + expect(mockOnScanError).not.toHaveBeenCalled(); + expect(mockTrackEvent).not.toHaveBeenCalled(); + }); + }); + }); + + describe('Camera Permission Error', () => { + it('calls onScanError when camera permission is not granted', async () => { + const mockUseCameraPermission = jest.requireMock( + 'react-native-vision-camera', + ).useCameraPermission; + + mockUseCameraPermission.mockReturnValue({ + hasPermission: false, + requestPermission: jest.fn(), + }); + + render(); + + await waitFor(() => { + expect(mockOnScanError).toHaveBeenCalledWith( + 'transaction.no_camera_permission', + ); + }); + }); + + it('does not call onScanError when camera permission is granted', async () => { + const mockUseCameraPermission = jest.requireMock( + 'react-native-vision-camera', + ).useCameraPermission; + + mockUseCameraPermission.mockReturnValue({ + hasPermission: true, + requestPermission: jest.fn(), + }); + + render(); + + await waitFor(() => { + const callbacks = getCapturedCallbacks(); + expect(callbacks.onCodeScanned).not.toBeNull(); + }); + + expect(mockOnScanError).not.toHaveBeenCalled(); + }); + + it('does not call onScanError when modal is not visible', async () => { + const mockUseCameraPermission = jest.requireMock( + 'react-native-vision-camera', + ).useCameraPermission; + + mockUseCameraPermission.mockReturnValue({ + hasPermission: false, + requestPermission: jest.fn(), + }); + + const propsWithoutVisibility = { ...defaultProps, visible: false }; + + render(); + + await waitFor(() => { + expect(mockOnScanError).not.toHaveBeenCalled(); + }); + }); + }); + + describe('Device Information in Metrics', () => { + it('includes device name in all error metrics', async () => { + const mockDecoderInstance = { + receivePart: jest.fn(), + getProgress: jest.fn(() => 0.5), + isError: jest.fn(() => true), + isSuccess: jest.fn(() => false), + resultError: jest.fn(() => 'Test error'), + resultUR: jest.fn(), + }; + + mockURRegistryDecoder.mockImplementation( + () => mockDecoderInstance as unknown as URRegistryDecoder, + ); + + render(); + + await waitFor(() => { + const callbacks = getCapturedCallbacks(); + expect(callbacks.onCodeScanned).not.toBeNull(); + }); + + const callbacks = getCapturedCallbacks(); + if (!callbacks.onCodeScanned) { + throw new Error('onCodeScanned callback is null'); + } + + const onCodeScanned = callbacks.onCodeScanned; + await act(async () => { + await onCodeScanned([{ value: 'mock-qr-data', type: 'qr' }]); + }); + + await waitFor(() => { + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ + device_model: 'MockDevice', + device_type: HardwareDeviceTypes.QR, + }), + ); + }); + }); + + it('uses "Unknown" device name when withQrKeyring fails during camera error', async () => { + const { withQrKeyring } = jest.requireMock( + '../../../core/QrKeyring/QrKeyring', + ); + withQrKeyring.mockRejectedValueOnce(new Error('Keyring not initialized')); + + const mockUseCameraPermission = jest.requireMock( + 'react-native-vision-camera', + ).useCameraPermission; + + mockUseCameraPermission.mockReturnValue({ + hasPermission: true, + requestPermission: jest.fn(), + }); + + render(); + + await waitFor(() => { + const callbacks = getCapturedCallbacks(); + expect(callbacks.onError).not.toBeNull(); + }); + + const callbacks = getCapturedCallbacks(); + if (!callbacks.onError) { + throw new Error('onError callback is null'); + } + + const onError = callbacks.onError; + const mockError = new Error('Camera error'); + await act(async () => { + await onError(mockError); + }); + + await waitFor(() => { + expect(mockAddProperties).toHaveBeenCalledWith({ + error: 'Camera error', + device_model: 'Unknown', + device_type: HardwareDeviceTypes.QR, + }); + expect(mockOnScanError).toHaveBeenCalledWith('Camera error'); + }); + }); + + it('uses "Unknown" device name when withQrKeyring fails during QR scanning', async () => { + const { withQrKeyring } = jest.requireMock( + '../../../core/QrKeyring/QrKeyring', + ); + withQrKeyring.mockRejectedValueOnce(new Error('Keyring not initialized')); + + const mockDecoderInstance = { + receivePart: jest.fn(), + getProgress: jest.fn(() => 0.5), + isError: jest.fn(() => true), + isSuccess: jest.fn(() => false), + resultError: jest.fn(() => 'Decoder error'), + resultUR: jest.fn(), + }; + + mockURRegistryDecoder.mockImplementation( + () => mockDecoderInstance as unknown as URRegistryDecoder, + ); + + render(); + + await waitFor(() => { + const callbacks = getCapturedCallbacks(); + expect(callbacks.onCodeScanned).not.toBeNull(); + }); + + const callbacks = getCapturedCallbacks(); + if (!callbacks.onCodeScanned) { + throw new Error('onCodeScanned callback is null'); + } + + const onCodeScanned = callbacks.onCodeScanned; + await act(async () => { + await onCodeScanned([{ value: 'mock-qr-data', type: 'qr' }]); + }); + + await waitFor(() => { + expect(mockAddProperties).toHaveBeenCalledWith({ + error: 'Decoder error', + device_model: 'Unknown', + device_type: HardwareDeviceTypes.QR, + }); + expect(mockOnScanError).toHaveBeenCalledWith( + 'transaction.unknown_qr_code', + ); + }); + }); + }); + + describe('Modal Lifecycle', () => { + it('calls pauseQRCode with true when modal is shown', async () => { + const propsHidden = { ...defaultProps, visible: false }; + const propsVisible = { ...defaultProps, visible: true }; + + const { rerender } = render(); + + mockPauseQRCode.mockClear(); + rerender(); + + await waitFor(() => { + expect(mockPauseQRCode).toHaveBeenCalledWith(true); + }); + }); + + it('calls pauseQRCode with false and resets state when modal is hidden', async () => { + const propsHidden = { ...defaultProps, visible: false }; + const propsVisible = { ...defaultProps, visible: true }; + + // Start with hidden modal + const { rerender } = render(); + + // Show modal + rerender(); + + // Wait for initial show callback + await waitFor( + () => { + expect(mockPauseQRCode).toHaveBeenCalledWith(true); + }, + { timeout: 2000 }, + ); + + mockPauseQRCode.mockClear(); + + // Hide modal + rerender(); + + await waitFor( + () => { + expect(mockPauseQRCode).toHaveBeenCalledWith(false); + }, + { timeout: 2000 }, + ); + }); + + it('does not throw error when pauseQRCode is not provided', async () => { + const propsWithoutPauseHidden = { + ...defaultProps, + pauseQRCode: undefined, + visible: false, + }; + const propsWithoutPauseVisible = { + ...defaultProps, + pauseQRCode: undefined, + visible: true, + }; + + const { rerender } = render( + , + ); + + expect(() => { + rerender(); + }).not.toThrow(); + + expect(() => { + rerender(); + }).not.toThrow(); + }); + + it('resets progress and decoder when modal is hidden', async () => { + const mockDecoderInstance = { + receivePart: jest.fn(), + getProgress: jest.fn(() => 0.5), + isError: jest.fn(() => false), + isSuccess: jest.fn(() => false), + resultError: jest.fn(), + resultUR: jest.fn(), + }; + + mockURRegistryDecoder.mockImplementation( + () => mockDecoderInstance as unknown as URRegistryDecoder, + ); + + const propsVisible = { ...defaultProps, visible: true }; + const propsHidden = { ...defaultProps, visible: false }; + + const { rerender } = render(); + + // Simulate scanning to set progress + await waitFor(() => { + const callbacks = getCapturedCallbacks(); + expect(callbacks.onCodeScanned).not.toBeNull(); + }); + + const callbacks = getCapturedCallbacks(); + if (!callbacks.onCodeScanned) { + throw new Error('onCodeScanned callback is null'); + } + + const onCodeScanned = callbacks.onCodeScanned; + await act(async () => { + await onCodeScanned([{ value: 'mock-qr-data', type: 'qr' }]); + }); + + // Hide modal to trigger reset + mockPauseQRCode.mockClear(); + rerender(); + + await waitFor(() => { + expect(mockPauseQRCode).toHaveBeenCalledWith(false); + }); + + // When modal is shown again, it should start fresh + mockPauseQRCode.mockClear(); + rerender(); + + await waitFor(() => { + expect(mockPauseQRCode).toHaveBeenCalledWith(true); + }); + }); + }); +}); diff --git a/app/components/UI/QRHardware/AnimatedQRScanner.tsx b/app/components/UI/QRHardware/AnimatedQRScanner.tsx index ffb83949c05..dabdc5391b2 100644 --- a/app/components/UI/QRHardware/AnimatedQRScanner.tsx +++ b/app/components/UI/QRHardware/AnimatedQRScanner.tsx @@ -33,6 +33,8 @@ import Icon, { IconSize, } from '../../../component-library/components/Icons/Icon'; import { QrScanRequestType } from '@metamask/eth-qr-keyring'; +import { withQrKeyring } from '../../../core/QrKeyring/QrKeyring'; +import { HardwareDeviceTypes } from '../../../constants/keyringTypes'; const createStyles = (theme: Theme) => StyleSheet.create({ @@ -164,21 +166,41 @@ const AnimatedQRScannerModal = (props: AnimatedQRScannerProps) => { ); const onError = useCallback( - (error: Error) => { + async (error: Error) => { if (onScanError && error) { - trackEvent( - createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_ERROR) - .addProperties({ purpose, error: error.message }) - .build(), - ); + // Get device name asynchronously without blocking error handling + withQrKeyring(({ keyring }) => Promise.resolve(keyring.getName())) + .then((deviceName) => { + trackEvent( + createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_ERROR) + .addProperties({ + error: error.message, + device_model: deviceName, + device_type: HardwareDeviceTypes.QR, + }) + .build(), + ); + }) + .catch(() => { + // If getName fails, send analytics with 'Unknown' + trackEvent( + createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_ERROR) + .addProperties({ + error: error.message, + device_model: 'Unknown', + device_type: HardwareDeviceTypes.QR, + }) + .build(), + ); + }); onScanError(error.message); } }, - [purpose, onScanError, trackEvent, createEventBuilder], + [onScanError, trackEvent, createEventBuilder], ); const onBarCodeRead = useCallback( - (codes: Code[]) => { + async (codes: Code[]) => { if (!visible || !codes.length) { return; } @@ -186,16 +208,41 @@ const AnimatedQRScannerModal = (props: AnimatedQRScannerProps) => { if (!response.data) { return; } + try { const content = response.data; urDecoder.receivePart(content); setProgress(Math.ceil(urDecoder.getProgress() * 100)); + + // Helper to send analytics with device name + const sendAnalytics = (properties: Record) => { + withQrKeyring(({ keyring }) => Promise.resolve(keyring.getName())) + .then((deviceName) => { + trackEvent( + createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_ERROR) + .addProperties({ + ...properties, + device_model: deviceName, + device_type: HardwareDeviceTypes.QR, + }) + .build(), + ); + }) + .catch(() => { + trackEvent( + createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_ERROR) + .addProperties({ + ...properties, + device_model: 'Unknown', + device_type: HardwareDeviceTypes.QR, + }) + .build(), + ); + }); + }; + if (urDecoder.isError()) { - trackEvent( - createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_ERROR) - .addProperties({ purpose, error: urDecoder.resultError() }) - .build(), - ); + sendAnalytics({ error: urDecoder.resultError() }); onScanError(strings('transaction.unknown_qr_code')); } else if (urDecoder.isSuccess()) { const ur = urDecoder.resultUR(); @@ -204,30 +251,40 @@ const AnimatedQRScannerModal = (props: AnimatedQRScannerProps) => { setProgress(0); setURDecoder(new URRegistryDecoder()); } else if (purpose === QrScanRequestType.PAIR) { + sendAnalytics({ + received_ur_type: ur.type, + error: 'invalid `sync` qr code', + }); + onScanError(strings('transaction.invalid_qr_code_sync')); + } else { + sendAnalytics({ error: 'invalid `sign` qr code' }); + onScanError(strings('transaction.invalid_qr_code_sign')); + } + } + } catch (e) { + withQrKeyring(({ keyring }) => Promise.resolve(keyring.getName())) + .then((deviceName) => { trackEvent( createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_ERROR) .addProperties({ - purpose, - received_ur_type: ur.type, - error: 'invalid `sync` qr code', + error: strings('transaction.unknown_qr_code'), + device_model: deviceName, + device_type: HardwareDeviceTypes.QR, }) .build(), ); - onScanError(strings('transaction.invalid_qr_code_sync')); - } else { + }) + .catch(() => { trackEvent( createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_ERROR) .addProperties({ - purpose, - received_ur_type: ur.type, - error: 'invalid `sign` qr code', + error: strings('transaction.unknown_qr_code'), + device_model: 'Unknown', + device_type: HardwareDeviceTypes.QR, }) .build(), ); - onScanError(strings('transaction.invalid_qr_code_sign')); - } - } - } catch (e) { + }); onScanError(strings('transaction.unknown_qr_code')); } }, diff --git a/app/components/UI/Ramp/Deposit/Views/Modals/WebviewModal/KycWebviewModal.tsx b/app/components/UI/Ramp/Deposit/Views/Modals/WebviewModal/KycWebviewModal.tsx index 46888a3cba2..f9fbbaf2264 100644 --- a/app/components/UI/Ramp/Deposit/Views/Modals/WebviewModal/KycWebviewModal.tsx +++ b/app/components/UI/Ramp/Deposit/Views/Modals/WebviewModal/KycWebviewModal.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useRef } from 'react'; import { BuyQuote } from '@consensys/native-ramps-sdk'; import WebviewModal, { WebviewModalParams } from './WebviewModal'; @@ -24,6 +24,7 @@ export const createKycWebviewModalNavigationDetails = function KycWebviewModal() { const { quote, workFlowRunId } = useParams(); + const hasNavigatedRef = useRef(false); const { routeAfterAuthentication } = useDepositRouting({ screenLocation: 'KycWebviewModal Screen', @@ -48,7 +49,8 @@ function KycWebviewModal() { }, []); useEffect(() => { - if (idProofStatus === 'SUBMITTED' && quote) { + if (idProofStatus === 'SUBMITTED' && quote && !hasNavigatedRef.current) { + hasNavigatedRef.current = true; routeAfterAuthentication(quote); } }, [idProofStatus, quote, routeAfterAuthentication]); diff --git a/app/components/UI/Ramp/Deposit/hooks/useDepositRouting.ts b/app/components/UI/Ramp/Deposit/hooks/useDepositRouting.ts index c6e05b2e82a..ee8d15b1607 100644 --- a/app/components/UI/Ramp/Deposit/hooks/useDepositRouting.ts +++ b/app/components/UI/Ramp/Deposit/hooks/useDepositRouting.ts @@ -118,6 +118,10 @@ export const useDepositRouting = (config?: UseDepositRoutingConfig) => { (route) => route.name === 'BuildQuote', ); + if (buildQuoteIndex === -1) { + return state; + } + return { payload: { count: state.routes.length - buildQuoteIndex - 1, diff --git a/app/components/UI/SimulationDetails/BalanceChangeRow/BalanceChangeRow.test.tsx b/app/components/UI/SimulationDetails/BalanceChangeRow/BalanceChangeRow.test.tsx index ff254ed18d0..3e2a0daf716 100644 --- a/app/components/UI/SimulationDetails/BalanceChangeRow/BalanceChangeRow.test.tsx +++ b/app/components/UI/SimulationDetails/BalanceChangeRow/BalanceChangeRow.test.tsx @@ -10,6 +10,16 @@ jest.mock('../AssetPill/AssetPill', () => 'AssetPill'); jest.mock('../FiatDisplay/FiatDisplay', () => ({ IndividualFiatDisplay: 'IndividualFiatDisplay', })); +jest.mock( + '../../../Views/confirmations/hooks/metrics/useConfirmationAlertMetrics', + () => ({ + useConfirmationAlertMetrics: () => ({ + trackInlineAlertClicked: jest.fn(), + trackAlertActionClicked: jest.fn(), + trackAlertRendered: jest.fn(), + }), + }), +); const CHAIN_ID_MOCK = '0x123'; diff --git a/app/components/UI/SimulationDetails/BatchApprovalRow/BatchApprovalRow.test.tsx b/app/components/UI/SimulationDetails/BatchApprovalRow/BatchApprovalRow.test.tsx index 012bc5c9a20..a1aff88a20f 100644 --- a/app/components/UI/SimulationDetails/BatchApprovalRow/BatchApprovalRow.test.tsx +++ b/app/components/UI/SimulationDetails/BatchApprovalRow/BatchApprovalRow.test.tsx @@ -17,6 +17,17 @@ import { Severity } from '../../../Views/confirmations/types/alerts'; import { AssetType } from '../types'; import BatchApprovalRow from './BatchApprovalRow'; +jest.mock( + '../../../Views/confirmations/hooks/metrics/useConfirmationAlertMetrics', + () => ({ + useConfirmationAlertMetrics: () => ({ + trackInlineAlertClicked: jest.fn(), + trackAlertActionClicked: jest.fn(), + trackAlertRendered: jest.fn(), + }), + }), +); + const approvalData = [ { asset: { @@ -68,7 +79,7 @@ describe('BatchApprovalRow', () => { expect(getByTestId('edit-spending-cap-button')).toBeTruthy(); }); - it('displays alert if BatchedApprovals alert is present', () => { + it('displays alert icon if BatchedApprovals alert is present', () => { jest .spyOn(BatchApprovalUtils, 'useBatchApproveBalanceChanges') .mockReturnValue({ value: approvalData, pending: false }); @@ -77,10 +88,10 @@ describe('BatchApprovalRow', () => { fieldAlerts: [mockBatchedUnusedApprovalAlert], isAlertConfirmed: () => false, } as unknown as AlertContextFunctions.AlertsContextParams); - const { getByText } = renderWithProvider(, { + const { getByTestId } = renderWithProvider(, { state: getAppStateForConfirmation(upgradeAccountConfirmation), }); - expect(getByText('Alert')).toBeTruthy(); + expect(getByTestId('inline-alert-icon')).toBeTruthy(); }); }); diff --git a/app/components/Views/AccountActions/AccountActions.test.tsx b/app/components/Views/AccountActions/AccountActions.test.tsx index 2ca426abf36..ea0ae55f85a 100644 --- a/app/components/Views/AccountActions/AccountActions.test.tsx +++ b/app/components/Views/AccountActions/AccountActions.test.tsx @@ -19,6 +19,7 @@ import { import { BITCOIN_WALLET_SNAP_ID } from '../../../core/SnapKeyring/BitcoinWalletSnap'; import { SOLANA_WALLET_SNAP_ID } from '../../../core/SnapKeyring/SolanaWalletSnap'; import { KeyringTypes } from '@metamask/keyring-controller'; +import ExtendedKeyringTypes from '../../../constants/keyringTypes'; import { strings } from '../../../../locales/i18n'; // eslint-disable-next-line import/no-namespace @@ -226,6 +227,11 @@ jest.mock('@react-navigation/native', () => { import { useRoute } from '@react-navigation/native'; import { RootState } from '../../../reducers'; import { InternalAccount } from '@metamask/keyring-internal-api'; +import { forgetLedger } from '../../../core/Ledger/Ledger'; +import { + forgetQrDevice, + withQrKeyring, +} from '../../../core/QrKeyring/QrKeyring'; // Set the implementation after the mock is defined const mockedUseRoute = jest.mocked(useRoute); @@ -325,6 +331,15 @@ jest.mock('../../../core/Multichain/utils', () => ({ isNonEvmChainId: jest.fn(() => false), })); +jest.mock('../../../core/Ledger/Ledger', () => ({ + forgetLedger: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock('../../../core/QrKeyring/QrKeyring', () => ({ + forgetQrDevice: jest.fn().mockResolvedValue(undefined), + withQrKeyring: jest.fn().mockResolvedValue(undefined), +})); + describe('AccountActions', () => { const mockKeyringController = mockEngine.context.KeyringController; @@ -701,4 +716,208 @@ describe('AccountActions', () => { expect(queryByText('Switch to Smart account')).toBeNull(); }); }); + + describe('forgetDeviceIfRequired', () => { + const MOCK_LEDGER_ACCOUNT = { + address: '0xC4966c0D659D99699BFD7EB54D8fafEE40e4a756', + id: '123', + metadata: { + name: 'Ledger Account', + importTime: 1684232000456, + keyring: { + type: ExtendedKeyringTypes.ledger, + }, + }, + options: { + entropySource: 'mock-id', + }, + methods: [ + 'personal_sign', + 'eth_signTransaction', + 'eth_signTypedData_v1', + 'eth_signTypedData_v3', + 'eth_signTypedData_v4', + ], + type: 'eoa', + scopes: ['eip155:1'], + }; + + const MOCK_QR_ACCOUNT = { + address: '0xC4966c0D659D99699BFD7EB54D8fafEE40e4a756', + id: '124', + metadata: { + name: 'QR Account', + importTime: 1684232000456, + keyring: { + type: ExtendedKeyringTypes.qr, + }, + }, + options: { + entropySource: 'mock-id', + }, + methods: [ + 'personal_sign', + 'eth_signTransaction', + 'eth_signTypedData_v1', + 'eth_signTypedData_v3', + 'eth_signTypedData_v4', + ], + type: 'eoa', + scopes: ['eip155:1'], + }; + + beforeEach(() => { + (forgetLedger as jest.Mock).mockClear(); + (forgetQrDevice as jest.Mock).mockClear(); + (withQrKeyring as jest.Mock).mockClear(); + }); + + it('triggers blocking modal when remove hardware account is confirmed for Ledger', async () => { + jest.spyOn(AddressUtils, 'isHardwareAccount').mockReturnValue(true); + + mockedUseRoute.mockImplementationOnce(() => ({ + key: 'mock-key', + name: 'mock-route', + params: { + selectedAccount: MOCK_LEDGER_ACCOUNT, + }, + })); + + Object.assign(mockKeyringController.state, { + keyrings: [ + { + type: ExtendedKeyringTypes.ledger, + accounts: [], + }, + ], + }); + + const { getByTestId, getByText } = renderWithProvider( + , + { + state: initialState, + }, + ); + + fireEvent.press( + getByTestId( + AccountActionsBottomSheetSelectorsIDs.REMOVE_HARDWARE_ACCOUNT, + ), + ); + + const alertFnMock = Alert.alert as jest.MockedFn; + expect(alertFnMock).toHaveBeenCalledWith( + strings('accounts.remove_hardware_account'), + strings('accounts.remove_hw_account_alert_description'), + expect.any(Array), + ); + + await act(async () => { + const alertButtons = alertFnMock.mock.calls[0][2] as AlertButton[]; + if (alertButtons[1].onPress !== undefined) { + alertButtons[1].onPress(); + } + }); + + await waitFor(() => { + expect(getByText(strings('common.please_wait'))).toBeDefined(); + }); + }); + + it('triggers blocking modal when remove hardware account is confirmed for QR', async () => { + jest.spyOn(AddressUtils, 'isHardwareAccount').mockReturnValue(true); + + mockedUseRoute.mockImplementationOnce(() => ({ + key: 'mock-key', + name: 'mock-route', + params: { + selectedAccount: MOCK_QR_ACCOUNT, + }, + })); + + Object.assign(mockKeyringController.state, { + keyrings: [ + { + type: ExtendedKeyringTypes.qr, + accounts: [], + }, + ], + }); + + const { getByTestId, getByText } = renderWithProvider( + , + { + state: initialState, + }, + ); + + fireEvent.press( + getByTestId( + AccountActionsBottomSheetSelectorsIDs.REMOVE_HARDWARE_ACCOUNT, + ), + ); + + const alertFnMock = Alert.alert as jest.MockedFn; + + await act(async () => { + const alertButtons = alertFnMock.mock.calls[0][2] as AlertButton[]; + if (alertButtons[1].onPress !== undefined) { + alertButtons[1].onPress(); + } + }); + + await waitFor(() => { + expect(getByText(strings('common.please_wait'))).toBeDefined(); + }); + }); + + it('sets blockingModalVisible to true when removing hardware account', async () => { + jest.spyOn(AddressUtils, 'isHardwareAccount').mockReturnValue(true); + + mockedUseRoute.mockImplementationOnce(() => ({ + key: 'mock-key', + name: 'mock-route', + params: { + selectedAccount: MOCK_LEDGER_ACCOUNT, + }, + })); + + Object.assign(mockKeyringController.state, { + keyrings: [ + { + type: ExtendedKeyringTypes.ledger, + accounts: [], + }, + ], + }); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + fireEvent.press( + getByTestId( + AccountActionsBottomSheetSelectorsIDs.REMOVE_HARDWARE_ACCOUNT, + ), + ); + + const alertFnMock = Alert.alert as jest.MockedFn; + + await act(async () => { + const alertButtons = alertFnMock.mock.calls[0][2] as AlertButton[]; + if (alertButtons[1].onPress !== undefined) { + alertButtons[1].onPress(); + } + }); + + await waitFor(() => { + expect(mockKeyringController.state.keyrings).toEqual([ + { + type: ExtendedKeyringTypes.ledger, + accounts: [], + }, + ]); + }); + }); + }); }); diff --git a/app/components/Views/AccountActions/AccountActions.tsx b/app/components/Views/AccountActions/AccountActions.tsx index 85e47029896..333c5786772 100644 --- a/app/components/Views/AccountActions/AccountActions.tsx +++ b/app/components/Views/AccountActions/AccountActions.tsx @@ -47,7 +47,11 @@ import { useEIP7702Networks } from '../confirmations/hooks/7702/useEIP7702Networ import { isEvmAccountType } from '@metamask/keyring-api'; import { toHex } from '@metamask/controller-utils'; import { getMultichainBlockExplorer } from '../../../core/Multichain/networks'; -import { forgetQrDevice } from '../../../core/QrKeyring/QrKeyring'; +import { + forgetQrDevice, + withQrKeyring, +} from '../../../core/QrKeyring/QrKeyring'; +import useLedgerDeviceForAccount from '../../hooks/Ledger/useLedgerDeviceForAccount'; interface AccountActionsParams { selectedAccount: InternalAccount; @@ -83,6 +87,8 @@ const AccountActions = () => { const selectedAddress = selectedAccount?.address; const keyring = selectedAccount?.metadata.keyring; + const { ledgerDevice } = useLedgerDeviceForAccount(selectedAccount); + const blockExplorer: | { url: string; @@ -298,26 +304,35 @@ const AccountActions = () => { } if (requestForgetDevice) { switch (keyringType) { - case ExtendedKeyringTypes.ledger: + case ExtendedKeyringTypes.ledger: { + const ledgerDeviceId = ledgerDevice?.id; await forgetLedger(); trackEvent( createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_FORGOTTEN) .addProperties({ device_type: HardwareDeviceTypes.LEDGER, + device_model: ledgerDeviceId, }) .build(), ); break; - case ExtendedKeyringTypes.qr: + } + case ExtendedKeyringTypes.qr: { + const deviceName = await withQrKeyring( + // eslint-disable-next-line @typescript-eslint/no-shadow + async ({ keyring }) => await keyring.getName(), + ); await forgetQrDevice(); trackEvent( createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_FORGOTTEN) .addProperties({ device_type: HardwareDeviceTypes.QR, + device_model: deviceName, }) .build(), ); break; + } default: break; } @@ -325,6 +340,7 @@ const AccountActions = () => { }, [ controllers.KeyringController, keyring?.type, + ledgerDevice?.id, trackEvent, createEventBuilder, ]); diff --git a/app/components/Views/AccountConnect/AccountConnect.test.tsx b/app/components/Views/AccountConnect/AccountConnect.test.tsx index 5cc9bc74772..88dbc5cc875 100644 --- a/app/components/Views/AccountConnect/AccountConnect.test.tsx +++ b/app/components/Views/AccountConnect/AccountConnect.test.tsx @@ -6,7 +6,6 @@ import AccountConnect from './AccountConnect'; import { backgroundState } from '../../../util/test/initial-root-state'; import { RootState } from '../../../reducers'; import { fireEvent, waitFor } from '@testing-library/react-native'; -import AccountConnectMultiSelector from './AccountConnectMultiSelector/AccountConnectMultiSelector'; import Engine from '../../../core/Engine'; import { createMockAccountsControllerState as createMockAccountsControllerStateUtil, @@ -25,6 +24,7 @@ import { KeyringTypes } from '@metamask/keyring-controller'; import { SolScope } from '@metamask/keyring-api'; import { PermissionDoesNotExistError } from '@metamask/permission-controller'; import { ConnectedAccountsSelectorsIDs } from '../../../../e2e/selectors/Browser/ConnectedAccountModal.selectors'; +import AccountConnectMultiSelector from './AccountConnectMultiSelector/AccountConnectMultiSelector'; const MOCK_ACCOUNTS_CONTROLLER_STATE = createMockAccountsControllerStateUtil([ mockAddress1, @@ -55,14 +55,18 @@ const createMockCaip25Permission = ( const mockedNavigate = jest.fn(); const mockedGoBack = jest.fn(); const mockedTrackEvent = jest.fn(); +const mockBuild = jest.fn(); +const mockAddProperties = jest.fn().mockReturnValue({ + build: mockBuild, +}); const mockCreateEventBuilder = jest.fn().mockReturnValue({ - addProperties: jest.fn().mockReturnValue({ - build: jest.fn(), - }), + addProperties: mockAddProperties, + build: mockBuild, }); const mockGetNextAvailableAccountName = jest .fn() .mockReturnValue('Snap Account 1'); +const mockGetConnectedDevicesCount = jest.fn(); jest.mock('@react-navigation/native', () => { const actualNav = jest.requireActual('@react-navigation/native'); @@ -215,6 +219,10 @@ jest.mock('../../../core/AppConstants', () => ({ MM_UNIVERSAL_LINK_HOST: 'metamask.app.link', })); +jest.mock('../../../core/HardwareWallets/analytics', () => ({ + getConnectedDevicesCount: () => mockGetConnectedDevicesCount(), +})); + // Setup test state with proper account data const mockInitialState: DeepPartial = { settings: {}, @@ -744,7 +752,7 @@ describe('AccountConnect', () => { describe('Phishing detection', () => { describe('dapp scanning is enabled', () => { - it('should show phishing modal for phishing URLs', async () => { + it('displays phishing modal when origin is flagged as phishing', async () => { const { findByText } = renderWithProvider( { }); }); + describe('Metrics tracking', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('tracks metrics when user cancels connection', () => { + const { getByTestId } = renderWithProvider( + , + { state: mockInitialState }, + ); + + const cancelButton = getByTestId('cancel-button'); + fireEvent.press(cancelButton); + + expect(mockedTrackEvent).toHaveBeenCalled(); + expect(mockCreateEventBuilder).toHaveBeenCalled(); + }); + }); + describe('Domain title and hostname logic', () => { beforeEach(() => { // Reset mocks before each test diff --git a/app/components/Views/AccountConnect/AccountConnect.tsx b/app/components/Views/AccountConnect/AccountConnect.tsx index 07be7acf86b..d13b62f47b0 100644 --- a/app/components/Views/AccountConnect/AccountConnect.tsx +++ b/app/components/Views/AccountConnect/AccountConnect.tsx @@ -106,6 +106,8 @@ import AddNewAccount from '../AddNewAccount'; import { InternalAccount } from '@metamask/keyring-internal-api'; import { getApiAnalyticsProperties } from '../../../util/metrics/MultichainAPI/getApiAnalyticsProperties'; import { isSnapId } from '@metamask/snaps-utils'; +import { HardwareDeviceTypes } from '../../../constants/keyringTypes'; +import { getConnectedDevicesCount } from '../../../core/HardwareWallets/analytics'; const AccountConnect = (props: AccountConnectProps) => { const { colors } = useTheme(); @@ -654,7 +656,7 @@ const AccountConnect = (props: AccountConnectProps) => { useEffect(() => { if (userIntent === USER_INTENT.None) return; - const handleUserActions = (action: USER_INTENT) => { + const handleUserActions = async (action: USER_INTENT) => { switch (action) { case USER_INTENT.Confirm: { handleConfirm(); @@ -681,11 +683,20 @@ const AccountConnect = (props: AccountConnectProps) => { case USER_INTENT.ConnectHW: { navigation.navigate('ConnectQRHardwareFlow'); // TODO: Confirm if this is where we want to track connecting a hardware wallet or within ConnectQRHardwareFlow screen. - trackEvent( - createEventBuilder( - MetaMetricsEvents.CONNECT_HARDWARE_WALLET, - ).build(), - ); + try { + const connectedDeviceCount = await getConnectedDevicesCount(); + trackEvent( + createEventBuilder(MetaMetricsEvents.CONNECT_HARDWARE_WALLET) + .addProperties({ + device_type: HardwareDeviceTypes.QR, + connected_device_count: connectedDeviceCount, + }) + .build(), + ); + } catch (error) { + // [AccountConnect] Analytics error should not disrupt user flow + console.error('[AccountConnect] Failed to track analytics:', error); + } break; } diff --git a/app/components/Views/AccountPermissions/AccountPermissions.tsx b/app/components/Views/AccountPermissions/AccountPermissions.tsx index bdad4ba3b31..be09c49c7ad 100755 --- a/app/components/Views/AccountPermissions/AccountPermissions.tsx +++ b/app/components/Views/AccountPermissions/AccountPermissions.tsx @@ -89,6 +89,8 @@ import { WalletClientType } from '../../../core/SnapKeyring/MultichainWalletSnap import AddNewAccount from '../AddNewAccount'; import { trace, endTrace, TraceName } from '../../../util/trace'; import { selectAvatarAccountType } from '../../../selectors/settings'; +import { HardwareDeviceTypes } from '../../../constants/keyringTypes'; +import { getConnectedDevicesCount } from '../../../core/HardwareWallets/analytics'; const AccountPermissions = (props: AccountPermissionsProps) => { const { navigate } = useNavigation(); @@ -561,7 +563,7 @@ const AccountPermissions = (props: AccountPermissionsProps) => { useEffect(() => { if (userIntent === USER_INTENT.None) return; - const handleUserActions = (action: USER_INTENT) => { + const handleUserActions = async (action: USER_INTENT) => { switch (action) { case USER_INTENT.Confirm: { hideSheet(() => { @@ -598,10 +600,14 @@ const AccountPermissions = (props: AccountPermissionsProps) => { case USER_INTENT.ConnectHW: { navigate('ConnectQRHardwareFlow'); // Is this where we want to track connecting a hardware wallet or within ConnectQRHardwareFlow screen? + const connectedDeviceCount = await getConnectedDevicesCount(); trackEvent( - createEventBuilder( - MetaMetricsEvents.CONNECT_HARDWARE_WALLET, - ).build(), + createEventBuilder(MetaMetricsEvents.CONNECT_HARDWARE_WALLET) + .addProperties({ + device_type: HardwareDeviceTypes.QR, + connected_device_count: connectedDeviceCount, + }) + .build(), ); break; diff --git a/app/components/Views/ConnectHardware/SelectHardware/index.test.tsx b/app/components/Views/ConnectHardware/SelectHardware/index.test.tsx new file mode 100644 index 00000000000..2ec1f165234 --- /dev/null +++ b/app/components/Views/ConnectHardware/SelectHardware/index.test.tsx @@ -0,0 +1,315 @@ +import React from 'react'; +import { screen } from '@testing-library/react-native'; +import renderWithProvider from '../../../../util/test/renderWithProvider'; +import SelectHardwareWallet from './index'; +import { strings } from '../../../../../locales/i18n'; +import Routes from '../../../../constants/navigation/Routes'; +import { MetaMetricsEvents } from '../../../../core/Analytics'; +import { HardwareDeviceTypes } from '../../../../constants/keyringTypes'; +import { getConnectedDevicesCount } from '../../../../core/HardwareWallets/analytics'; +import { AppThemeKey } from '../../../../util/theme/models'; + +jest.mock('../../../../../locales/i18n', () => ({ + strings: jest.fn((key: string) => key), +})); + +jest.mock('../../../UI/Navbar', () => ({ + getNavigationOptionsTitle: jest.fn(), +})); + +jest.mock('../../../../core/HardwareWallets/analytics'); + +const mockNavigate = jest.fn(); +const mockSetOptions = jest.fn(); +const mockTrackEvent = jest.fn(); +const mockCreateEventBuilder = jest.fn(() => ({ + addProperties: jest.fn().mockReturnThis(), + build: jest.fn().mockReturnValue({}), +})); + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: () => ({ + navigate: mockNavigate, + setOptions: mockSetOptions, + }), +})); + +jest.mock('../../../../components/hooks/useMetrics', () => ({ + useMetrics: () => ({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }), +})); + +const mockGetConnectedDevicesCount = + getConnectedDevicesCount as jest.MockedFunction< + typeof getConnectedDevicesCount + >; + +const initialState = { + user: { + appTheme: AppThemeKey.light, + }, +}; + +describe('SelectHardwareWallet', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetConnectedDevicesCount.mockResolvedValue(0); + // Reset mockCreateEventBuilder to return proper chained object + mockCreateEventBuilder.mockReturnValue({ + addProperties: jest.fn().mockReturnThis(), + build: jest.fn().mockReturnValue({}), + }); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('renders component with correct text', () => { + renderWithProvider(, { state: initialState }); + + expect(strings).toHaveBeenCalledWith('connect_hardware.select_hardware'); + expect(screen.getByText('connect_hardware.select_hardware')).toBeTruthy(); + }); + + it('sets navigation options on mount', () => { + renderWithProvider(, { state: initialState }); + + expect(mockSetOptions).toHaveBeenCalled(); + }); + + describe('Ledger button navigation', () => { + it('tracks event and navigates to Ledger connection when pressed', async () => { + const connectedDeviceCount = 2; + mockGetConnectedDevicesCount.mockResolvedValue(connectedDeviceCount); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + const ledgerButton = getByTestId('ledger-hardware-button'); + + await ledgerButton.props.onPress(); + + expect(mockGetConnectedDevicesCount).toHaveBeenCalled(); + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.CONNECT_HARDWARE_WALLET, + ); + expect(mockTrackEvent).toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith(Routes.HW.CONNECT_LEDGER); + }); + + it('includes connected devices count in metrics event', async () => { + const connectedDeviceCount = 5; + mockGetConnectedDevicesCount.mockResolvedValue(connectedDeviceCount); + const mockAddProperties = jest.fn().mockReturnThis(); + const mockBuild = jest.fn().mockReturnValue({}); + mockCreateEventBuilder.mockReturnValue({ + addProperties: mockAddProperties, + build: mockBuild, + }); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + const ledgerButton = getByTestId('ledger-hardware-button'); + + await ledgerButton.props.onPress(); + + expect(mockAddProperties).toHaveBeenCalledWith({ + device_type: HardwareDeviceTypes.LEDGER, + connected_device_count: connectedDeviceCount.toString(), + }); + expect(mockBuild).toHaveBeenCalled(); + }); + + it('handles zero connected devices count', async () => { + mockGetConnectedDevicesCount.mockResolvedValue(0); + const mockAddProperties = jest.fn().mockReturnThis(); + const mockBuild = jest.fn().mockReturnValue({}); + mockCreateEventBuilder.mockReturnValue({ + addProperties: mockAddProperties, + build: mockBuild, + }); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + const ledgerButton = getByTestId('ledger-hardware-button'); + + await ledgerButton.props.onPress(); + + expect(mockAddProperties).toHaveBeenCalledWith({ + device_type: HardwareDeviceTypes.LEDGER, + connected_device_count: '0', + }); + }); + }); + + describe('QR Hardware button navigation', () => { + it('tracks event and navigates to QR device connection when pressed', async () => { + const connectedDeviceCount = 3; + mockGetConnectedDevicesCount.mockResolvedValue(connectedDeviceCount); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + const qrButton = getByTestId('qr-hardware-button'); + + await qrButton.props.onPress(); + + expect(mockGetConnectedDevicesCount).toHaveBeenCalled(); + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.CONNECT_HARDWARE_WALLET, + ); + expect(mockTrackEvent).toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith(Routes.HW.CONNECT_QR_DEVICE); + }); + + it('includes connected devices count in metrics event', async () => { + const connectedDeviceCount = 1; + mockGetConnectedDevicesCount.mockResolvedValue(connectedDeviceCount); + const mockAddProperties = jest.fn().mockReturnThis(); + const mockBuild = jest.fn().mockReturnValue({}); + mockCreateEventBuilder.mockReturnValue({ + addProperties: mockAddProperties, + build: mockBuild, + }); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + const qrButton = getByTestId('qr-hardware-button'); + + await qrButton.props.onPress(); + + expect(mockAddProperties).toHaveBeenCalledWith({ + device_type: HardwareDeviceTypes.QR, + connected_device_count: connectedDeviceCount.toString(), + }); + expect(mockBuild).toHaveBeenCalled(); + }); + }); + + describe('useMetrics integration', () => { + it('uses the useMetrics hook', () => { + renderWithProvider(, { state: initialState }); + + expect(mockCreateEventBuilder).toBeDefined(); + expect(mockTrackEvent).toBeDefined(); + }); + + it('creates event builder with correct event type', async () => { + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + const ledgerButton = getByTestId('ledger-hardware-button'); + + await ledgerButton.props.onPress(); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.CONNECT_HARDWARE_WALLET, + ); + }); + }); + + describe('error handling', () => { + it('continues navigation to Ledger when getConnectedDevicesCount fails', async () => { + const error = new Error('Failed to get device count'); + mockGetConnectedDevicesCount.mockRejectedValue(error); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + const ledgerButton = getByTestId('ledger-hardware-button'); + + await ledgerButton.props.onPress(); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.HW.CONNECT_LEDGER); + }); + + it('continues navigation to QR when getConnectedDevicesCount fails', async () => { + const error = new Error('Failed to get device count'); + mockGetConnectedDevicesCount.mockRejectedValue(error); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + const qrButton = getByTestId('qr-hardware-button'); + + await qrButton.props.onPress(); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.HW.CONNECT_QR_DEVICE); + }); + + it('logs error when analytics tracking fails for Ledger', async () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + const error = new Error('Analytics failure'); + mockGetConnectedDevicesCount.mockRejectedValue(error); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + const ledgerButton = getByTestId('ledger-hardware-button'); + + await ledgerButton.props.onPress(); + + expect(consoleSpy).toHaveBeenCalledWith( + '[SelectHardware] Failed to track analytics:', + error, + ); + + consoleSpy.mockRestore(); + }); + + it('logs error when analytics tracking fails for QR', async () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + const error = new Error('Analytics failure'); + mockGetConnectedDevicesCount.mockRejectedValue(error); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + const qrButton = getByTestId('qr-hardware-button'); + + await qrButton.props.onPress(); + + expect(consoleSpy).toHaveBeenCalledWith( + '[SelectHardware] Failed to track analytics:', + error, + ); + + consoleSpy.mockRestore(); + }); + + it('does not track analytics event when getConnectedDevicesCount fails for Ledger', async () => { + const error = new Error('Failed to get device count'); + mockGetConnectedDevicesCount.mockRejectedValue(error); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + const ledgerButton = getByTestId('ledger-hardware-button'); + + await ledgerButton.props.onPress(); + + expect(mockTrackEvent).not.toHaveBeenCalled(); + }); + + it('does not track analytics event when getConnectedDevicesCount fails for QR', async () => { + const error = new Error('Failed to get device count'); + mockGetConnectedDevicesCount.mockRejectedValue(error); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + const qrButton = getByTestId('qr-hardware-button'); + + await qrButton.props.onPress(); + + expect(mockTrackEvent).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/app/components/Views/ConnectHardware/SelectHardware/index.tsx b/app/components/Views/ConnectHardware/SelectHardware/index.tsx index 9039d9a4970..09f0c035b6a 100644 --- a/app/components/Views/ConnectHardware/SelectHardware/index.tsx +++ b/app/components/Views/ConnectHardware/SelectHardware/index.tsx @@ -25,6 +25,7 @@ import { import { getNavigationOptionsTitle } from '../../../UI/Navbar'; import { useMetrics } from '../../../../components/hooks/useMetrics'; import { HardwareDeviceTypes } from '../../../../constants/keyringTypes'; +import { getConnectedDevicesCount } from '../../../../core/HardwareWallets/analytics'; // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -102,33 +103,62 @@ const SelectHardwareWallet = () => { ); }, [navigation, colors]); - const navigateToConnectQRWallet = () => { + const navigateToConnectQRWallet = async () => { + try { + const connectedDeviceCount = await getConnectedDevicesCount(); + trackEvent( + createEventBuilder(MetaMetricsEvents.CONNECT_HARDWARE_WALLET) + .addProperties({ + device_type: HardwareDeviceTypes.QR, + connected_device_count: connectedDeviceCount.toString(), + }) + .build(), + ); + } catch (error) { + // [SelectHardware] Analytics error should not block navigation + console.error('[SelectHardware] Failed to track analytics:', error); + } navigation.navigate(Routes.HW.CONNECT_QR_DEVICE); }; const navigateToConnectLedger = async () => { - trackEvent( - createEventBuilder(MetaMetricsEvents.CONNECT_LEDGER) - .addProperties({ - device_type: HardwareDeviceTypes.LEDGER, - }) - .build(), - ); + try { + const connectedDeviceCount = await getConnectedDevicesCount(); + trackEvent( + createEventBuilder(MetaMetricsEvents.CONNECT_HARDWARE_WALLET) + .addProperties({ + device_type: HardwareDeviceTypes.LEDGER, + connected_device_count: connectedDeviceCount.toString(), + }) + .build(), + ); + } catch (error) { + // [SelectHardware] Analytics error should not block navigation + console.error('[SelectHardware] Failed to track analytics:', error); + } navigation.navigate(Routes.HW.CONNECT_LEDGER); }; // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any - const renderHardwareButton = (image: any, onPress: any) => ( - + const renderHardwareButton = (image: any, onPress: any, testID?: string) => ( + ); const LedgerButton = () => { const ledgerLogo = useAssetFromTheme(ledgerLogoLight, ledgerLogoDark); - return renderHardwareButton(ledgerLogo, navigateToConnectLedger); + return renderHardwareButton( + ledgerLogo, + navigateToConnectLedger, + 'ledger-hardware-button', + ); }; const QRButton = () => { @@ -136,7 +166,11 @@ const SelectHardwareWallet = () => { qrHardwareLogoLight, qrHardwareLogoDark, ); - return renderHardwareButton(qrHardwareLogo, navigateToConnectQRWallet); + return renderHardwareButton( + qrHardwareLogo, + navigateToConnectQRWallet, + 'qr-hardware-button', + ); }; return ( diff --git a/app/components/Views/ConnectQRHardware/Instruction/index.test.tsx b/app/components/Views/ConnectQRHardware/Instruction/index.test.tsx new file mode 100644 index 00000000000..9b4a4ce88b1 --- /dev/null +++ b/app/components/Views/ConnectQRHardware/Instruction/index.test.tsx @@ -0,0 +1,371 @@ +import React from 'react'; +import { Text } from 'react-native'; +import { fireEvent } from '@testing-library/react-native'; +import renderWithProvider from '../../../../util/test/renderWithProvider'; +import ConnectQRInstruction from './index'; +import { MetaMetricsEvents } from '../../../../core/Analytics'; +import { + HARDWARE_WALLET_BUTTON_TYPE, + HARDWARE_WALLET_DEVICE_TYPE, +} from '../../../../core/Analytics/MetaMetrics.events'; +import { + KEYSTONE_LEARN_MORE, + KEYSTONE_SUPPORT, + KEYSTONE_SUPPORT_VIDEO, + NGRAVE_BUY, + NGRAVE_LEARN_MORE, +} from '../../../../constants/urls'; +import { QR_CONTINUE_BUTTON } from '../../../../../wdio/screen-objects/testIDs/Components/ConnectQRHardware.testIds'; +import { AppThemeKey } from '../../../../util/theme/models'; + +jest.mock('../../../../../locales/i18n', () => ({ + strings: jest.fn((key: string) => key), +})); + +const mockTrackEvent = jest.fn(); +const mockAddProperties = jest.fn(); +const mockBuild = jest.fn(); +const mockCreateEventBuilder = jest.fn(); + +jest.mock('../../../../components/hooks/useMetrics', () => { + const actualMetrics = jest.requireActual('../../../../core/Analytics'); + const actualHooks = jest.requireActual( + '../../../../components/hooks/useMetrics', + ); + return { + ...actualHooks, + useMetrics: () => ({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }), + MetaMetricsEvents: actualMetrics.MetaMetricsEvents, + }; +}); + +const mockNavigate = jest.fn(); +const mockOnConnect = jest.fn(); +const mockRenderAlert = jest.fn(() => <>); + +const mockNavigation = { + navigate: mockNavigate, +}; + +const initialState = { + user: { + appTheme: AppThemeKey.light, + }, +}; + +describe('ConnectQRInstruction', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockAddProperties.mockReturnValue({ + build: mockBuild, + }); + mockBuild.mockReturnValue({}); + mockCreateEventBuilder.mockReturnValue({ + addProperties: mockAddProperties, + build: mockBuild, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders correctly', () => { + const { getByText } = renderWithProvider( + , + { state: initialState }, + ); + + expect(getByText('connect_qr_hardware.title')).toBeTruthy(); + }); + + it('renders all description text elements', () => { + const { getByText } = renderWithProvider( + , + { state: initialState }, + ); + + expect(getByText('connect_qr_hardware.description1')).toBeTruthy(); + expect(getByText('connect_qr_hardware.description3')).toBeTruthy(); + }); + + it('renders Keystone section label', () => { + const { getByText } = renderWithProvider( + , + { state: initialState }, + ); + + expect(getByText('connect_qr_hardware.keystone')).toBeTruthy(); + }); + + it('renders Ngrave Zero section label', () => { + const { getByText } = renderWithProvider( + , + { state: initialState }, + ); + + expect(getByText('connect_qr_hardware.ngravezero')).toBeTruthy(); + }); + + it('renders alert component from renderAlert prop', () => { + const mockRenderAlertWithContent = jest.fn(() => ( + Alert Content + )); + + const { getByTestId } = renderWithProvider( + , + { state: initialState }, + ); + + expect(mockRenderAlertWithContent).toHaveBeenCalled(); + expect(getByTestId('test-alert')).toBeTruthy(); + }); + + it('renders continue button with correct text', () => { + const { getByText } = renderWithProvider( + , + { state: initialState }, + ); + + expect(getByText('connect_qr_hardware.button_continue')).toBeTruthy(); + }); + + it('calls onConnect when continue button is pressed', () => { + const { getByTestId } = renderWithProvider( + , + { state: initialState }, + ); + + const continueButton = getByTestId(QR_CONTINUE_BUTTON); + fireEvent.press(continueButton); + + expect(mockOnConnect).toHaveBeenCalledTimes(1); + }); + + describe('Keystone marketing metrics', () => { + it('tracks metrics and navigates when Keystone tutorial video link is pressed', () => { + const { getByText } = renderWithProvider( + , + { state: initialState }, + ); + + const tutorialVideoLink = getByText('connect_qr_hardware.description2'); + fireEvent.press(tutorialVideoLink); + + expect(mockTrackEvent).toHaveBeenCalled(); + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.HARDWARE_WALLET_MARKETING, + ); + expect(mockAddProperties).toHaveBeenCalledWith({ + device_type: HARDWARE_WALLET_DEVICE_TYPE.Keystone, + button_type: HARDWARE_WALLET_BUTTON_TYPE.TUTORIAL, + }); + expect(mockBuild).toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith('Webview', { + screen: 'SimpleWebview', + params: { + url: KEYSTONE_SUPPORT_VIDEO, + title: 'connect_qr_hardware.description2', + }, + }); + }); + + it('tracks metrics and navigates when Keystone Learn More link is pressed', () => { + const { getAllByText } = renderWithProvider( + , + { state: initialState }, + ); + + const learnMoreLinks = getAllByText('connect_qr_hardware.learnMore'); + const keystoneLearnMore = learnMoreLinks[0]; + fireEvent.press(keystoneLearnMore); + + expect(mockTrackEvent).toHaveBeenCalled(); + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.HARDWARE_WALLET_MARKETING, + ); + expect(mockAddProperties).toHaveBeenCalledWith({ + device_type: HARDWARE_WALLET_DEVICE_TYPE.Keystone, + button_type: HARDWARE_WALLET_BUTTON_TYPE.LEARN_MORE, + }); + expect(mockBuild).toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith('Webview', { + screen: 'SimpleWebview', + params: { + url: KEYSTONE_LEARN_MORE, + title: 'connect_qr_hardware.keystone', + }, + }); + }); + + it('tracks metrics and navigates when Keystone tutorial link is pressed', () => { + const { getByText } = renderWithProvider( + , + { state: initialState }, + ); + + const tutorialLink = getByText('connect_qr_hardware.tutorial'); + fireEvent.press(tutorialLink); + + expect(mockTrackEvent).toHaveBeenCalled(); + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.HARDWARE_WALLET_MARKETING, + ); + expect(mockAddProperties).toHaveBeenCalledWith({ + device_type: HARDWARE_WALLET_DEVICE_TYPE.Keystone, + button_type: HARDWARE_WALLET_BUTTON_TYPE.TUTORIAL, + }); + expect(mockBuild).toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith('Webview', { + screen: 'SimpleWebview', + params: { + url: KEYSTONE_SUPPORT, + title: 'connect_qr_hardware.description4', + }, + }); + }); + }); + + describe('Ngrave marketing metrics', () => { + it('tracks metrics and navigates when Ngrave Learn More link is pressed', () => { + const { getAllByText } = renderWithProvider( + , + { state: initialState }, + ); + + const learnMoreLinks = getAllByText('connect_qr_hardware.learnMore'); + const ngraveLearnMore = learnMoreLinks[1]; + fireEvent.press(ngraveLearnMore); + + expect(mockTrackEvent).toHaveBeenCalled(); + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.HARDWARE_WALLET_MARKETING, + ); + expect(mockAddProperties).toHaveBeenCalledWith({ + device_type: HARDWARE_WALLET_DEVICE_TYPE.NgraveZero, + button_type: HARDWARE_WALLET_BUTTON_TYPE.LEARN_MORE, + }); + expect(mockBuild).toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith('Webview', { + screen: 'SimpleWebview', + params: { + url: NGRAVE_LEARN_MORE, + title: 'connect_qr_hardware.ngravezero', + }, + }); + }); + + it('tracks metrics and navigates when Ngrave Buy Now link is pressed', () => { + const { getByText } = renderWithProvider( + , + { state: initialState }, + ); + + const buyNowLink = getByText('connect_qr_hardware.buyNow'); + fireEvent.press(buyNowLink); + + expect(mockTrackEvent).toHaveBeenCalled(); + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.HARDWARE_WALLET_MARKETING, + ); + expect(mockAddProperties).toHaveBeenCalledWith({ + device_type: HARDWARE_WALLET_DEVICE_TYPE.NgraveZero, + button_type: HARDWARE_WALLET_BUTTON_TYPE.BUY_NOW, + }); + expect(mockBuild).toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith('Webview', { + screen: 'SimpleWebview', + params: { + url: NGRAVE_BUY, + title: 'connect_qr_hardware.ngravezero', + }, + }); + }); + }); + + describe('useMetrics integration', () => { + it('uses the useMetrics hook correctly', () => { + renderWithProvider( + , + { state: initialState }, + ); + + expect(mockCreateEventBuilder).toBeDefined(); + expect(mockTrackEvent).toBeDefined(); + }); + + it('creates event builder with correct event type for marketing events', () => { + const { getByText } = renderWithProvider( + , + { state: initialState }, + ); + + const buyNowLink = getByText('connect_qr_hardware.buyNow'); + fireEvent.press(buyNowLink); + + expect(mockTrackEvent).toHaveBeenCalled(); + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.HARDWARE_WALLET_MARKETING, + ); + }); + }); +}); diff --git a/app/components/Views/ConnectQRHardware/Instruction/index.tsx b/app/components/Views/ConnectQRHardware/Instruction/index.tsx index 0a0f6d08f14..b37ab8c2af4 100644 --- a/app/components/Views/ConnectQRHardware/Instruction/index.tsx +++ b/app/components/Views/ConnectQRHardware/Instruction/index.tsx @@ -17,6 +17,11 @@ import { createStyles } from './styles'; import StyledButton from '../../../UI/StyledButton'; import generateTestId from '../../../../../wdio/utils/generateTestId'; import { QR_CONTINUE_BUTTON } from '../../../../../wdio/screen-objects/testIDs/Components/ConnectQRHardware.testIds'; +import { MetaMetricsEvents, useMetrics } from '../../../hooks/useMetrics'; +import { + HARDWARE_WALLET_BUTTON_TYPE, + HARDWARE_WALLET_DEVICE_TYPE, +} from '../../../../core/Analytics/MetaMetrics.events'; interface IConnectQRInstructionProps { // TODO: Replace "any" with type @@ -30,11 +35,31 @@ interface IConnectQRInstructionProps { const ConnectQRInstruction = (props: IConnectQRInstructionProps) => { const { onConnect, renderAlert, navigation } = props; + const { trackEvent, createEventBuilder } = useMetrics(); const theme = useTheme(); const insets = useSafeAreaInsets(); const styles = createStyles(theme, insets); - const navigateTo = (url: string, title: string) => { + interface NavigateOptions { + url: string; + title: string; + trackingProperties?: { + device_type?: string; + button_type?: string; + }; + } + + const navigateToWebview = (options: NavigateOptions) => { + const { url, title, trackingProperties } = options; + + if (trackingProperties) { + trackEvent( + createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_MARKETING) + .addProperties(trackingProperties) + .build(), + ); + } + navigation.navigate('Webview', { screen: 'SimpleWebview', params: { @@ -43,34 +68,6 @@ const ConnectQRInstruction = (props: IConnectQRInstructionProps) => { }, }); }; - - const navigateToVideo = () => { - navigation.navigate('Webview', { - screen: 'SimpleWebview', - params: { - url: KEYSTONE_SUPPORT_VIDEO, - title: strings('connect_qr_hardware.description2'), - }, - }); - }; - const navigateToLearnMoreKeystone = () => { - navigation.navigate('Webview', { - screen: 'SimpleWebview', - params: { - url: KEYSTONE_LEARN_MORE, - title: strings('connect_qr_hardware.keystone'), - }, - }); - }; - const navigateToTutorial = () => { - navigation.navigate('Webview', { - screen: 'SimpleWebview', - params: { - url: KEYSTONE_SUPPORT, - title: strings('connect_qr_hardware.description4'), - }, - }); - }; return ( { {strings('connect_qr_hardware.description1')} - + + navigateToWebview({ + url: KEYSTONE_SUPPORT_VIDEO, + title: 'connect_qr_hardware.description2', + trackingProperties: { + device_type: HARDWARE_WALLET_DEVICE_TYPE.Keystone, + button_type: HARDWARE_WALLET_BUTTON_TYPE.TUTORIAL, + }, + }) + } + > {strings('connect_qr_hardware.description2')} @@ -95,13 +104,31 @@ const ConnectQRInstruction = (props: IConnectQRInstructionProps) => { + navigateToWebview({ + url: KEYSTONE_LEARN_MORE, + title: 'connect_qr_hardware.keystone', + trackingProperties: { + device_type: HARDWARE_WALLET_DEVICE_TYPE.Keystone, + button_type: HARDWARE_WALLET_BUTTON_TYPE.LEARN_MORE, + }, + }) + } > {strings('connect_qr_hardware.learnMore')} + navigateToWebview({ + url: KEYSTONE_SUPPORT, + title: 'connect_qr_hardware.description4', + trackingProperties: { + device_type: HARDWARE_WALLET_DEVICE_TYPE.Keystone, + button_type: HARDWARE_WALLET_BUTTON_TYPE.TUTORIAL, + }, + }) + } > {strings('connect_qr_hardware.tutorial')} @@ -113,7 +140,14 @@ const ConnectQRInstruction = (props: IConnectQRInstructionProps) => { - navigateTo(NGRAVE_LEARN_MORE, 'connect_qr_hardware.ngravezero') + navigateToWebview({ + url: NGRAVE_LEARN_MORE, + title: 'connect_qr_hardware.ngravezero', + trackingProperties: { + device_type: HARDWARE_WALLET_DEVICE_TYPE.NgraveZero, + button_type: HARDWARE_WALLET_BUTTON_TYPE.LEARN_MORE, + }, + }) } > {strings('connect_qr_hardware.learnMore')} @@ -121,7 +155,14 @@ const ConnectQRInstruction = (props: IConnectQRInstructionProps) => { - navigateTo(NGRAVE_BUY, 'connect_qr_hardware.ngravezero') + navigateToWebview({ + url: NGRAVE_BUY, + title: 'connect_qr_hardware.ngravezero', + trackingProperties: { + device_type: HARDWARE_WALLET_DEVICE_TYPE.NgraveZero, + button_type: HARDWARE_WALLET_BUTTON_TYPE.BUY_NOW, + }, + }) } > {strings('connect_qr_hardware.buyNow')} diff --git a/app/components/Views/ConnectQRHardware/index.test.tsx b/app/components/Views/ConnectQRHardware/index.test.tsx index 5a94be652e6..1919f40d5d3 100644 --- a/app/components/Views/ConnectQRHardware/index.test.tsx +++ b/app/components/Views/ConnectQRHardware/index.test.tsx @@ -13,6 +13,8 @@ import { } from '../../../../wdio/screen-objects/testIDs/Components/AccountSelector.testIds'; import { QrKeyringBridge } from '@metamask/eth-qr-keyring'; import { removeAccountsFromPermissions } from '../../../core/Permissions'; +import { MetaMetricsEvents } from '../../../core/Analytics'; +import { HardwareDeviceTypes } from '../../../constants/keyringTypes'; jest.mock('../../../core/Permissions', () => ({ removeAccountsFromPermissions: jest.fn(), @@ -22,6 +24,19 @@ const MockRemoveAccountsFromPermissions = jest.mocked( removeAccountsFromPermissions, ); +const mockTrackEvent = jest.fn(); +const mockCreateEventBuilder = jest.fn(() => ({ + addProperties: jest.fn().mockReturnThis(), + build: jest.fn().mockReturnValue({}), +})); + +jest.mock('../../../components/hooks/useMetrics', () => ({ + useMetrics: () => ({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }), +})); + const mockedNavigate = { pop: jest.fn(), goBack: jest.fn(), @@ -98,6 +113,7 @@ const mockQrKeyring = { getNextPage: jest.fn(), getPreviousPage: jest.fn(), forgetDevice: jest.fn(), + getName: jest.fn().mockResolvedValue('KeystoneDevice'), getAccounts: jest .fn() .mockReturnValue([ @@ -128,6 +144,7 @@ jest.mock('../../../core/Engine', () => ({ keyrings: [], }, getAccounts: jest.fn(), + getKeyringsByType: jest.fn().mockResolvedValue([]), withKeyring: (_selector: unknown, operation: (args: unknown) => void) => operation({ keyring: mockQrKeyring, @@ -180,6 +197,8 @@ describe('ConnectQRHardware', () => { beforeEach(() => { jest.clearAllMocks(); + mockTrackEvent.mockClear(); + mockCreateEventBuilder.mockClear(); }); it('renders correctly to match snapshot', () => { @@ -306,4 +325,109 @@ describe('ConnectQRHardware', () => { ]); expect(mockQrKeyring.forgetDevice).toHaveBeenCalled(); }); + + it('tracks hardware wallet continue connection event when continue button is pressed', async () => { + mockKeyringController.getAccounts.mockResolvedValue([]); + + const { getByTestId } = renderWithProvider( + , + { state: mockInitialState }, + ); + + const button = getByTestId(QR_CONTINUE_BUTTON); + + await act(async () => { + fireEvent.press(button); + }); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.HARDWARE_WALLET_CONTINUE_CONNECTION, + ); + expect(mockTrackEvent).toHaveBeenCalled(); + }); + + it('tracks hardware wallet add account event with QR device type when accounts are unlocked', async () => { + mockKeyringController.getAccounts.mockResolvedValue([]); + + const { getByTestId, getByText } = renderWithProvider( + , + { state: mockInitialState }, + ); + + const button = getByTestId(QR_CONTINUE_BUTTON); + + await act(async () => { + fireEvent.press(button); + }); + + const checkbox = getByText(mockPage0Accounts[0].shortenedAddress); + + await act(async () => { + fireEvent.press(checkbox); + }); + + const unlockButton = getByText('Unlock'); + + await act(async () => { + fireEvent.press(unlockButton); + }); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.HARDWARE_WALLET_ADD_ACCOUNT, + ); + expect(mockTrackEvent).toHaveBeenCalled(); + }); + + it('tracks hardware wallet forgotten event with QR device type when device is forgotten', async () => { + mockKeyringController.getAccounts.mockResolvedValue([]); + + const { getByTestId } = renderWithProvider( + , + { state: mockInitialState }, + ); + + const button = getByTestId(QR_CONTINUE_BUTTON); + + await act(async () => { + fireEvent.press(button); + }); + + const forgetButton = getByTestId(ACCOUNT_SELECTOR_FORGET_BUTTON); + + await act(async () => { + fireEvent.press(forgetButton); + }); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.HARDWARE_WALLET_FORGOTTEN, + ); + expect(mockTrackEvent).toHaveBeenCalled(); + }); + + it('includes device type property in continue connection event', async () => { + mockKeyringController.getAccounts.mockResolvedValue([]); + + const mockAddProperties = jest.fn().mockReturnThis(); + const mockBuild = jest.fn().mockReturnValue({}); + mockCreateEventBuilder.mockReturnValue({ + addProperties: mockAddProperties, + build: mockBuild, + }); + + const { getByTestId } = renderWithProvider( + , + { state: mockInitialState }, + ); + + const button = getByTestId(QR_CONTINUE_BUTTON); + + await act(async () => { + fireEvent.press(button); + }); + + expect(mockAddProperties).toHaveBeenCalledWith({ + device_type: HardwareDeviceTypes.QR, + }); + expect(mockBuild).toHaveBeenCalled(); + }); }); diff --git a/app/components/Views/ConnectQRHardware/index.tsx b/app/components/Views/ConnectQRHardware/index.tsx index 752c4331bea..798d46decdc 100644 --- a/app/components/Views/ConnectQRHardware/index.tsx +++ b/app/components/Views/ConnectQRHardware/index.tsx @@ -34,6 +34,7 @@ import { ThemeColors } from '@metamask/design-tokens'; import { QrScanRequestType } from '@metamask/eth-qr-keyring'; import { withQrKeyring } from '../../../core/QrKeyring/QrKeyring'; import { getChecksumAddress } from '@metamask/utils'; +import { getConnectedDevicesCount } from '../../../core/HardwareWallets/analytics'; interface IConnectQRHardwareProps { // TODO: Replace "any" with type @@ -112,7 +113,7 @@ const ConnectQRHardware = ({ navigation }: IConnectQRHardwareProps) => { const onConnectHardware = async () => { trackEvent( - createEventBuilder(MetaMetricsEvents.CONTINUE_QR_HARDWARE_WALLET) + createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_CONTINUE_CONNECTION) .addProperties({ device_type: HardwareDeviceTypes.QR, }) @@ -128,6 +129,19 @@ const ConnectQRHardware = ({ navigation }: IConnectQRHardwareProps) => { // TODO: Add `balance` to the QR Keyring accounts or remove it from the expected type firstAccountsPage.map((account) => ({ ...account, balance: '0x0' })), ); + const deviceName = await withQrKeyring( + async ({ keyring }) => await keyring.getName(), + ); + trackEvent( + createEventBuilder( + MetaMetricsEvents.HARDWARE_WALLET_ACCOUNT_SELECTOR_OPEN, + ) + .addProperties({ + device_type: HardwareDeviceTypes.QR, + device_model: deviceName, + }) + .build(), + ); } finally { setIsScanning(false); } @@ -136,20 +150,13 @@ const ConnectQRHardware = ({ navigation }: IConnectQRHardwareProps) => { const onScanSuccess = useCallback( async (ur: UR) => { setIsScanning(false); - trackEvent( - createEventBuilder(MetaMetricsEvents.CONNECT_HARDWARE_WALLET_SUCCESS) - .addProperties({ - device_type: HardwareDeviceTypes.QR, - }) - .build(), - ); Engine.getQrKeyringScanner().resolvePendingScan({ type: ur.type, cbor: ur.cbor.toString('hex'), }); resetError(); }, - [resetError, trackEvent, createEventBuilder], + [resetError], ); const onScanError = useCallback(async (error: string) => { @@ -204,6 +211,21 @@ const ConnectQRHardware = ({ navigation }: IConnectQRHardwareProps) => { } return lastAccount; }); + const deviceName = await withQrKeyring( + async ({ keyring }) => await keyring.getName(), + ); + trackEvent( + createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_ADD_ACCOUNT) + .addProperties({ + device_type: HardwareDeviceTypes.QR, + device_model: deviceName, + hd_path: null, + connected_device_count: ( + await getConnectedDevicesCount() + ).toString(), + }) + .build(), + ); if (accountToSelect) { Engine.setSelectedAddress(accountToSelect); @@ -214,10 +236,21 @@ const ConnectQRHardware = ({ navigation }: IConnectQRHardwareProps) => { setBlockingModalVisible(false); navigation.pop(2); }, - [navigation, resetError], + [createEventBuilder, navigation, resetError, trackEvent], ); const onForget = useCallback(async () => { + const deviceName = await withQrKeyring( + async ({ keyring }) => await keyring.getName(), + ); + trackEvent( + createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_FORGOTTEN) + .addProperties({ + device_type: HardwareDeviceTypes.QR, + device_model: deviceName, + }) + .build(), + ); resetError(); const remainingAccounts = KeyringController.state.keyrings .filter((keyring) => keyring.type !== ExtendedKeyringTypes.qr) @@ -236,7 +269,13 @@ const ConnectQRHardware = ({ navigation }: IConnectQRHardwareProps) => { return existingQrAccounts; }); navigation.pop(2); - }, [KeyringController, navigation, resetError]); + }, [ + KeyringController.state.keyrings, + createEventBuilder, + navigation, + resetError, + trackEvent, + ]); const renderAlert = () => errorMsg !== '' ? ( diff --git a/app/components/Views/LedgerConnect/Scan.test.tsx b/app/components/Views/LedgerConnect/Scan.test.tsx index 85f6c6e70d8..0a6e92be6cb 100644 --- a/app/components/Views/LedgerConnect/Scan.test.tsx +++ b/app/components/Views/LedgerConnect/Scan.test.tsx @@ -69,6 +69,7 @@ describe('Scan', () => { const selectedDevice = { id: 'device1', name: 'Device 1', + serviceUUIDs: ['service1'], }; jest.mocked(useBluetoothDevices).mockReturnValue({ @@ -192,10 +193,12 @@ describe('Scan', () => { const device1 = { id: 'device1', name: 'Device 1', + serviceUUIDs: ['service1'], }; const device2 = { id: 'device2', name: 'Device 2', + serviceUUIDs: ['service2'], }; const onDeviceSelected = jest.fn(); @@ -225,4 +228,91 @@ describe('Scan', () => { expect(onDeviceSelected).toHaveBeenCalledWith(device2); }); + + it('clears error state when all errors are resolved', () => { + const onScanningErrorStateChanged = jest.fn(); + + jest.mocked(useBluetoothPermissions).mockReturnValue({ + hasBluetoothPermissions: true, + bluetoothPermissionError: undefined, + checkPermissions: jest.fn(), + }); + + jest.mocked(useBluetooth).mockReturnValue({ + bluetoothOn: true, + bluetoothConnectionError: false, + }); + + jest.mocked(useBluetoothDevices).mockReturnValue({ + devices: [], + deviceScanError: false, + }); + + renderWithProvider( + , + ); + + expect(onScanningErrorStateChanged).toHaveBeenCalledWith(undefined); + }); + + it('does not display devices when bluetooth is off', () => { + const bluetoothDevice = { + id: 'device1', + name: 'Device 1', + serviceUUIDs: ['service1'], + }; + + jest.mocked(useBluetooth).mockReturnValue({ + bluetoothOn: false, + bluetoothConnectionError: false, + }); + + jest.mocked(useBluetoothDevices).mockReturnValue({ + devices: [bluetoothDevice], + deviceScanError: false, + }); + + const { queryByTestId } = renderWithProvider( + , + ); + + expect(queryByTestId(SELECT_DROP_DOWN)).toBeNull(); + }); + + it('does not display devices when permissions are not granted', () => { + const bluetoothDevice = { + id: 'device1', + name: 'Device 1', + serviceUUIDs: ['service1'], + }; + + jest.mocked(useBluetoothPermissions).mockReturnValue({ + hasBluetoothPermissions: false, + bluetoothPermissionError: undefined, + checkPermissions: jest.fn(), + }); + + jest.mocked(useBluetoothDevices).mockReturnValue({ + devices: [bluetoothDevice], + deviceScanError: false, + }); + + const { queryByTestId } = renderWithProvider( + , + ); + + expect(queryByTestId(SELECT_DROP_DOWN)).toBeNull(); + }); }); diff --git a/app/components/Views/LedgerConnect/Scan.tsx b/app/components/Views/LedgerConnect/Scan.tsx index af0bdf45a63..c32a491a3f6 100644 --- a/app/components/Views/LedgerConnect/Scan.tsx +++ b/app/components/Views/LedgerConnect/Scan.tsx @@ -16,6 +16,9 @@ import { LedgerCommunicationErrors, } from '../../../core/Ledger/ledgerErrors'; import SelectOptionSheet, { ISelectOption } from '../../UI/SelectOptionSheet'; +import { MetaMetricsEvents, useMetrics } from '../../hooks/useMetrics'; +import { HardwareDeviceTypes } from '../../../constants/keyringTypes'; +import { ledgerDeviceUUIDToModelName } from '../../../util/hardwareWallet/deviceNameUtils'; const createStyles = (colors: Colors) => StyleSheet.create({ @@ -49,6 +52,7 @@ const Scan = ({ ledgerError, }: ScanProps) => { const { colors } = useAppThemeFromContext() || mockTheme; + const { trackEvent, createEventBuilder } = useMetrics(); const styles = useMemo(() => createStyles(colors), [colors]); const [selectedDevice, setSelectedDevice] = useState< BluetoothDevice | undefined @@ -67,6 +71,14 @@ const Scan = ({ ); const [permissionErrorShown, setPermissionErrorShown] = useState(false); + const ledgerModelName = useMemo(() => { + if (selectedDevice) { + const [bluetoothServiceId] = selectedDevice.serviceUUIDs; + return ledgerDeviceUUIDToModelName(bluetoothServiceId); + } + return undefined; + }, [selectedDevice]); + useEffect(() => { if ( !bluetoothPermissionError && @@ -109,7 +121,16 @@ const Scan = ({ }, }); break; - case BluetoothPermissionErrors.BluetoothAccessBlocked: + case BluetoothPermissionErrors.BluetoothAccessBlocked: { + trackEvent( + createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_ERROR) + .addProperties({ + device_type: HardwareDeviceTypes.LEDGER, + device_model: ledgerModelName, + error: 'LEDGER_BLUETOOTH_PERMISSION_ERR', + }) + .build(), + ); onScanningErrorStateChanged({ errorTitle: strings('ledger.bluetooth_access_blocked'), errorSubtitle: strings('ledger.bluetooth_access_blocked_message'), @@ -121,6 +142,7 @@ const Scan = ({ }, }); break; + } case BluetoothPermissionErrors.NearbyDevicesAccessBlocked: onScanningErrorStateChanged({ errorTitle: strings('ledger.nearbyDevices_access_blocked'), @@ -172,6 +194,8 @@ const Scan = ({ bluetoothPermissionError, bluetoothConnectionError, permissionErrorShown, + selectedDevice, + ledgerModelName, ]); useEffect(() => { diff --git a/app/components/Views/LedgerConnect/index.test.tsx b/app/components/Views/LedgerConnect/index.test.tsx index 285265f1373..542fdf69345 100644 --- a/app/components/Views/LedgerConnect/index.test.tsx +++ b/app/components/Views/LedgerConnect/index.test.tsx @@ -112,7 +112,11 @@ describe('LedgerConnect', () => { const onConfirmationComplete = jest.fn(); const ledgerLogicToRun = jest.fn(); - const selectedDevice: BluetoothDevice = { id: '1', name: 'Ledger device' }; + const selectedDevice: BluetoothDevice = { + id: '1', + name: 'Ledger device', + serviceUUIDs: ['service1'], + }; const setSelectedDevice = jest.fn(); const checkLedgerCommunicationErrorFlow = function ( @@ -159,7 +163,7 @@ describe('LedgerConnect', () => { ( useBluetoothDevices as jest.MockedFunction<() => UseBluetoothDevicesHook> ).mockReturnValue({ - devices: [{ id: '1', name: 'Ledger device' }], + devices: [{ id: '1', name: 'Ledger device', serviceUUIDs: ['service1'] }], deviceScanError: false, }); diff --git a/app/components/Views/LedgerConnect/index.tsx b/app/components/Views/LedgerConnect/index.tsx index a0b83cc8413..f49212f852d 100644 --- a/app/components/Views/LedgerConnect/index.tsx +++ b/app/components/Views/LedgerConnect/index.tsx @@ -40,6 +40,10 @@ import { BluetoothInterface, } from '../../hooks/Ledger/useBluetoothDevices'; import { getDeviceId } from '../../../core/Ledger/Ledger'; +import { HardwareDeviceTypes } from '../../../constants/keyringTypes'; +import { MetaMetricsEvents, useMetrics } from '../../hooks/useMetrics'; +import { HARDWARE_WALLET_BUTTON_TYPE } from '../../../core/Analytics/MetaMetrics.events'; +import { ledgerDeviceUUIDToModelName } from '../../../util/hardwareWallet/deviceNameUtils'; interface LedgerConnectProps { onConnectLedger: () => void; @@ -75,6 +79,38 @@ const LedgerConnect = ({ const [retryTimes, setRetryTimes] = useState(0); const dispatch = useDispatch(); const deviceOSVersion = Number(getSystemVersion()) || 0; + const { trackEvent, createEventBuilder } = useMetrics(); + + const ledgerModelName = useMemo(() => { + if (selectedDevice) { + const [bluetoothServiceId] = selectedDevice.serviceUUIDs; + return ledgerDeviceUUIDToModelName(bluetoothServiceId); + } + return undefined; + }, [selectedDevice]); + + useEffect(() => { + trackEvent( + createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_CONNECT_INSTRUCTIONS) + .addProperties({ + device_type: HardwareDeviceTypes.LEDGER, + }) + .build(), + ); + }, [trackEvent, createEventBuilder]); + + useEffect(() => { + if (selectedDevice) { + trackEvent( + createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_FOUND) + .addProperties({ + device_type: HardwareDeviceTypes.LEDGER, + device_model: ledgerModelName, + }) + .build(), + ); + } + }, [selectedDevice, trackEvent, createEventBuilder, ledgerModelName]); useEffect(() => { navigation.setOptions( @@ -84,6 +120,14 @@ const LedgerConnect = ({ const connectLedger = () => { setLoading(true); + trackEvent( + createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_CONTINUE_CONNECTION) + .addProperties({ + device_type: HardwareDeviceTypes.LEDGER, + device_model: ledgerModelName, + }) + .build(), + ); ledgerLogicToRun(async () => { onConnectLedger(); }); @@ -110,7 +154,20 @@ const LedgerConnect = ({ primaryButtonConfig: { title: strings('ledger.retry'), onPress: () => { + const retryCount = retryTimes + 1; + trackEvent( + createEventBuilder( + MetaMetricsEvents.HARDWARE_WALLET_CONNECTION_RETRY, + ) + .addProperties({ + device_type: HardwareDeviceTypes.LEDGER, + device_model: ledgerModelName, + retry_count: retryCount, + }) + .build(), + ); setErrorDetails(undefined); + setRetryTimes(retryCount); connectLedger(); }, }, @@ -125,6 +182,14 @@ const LedgerConnect = ({ }, [deviceOSVersion]); const openHowToInstallEthApp = () => { + trackEvent( + createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_MARKETING) + .addProperties({ + device_type: HardwareDeviceTypes.LEDGER, + button_type: HARDWARE_WALLET_BUTTON_TYPE.TUTORIAL, + }) + .build(), + ); navigation.navigate('Webview', { screen: 'SimpleWebview', params: { diff --git a/app/components/Views/LedgerSelectAccount/__snapshots__/index.test.tsx.snap b/app/components/Views/LedgerSelectAccount/__snapshots__/index.test.tsx.snap index c5faed12f05..ad8a16ec05a 100644 --- a/app/components/Views/LedgerSelectAccount/__snapshots__/index.test.tsx.snap +++ b/app/components/Views/LedgerSelectAccount/__snapshots__/index.test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`LedgerSelectAccount renders correctly to match snapshot 1`] = ` +exports[`LedgerSelectAccount Initial Rendering renders LedgerConnect when ledger error exists 1`] = ` `; -exports[`LedgerSelectAccount renders correctly to match snapshot when getAccounts return valid accounts 1`] = ` +exports[`LedgerSelectAccount Initial Rendering renders LedgerConnect when no accounts are loaded 1`] = ` ({ + addProperties: jest.fn().mockReturnThis(), + build: jest.fn().mockReturnValue({}), +})); jest.mock('../../hooks/Ledger/useLedgerBluetooth', () => ({ __esModule: true, @@ -16,6 +26,14 @@ jest.mock('../../hooks/Ledger/useLedgerBluetooth', () => ({ })), })); +jest.mock('../../hooks/useMetrics/useMetrics', () => ({ + __esModule: true, + default: jest.fn(() => ({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + })), +})); + jest.mock('@react-navigation/native', () => { const actualNav = jest.requireActual('@react-navigation/native'); return { @@ -23,10 +41,42 @@ jest.mock('@react-navigation/native', () => { useNavigation: () => ({ navigate: mockedNavigate, setOptions: jest.fn(), + goBack: jest.fn(), + pop: mockedPop, + dispatch: jest.fn(), }), + StackActions: { + pop: jest.fn(), + }, }; }); +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useDispatch: () => mockedDispatch, +})); + +jest.mock('../../../core/Ledger/Ledger', () => ({ + forgetLedger: jest.fn(), + getHDPath: jest.fn(), + getLedgerAccounts: jest.fn(), + getLedgerAccountsByOperation: jest.fn(), + setHDPath: jest.fn(), + unlockLedgerWalletAccount: jest.fn(), +})); + +jest.mock('../../../core/HardwareWallets/analytics', () => ({ + getConnectedDevicesCount: jest.fn(), +})); + +jest.mock('../../../util/hardwareWallet/deviceNameUtils', () => ({ + sanitizeDeviceName: jest.fn((name: string) => name), +})); + +jest.mock('../../../util/address', () => ({ + toFormattedAddress: jest.fn((address: string) => address), +})); + jest.mock('../../../core/Engine', () => ({ context: { KeyringController: { @@ -45,9 +95,14 @@ const MockEngine = jest.mocked(Engine); describe('LedgerSelectAccount', () => { const mockKeyringController = MockEngine.context.KeyringController; + const mockExistingAccounts = [ + '0xd0a1e359811322d97991e03f863a0c30c2cf029c', + '0xa1e359811322d97991e03f863a0c30c2cf029cd', + ]; beforeEach(() => { jest.clearAllMocks(); + mockKeyringController.getAccounts.mockResolvedValue(mockExistingAccounts); ( useLedgerBluetooth as unknown as jest.MockedFunction< @@ -64,20 +119,131 @@ describe('LedgerSelectAccount', () => { })); }); - it('renders correctly to match snapshot', () => { - mockKeyringController.getAccounts.mockResolvedValue([]); - const wrapper = renderWithProvider(); + describe('Initial Rendering', () => { + it('renders LedgerConnect when no accounts are loaded', () => { + mockKeyringController.getAccounts.mockResolvedValue([]); + const { toJSON } = renderWithProvider(); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders LedgerConnect when ledger error exists', () => { + ( + useLedgerBluetooth as unknown as jest.MockedFunction< + typeof useLedgerBluetooth + > + ).mockImplementation(() => ({ + isSendingLedgerCommands: false, + isAppLaunchConfirmationNeeded: false, + ledgerLogicToRun: jest.fn(), + error: LedgerCommunicationErrors.LedgerDisconnected, + cleanupBluetoothConnection(): void { + throw new Error('Function not implemented.'); + }, + })); + + const { toJSON } = renderWithProvider(); - expect(wrapper).toMatchSnapshot(); + expect(toJSON()).toMatchSnapshot(); + }); }); - it('renders correctly to match snapshot when getAccounts return valid accounts', () => { - mockKeyringController.getAccounts.mockResolvedValue([ - '0xd0a1e359811322d97991e03f863a0c30c2cf029c', - '0xa1e359811322d97991e03f863a0c30c2cf029cd', - ]); - const wrapper = renderWithProvider(); + describe('Account Loading', () => { + it('loads existing accounts on mount', async () => { + renderWithProvider(); + + await waitFor(() => { + expect(mockKeyringController.getAccounts).toHaveBeenCalled(); + }); + }); + + it('formats existing account addresses', async () => { + const mockToFormattedAddress = jest.requireMock( + '../../../util/address', + ).toFormattedAddress; + + renderWithProvider(); + + await waitFor(() => { + expect(mockToFormattedAddress).toHaveBeenCalledWith( + mockExistingAccounts[0], + 0, + mockExistingAccounts, + ); + expect(mockToFormattedAddress).toHaveBeenCalledWith( + mockExistingAccounts[1], + 1, + mockExistingAccounts, + ); + }); + }); + }); + + describe('Metrics Tracking', () => { + it('tracks metrics when rendering with LedgerConnect', () => { + mockKeyringController.getAccounts.mockResolvedValue([]); + + renderWithProvider(); + + expect(mockCreateEventBuilder).toHaveBeenCalled(); + expect(mockTrackEvent).toHaveBeenCalled(); + }); + + it('includes hardware device type when tracking hardware wallet instructions', () => { + mockKeyringController.getAccounts.mockResolvedValue([]); + const mockBuilder = { + addProperties: jest.fn().mockReturnThis(), + build: jest.fn().mockReturnValue({ event: 'instructions_viewed' }), + }; + mockCreateEventBuilder.mockReturnValue(mockBuilder); + + renderWithProvider(); + + expect(mockBuilder.addProperties).toHaveBeenCalledWith( + expect.objectContaining({ + device_type: HardwareDeviceTypes.LEDGER, + }), + ); + }); + + it('calls trackEvent with built event object', () => { + mockKeyringController.getAccounts.mockResolvedValue([]); + const mockEvent = { event: 'test_event' }; + const mockBuilder = { + addProperties: jest.fn().mockReturnThis(), + build: jest.fn().mockReturnValue(mockEvent), + }; + mockCreateEventBuilder.mockReturnValue(mockBuilder); + + renderWithProvider(); + + expect(mockTrackEvent).toHaveBeenCalledWith(mockEvent); + }); + }); + + describe('Error Handling', () => { + it('hides blocking modal when ledger error occurs', async () => { + const { rerender } = renderWithProvider(); + + ( + useLedgerBluetooth as unknown as jest.MockedFunction< + typeof useLedgerBluetooth + > + ).mockImplementation(() => ({ + isSendingLedgerCommands: false, + isAppLaunchConfirmationNeeded: false, + ledgerLogicToRun: jest.fn(), + error: LedgerCommunicationErrors.LedgerDisconnected, + cleanupBluetoothConnection(): void { + throw new Error('Function not implemented.'); + }, + })); + + rerender(); - expect(wrapper).toMatchSnapshot(); + await waitFor(() => { + expect(useLedgerBluetooth).toHaveBeenCalled(); + }); + }); }); }); diff --git a/app/components/Views/LedgerSelectAccount/index.tsx b/app/components/Views/LedgerSelectAccount/index.tsx index 330322e54c0..1ac0961a4f0 100644 --- a/app/components/Views/LedgerSelectAccount/index.tsx +++ b/app/components/Views/LedgerSelectAccount/index.tsx @@ -28,6 +28,7 @@ import { HardwareDeviceTypes } from '../../../constants/keyringTypes'; import MaterialIcon from 'react-native-vector-icons/MaterialIcons'; import PAGINATION_OPERATIONS from '../../../constants/pagination'; import { Device as LedgerDevice } from '@ledgerhq/react-native-hw-transport-ble/lib/types'; +import { ledgerDeviceUUIDToModelName } from '../../../util/hardwareWallet/deviceNameUtils'; import useLedgerBluetooth from '../../hooks/Ledger/useLedgerBluetooth'; import { LEDGER_BIP44_PATH, @@ -41,6 +42,7 @@ import { import SelectOptionSheet from '../../UI/SelectOptionSheet'; import { AccountsController } from '@metamask/accounts-controller'; import { toFormattedAddress } from '../../../util/address'; +import { getConnectedDevicesCount } from '../../../core/HardwareWallets/analytics'; interface OptionType { key: string; @@ -61,6 +63,14 @@ const LedgerSelectAccount = () => { ledgerDeviceDarkImage, ); + const ledgerModelName = useMemo(() => { + if (selectedDevice) { + const [bluetoothServiceId] = selectedDevice.serviceUUIDs; + return ledgerDeviceUUIDToModelName(bluetoothServiceId); + } + return undefined; + }, [selectedDevice]); + const ledgerPathOptions: OptionType[] = useMemo( () => [ { @@ -138,22 +148,35 @@ const LedgerSelectAccount = () => { setBlockingModalVisible(true); }; + useEffect(() => { + if (selectedDevice && accounts.length > 0) { + trackEvent( + createEventBuilder( + MetaMetricsEvents.HARDWARE_WALLET_ACCOUNT_SELECTOR_OPEN, + ) + .addProperties({ + device_type: HardwareDeviceTypes.LEDGER, + device_model: ledgerModelName, + }) + .build(), + ); + } + }, [ + trackEvent, + createEventBuilder, + selectedDevice, + accounts, + ledgerModelName, + ]); + const onConnectHardware = useCallback(async () => { setErrorMsg(null); - trackEvent( - createEventBuilder(MetaMetricsEvents.CONTINUE_LEDGER_HARDWARE_WALLET) - .addProperties({ - device_type: HardwareDeviceTypes.LEDGER, - }) - .build(), - ); - const _accounts = await getLedgerAccountsByOperation( PAGINATION_OPERATIONS.GET_FIRST_PAGE, ); setAccounts(_accounts); - }, [trackEvent, createEventBuilder]); + }, []); useEffect(() => { if (accounts.length > 0 && selectedOption) { @@ -226,34 +249,47 @@ const LedgerSelectAccount = () => { const onUnlock = useCallback( async (accountIndexes: number[]) => { showLoadingModal(); + try { for (const index of accountIndexes) { await unlockLedgerWalletAccount(index); } - + const numberOfConnectedDevices = await getConnectedDevicesCount(); await updateNewLegacyAccountsLabel(); trackEvent( - createEventBuilder(MetaMetricsEvents.CONNECT_LEDGER_SUCCESS) + createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_ADD_ACCOUNT) .addProperties({ device_type: HardwareDeviceTypes.LEDGER, + device_model: ledgerModelName, hd_path: getPathString(selectedOption.value), + connected_device_count: numberOfConnectedDevices.toString(), }) .build(), ); navigation.pop(2); } catch (err) { + trackEvent( + createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_ERROR) + .addProperties({ + device_type: HardwareDeviceTypes.LEDGER, + device_model: ledgerModelName, + error: (err as Error).message, + }) + .build(), + ); setErrorMsg((err as Error).message); } finally { setBlockingModalVisible(false); } }, [ - navigation, - selectedOption.value, - trackEvent, updateNewLegacyAccountsLabel, + ledgerModelName, + trackEvent, createEventBuilder, + selectedOption.value, + navigation, ], ); @@ -265,12 +301,13 @@ const LedgerSelectAccount = () => { createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_FORGOTTEN) .addProperties({ device_type: HardwareDeviceTypes.LEDGER, + device_model: ledgerModelName, }) .build(), ); setBlockingModalVisible(false); navigation.dispatch(StackActions.pop(2)); - }, [dispatch, navigation, trackEvent, createEventBuilder]); + }, [dispatch, trackEvent, createEventBuilder, ledgerModelName, navigation]); const onAnimationCompleted = useCallback(async () => { if (!blockingModalVisible) { diff --git a/app/components/Views/confirmations/components/UI/info-row/alert-row/alert-row.test.tsx b/app/components/Views/confirmations/components/UI/info-row/alert-row/alert-row.test.tsx index 2e8f4f1f84f..e3632234178 100755 --- a/app/components/Views/confirmations/components/UI/info-row/alert-row/alert-row.test.tsx +++ b/app/components/Views/confirmations/components/UI/info-row/alert-row/alert-row.test.tsx @@ -85,7 +85,7 @@ describe('AlertRow', () => { expect(getByText(LABEL_MOCK)).toBeDefined(); expect(getByText(CHILDREN_MOCK)).toBeDefined(); const icon = getByTestId('inline-alert-icon'); - expect(icon.props.name).toBe(IconName.Danger); + expect(icon.props.name).toBe(IconName.Info); }); it('renders correctly with default alert', () => { @@ -150,6 +150,41 @@ describe('AlertRow', () => { expect(mockTrackInlineAlertClicked).not.toHaveBeenCalled(); }); + it('calls showAlertModal, setAlertKey and trackInlineAlertClicked when label is clicked', () => { + const { getByText } = render(); + + fireEvent.press(getByText(LABEL_MOCK)); + + expect(mockSetAlertKey).toHaveBeenCalledWith(ALERT_KEY_DANGER); + expect(mockShowAlertModal).toHaveBeenCalled(); + expect(mockTrackInlineAlertClicked).toHaveBeenCalledWith( + ALERT_FIELD_DANGER, + ); + }); + + it('does not trigger alert modal when label is clicked and disableAlertInteraction is true', () => { + const { getByText } = render( + , + ); + + fireEvent.press(getByText(LABEL_MOCK)); + + expect(mockSetAlertKey).not.toHaveBeenCalled(); + expect(mockShowAlertModal).not.toHaveBeenCalled(); + expect(mockTrackInlineAlertClicked).not.toHaveBeenCalled(); + }); + + it('does not trigger alert modal when label is clicked and no alert is selected', () => { + const props = { ...baseProps, alertField: 'non_existent_field' }; + const { getByText } = render(); + + fireEvent.press(getByText(LABEL_MOCK)); + + expect(mockSetAlertKey).not.toHaveBeenCalled(); + expect(mockShowAlertModal).not.toHaveBeenCalled(); + expect(mockTrackInlineAlertClicked).not.toHaveBeenCalled(); + }); + it('renders with the given style if provided', () => { const props = { ...baseProps, style: { backgroundColor: 'red' } }; const { getByTestId } = render(); diff --git a/app/components/Views/confirmations/components/UI/info-row/alert-row/alert-row.tsx b/app/components/Views/confirmations/components/UI/info-row/alert-row/alert-row.tsx index 042e0a8eccd..15818f6b431 100755 --- a/app/components/Views/confirmations/components/UI/info-row/alert-row/alert-row.tsx +++ b/app/components/Views/confirmations/components/UI/info-row/alert-row/alert-row.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import InlineAlert from '../../inline-alert'; import { useAlerts } from '../../../../context/alert-system-context'; import { Severity } from '../../../../types/alerts'; @@ -7,6 +7,7 @@ import { useStyles } from '../../../../../../../component-library/hooks'; import InfoRow, { InfoRowProps, InfoRowVariant } from '../info-row'; import styleSheet from './alert-row.styles'; import { IconColor } from '../../../../../../../component-library/components/Icons/Icon'; +import { useConfirmationAlertMetrics } from '../../../../hooks/metrics/useConfirmationAlertMetrics'; function getAlertTextColors(severity?: Severity): TextColor { switch (severity) { @@ -44,11 +45,19 @@ const AlertRow = ({ disableAlertInteraction, ...props }: AlertRowProps) => { - const { fieldAlerts } = useAlerts(); + const { fieldAlerts, showAlertModal, setAlertKey } = useAlerts(); + const { trackInlineAlertClicked } = useConfirmationAlertMetrics(); const alertSelected = fieldAlerts.find((a) => a.field === alertField); const { styles } = useStyles(styleSheet, {}); const { rowVariant, style } = props; + const handleLabelClick = useCallback(() => { + if (!alertSelected) return; + setAlertKey(alertSelected.key); + showAlertModal(); + trackInlineAlertClicked(alertSelected.field); + }, [alertSelected, setAlertKey, showAlertModal, trackInlineAlertClicked]); + if (!alertSelected && isShownWithAlertsOnly) { return null; } @@ -61,6 +70,8 @@ const AlertRow = ({ tooltipColor: isSmall ? getAlertIconColors(alertSelected?.severity) : undefined, + onLabelClick: + alertSelected && !disableAlertInteraction ? handleLabelClick : undefined, }; const inlineAlert = diff --git a/app/components/Views/confirmations/components/UI/info-row/info-row.tsx b/app/components/Views/confirmations/components/UI/info-row/info-row.tsx index 765262bb5f9..95aeb8831de 100644 --- a/app/components/Views/confirmations/components/UI/info-row/info-row.tsx +++ b/app/components/Views/confirmations/components/UI/info-row/info-row.tsx @@ -24,6 +24,7 @@ export interface InfoRowProps { label?: string; children?: ReactNode | string; onTooltipPress?: () => void; + onLabelClick?: () => void; tooltip?: ReactNode; tooltipTitle?: string; tooltipColor?: IconColor; @@ -45,6 +46,7 @@ const InfoRow = ({ label, children, onTooltipPress, + onLabelClick, style = {}, labelChildren = null, tooltip, @@ -80,7 +82,7 @@ const InfoRow = ({ > {Boolean(label) && ( - + {label} {labelChildren} diff --git a/app/components/Views/confirmations/components/UI/inline-alert/inline-alert.styles.ts b/app/components/Views/confirmations/components/UI/inline-alert/inline-alert.styles.ts index 1f0cc29ba54..bdde91556c6 100644 --- a/app/components/Views/confirmations/components/UI/inline-alert/inline-alert.styles.ts +++ b/app/components/Views/confirmations/components/UI/inline-alert/inline-alert.styles.ts @@ -1,29 +1,11 @@ import { StyleSheet } from 'react-native'; -import { Theme } from '../../../../../../util/theme/models'; - -const styleSheet = (params: { theme: Theme }) => { - const { theme } = params; - - return StyleSheet.create({ - wrapper: { - display: 'flex', - flexDirection: 'row', - alignItems: 'center', - padding: 4, - backgroundColor: theme.colors.error.default, - borderRadius: 4, +const styleSheet = () => + StyleSheet.create({ + iconContainer: { marginLeft: 4, - }, - inlineContainer: { - flexDirection: 'row', - alignItems: 'center', - flexShrink: 1, - }, - icon: { - marginRight: 4, + marginTop: 1, // Slight adjustment for visual centering with text }, }); -}; export default styleSheet; diff --git a/app/components/Views/confirmations/components/UI/inline-alert/inline-alert.test.tsx b/app/components/Views/confirmations/components/UI/inline-alert/inline-alert.test.tsx index ee09d464ad8..7d9fc5deb19 100755 --- a/app/components/Views/confirmations/components/UI/inline-alert/inline-alert.test.tsx +++ b/app/components/Views/confirmations/components/UI/inline-alert/inline-alert.test.tsx @@ -43,7 +43,6 @@ const mockAlerts = [ ]; describe('InlineAlert', () => { - const INLINE_ALERT_LABEL = 'Alert'; const mockShowAlertModal = jest.fn(); const mockSetAlertKey = jest.fn(); const mockTrackInlineAlertClicked = jest.fn(); @@ -68,12 +67,12 @@ describe('InlineAlert', () => { render(); it('renders correctly with default props', () => { - const { getByTestId, getByText } = renderComponent(); + const { getByTestId } = renderComponent(); const inlineAlert = getByTestId('inline-alert'); - const label = getByText(INLINE_ALERT_LABEL); + const icon = getByTestId('inline-alert-icon'); expect(inlineAlert).toBeDefined(); - expect(label).toBeDefined(); + expect(icon).toBeDefined(); }); it('renders with danger severity', () => { @@ -87,7 +86,7 @@ describe('InlineAlert', () => { const { getByTestId } = renderComponent(mockAlerts[1]); const icon = getByTestId('inline-alert-icon'); - expect(icon.props.name).toBe(IconName.Danger); + expect(icon.props.name).toBe(IconName.Info); }); it('renders with info severity', () => { diff --git a/app/components/Views/confirmations/components/UI/inline-alert/inline-alert.tsx b/app/components/Views/confirmations/components/UI/inline-alert/inline-alert.tsx index 9b0bf8c85ea..41bbed7b0d8 100755 --- a/app/components/Views/confirmations/components/UI/inline-alert/inline-alert.tsx +++ b/app/components/Views/confirmations/components/UI/inline-alert/inline-alert.tsx @@ -1,19 +1,12 @@ import React, { useCallback } from 'react'; -import { TouchableOpacity, View, ViewStyle } from 'react-native'; -import { ThemeColors } from '@metamask/design-tokens'; -import { strings } from '../../../../../../../locales/i18n'; -import { useStyles } from '../../../../../../component-library/hooks'; +import { TouchableOpacity, ViewStyle } from 'react-native'; import Icon, { IconName, IconSize, } from '../../../../../../component-library/components/Icons/Icon'; -import Text, { - TextColor, - TextVariant, -} from '../../../../../../component-library/components/Texts/Text'; +import { TextColor } from '../../../../../../component-library/components/Texts/Text'; +import { useStyles } from '../../../../../../component-library/hooks'; import { AlertTypeIDs } from '../../../../../../../e2e/selectors/Confirmation/ConfirmationView.selectors'; -import { IconSizes } from '../../../../../../component-library/components-temp/KeyValueRow'; -import { useTheme } from '../../../../../../util/theme'; import { Alert, Severity } from '../../../types/alerts'; import { useAlerts } from '../../../context/alert-system-context'; import { useConfirmationAlertMetrics } from '../../../hooks/metrics/useConfirmationAlertMetrics'; @@ -28,17 +21,6 @@ export interface InlineAlertProps { disabled?: boolean; } -const getBackgroundColor = (severity: Severity, colors: ThemeColors) => { - switch (severity) { - case Severity.Danger: - return colors.error.muted; - case Severity.Warning: - return colors.warning.muted; - default: - return colors.info.muted; - } -}; - const getTextColor = (severity: Severity) => { switch (severity) { case Severity.Danger: @@ -57,6 +39,7 @@ export default function InlineAlert({ }: InlineAlertProps) { const { showAlertModal, setAlertKey } = useAlerts(); const { trackInlineAlertClicked } = useConfirmationAlertMetrics(); + const { styles } = useStyles(styleSheet, {}); const handleInlineAlertClick = useCallback(() => { if (!alertObj || disabled) return; @@ -72,39 +55,21 @@ export default function InlineAlert({ ]); const severity = alertObj.severity ?? Severity.Info; - const { colors } = useTheme(); - const { styles } = useStyles(styleSheet, {}); return ( - - - - - {strings('alert_system.inline_alert_label')} - - - - + + ); } diff --git a/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.test.tsx b/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.test.tsx index 7df71eb925c..d062b132bcc 100644 --- a/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.test.tsx +++ b/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.test.tsx @@ -37,6 +37,13 @@ jest.mock('../../../hooks/send/useAccountTokens'); jest.mock('../../../hooks/pay/useTransactionPayAvailableTokens'); jest.mock('../../../hooks/pay/useTransactionPayData'); jest.mock('../../../hooks/transactions/useTransactionConfirm'); +jest.mock('../../../hooks/metrics/useConfirmationAlertMetrics', () => ({ + useConfirmationAlertMetrics: () => ({ + trackInlineAlertClicked: jest.fn(), + trackAlertActionClicked: jest.fn(), + trackAlertRendered: jest.fn(), + }), +})); const mockGoToBuy = jest.fn(); diff --git a/app/components/Views/confirmations/components/predict-confirmations/predict-claim-footer/predict-claim-footer.styles.ts b/app/components/Views/confirmations/components/predict-confirmations/predict-claim-footer/predict-claim-footer.styles.ts index 2bd46b0a5f1..4c400478e06 100644 --- a/app/components/Views/confirmations/components/predict-confirmations/predict-claim-footer/predict-claim-footer.styles.ts +++ b/app/components/Views/confirmations/components/predict-confirmations/predict-claim-footer/predict-claim-footer.styles.ts @@ -22,6 +22,10 @@ const styleSheet = (params: { theme: Theme }) => bottom: { textAlign: 'center', }, + + textContainer: { + flex: 1, + }, }); export default styleSheet; diff --git a/app/components/Views/confirmations/components/predict-confirmations/predict-claim-footer/predict-claim-footer.tsx b/app/components/Views/confirmations/components/predict-confirmations/predict-claim-footer/predict-claim-footer.tsx index 0a23df76696..2ae742ced40 100644 --- a/app/components/Views/confirmations/components/predict-confirmations/predict-claim-footer/predict-claim-footer.tsx +++ b/app/components/Views/confirmations/components/predict-confirmations/predict-claim-footer/predict-claim-footer.tsx @@ -70,6 +70,7 @@ export function PredictClaimFooter({ onPress }: PredictClaimFooterProps) { } function SingleWin({ wonPositions }: { wonPositions: PredictPosition[] }) { + const { styles } = useStyles(styleSheet, {}); const formatFiat = useFiatFormatter({ currency: 'usd' }); const position = wonPositions[0]; @@ -91,9 +92,15 @@ function SingleWin({ wonPositions }: { wonPositions: PredictPosition[] }) { imageSource={{ uri: position.icon }} size={AvatarSize.Lg} /> - - {position.title} - + + + {position.title} + + {amountFormatted} on {position.outcome} diff --git a/app/components/Views/confirmations/components/rows/bridge-fee-row/bridge-fee-row.test.tsx b/app/components/Views/confirmations/components/rows/bridge-fee-row/bridge-fee-row.test.tsx index a2bfd54f21d..478e83ac66c 100644 --- a/app/components/Views/confirmations/components/rows/bridge-fee-row/bridge-fee-row.test.tsx +++ b/app/components/Views/confirmations/components/rows/bridge-fee-row/bridge-fee-row.test.tsx @@ -22,6 +22,13 @@ import { otherControllersMock } from '../../../__mocks__/controllers/other-contr import { Json } from '@metamask/utils'; jest.mock('../../../hooks/pay/useTransactionPayData'); +jest.mock('../../../hooks/metrics/useConfirmationAlertMetrics', () => ({ + useConfirmationAlertMetrics: () => ({ + trackInlineAlertClicked: jest.fn(), + trackAlertActionClicked: jest.fn(), + trackAlertRendered: jest.fn(), + }), +})); function render() { const state = merge( diff --git a/app/components/Views/confirmations/components/rows/transactions/network-and-origin-row/network-and-origin-row.test.tsx b/app/components/Views/confirmations/components/rows/transactions/network-and-origin-row/network-and-origin-row.test.tsx index d54aa81e974..abdabd73c5b 100644 --- a/app/components/Views/confirmations/components/rows/transactions/network-and-origin-row/network-and-origin-row.test.tsx +++ b/app/components/Views/confirmations/components/rows/transactions/network-and-origin-row/network-and-origin-row.test.tsx @@ -8,6 +8,13 @@ import { MMM_ORIGIN } from '../../../../constants/confirmations'; import { NetworkAndOriginRow } from './network-and-origin-row'; jest.mock('../../../../hooks/metrics/useConfirmationMetricEvents'); +jest.mock('../../../../hooks/metrics/useConfirmationAlertMetrics', () => ({ + useConfirmationAlertMetrics: () => ({ + trackInlineAlertClicked: jest.fn(), + trackAlertActionClicked: jest.fn(), + trackAlertRendered: jest.fn(), + }), +})); jest.mock('../../../../../../../core/Engine', () => ({ context: { GasFeeController: { diff --git a/app/components/Views/confirmations/context/qr-hardware-context/useCamera.ts b/app/components/Views/confirmations/context/qr-hardware-context/useCamera.ts index 0bb2c717b48..7c4f66c1f62 100644 --- a/app/components/Views/confirmations/context/qr-hardware-context/useCamera.ts +++ b/app/components/Views/confirmations/context/qr-hardware-context/useCamera.ts @@ -4,8 +4,15 @@ import { PermissionsAndroid, AppStateStatus, AppState } from 'react-native'; import { strings } from '../../../../../../locales/i18n'; import Device from '../../../../../util/device'; +import { MetaMetricsEvents, useMetrics } from '../../../../hooks/useMetrics'; +import { HardwareDeviceTypes } from '../../../../../constants/keyringTypes'; +import { + PERMISSION_RESULT, + PERMISSION_TYPE, +} from '../../../../../core/Analytics/MetaMetrics.events'; export const useCamera = (isSigningQRObject: boolean) => { + const { trackEvent, createEventBuilder } = useMetrics(); // todo: integrate with alert system const [cameraError, setCameraError] = useState(); @@ -16,16 +23,51 @@ export const useCamera = (isSigningQRObject: boolean) => { if (Device.isAndroid() && !hasCameraPermission) { PermissionsAndroid.check(PermissionsAndroid.PERMISSIONS.CAMERA).then( (_hasPermission) => { + trackEvent( + createEventBuilder( + MetaMetricsEvents.HARDWARE_WALLET_PERMISSION_REQUEST, + ) + .addProperties({ + permission: PERMISSION_TYPE.CAMERA, + result: _hasPermission + ? PERMISSION_RESULT.GRANTED + : PERMISSION_RESULT.DENIED, + device_type: HardwareDeviceTypes.QR, + }) + .build(), + ); setCameraPermission(_hasPermission); if (!_hasPermission) { + trackEvent( + createEventBuilder( + MetaMetricsEvents.HARDWARE_WALLET_PERMISSION_REQUEST, + ) + .addProperties({ + permission: PERMISSION_TYPE.CAMERA, + result: PERMISSION_RESULT.LIMITED, + device_type: HardwareDeviceTypes.QR, + }) + .build(), + ); setCameraError(strings('transaction.no_camera_permission_android')); } else { + trackEvent( + createEventBuilder( + MetaMetricsEvents.HARDWARE_WALLET_PERMISSION_REQUEST, + ) + .addProperties({ + permission: PERMISSION_TYPE.CAMERA, + result: PERMISSION_RESULT.UNAVAILABLE, + device_type: HardwareDeviceTypes.QR, + }) + .build(), + ); setCameraError(undefined); } }, ); } - }, [hasCameraPermission]); + }, [hasCameraPermission, trackEvent, createEventBuilder]); const handleAppState = useCallback( (appState: AppStateStatus) => { diff --git a/app/components/Views/confirmations/hooks/send/useNetworkFilter.test.ts b/app/components/Views/confirmations/hooks/send/useNetworkFilter.test.ts index b2abee455c9..d6a86c67cee 100644 --- a/app/components/Views/confirmations/hooks/send/useNetworkFilter.test.ts +++ b/app/components/Views/confirmations/hooks/send/useNetworkFilter.test.ts @@ -1,4 +1,5 @@ import { renderHook, act } from '@testing-library/react-native'; +import { SolScope, BtcScope } from '@metamask/keyring-api'; import { useNetworkFilter, NETWORK_FILTER_ALL } from './useNetworkFilter'; import { AssetType } from '../../types/token'; import { NetworkInfo } from './useNetworks'; @@ -28,18 +29,36 @@ describe('useNetworkFilter', () => { address: '0x123', symbol: 'ETH', name: 'Ethereum', + aggregators: [], + decimals: 18, + image: '', + balance: '0', + logo: undefined, + isETH: false, } as AssetType, { chainId: '0x1', address: '0x456', symbol: 'USDC', name: 'USD Coin', + aggregators: [], + decimals: 6, + image: '', + balance: '0', + logo: undefined, + isETH: false, } as AssetType, { chainId: '0x89', address: '0x789', symbol: 'MATIC', name: 'Polygon', + aggregators: [], + decimals: 18, + image: '', + balance: '0', + logo: undefined, + isETH: false, } as AssetType, ]; @@ -161,4 +180,264 @@ describe('useNetworkFilter', () => { expect(result.current.filteredTokensByNetwork).toEqual(mockTokens); expect(result.current.filteredTokensByNetwork).toHaveLength(3); }); + + describe('network sorting by mainnet/testnet groups', () => { + it('sorts networks with mainnets first, then testnets, each sorted by value', () => { + // Create networks with mixed mainnets and testnets + const networksWithTestnets: NetworkInfo[] = [ + { + chainId: '0x89', // Polygon Mainnet (lower value) + name: 'Polygon', + image: { uri: 'polygon.png' }, + }, + { + chainId: '0xaa36a7', // Sepolia Testnet (high value) + name: 'Sepolia', + image: { uri: 'sepolia.png' }, + }, + { + chainId: '0x1', // Ethereum Mainnet (high value) + name: 'Ethereum Mainnet', + image: { uri: 'ethereum.png' }, + }, + { + chainId: SolScope.Devnet, // Solana Devnet (lower value) + name: 'Solana Devnet', + image: { uri: 'solana-devnet.png' }, + }, + ]; + + // Create tokens with different fiat values + // Ethereum Mainnet: $5000 (highest mainnet) + // Polygon: $1000 (lower mainnet) + // Sepolia: $2000 (highest testnet) + // Solana Devnet: $500 (lower testnet) + const tokensWithValues: AssetType[] = [ + { + chainId: '0x1', + address: '0x123', + symbol: 'ETH', + name: 'Ethereum', + aggregators: [], + decimals: 18, + image: '', + balance: '0', + logo: undefined, + isETH: false, + fiat: { balance: 5000 }, + } as AssetType, + { + chainId: '0x89', + address: '0x456', + symbol: 'MATIC', + name: 'Polygon', + aggregators: [], + decimals: 18, + image: '', + balance: '0', + logo: undefined, + isETH: false, + fiat: { balance: 1000 }, + } as AssetType, + { + chainId: '0xaa36a7', + address: '0x789', + symbol: 'ETH', + name: 'Sepolia ETH', + aggregators: [], + decimals: 18, + image: '', + balance: '0', + logo: undefined, + isETH: false, + fiat: { balance: 2000 }, + } as AssetType, + { + chainId: SolScope.Devnet, + address: '0xabc', + symbol: 'SOL', + name: 'Solana', + aggregators: [], + decimals: 9, + image: '', + balance: '0', + logo: undefined, + isETH: false, + fiat: { balance: 500 }, + } as AssetType, + ]; + + const { result } = renderHook(() => + useNetworkFilter(tokensWithValues, networksWithTestnets), + ); + + const sortedNetworks = result.current.networksWithTokens; + + // Should have 4 networks + expect(sortedNetworks).toHaveLength(4); + + // Mainnets should come first, sorted by value (descending) + // Ethereum Mainnet ($5000) should be first + expect(sortedNetworks[0].chainId).toBe('0x1'); + expect(sortedNetworks[0].name).toBe('Ethereum Mainnet'); + + // Polygon ($1000) should be second + expect(sortedNetworks[1].chainId).toBe('0x89'); + expect(sortedNetworks[1].name).toBe('Polygon'); + + // Testnets should come after mainnets, sorted by value (descending) + // Sepolia ($2000) should be third + expect(sortedNetworks[2].chainId).toBe('0xaa36a7'); + expect(sortedNetworks[2].name).toBe('Sepolia'); + + // Solana Devnet ($500) should be last + expect(sortedNetworks[3].chainId).toBe(SolScope.Devnet); + expect(sortedNetworks[3].name).toBe('Solana Devnet'); + }); + + it('handles networks with zero balance correctly', () => { + const networksWithZeroBalance: NetworkInfo[] = [ + { + chainId: '0x1', // Ethereum Mainnet + name: 'Ethereum Mainnet', + image: { uri: 'ethereum.png' }, + }, + { + chainId: '0xaa36a7', // Sepolia Testnet + name: 'Sepolia', + image: { uri: 'sepolia.png' }, + }, + ]; + + const tokensWithZeroBalance: AssetType[] = [ + { + chainId: '0x1', + address: '0x123', + symbol: 'ETH', + name: 'Ethereum', + aggregators: [], + decimals: 18, + image: '', + balance: '0', + logo: undefined, + isETH: false, + fiat: { balance: 0 }, + } as AssetType, + { + chainId: '0xaa36a7', + address: '0x789', + symbol: 'ETH', + name: 'Sepolia ETH', + aggregators: [], + decimals: 18, + image: '', + balance: '0', + logo: undefined, + isETH: false, + // No fiat balance + } as AssetType, + ]; + + const { result } = renderHook(() => + useNetworkFilter(tokensWithZeroBalance, networksWithZeroBalance), + ); + + const sortedNetworks = result.current.networksWithTokens; + + // Should have 2 networks + expect(sortedNetworks).toHaveLength(2); + + // Mainnet should come first even with zero balance + expect(sortedNetworks[0].chainId).toBe('0x1'); + expect(sortedNetworks[0].name).toBe('Ethereum Mainnet'); + + // Testnet should come after + expect(sortedNetworks[1].chainId).toBe('0xaa36a7'); + expect(sortedNetworks[1].name).toBe('Sepolia'); + }); + + it('handles Bitcoin testnet networks correctly', () => { + const networksWithBitcoin: NetworkInfo[] = [ + { + chainId: BtcScope.Mainnet, + name: 'Bitcoin Mainnet', + image: { uri: 'bitcoin.png' }, + }, + { + chainId: BtcScope.Testnet, + name: 'Bitcoin Testnet', + image: { uri: 'bitcoin-testnet.png' }, + }, + { + chainId: '0x1', + name: 'Ethereum Mainnet', + image: { uri: 'ethereum.png' }, + }, + ]; + + const tokensWithBitcoin: AssetType[] = [ + { + chainId: BtcScope.Mainnet, + address: '0xbtc1', + symbol: 'BTC', + name: 'Bitcoin', + aggregators: [], + decimals: 8, + image: '', + balance: '0', + logo: undefined, + isETH: false, + fiat: { balance: 3000 }, + } as AssetType, + { + chainId: BtcScope.Testnet, + address: '0xbtc2', + symbol: 'BTC', + name: 'Bitcoin Testnet', + aggregators: [], + decimals: 8, + image: '', + balance: '0', + logo: undefined, + isETH: false, + fiat: { balance: 100 }, + } as AssetType, + { + chainId: '0x1', + address: '0x123', + symbol: 'ETH', + name: 'Ethereum', + aggregators: [], + decimals: 18, + image: '', + balance: '0', + logo: undefined, + isETH: false, + fiat: { balance: 5000 }, + } as AssetType, + ]; + + const { result } = renderHook(() => + useNetworkFilter(tokensWithBitcoin, networksWithBitcoin), + ); + + const sortedNetworks = result.current.networksWithTokens; + + // Should have 3 networks + expect(sortedNetworks).toHaveLength(3); + + // Mainnets should come first, sorted by value + // Ethereum Mainnet ($5000) should be first + expect(sortedNetworks[0].chainId).toBe('0x1'); + expect(sortedNetworks[0].name).toBe('Ethereum Mainnet'); + + // Bitcoin Mainnet ($3000) should be second + expect(sortedNetworks[1].chainId).toBe(BtcScope.Mainnet); + expect(sortedNetworks[1].name).toBe('Bitcoin Mainnet'); + + // Bitcoin Testnet ($100) should come after all mainnets + expect(sortedNetworks[2].chainId).toBe(BtcScope.Testnet); + expect(sortedNetworks[2].name).toBe('Bitcoin Testnet'); + }); + }); }); diff --git a/app/components/Views/confirmations/hooks/send/useNetworkFilter.ts b/app/components/Views/confirmations/hooks/send/useNetworkFilter.ts index f500f14217c..fa604478e7c 100644 --- a/app/components/Views/confirmations/hooks/send/useNetworkFilter.ts +++ b/app/components/Views/confirmations/hooks/send/useNetworkFilter.ts @@ -1,5 +1,8 @@ import { useState, useMemo } from 'react'; import { BigNumber } from 'bignumber.js'; +import { parseCaipChainId, Hex, CaipChainId } from '@metamask/utils'; +import { BtcScope, SolScope } from '@metamask/keyring-api'; +import { isTestNet } from '../../../../../util/networks'; import { AssetType } from '../../types/token'; import { type NetworkInfo } from './useNetworks'; @@ -12,6 +15,52 @@ export interface UseNetworkFilterResult { networksWithTokens: NetworkInfo[]; } +/** + * Check if a network is a testnet based on its chainId. + * Handles both EVM (hex chainIds) and non-EVM (CAIP chainIds) networks. + * + * @param chainId - The chain ID to check (can be hex format or CAIP format) + * @returns True if the network is a testnet, false otherwise + */ +const isNetworkTestnet = (chainId: string): boolean => { + // Check if it's a CAIP chain ID (non-EVM networks) + if (chainId.includes(':')) { + try { + const { namespace, reference } = parseCaipChainId(chainId as CaipChainId); + + // Check EVM testnets using isTestNet helper + if (namespace === 'eip155') { + const hexChainId = `0x${parseInt(reference, 10).toString(16)}` as Hex; + return isTestNet(hexChainId); + } + + // Check Bitcoin testnets using full CAIP IDs from BtcScope + if (namespace === 'bip122') { + return ( + chainId === BtcScope.Testnet || + chainId === BtcScope.Testnet4 || + chainId === BtcScope.Regtest || + chainId === BtcScope.Signet + ); + } + + // Check Solana testnets using full CAIP IDs from SolScope + if (namespace === 'solana') { + return chainId === SolScope.Devnet; + } + + // For other namespaces, assume mainnet if not explicitly a testnet + return false; + } catch { + // If parsing fails, fall back to EVM check + return isTestNet(chainId as Hex); + } + } + + // For hex chainIds (EVM networks), use isTestNet directly + return isTestNet(chainId as Hex); +}; + export const useNetworkFilter = ( tokens: AssetType[], networks: NetworkInfo[], @@ -25,23 +74,32 @@ export const useNetworkFilter = ( tokenChainIds.has(network.chainId), ); - return filteredNetworks.sort((networkA, networkB) => { - const networkATotal = tokens - .filter((token) => token.chainId === networkA.chainId) + // Calculate total fiat value for each network + const networksWithValues = filteredNetworks.map((network) => { + const totalValue = tokens + .filter((token) => token.chainId === network.chainId) .reduce((sum, token) => { const fiatBalance = token.fiat?.balance || '0'; return sum.plus(new BigNumber(fiatBalance)); }, new BigNumber(0)); - const networkBTotal = tokens - .filter((token) => token.chainId === networkB.chainId) - .reduce((sum, token) => { - const fiatBalance = token.fiat?.balance || '0'; - return sum.plus(new BigNumber(fiatBalance)); - }, new BigNumber(0)); - - return networkBTotal.comparedTo(networkATotal) || 0; + return { + network, + totalValue, + isTestnet: isNetworkTestnet(network.chainId), + }; }); + + // Separate into mainnet and testnet groups + const mainnets = networksWithValues.filter((item) => !item.isTestnet); + const testnets = networksWithValues.filter((item) => item.isTestnet); + + // Sort each group by value (descending - highest first) + mainnets.sort((a, b) => b.totalValue.comparedTo(a.totalValue) || 0); + testnets.sort((a, b) => b.totalValue.comparedTo(a.totalValue) || 0); + + // Combine: mainnets first, then testnets + return [...mainnets, ...testnets].map((item) => item.network); }, [tokens, networks]); const filteredTokensByNetwork = useMemo(() => { diff --git a/app/components/Views/confirmations/legacy/components/PersonalSign/PersonalSign.tsx b/app/components/Views/confirmations/legacy/components/PersonalSign/PersonalSign.tsx index 755a265b93e..6eeb7e765a8 100644 --- a/app/components/Views/confirmations/legacy/components/PersonalSign/PersonalSign.tsx +++ b/app/components/Views/confirmations/legacy/components/PersonalSign/PersonalSign.tsx @@ -127,7 +127,7 @@ const PersonalSign = ({ const onSignatureError = ({ error }: { error: Error }) => { if (error?.message.startsWith(KEYSTONE_TX_CANCELED)) { trackEvent( - createEventBuilder(MetaMetricsEvents.QR_HARDWARE_TRANSACTION_CANCELED) + createEventBuilder(MetaMetricsEvents.DAPP_TRANSACTION_CANCELLED) .addProperties(getAnalyticsParams()) .build(), ); diff --git a/app/components/hooks/Ledger/useBluetoothDevices.ts b/app/components/hooks/Ledger/useBluetoothDevices.ts index 0f749d1b045..3e0d8946193 100644 --- a/app/components/hooks/Ledger/useBluetoothDevices.ts +++ b/app/components/hooks/Ledger/useBluetoothDevices.ts @@ -5,6 +5,7 @@ import { Observable, Observer, Subscription } from 'rxjs'; export interface BluetoothDevice { id: string; name: string; + serviceUUIDs: string[]; } // Works with any Bluetooth Interface that provides a listen method diff --git a/app/components/hooks/Ledger/useLedgerDeviceForAccount.test.ts b/app/components/hooks/Ledger/useLedgerDeviceForAccount.test.ts new file mode 100644 index 00000000000..09f97055b5c --- /dev/null +++ b/app/components/hooks/Ledger/useLedgerDeviceForAccount.test.ts @@ -0,0 +1,432 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { InternalAccount } from '@metamask/keyring-internal-api'; +import useLedgerDeviceForAccount from './useLedgerDeviceForAccount'; +import useBluetoothDevices, { BluetoothDevice } from './useBluetoothDevices'; +import useBluetoothPermissions from '../useBluetoothPermissions'; +import useBluetooth from './useBluetooth'; +import ExtendedKeyringTypes from '../../../constants/keyringTypes'; +import { BluetoothPermissionErrors } from '../../../core/Ledger/ledgerErrors'; + +jest.mock('./useBluetoothDevices'); +jest.mock('../useBluetoothPermissions'); +jest.mock('./useBluetooth'); + +const mockUseBluetoothDevices = useBluetoothDevices as jest.MockedFunction< + typeof useBluetoothDevices +>; +const mockUseBluetoothPermissions = + useBluetoothPermissions as jest.MockedFunction< + typeof useBluetoothPermissions + >; +const mockUseBluetooth = useBluetooth as jest.MockedFunction< + typeof useBluetooth +>; + +describe('useLedgerDeviceForAccount', () => { + const mockCheckPermissions = jest.fn(); + + const createMockAccount = ( + keyringType: string = ExtendedKeyringTypes.hd, + ): InternalAccount => + ({ + id: 'test-account-id', + address: '0x123', + metadata: { + name: 'Test Account', + keyring: { + type: keyringType, + }, + }, + }) as InternalAccount; + + const createMockDevice = (id = 'device-1'): BluetoothDevice => ({ + id, + name: 'Ledger Nano X', + serviceUUIDs: ['uuid-1'], + }); + + beforeEach(() => { + jest.clearAllMocks(); + + mockUseBluetoothPermissions.mockReturnValue({ + hasBluetoothPermissions: false, + checkPermissions: mockCheckPermissions, + bluetoothPermissionError: undefined, + }); + + mockUseBluetooth.mockReturnValue({ + bluetoothOn: false, + bluetoothConnectionError: undefined, + }); + + mockUseBluetoothDevices.mockReturnValue({ + devices: [], + deviceScanError: false, + }); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('when account is not a Ledger account', () => { + it('returns undefined for all Ledger-specific properties', () => { + const account = createMockAccount(ExtendedKeyringTypes.hd); + + const { result } = renderHook(() => useLedgerDeviceForAccount(account)); + + expect(result.current.ledgerDevice).toBeUndefined(); + expect(result.current.hasBluetoothPermissions).toBeUndefined(); + expect(result.current.bluetoothOn).toBeUndefined(); + expect(result.current.checkPermissions).toBeUndefined(); + expect(result.current.bluetoothPermissionError).toBeUndefined(); + expect(result.current.bluetoothConnectionError).toBeUndefined(); + expect(result.current.deviceScanError).toBeUndefined(); + }); + + it('returns undefined when account has no keyring metadata', () => { + const account = { + id: 'test-account-id', + address: '0x123', + metadata: {}, + } as InternalAccount; + + const { result } = renderHook(() => useLedgerDeviceForAccount(account)); + + expect(result.current.ledgerDevice).toBeUndefined(); + expect(result.current.hasBluetoothPermissions).toBeUndefined(); + }); + + it('returns undefined when account keyring type is simple', () => { + const account = createMockAccount(ExtendedKeyringTypes.simple); + + const { result } = renderHook(() => useLedgerDeviceForAccount(account)); + + expect(result.current.ledgerDevice).toBeUndefined(); + }); + + it('returns undefined when account keyring type is qr', () => { + const account = createMockAccount(ExtendedKeyringTypes.qr); + + const { result } = renderHook(() => useLedgerDeviceForAccount(account)); + + expect(result.current.ledgerDevice).toBeUndefined(); + }); + }); + + describe('when account is a Ledger account', () => { + describe('with available devices', () => { + it('returns first device when one device is available', () => { + const account = createMockAccount(ExtendedKeyringTypes.ledger); + const mockDevice = createMockDevice(); + mockUseBluetoothDevices.mockReturnValue({ + devices: [mockDevice], + deviceScanError: false, + }); + mockUseBluetoothPermissions.mockReturnValue({ + hasBluetoothPermissions: true, + checkPermissions: mockCheckPermissions, + bluetoothPermissionError: undefined, + }); + mockUseBluetooth.mockReturnValue({ + bluetoothOn: true, + bluetoothConnectionError: false, + }); + + const { result } = renderHook(() => useLedgerDeviceForAccount(account)); + + expect(result.current.ledgerDevice).toEqual(mockDevice); + expect(result.current.hasBluetoothPermissions).toBe(true); + expect(result.current.bluetoothOn).toBe(true); + }); + + it('returns first device when multiple devices are available', () => { + const account = createMockAccount(ExtendedKeyringTypes.ledger); + const device1 = createMockDevice('device-1'); + const device2 = createMockDevice('device-2'); + mockUseBluetoothDevices.mockReturnValue({ + devices: [device1, device2], + deviceScanError: false, + }); + + const { result } = renderHook(() => useLedgerDeviceForAccount(account)); + + expect(result.current.ledgerDevice).toEqual(device1); + }); + + it('returns checkPermissions function', () => { + const account = createMockAccount(ExtendedKeyringTypes.ledger); + mockUseBluetoothPermissions.mockReturnValue({ + hasBluetoothPermissions: true, + checkPermissions: mockCheckPermissions, + bluetoothPermissionError: undefined, + }); + + const { result } = renderHook(() => useLedgerDeviceForAccount(account)); + + expect(result.current.checkPermissions).toBe(mockCheckPermissions); + }); + }); + + describe('without available devices', () => { + it('returns undefined for ledgerDevice when devices array is empty', () => { + const account = createMockAccount(ExtendedKeyringTypes.ledger); + mockUseBluetoothDevices.mockReturnValue({ + devices: [], + deviceScanError: false, + }); + + const { result } = renderHook(() => useLedgerDeviceForAccount(account)); + + expect(result.current.ledgerDevice).toBeUndefined(); + }); + + it('returns Bluetooth state even when no devices are found', () => { + const account = createMockAccount(ExtendedKeyringTypes.ledger); + mockUseBluetoothPermissions.mockReturnValue({ + hasBluetoothPermissions: true, + checkPermissions: mockCheckPermissions, + bluetoothPermissionError: undefined, + }); + mockUseBluetooth.mockReturnValue({ + bluetoothOn: true, + bluetoothConnectionError: false, + }); + mockUseBluetoothDevices.mockReturnValue({ + devices: [], + deviceScanError: false, + }); + + const { result } = renderHook(() => useLedgerDeviceForAccount(account)); + + expect(result.current.ledgerDevice).toBeUndefined(); + expect(result.current.hasBluetoothPermissions).toBe(true); + expect(result.current.bluetoothOn).toBe(true); + }); + }); + + describe('permission states', () => { + it('returns false when Bluetooth permissions are denied', () => { + const account = createMockAccount(ExtendedKeyringTypes.ledger); + mockUseBluetoothPermissions.mockReturnValue({ + hasBluetoothPermissions: false, + checkPermissions: mockCheckPermissions, + bluetoothPermissionError: + BluetoothPermissionErrors.BluetoothAccessBlocked, + }); + + const { result } = renderHook(() => useLedgerDeviceForAccount(account)); + + expect(result.current.hasBluetoothPermissions).toBe(false); + expect(result.current.bluetoothPermissionError).toBe( + BluetoothPermissionErrors.BluetoothAccessBlocked, + ); + }); + + it('returns nearby devices permission error on Android 12+', () => { + const account = createMockAccount(ExtendedKeyringTypes.ledger); + mockUseBluetoothPermissions.mockReturnValue({ + hasBluetoothPermissions: false, + checkPermissions: mockCheckPermissions, + bluetoothPermissionError: + BluetoothPermissionErrors.NearbyDevicesAccessBlocked, + }); + + const { result } = renderHook(() => useLedgerDeviceForAccount(account)); + + expect(result.current.bluetoothPermissionError).toBe( + BluetoothPermissionErrors.NearbyDevicesAccessBlocked, + ); + }); + + it('returns location permission error on Android below 12', () => { + const account = createMockAccount(ExtendedKeyringTypes.ledger); + mockUseBluetoothPermissions.mockReturnValue({ + hasBluetoothPermissions: false, + checkPermissions: mockCheckPermissions, + bluetoothPermissionError: + BluetoothPermissionErrors.LocationAccessBlocked, + }); + + const { result } = renderHook(() => useLedgerDeviceForAccount(account)); + + expect(result.current.bluetoothPermissionError).toBe( + BluetoothPermissionErrors.LocationAccessBlocked, + ); + }); + }); + + describe('Bluetooth connection states', () => { + it('returns true when Bluetooth is turned on', () => { + const account = createMockAccount(ExtendedKeyringTypes.ledger); + mockUseBluetooth.mockReturnValue({ + bluetoothOn: true, + bluetoothConnectionError: false, + }); + + const { result } = renderHook(() => useLedgerDeviceForAccount(account)); + + expect(result.current.bluetoothOn).toBe(true); + expect(result.current.bluetoothConnectionError).toBe(false); + }); + + it('returns false when Bluetooth is turned off', () => { + const account = createMockAccount(ExtendedKeyringTypes.ledger); + mockUseBluetooth.mockReturnValue({ + bluetoothOn: false, + bluetoothConnectionError: true, + }); + + const { result } = renderHook(() => useLedgerDeviceForAccount(account)); + + expect(result.current.bluetoothOn).toBe(false); + expect(result.current.bluetoothConnectionError).toBe(true); + }); + + it('returns undefined for Bluetooth connection error when not set', () => { + const account = createMockAccount(ExtendedKeyringTypes.ledger); + mockUseBluetooth.mockReturnValue({ + bluetoothOn: false, + bluetoothConnectionError: undefined, + }); + + const { result } = renderHook(() => useLedgerDeviceForAccount(account)); + + expect(result.current.bluetoothConnectionError).toBeUndefined(); + }); + }); + + describe('device scan errors', () => { + it('returns true when device scan encounters an error', () => { + const account = createMockAccount(ExtendedKeyringTypes.ledger); + mockUseBluetoothDevices.mockReturnValue({ + devices: [], + deviceScanError: true, + }); + + const { result } = renderHook(() => useLedgerDeviceForAccount(account)); + + expect(result.current.deviceScanError).toBe(true); + }); + + it('returns false when device scan completes without error', () => { + const account = createMockAccount(ExtendedKeyringTypes.ledger); + mockUseBluetoothDevices.mockReturnValue({ + devices: [], + deviceScanError: false, + }); + + const { result } = renderHook(() => useLedgerDeviceForAccount(account)); + + expect(result.current.deviceScanError).toBe(false); + }); + }); + + describe('hook dependencies', () => { + it('passes hasBluetoothPermissions to useBluetooth', () => { + const account = createMockAccount(ExtendedKeyringTypes.ledger); + mockUseBluetoothPermissions.mockReturnValue({ + hasBluetoothPermissions: true, + checkPermissions: mockCheckPermissions, + bluetoothPermissionError: undefined, + }); + + renderHook(() => useLedgerDeviceForAccount(account)); + + expect(mockUseBluetooth).toHaveBeenCalledWith(true); + }); + + it('passes hasBluetoothPermissions and bluetoothOn to useBluetoothDevices', () => { + const account = createMockAccount(ExtendedKeyringTypes.ledger); + mockUseBluetoothPermissions.mockReturnValue({ + hasBluetoothPermissions: true, + checkPermissions: mockCheckPermissions, + bluetoothPermissionError: undefined, + }); + mockUseBluetooth.mockReturnValue({ + bluetoothOn: true, + bluetoothConnectionError: false, + }); + + renderHook(() => useLedgerDeviceForAccount(account)); + + expect(mockUseBluetoothDevices).toHaveBeenCalledWith(true, true); + }); + + it('passes false values when permissions are denied', () => { + const account = createMockAccount(ExtendedKeyringTypes.ledger); + mockUseBluetoothPermissions.mockReturnValue({ + hasBluetoothPermissions: false, + checkPermissions: mockCheckPermissions, + bluetoothPermissionError: + BluetoothPermissionErrors.BluetoothAccessBlocked, + }); + + renderHook(() => useLedgerDeviceForAccount(account)); + + expect(mockUseBluetooth).toHaveBeenCalledWith(false); + }); + }); + + describe('complete error scenario', () => { + it('returns all error states when everything fails', () => { + const account = createMockAccount(ExtendedKeyringTypes.ledger); + mockUseBluetoothPermissions.mockReturnValue({ + hasBluetoothPermissions: false, + checkPermissions: mockCheckPermissions, + bluetoothPermissionError: + BluetoothPermissionErrors.BluetoothAccessBlocked, + }); + mockUseBluetooth.mockReturnValue({ + bluetoothOn: false, + bluetoothConnectionError: true, + }); + mockUseBluetoothDevices.mockReturnValue({ + devices: [], + deviceScanError: true, + }); + + const { result } = renderHook(() => useLedgerDeviceForAccount(account)); + + expect(result.current.ledgerDevice).toBeUndefined(); + expect(result.current.hasBluetoothPermissions).toBe(false); + expect(result.current.bluetoothOn).toBe(false); + expect(result.current.bluetoothPermissionError).toBe( + BluetoothPermissionErrors.BluetoothAccessBlocked, + ); + expect(result.current.bluetoothConnectionError).toBe(true); + expect(result.current.deviceScanError).toBe(true); + }); + }); + + describe('complete success scenario', () => { + it('returns all success states when everything works', () => { + const account = createMockAccount(ExtendedKeyringTypes.ledger); + const mockDevice = createMockDevice(); + mockUseBluetoothPermissions.mockReturnValue({ + hasBluetoothPermissions: true, + checkPermissions: mockCheckPermissions, + bluetoothPermissionError: undefined, + }); + mockUseBluetooth.mockReturnValue({ + bluetoothOn: true, + bluetoothConnectionError: false, + }); + mockUseBluetoothDevices.mockReturnValue({ + devices: [mockDevice], + deviceScanError: false, + }); + + const { result } = renderHook(() => useLedgerDeviceForAccount(account)); + + expect(result.current.ledgerDevice).toEqual(mockDevice); + expect(result.current.hasBluetoothPermissions).toBe(true); + expect(result.current.bluetoothOn).toBe(true); + expect(result.current.checkPermissions).toBe(mockCheckPermissions); + expect(result.current.bluetoothPermissionError).toBeUndefined(); + expect(result.current.bluetoothConnectionError).toBe(false); + expect(result.current.deviceScanError).toBe(false); + }); + }); + }); +}); diff --git a/app/components/hooks/Ledger/useLedgerDeviceForAccount.ts b/app/components/hooks/Ledger/useLedgerDeviceForAccount.ts new file mode 100644 index 00000000000..c18ffa0ae19 --- /dev/null +++ b/app/components/hooks/Ledger/useLedgerDeviceForAccount.ts @@ -0,0 +1,48 @@ +import { InternalAccount } from '@metamask/keyring-internal-api'; +import ExtendedKeyringTypes from '../../../constants/keyringTypes'; +import useBluetoothDevices from './useBluetoothDevices'; +import useBluetoothPermissions from '../useBluetoothPermissions'; +import useBluetooth from './useBluetooth'; + +/** + * Hook to get Ledger device information for an account + * Returns device info only for Ledger accounts + */ +const useLedgerDeviceForAccount = (selectedAccount: InternalAccount) => { + const isLedgerAccount = + selectedAccount?.metadata?.keyring?.type === ExtendedKeyringTypes.ledger; + + const { + hasBluetoothPermissions, + checkPermissions, + bluetoothPermissionError, + } = useBluetoothPermissions(); + + const { bluetoothOn, bluetoothConnectionError } = useBluetooth( + hasBluetoothPermissions, + ); + + const { devices, deviceScanError } = useBluetoothDevices( + hasBluetoothPermissions, + bluetoothOn, + ); + + return { + ledgerDevice: + isLedgerAccount && devices.length > 0 ? devices[0] : undefined, + hasBluetoothPermissions: isLedgerAccount + ? hasBluetoothPermissions + : undefined, + bluetoothOn: isLedgerAccount ? bluetoothOn : undefined, + checkPermissions: isLedgerAccount ? checkPermissions : undefined, + bluetoothPermissionError: isLedgerAccount + ? bluetoothPermissionError + : undefined, + bluetoothConnectionError: isLedgerAccount + ? bluetoothConnectionError + : undefined, + deviceScanError: isLedgerAccount ? deviceScanError : undefined, + }; +}; + +export default useLedgerDeviceForAccount; diff --git a/app/constants/bridge.ts b/app/constants/bridge.ts index f249feb8808..6a09a1b9bda 100644 --- a/app/constants/bridge.ts +++ b/app/constants/bridge.ts @@ -48,10 +48,10 @@ export const NETWORK_TO_SHORT_NETWORK_NAME_MAP: Record< [CHAIN_IDS.LINEA_MAINNET]: 'Linea', [CHAIN_IDS.POLYGON]: 'Polygon', [CHAIN_IDS.AVALANCHE]: 'Avalanche', - [CHAIN_IDS.BSC]: 'Binance Smart Chain', + [CHAIN_IDS.BSC]: 'BNB', [CHAIN_IDS.ARBITRUM]: 'Arbitrum', [CHAIN_IDS.OPTIMISM]: 'Optimism', - [CHAIN_IDS.ZKSYNC_ERA]: 'ZkSync Era', + [CHAIN_IDS.ZKSYNC_ERA]: 'zkSync', [CHAIN_IDS.BASE]: 'Base', [CHAIN_IDS.SEI]: 'Sei', [CHAIN_IDS.MONAD]: 'Monad', diff --git a/app/core/Analytics/MetaMetrics.events.ts b/app/core/Analytics/MetaMetrics.events.ts index 79633cf8af5..e2b237978d0 100644 --- a/app/core/Analytics/MetaMetrics.events.ts +++ b/app/core/Analytics/MetaMetrics.events.ts @@ -200,12 +200,19 @@ enum EVENT_NAME { // Key Management ANDROID_HARDWARE_KEYSTORE = 'Android Hardware Keystore', - // QR Hardware Wallet - CONNECT_HARDWARE_WALLET = 'Clicked Connect Hardware Wallet', - CONTINUE_QR_HARDWARE_WALLET = 'Clicked Continue QR Hardware Wallet', - CONNECT_HARDWARE_WALLET_SUCCESS = 'Connected Account with hardware wallet', - QR_HARDWARE_TRANSACTION_CANCELED = 'User canceled QR hardware transaction', - HARDWARE_WALLET_ERROR = 'Hardware wallet error', + // Common Hardware Wallet + ADD_HARDWARE_WALLET = 'Add Hardware Wallet Clicked', + CONNECT_HARDWARE_WALLET = 'Connect Hardware Wallet Clicked', + HARDWARE_WALLET_FOUND = 'Connect Hardware Wallet Device Found', + HARDWARE_WALLET_CONTINUE_CONNECTION = 'Connect Hardware Wallet Continue Button Clicked', + HARDWARE_WALLET_PERMISSION_REQUEST = 'Hardware Wallet Permission Request Clicked', + HARDWARE_WALLET_ACCOUNT_SELECTOR_OPEN = 'Connect Hardware Wallet Account Selector Viewed', + HARDWARE_WALLET_MARKETING = 'Hardware Wallet Marketing Button Clicked', + HARDWARE_WALLET_CONNECT_INSTRUCTIONS = 'Connect Hardware Wallet Instructions Viewed', + HARDWARE_WALLET_CONNECTION_RETRY = 'Hardware Wallet Connection Error Retry Button Clicked', + HARDWARE_WALLET_ADD_ACCOUNT = 'Hardware Wallet Account Connected', + HARDWARE_WALLET_FORGOTTEN = 'Hardware Wallet Forgotten', + HARDWARE_WALLET_ERROR = 'Hardware Wallet Connection Failed', // Tokens TOKEN_DETECTED = 'Token Detected', @@ -445,16 +452,6 @@ enum EVENT_NAME { // Edit account name ACCOUNT_RENAMED = 'Account Renamed', - //Ledger - CONNECT_LEDGER = 'Clicked Connect Ledger', - CONTINUE_LEDGER_HARDWARE_WALLET = 'Clicked Continue Ledger Hardware Wallet', - CONNECT_LEDGER_SUCCESS = 'Connected Account with hardware wallet', - LEDGER_HARDWARE_TRANSACTION_CANCELLED = 'User canceled Ledger hardware transaction', - LEDGER_HARDWARE_WALLET_ERROR = 'Ledger hardware wallet error', - - // common hardware wallet - HARDWARE_WALLET_FORGOTTEN = 'Hardware wallet forgotten', - // Remove an account ACCOUNT_REMOVED = 'Account removed', ACCOUNT_REMOVE_FAILED = 'Account remove failed', @@ -597,6 +594,24 @@ enum EVENT_NAME { QR_SCANNED = 'QR Scanned', } +export enum HARDWARE_WALLET_BUTTON_TYPE { + TUTORIAL = 'Tutorial', + PICKER = 'Picker', + BUY_NOW = 'Buy Now', + LEARN_MORE = 'Learn More', +} + +export enum HARDWARE_WALLET_DEVICE_TYPE { + LEDGER = 'Ledger', + Keystone = 'Keystone', + NgraveZero = 'Ngrave Zero', + AIRGAP_VAULT = 'AirGap Vault', + COOL_WALLET = 'Cool Wallet', + DCENT = 'DCent', + GRID_PLUS = 'Grid Plus', + IMToken = 'IMToken', +} + enum ACTIONS { // Navigation Drawer NAVIGATION_DRAWER = 'Navigation Drawer', @@ -634,6 +649,19 @@ enum ACTIONS { SELECTS_ANNOUCEMENTS_NOTIFICATIONS = 'Selects Annoucements Notifications', } +export enum PERMISSION_RESULT { + GRANTED = 'granted', + DENIED = 'denied', + BLOCKED = 'blocked', + LIMITED = 'limited', + UNAVAILABLE = 'unavailable', +} + +export enum PERMISSION_TYPE { + CAMERA = 'Camera', + BLUETOOTH = 'Bluetooth', +} + const events = { APP_OPENED: generateOpt(EVENT_NAME.APP_OPENED), ERROR_SCREEN_VIEWED: generateOpt(EVENT_NAME.ERROR_SCREEN_VIEWED), @@ -833,17 +861,34 @@ const events = { EVENT_NAME.REVEAL_PRIVATE_KEY_COMPLETED, ), ANDROID_HARDWARE_KEYSTORE: generateOpt(EVENT_NAME.ANDROID_HARDWARE_KEYSTORE), + + // Hardware Wallet + ADD_HARDWARE_WALLET: generateOpt(EVENT_NAME.ADD_HARDWARE_WALLET), CONNECT_HARDWARE_WALLET: generateOpt(EVENT_NAME.CONNECT_HARDWARE_WALLET), - CONTINUE_QR_HARDWARE_WALLET: generateOpt( - EVENT_NAME.CONTINUE_QR_HARDWARE_WALLET, + + HARDWARE_WALLET_MARKETING: generateOpt(EVENT_NAME.HARDWARE_WALLET_MARKETING), + HARDWARE_WALLET_PERMISSION_REQUEST: generateOpt( + EVENT_NAME.HARDWARE_WALLET_PERMISSION_REQUEST, + ), + HARDWARE_WALLET_CONNECT_INSTRUCTIONS: generateOpt( + EVENT_NAME.HARDWARE_WALLET_CONNECT_INSTRUCTIONS, + ), + HARDWARE_WALLET_FOUND: generateOpt(EVENT_NAME.HARDWARE_WALLET_FOUND), + HARDWARE_WALLET_CONTINUE_CONNECTION: generateOpt( + EVENT_NAME.HARDWARE_WALLET_CONTINUE_CONNECTION, ), - CONNECT_HARDWARE_WALLET_SUCCESS: generateOpt( - EVENT_NAME.CONNECT_HARDWARE_WALLET_SUCCESS, + HARDWARE_WALLET_CONNECTION_RETRY: generateOpt( + EVENT_NAME.HARDWARE_WALLET_CONNECTION_RETRY, ), - QR_HARDWARE_TRANSACTION_CANCELED: generateOpt( - EVENT_NAME.QR_HARDWARE_TRANSACTION_CANCELED, + HARDWARE_WALLET_ACCOUNT_SELECTOR_OPEN: generateOpt( + EVENT_NAME.HARDWARE_WALLET_ACCOUNT_SELECTOR_OPEN, ), + HARDWARE_WALLET_ADD_ACCOUNT: generateOpt( + EVENT_NAME.HARDWARE_WALLET_ADD_ACCOUNT, + ), + HARDWARE_WALLET_FORGOTTEN: generateOpt(EVENT_NAME.HARDWARE_WALLET_FORGOTTEN), HARDWARE_WALLET_ERROR: generateOpt(EVENT_NAME.HARDWARE_WALLET_ERROR), + TOKEN_DETECTED: generateOpt(EVENT_NAME.TOKEN_DETECTED), TOKEN_IMPORT_CLICKED: generateOpt(EVENT_NAME.TOKEN_IMPORT_CLICKED), TOKEN_IMPORT_CANCELED: generateOpt(EVENT_NAME.TOKEN_IMPORT_CANCELED), @@ -1098,20 +1143,6 @@ const events = { // Experimental Settings SETTINGS_SECURITY_ALERTS_ENABLED: generateOpt(EVENT_NAME.SETTINGS_UPDATED), - // Ledger - CONNECT_LEDGER: generateOpt(EVENT_NAME.CONNECT_LEDGER), - CONTINUE_LEDGER_HARDWARE_WALLET: generateOpt( - EVENT_NAME.CONTINUE_LEDGER_HARDWARE_WALLET, - ), - CONNECT_LEDGER_SUCCESS: generateOpt(EVENT_NAME.CONNECT_LEDGER_SUCCESS), - LEDGER_HARDWARE_TRANSACTION_CANCELLED: generateOpt( - EVENT_NAME.LEDGER_HARDWARE_TRANSACTION_CANCELLED, - ), - LEDGER_HARDWARE_WALLET_ERROR: generateOpt( - EVENT_NAME.LEDGER_HARDWARE_WALLET_ERROR, - ), - HARDWARE_WALLET_FORGOTTEN: generateOpt(EVENT_NAME.HARDWARE_WALLET_FORGOTTEN), - // Remove an account ACCOUNT_REMOVED: generateOpt(EVENT_NAME.ACCOUNT_REMOVED), ACCOUNT_REMOVE_FAILED: generateOpt(EVENT_NAME.ACCOUNT_REMOVE_FAILED), diff --git a/app/core/Engine/messengers/profile-metrics-controller-messenger.ts b/app/core/Engine/messengers/profile-metrics-controller-messenger.ts index ab88f2c4ec0..c96d9f5f310 100644 --- a/app/core/Engine/messengers/profile-metrics-controller-messenger.ts +++ b/app/core/Engine/messengers/profile-metrics-controller-messenger.ts @@ -35,6 +35,7 @@ export function getProfileMetricsControllerMessenger(messenger: RootMessenger) { ], events: [ 'AccountsController:accountAdded', + 'AccountsController:accountRemoved', 'KeyringController:lock', 'KeyringController:unlock', ], diff --git a/app/core/HardwareWallets/analytics.test.ts b/app/core/HardwareWallets/analytics.test.ts new file mode 100644 index 00000000000..7727547adf8 --- /dev/null +++ b/app/core/HardwareWallets/analytics.test.ts @@ -0,0 +1,216 @@ +import { KeyringTypes } from '@metamask/keyring-controller'; +import { getConnectedDevicesCount } from './analytics'; + +jest.mock('../Engine', () => ({ + __esModule: true, + default: { + context: { + KeyringController: { + getKeyringsByType: jest.fn(), + }, + }, + }, +})); + +import Engine from '../Engine'; + +describe('getConnectedDevicesCount', () => { + const mockKeyringController = Engine.context.KeyringController; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns the total count of all hardware wallets', async () => { + (mockKeyringController.getKeyringsByType as jest.Mock).mockImplementation( + (type: KeyringTypes) => { + switch (type) { + case KeyringTypes.ledger: + return Promise.resolve([ + { type: 'ledger', getAccounts: () => Promise.resolve(['0x123']) }, + ]); + case KeyringTypes.qr: + return Promise.resolve([ + { type: 'qr', getAccounts: () => Promise.resolve(['0x456']) }, + ]); + default: + return Promise.resolve([]); + } + }, + ); + + const result = await getConnectedDevicesCount(); + + expect(result).toBe(2); + }); + + it('returns 0 when all keyring types have no devices', async () => { + (mockKeyringController.getKeyringsByType as jest.Mock).mockResolvedValue( + [], + ); + + const result = await getConnectedDevicesCount(); + + expect(result).toBe(0); + }); + + it('handles rejected promises gracefully and counts only successful ones', async () => { + (mockKeyringController.getKeyringsByType as jest.Mock).mockImplementation( + (type: KeyringTypes) => { + switch (type) { + case KeyringTypes.ledger: + return Promise.reject(new Error('Ledger not available')); + case KeyringTypes.qr: + return Promise.resolve([ + { type: 'qr', getAccounts: () => Promise.resolve(['0x456']) }, + ]); + default: + return Promise.resolve([]); + } + }, + ); + + const result = await getConnectedDevicesCount(); + + expect(result).toBe(1); + }); + + it('handles all promises being rejected', async () => { + (mockKeyringController.getKeyringsByType as jest.Mock).mockRejectedValue( + new Error('Keyring error'), + ); + + const result = await getConnectedDevicesCount(); + + expect(result).toBe(0); + }); + + it('returns 0 when keyrings have no accounts', async () => { + (mockKeyringController.getKeyringsByType as jest.Mock).mockImplementation( + (type: KeyringTypes) => { + switch (type) { + case KeyringTypes.ledger: + return Promise.resolve([ + { type: 'ledger', getAccounts: () => Promise.resolve([]) }, + ]); + case KeyringTypes.qr: + return Promise.resolve([ + { type: 'qr', getAccounts: () => Promise.resolve([]) }, + ]); + default: + return Promise.resolve([]); + } + }, + ); + + const result = await getConnectedDevicesCount(); + + expect(result).toBe(0); + }); + + it('returns 0 when getAccounts returns non-array', async () => { + (mockKeyringController.getKeyringsByType as jest.Mock).mockImplementation( + (type: KeyringTypes) => { + switch (type) { + case KeyringTypes.ledger: + return Promise.resolve([ + { type: 'ledger', getAccounts: () => Promise.resolve(null) }, + ]); + case KeyringTypes.qr: + return Promise.resolve([ + { type: 'qr', getAccounts: () => Promise.resolve(undefined) }, + ]); + default: + return Promise.resolve([]); + } + }, + ); + + const result = await getConnectedDevicesCount(); + + expect(result).toBe(0); + }); + + it('counts all hardware wallet types with accounts', async () => { + (mockKeyringController.getKeyringsByType as jest.Mock).mockImplementation( + (type: KeyringTypes) => { + switch (type) { + case KeyringTypes.ledger: + return Promise.resolve([ + { type: 'ledger', getAccounts: () => Promise.resolve(['0x123']) }, + { type: 'ledger', getAccounts: () => Promise.resolve(['0x789']) }, + ]); + case KeyringTypes.qr: + return Promise.resolve([ + { type: 'qr', getAccounts: () => Promise.resolve(['0x456']) }, + ]); + case KeyringTypes.lattice: + return Promise.resolve([ + { + type: 'lattice', + getAccounts: () => Promise.resolve(['0xabc']), + }, + ]); + case KeyringTypes.trezor: + return Promise.resolve([ + { type: 'trezor', getAccounts: () => Promise.resolve(['0xdef']) }, + ]); + case KeyringTypes.oneKey: + return Promise.resolve([ + { type: 'oneKey', getAccounts: () => Promise.resolve(['0x111']) }, + ]); + default: + return Promise.resolve([]); + } + }, + ); + + const result = await getConnectedDevicesCount(); + + expect(result).toBe(6); + }); + + it('filters out keyrings with null accounts', async () => { + (mockKeyringController.getKeyringsByType as jest.Mock).mockImplementation( + (type: KeyringTypes) => { + switch (type) { + case KeyringTypes.ledger: + return Promise.resolve([ + { type: 'ledger', getAccounts: () => Promise.resolve(['0x123']) }, + { type: 'ledger', getAccounts: () => Promise.resolve(null) }, + ]); + case KeyringTypes.qr: + return Promise.resolve([ + { type: 'qr', getAccounts: () => Promise.resolve(['0x456']) }, + ]); + default: + return Promise.resolve([]); + } + }, + ); + + const result = await getConnectedDevicesCount(); + + expect(result).toBe(2); + }); + + it('filters out keyrings with undefined accounts', async () => { + (mockKeyringController.getKeyringsByType as jest.Mock).mockImplementation( + (type: KeyringTypes) => { + switch (type) { + case KeyringTypes.ledger: + return Promise.resolve([ + { type: 'ledger', getAccounts: () => Promise.resolve(['0x123']) }, + { type: 'ledger', getAccounts: () => Promise.resolve(undefined) }, + ]); + default: + return Promise.resolve([]); + } + }, + ); + + const result = await getConnectedDevicesCount(); + + expect(result).toBe(1); + }); +}); diff --git a/app/core/HardwareWallets/analytics.ts b/app/core/HardwareWallets/analytics.ts new file mode 100644 index 00000000000..77a0576029b --- /dev/null +++ b/app/core/HardwareWallets/analytics.ts @@ -0,0 +1,30 @@ +import { KeyringTypes } from '@metamask/keyring-controller'; +import Engine from '../Engine'; +import { Json, Keyring } from '@metamask/utils'; + +export const getConnectedDevicesCount = async (): Promise => { + const { KeyringController } = Engine.context; + + const keyringResults = await Promise.allSettled([ + KeyringController.getKeyringsByType(KeyringTypes.ledger), + KeyringController.getKeyringsByType(KeyringTypes.qr), + KeyringController.getKeyringsByType(KeyringTypes.lattice), + KeyringController.getKeyringsByType(KeyringTypes.trezor), + KeyringController.getKeyringsByType(KeyringTypes.oneKey), + ]); + + let count = 0; + for (const result of keyringResults) { + if (result.status === 'fulfilled' && Array.isArray(result.value)) { + // TODO: use type from keyring-utils + const keyrings = result.value as unknown as Keyring[]; + for (const keyring of keyrings) { + const accounts = await keyring.getAccounts(); + if (Array.isArray(accounts) && accounts.length > 0) { + count++; + } + } + } + } + return count; +}; diff --git a/app/core/QrKeyring/QrKeyring.ts b/app/core/QrKeyring/QrKeyring.ts index 077897912b7..3cd0cbadeed 100644 --- a/app/core/QrKeyring/QrKeyring.ts +++ b/app/core/QrKeyring/QrKeyring.ts @@ -21,7 +21,7 @@ export const withQrKeyring = async ( metadata: KeyringMetadata; }) => Promise, ): Promise => - Engine.context.KeyringController.withKeyring( + await Engine.context.KeyringController.withKeyring( { type: ExtendedKeyringTypes.qr }, operation, // TODO: Refactor this to stop creating the keyring on-demand diff --git a/app/images/image-icons.js b/app/images/image-icons.js index 91602622639..7d41e1dda85 100644 --- a/app/images/image-icons.js +++ b/app/images/image-icons.js @@ -52,6 +52,7 @@ import INJECTIVE from './injective-native.png'; import PLASMA from './plasma-native.png'; import CRONOS from './cronos.png'; import HYPE from './hyperevm.png'; +import X_LAYER from './x-layer-native.png'; /// BEGIN:ONLY_INCLUDE_IF(tron) import TRON from './tron-logo.png'; /// END:ONLY_INCLUDE_IF @@ -118,4 +119,5 @@ export default { XPL: PLASMA, CRO: CRONOS, HYPE, + OKB: X_LAYER, }; diff --git a/app/images/x-layer-native.png b/app/images/x-layer-native.png new file mode 100644 index 00000000000..863912330eb Binary files /dev/null and b/app/images/x-layer-native.png differ diff --git a/app/images/x-layer.png b/app/images/x-layer.png new file mode 100644 index 00000000000..661f1b5b5ae Binary files /dev/null and b/app/images/x-layer.png differ diff --git a/app/util/hardwareWallet/deviceNameUtils.test.ts b/app/util/hardwareWallet/deviceNameUtils.test.ts new file mode 100644 index 00000000000..2b7cba11167 --- /dev/null +++ b/app/util/hardwareWallet/deviceNameUtils.test.ts @@ -0,0 +1,205 @@ +import { + sanitizeDeviceName, + ledgerDeviceUUIDToModelName, + LEDGER_DEVICE_BLE_UUIDS_TO_MODEL_NAME, +} from './deviceNameUtils'; + +describe('sanitizeDeviceName', () => { + describe('Ledger Flex devices', () => { + it('returns "Ledger Flex" when device name is "Ledger Flex"', () => { + const result = sanitizeDeviceName('Ledger Flex'); + + expect(result).toBe('Ledger Flex'); + }); + + it('returns "Ledger Flex" when device name includes version number', () => { + const result = sanitizeDeviceName('Ledger Flex 1.0.0'); + + expect(result).toBe('Ledger Flex'); + }); + + it('returns "Ledger Flex" when device name includes additional text', () => { + const result = sanitizeDeviceName('Ledger Flex Plus Edition'); + + expect(result).toBe('Ledger Flex'); + }); + }); + + describe('Ledger Nano X devices', () => { + it('returns "Ledger Nano X" when device name is "Ledger Nano X"', () => { + const result = sanitizeDeviceName('Ledger Nano X'); + + expect(result).toBe('Ledger Nano X'); + }); + + it('returns "Ledger Nano X" when device name includes version number', () => { + const result = sanitizeDeviceName('Ledger Nano X 2.1.0'); + + expect(result).toBe('Ledger Nano X'); + }); + + it('returns "Ledger Nano X" when device name includes additional text', () => { + const result = sanitizeDeviceName('Ledger Nano X Special Edition'); + + expect(result).toBe('Ledger Nano X'); + }); + }); + + describe('Ledger Nano devices', () => { + it('returns "Ledger Nano" when device name is "Ledger Nano"', () => { + const result = sanitizeDeviceName('Ledger Nano'); + + expect(result).toBe('Ledger Nano'); + }); + + it('returns "Ledger Nano" when device name is "Ledger Nano S"', () => { + const result = sanitizeDeviceName('Ledger Nano S'); + + expect(result).toBe('Ledger Nano'); + }); + + it('returns "Ledger Nano" when device name is "Ledger Nano S Plus"', () => { + const result = sanitizeDeviceName('Ledger Nano S Plus'); + + expect(result).toBe('Ledger Nano'); + }); + + it('returns "Ledger Nano" when device name includes version number', () => { + const result = sanitizeDeviceName('Ledger Nano 1.6.1'); + + expect(result).toBe('Ledger Nano'); + }); + }); + + describe('non-Ledger devices', () => { + it('returns original name for Keystone devices', () => { + const result = sanitizeDeviceName('Keystone Pro'); + + expect(result).toBe('Keystone Pro'); + }); + + it('returns original name for AirGap Vault devices', () => { + const result = sanitizeDeviceName('AirGap Vault'); + + expect(result).toBe('AirGap Vault'); + }); + + it('returns original name for other QR-based devices', () => { + const result = sanitizeDeviceName('CoolWallet'); + + expect(result).toBe('CoolWallet'); + }); + }); + + describe('edge cases', () => { + it('returns empty string when device name is undefined', () => { + const result = sanitizeDeviceName(undefined); + + expect(result).toBe(''); + }); + + it('returns empty string when device name is empty string', () => { + const result = sanitizeDeviceName(''); + + expect(result).toBe(''); + }); + + it('returns original name when device name does not match known patterns', () => { + const result = sanitizeDeviceName('Unknown Device'); + + expect(result).toBe('Unknown Device'); + }); + }); + + describe('priority order', () => { + it('matches "Ledger Flex" before "Ledger Nano X"', () => { + const result = sanitizeDeviceName('Ledger Flex X'); + + expect(result).toBe('Ledger Flex'); + }); + + it('matches "Ledger Nano X" before "Ledger Nano"', () => { + const result = sanitizeDeviceName('Ledger Nano X S'); + + expect(result).toBe('Ledger Nano X'); + }); + }); +}); + +describe('ledgerDeviceUUIDToModelName', () => { + describe('known device UUIDs', () => { + it('returns UUID for Ledger Nano X device', () => { + const uuid = LEDGER_DEVICE_BLE_UUIDS_TO_MODEL_NAME.LEDGER_NANO_X; + + const result = ledgerDeviceUUIDToModelName(uuid); + + expect(result).toBe('13d63400-2c97-0004-0000-4c6564676572'); + }); + + it('returns UUID for Ledger Nano STAx device', () => { + const uuid = LEDGER_DEVICE_BLE_UUIDS_TO_MODEL_NAME.LEDGER_NANO_STAx; + + const result = ledgerDeviceUUIDToModelName(uuid); + + expect(result).toBe('13d63400-2c97-6004-0000-4c6564676572'); + }); + + it('returns UUID for Ledger Flex device', () => { + const uuid = LEDGER_DEVICE_BLE_UUIDS_TO_MODEL_NAME.LEDGER_FLEX; + + const result = ledgerDeviceUUIDToModelName(uuid); + + expect(result).toBe('13d63400-2c97-3004-0000-4c6564676572'); + }); + }); + + describe('unknown device UUIDs', () => { + it('returns "Unknown" for unrecognized UUID', () => { + const unknownUuid = '00000000-0000-0000-0000-000000000000'; + + const result = ledgerDeviceUUIDToModelName(unknownUuid); + + expect(result).toBe('Unknown'); + }); + + it('returns "Unknown" for random string', () => { + const randomString = 'not-a-valid-uuid'; + + const result = ledgerDeviceUUIDToModelName(randomString); + + expect(result).toBe('Unknown'); + }); + + it('returns "Unknown" for empty string', () => { + const result = ledgerDeviceUUIDToModelName(''); + + expect(result).toBe('Unknown'); + }); + }); + + describe('edge cases', () => { + it('returns "Unknown" for partial UUID match', () => { + const partialUuid = '13d63400-2c97'; + + const result = ledgerDeviceUUIDToModelName(partialUuid); + + expect(result).toBe('Unknown'); + }); + + it('returns "Unknown" for UUID with different casing', () => { + const uppercaseUuid = '13D63400-2C97-0004-0000-4C6564676572'; + + const result = ledgerDeviceUUIDToModelName(uppercaseUuid); + + expect(result).toBe('Unknown'); + }); + + it('returns "Unknown" for UUID with extra whitespace', () => { + const uuidWithSpace = ' 13d63400-2c97-0004-0000-4c6564676572 '; + + const result = ledgerDeviceUUIDToModelName(uuidWithSpace); + + expect(result).toBe('Unknown'); + }); + }); +}); diff --git a/app/util/hardwareWallet/deviceNameUtils.ts b/app/util/hardwareWallet/deviceNameUtils.ts new file mode 100644 index 00000000000..b5a0eadda1c --- /dev/null +++ b/app/util/hardwareWallet/deviceNameUtils.ts @@ -0,0 +1,46 @@ +/** + * Sanitizes device names for analytics tracking by removing any additional text + * after known base device names. This ensures consistent device_model tracking + * across different firmware versions or device variants. + * + * @param deviceName - The raw device name string from the hardware device + * @returns The sanitized device name with any suffix removed for known devices + * + * @example + * sanitizeDeviceName('Ledger Nano X 2.1.0') // returns 'Ledger Nano X' + * sanitizeDeviceName('Ledger Flex 1.0') // returns 'Ledger Flex' + * sanitizeDeviceName('Ledger Nano S Plus') // returns 'Ledger Nano' (strips "S Plus") + * sanitizeDeviceName('Keystone Pro') // returns 'Keystone Pro' (unchanged) + */ +export const sanitizeDeviceName = (deviceName: string | undefined): string => { + if (!deviceName) { + return ''; + } + + const baseDeviceNames = ['Ledger Flex', 'Ledger Nano X', 'Ledger Nano']; + + for (const baseName of baseDeviceNames) { + if (deviceName.startsWith(baseName)) { + return baseName; + } + } + + return deviceName; +}; + +export enum LEDGER_DEVICE_BLE_UUIDS_TO_MODEL_NAME { + LEDGER_NANO_X = '13d63400-2c97-0004-0000-4c6564676572', + LEDGER_NANO_STAx = '13d63400-2c97-6004-0000-4c6564676572', + LEDGER_FLEX = '13d63400-2c97-3004-0000-4c6564676572', +} + +export const ledgerDeviceUUIDToModelName = (deviceUUID: string): string => { + const deviceModelName = Object.values( + LEDGER_DEVICE_BLE_UUIDS_TO_MODEL_NAME, + ).find((uuid) => uuid === deviceUUID); + if (deviceModelName) { + return deviceModelName; + } + + return 'Unknown'; +}; diff --git a/app/util/networks/customNetworks.tsx b/app/util/networks/customNetworks.tsx index d10619a3665..05752193d1c 100644 --- a/app/util/networks/customNetworks.tsx +++ b/app/util/networks/customNetworks.tsx @@ -378,6 +378,7 @@ export const NETWORK_CHAIN_ID: { readonly PLASMA: '0x2611'; readonly CRONOS: '0x19'; readonly HYPE: '0x3e7'; + readonly X_LAYER: '0xc4'; } & typeof CHAIN_IDS = { FLARE_MAINNET: '0xe', SONGBIRD_TESTNET: '0x13', @@ -413,6 +414,7 @@ export const NETWORK_CHAIN_ID: { PLASMA: '0x2611', CRONOS: '0x19', HYPE: '0x3e7', + X_LAYER: '0xc4', ...CHAIN_IDS, }; @@ -454,4 +456,5 @@ export const CustomNetworkImgMapping: Record = { [NETWORK_CHAIN_ID.PLASMA]: require('../../images/plasma.png'), [NETWORK_CHAIN_ID.CRONOS]: require('../../images/cronos.png'), [NETWORK_CHAIN_ID.HYPE]: require('../../images/hyperevm.png'), + [NETWORK_CHAIN_ID.X_LAYER]: require('../../images/x-layer.png'), }; diff --git a/e2e/tools/e2e-ai-analyzer/ai-tools/handlers/grep-codebase.ts b/e2e/tools/e2e-ai-analyzer/ai-tools/handlers/grep-codebase.ts index 848c714502c..adb8b3eb4c1 100644 --- a/e2e/tools/e2e-ai-analyzer/ai-tools/handlers/grep-codebase.ts +++ b/e2e/tools/e2e-ai-analyzer/ai-tools/handlers/grep-codebase.ts @@ -40,7 +40,7 @@ export function handleGrepCodebase(input: ToolInput, baseDir: string): string { // Use grep -E for extended regex (supports |, +, ?, etc.) // -E: extended regex, -r: recursive, -n: line numbers, -i: case insensitive - const command = `grep -Erni --include="${filePattern}" "${pattern}" app/ | head -${maxResults}`; + const command = `grep -Erni --include="${filePattern}" --exclude-dir=node_modules "${pattern}" app/ e2e/ .github/ scripts/ 2>/dev/null | head -${maxResults}`; const result = execSync(command, { encoding: 'utf-8', diff --git a/e2e/tools/e2e-ai-analyzer/config.ts b/e2e/tools/e2e-ai-analyzer/config.ts index 51f4a15b317..ddf59923b53 100644 --- a/e2e/tools/e2e-ai-analyzer/config.ts +++ b/e2e/tools/e2e-ai-analyzer/config.ts @@ -12,7 +12,7 @@ export const CLAUDE_CONFIG = { * Claude model to use for analysis * - See available models: https://docs.anthropic.com/en/docs/about-claude/models */ - model: 'claude-sonnet-4-5-20250929' as const, + model: 'claude-opus-4-5-20251101' as const, /** * Maximum tokens allowed for the AI response. Controls the length of reasoning and tool calls @@ -41,7 +41,7 @@ export const CLAUDE_CONFIG = { * - Iteration 2: AI investigates dependencies (2-3 tool calls) * - Iteration 3: AI calls finalize tool (e.g., finalize_tag_selection) → DONE */ - maxIterations: 15, + maxIterations: 20, }; /** diff --git a/e2e/tools/e2e-ai-analyzer/modes/select-tags/prompt.ts b/e2e/tools/e2e-ai-analyzer/modes/select-tags/prompt.ts index 35f4b3fc508..63ed0c09db7 100644 --- a/e2e/tools/e2e-ai-analyzer/modes/select-tags/prompt.ts +++ b/e2e/tools/e2e-ai-analyzer/modes/select-tags/prompt.ts @@ -67,7 +67,7 @@ export function buildTaskPrompt( } const instruction = `Analyze the changed files and the impacted codebase to select the E2E test tags to run so the changes can be verified safely with minimal risk.`; - const tagsSection = `AVAILABLE TEST TAGS (select from these and don't search for additional tags):\n${tagCoverageList}`; + const tagsSection = `AVAILABLE TEST TAGS (these are the ONLY valid tags - do NOT search for tags.ts or any tags file, they are already provided here):\n${tagCoverageList}`; const filesSection = `CHANGED FILES (${ allFiles.length } total):\n${fileList.join('\n')}`; diff --git a/package.json b/package.json index 8563c1c608c..9acd8f73ef9 100644 --- a/package.json +++ b/package.json @@ -477,7 +477,7 @@ "zxcvbn": "4.4.2" }, "devDependencies": { - "@anthropic-ai/sdk": "^0.63.1", + "@anthropic-ai/sdk": "^0.71.0", "@babel/core": "^7.25.2", "@babel/eslint-parser": "^7.25.1", "@babel/preset-env": "^7.25.3", diff --git a/yarn.lock b/yarn.lock index cea8774d4f1..83d636b0ae2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -41,9 +41,9 @@ __metadata: languageName: node linkType: hard -"@anthropic-ai/sdk@npm:^0.63.1": - version: 0.63.1 - resolution: "@anthropic-ai/sdk@npm:0.63.1" +"@anthropic-ai/sdk@npm:^0.71.0": + version: 0.71.0 + resolution: "@anthropic-ai/sdk@npm:0.71.0" dependencies: json-schema-to-ts: "npm:^3.1.1" peerDependencies: @@ -53,7 +53,7 @@ __metadata: optional: true bin: anthropic-ai-sdk: bin/cli - checksum: 10/9ce43fd06a7d3fb62e5f51b78cbf74681ed9c5a5770127b2be62a3b392f2e6fa4c9a8fe38e6f43d33198f79ba37a6ab62568bb77fd11bf9842f5a4b0cb4f02db + checksum: 10/2c4da293d11e0284fe16f909fb59cbaaabe62014cf5f058e225697e4c0bdc029c05171a7a9d9449cb5535abb4d31d653ab96e0edd2443172fa1b272cfd8afa04 languageName: node linkType: hard @@ -8954,7 +8954,7 @@ __metadata: "@metamask/network-enablement-controller@patch:@metamask/network-enablement-controller@npm%3A3.1.0#~/.yarn/patches/@metamask-network-enablement-controller-npm-3.1.0-1c0cfefdc3.patch": version: 3.1.0 - resolution: "@metamask/network-enablement-controller@patch:@metamask/network-enablement-controller@npm%3A3.1.0#~/.yarn/patches/@metamask-network-enablement-controller-npm-3.1.0-1c0cfefdc3.patch::version=3.1.0&hash=0805d0" + resolution: "@metamask/network-enablement-controller@patch:@metamask/network-enablement-controller@npm%3A3.1.0#~/.yarn/patches/@metamask-network-enablement-controller-npm-3.1.0-1c0cfefdc3.patch::version=3.1.0&hash=e5166e" dependencies: "@metamask/base-controller": "npm:^9.0.0" "@metamask/controller-utils": "npm:^11.14.1" @@ -8966,7 +8966,7 @@ __metadata: "@metamask/multichain-network-controller": ^2.0.0 "@metamask/network-controller": ^25.0.0 "@metamask/transaction-controller": ^61.0.0 - checksum: 10/97b00477ec1550b19c7863991cd377ae73936ac466faf149cd2903325a28462f50647cdce9cd9c7aee4395e6cbf0aec8d5417012942c595d8aa3bb69682e8dc9 + checksum: 10/15d3c51ee3ec9bd2c914862915ff444a21626aaa4df15a8df3dc22df1204245e2789786af713f467fdd1b2dfded255ece55973f436c8910ad8c6bfa4439f6e95 languageName: node linkType: hard @@ -35927,7 +35927,7 @@ __metadata: version: 0.0.0-use.local resolution: "metamask@workspace:." dependencies: - "@anthropic-ai/sdk": "npm:^0.63.1" + "@anthropic-ai/sdk": "npm:^0.71.0" "@babel/core": "npm:^7.25.2" "@babel/eslint-parser": "npm:^7.25.1" "@babel/preset-env": "npm:^7.25.3"