diff --git a/app/components/Nav/App/index.js b/app/components/Nav/App/index.js index cc02c0d9f3f..04af644e3f6 100644 --- a/app/components/Nav/App/index.js +++ b/app/components/Nav/App/index.js @@ -72,8 +72,6 @@ import ImportPrivateKey from '../../Views/ImportPrivateKey'; import ImportPrivateKeySuccess from '../../Views/ImportPrivateKeySuccess'; import ConnectQRHardware from '../../Views/ConnectQRHardware'; import SelectHardwareWallet from '../../Views/ConnectHardware/SelectHardware'; -import LedgerAccountInfo from '../../Views/LedgerAccountInfo'; -import LedgerConnect from '../../Views/LedgerConnect'; import { AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS } from '../../../constants/error'; import { UpdateNeeded } from '../../../components/UI/UpdateNeeded'; import { EnableAutomaticSecurityChecksModal } from '../../../components/UI/EnableAutomaticSecurityChecksModal'; @@ -111,6 +109,7 @@ import { MetaMetrics } from '../../../core/Analytics'; import trackErrorAsAnalytics from '../../../util/metrics/TrackError/trackErrorAsAnalytics'; import generateDeviceAnalyticsMetaData from '../../../util/metrics/DeviceAnalyticsMetaData/generateDeviceAnalyticsMetaData'; import generateUserSettingsAnalyticsMetaData from '../../../util/metrics/UserSettingsAnalyticsMetaData/generateUserProfileAnalyticsMetaData'; +import LedgerSelectAccount from '../../Views/LedgerSelectAccount'; import OnboardingSuccess from '../../Views/OnboardingSuccess'; import DefaultSettings from '../../Views/OnboardingSuccess/DefaultSettings'; import BasicFunctionalityModal from '../../UI/BasicFunctionality/BasicFunctionalityModal/BasicFunctionalityModal'; @@ -473,6 +472,7 @@ const App = ({ userLoggedIn }) => { } } } + initSDKConnect() .then(() => { queueOfHandleDeeplinkFunctions.current.forEach((func) => func()); @@ -741,8 +741,16 @@ const App = ({ userLoggedIn }) => { ); const LedgerConnectFlow = () => ( - - + + ); @@ -753,7 +761,6 @@ const App = ({ userLoggedIn }) => { component={SelectHardwareWallet} options={SelectHardwareWallet.navigationOptions} /> - ); diff --git a/app/components/UI/BlockingActionModal/__snapshots__/index.test.tsx.snap b/app/components/UI/BlockingActionModal/__snapshots__/index.test.tsx.snap index 877f2d6e738..757058bdf97 100644 --- a/app/components/UI/BlockingActionModal/__snapshots__/index.test.tsx.snap +++ b/app/components/UI/BlockingActionModal/__snapshots__/index.test.tsx.snap @@ -16,7 +16,7 @@ exports[`BlockingActionModal should render correctly 1`] = ` deviceHeight={null} deviceWidth={null} hasBackdrop={true} - hideModalContentWhileAnimating={false} + hideModalContentWhileAnimating={true} isVisible={true} onBackButtonPress={[Function]} onBackdropPress={[Function]} diff --git a/app/components/UI/BlockingActionModal/index.js b/app/components/UI/BlockingActionModal/index.js index a4b03ec8ed6..b126438edf0 100644 --- a/app/components/UI/BlockingActionModal/index.js +++ b/app/components/UI/BlockingActionModal/index.js @@ -33,6 +33,7 @@ export default function BlockingActionModal({ children, modalVisible, isLoadingAction, + onAnimationCompleted, }) { const { colors } = useTheme(); const styles = createStyles(colors); @@ -43,6 +44,8 @@ export default function BlockingActionModal({ backdropOpacity={1} isVisible={modalVisible} style={styles.modal} + onModalShow={onAnimationCompleted} + hideModalContentWhileAnimating > @@ -69,4 +72,6 @@ BlockingActionModal.propTypes = { * Content to display above the action buttons */ children: PropTypes.node, + + onAnimationCompleted: PropTypes.func, }; diff --git a/app/components/UI/HardwareWallet/AccountSelector/index.tsx b/app/components/UI/HardwareWallet/AccountSelector/index.tsx index ed5cc1a6725..ab2378c90c0 100644 --- a/app/components/UI/HardwareWallet/AccountSelector/index.tsx +++ b/app/components/UI/HardwareWallet/AccountSelector/index.tsx @@ -19,7 +19,7 @@ interface ISelectQRAccountsProps { selectedAccounts: string[]; nextPage: () => void; prevPage: () => void; - toggleAccount: (index: number) => void; + onCheck?: (index: number) => void; onUnlock: (accountIndex: number[]) => void; onForget: () => void; title: string; @@ -30,7 +30,7 @@ const AccountSelector = (props: ISelectQRAccountsProps) => { accounts, prevPage, nextPage, - toggleAccount, + onCheck, selectedAccounts, onForget, onUnlock, @@ -69,9 +69,12 @@ const AccountSelector = (props: ISelectQRAccountsProps) => { prev.has(index) ? prev.delete(index) : prev.add(index); return new Set(prev); }); - toggleAccount(index); + + if (onCheck) { + onCheck(index); + } }, - [toggleAccount], + [onCheck], ); return ( diff --git a/app/components/UI/HardwareWallet/AccountSelector/styles.tsx b/app/components/UI/HardwareWallet/AccountSelector/styles.tsx index 306d52a971e..9d8463fa895 100644 --- a/app/components/UI/HardwareWallet/AccountSelector/styles.tsx +++ b/app/components/UI/HardwareWallet/AccountSelector/styles.tsx @@ -50,7 +50,7 @@ export const createStyle = (colors: any) => bottom: { alignItems: 'center', justifyContent: 'space-between', - paddingTop: 70, + paddingTop: 30, paddingBottom: Device.isIphoneX() ? 20 : 10, }, button: { diff --git a/app/components/UI/LedgerModals/LedgerConfirmationModal.test.tsx b/app/components/UI/LedgerModals/LedgerConfirmationModal.test.tsx index 5f5dbe702a0..e4445891966 100644 --- a/app/components/UI/LedgerModals/LedgerConfirmationModal.test.tsx +++ b/app/components/UI/LedgerModals/LedgerConfirmationModal.test.tsx @@ -15,15 +15,11 @@ import { BluetoothPermissionErrors, LedgerCommunicationErrors, } from '../../../core/Ledger/ledgerErrors'; -import { unlockLedgerDefaultAccount } from '../../../core/Ledger/Ledger'; import { strings } from '../../../../locales/i18n'; import { useMetrics } from '../../hooks/useMetrics'; import { MetaMetricsEvents } from '../../../core/Analytics'; import { fireEvent } from '@testing-library/react-native'; - -jest.mock('../../../core/Ledger/Ledger', () => ({ - unlockLedgerDefaultAccount: jest.fn(), -})); +import { HardwareDeviceTypes } from '../../../constants/keyringTypes'; jest.mock('../../hooks/Ledger/useBluetooth', () => ({ __esModule: true, @@ -340,7 +336,6 @@ describe('LedgerConfirmationModal', () => { it('calls onConfirmation when ledger commands are being sent and confirmed have been received.', async () => { const onConfirmation = jest.fn(); - unlockLedgerDefaultAccount.mockReturnValue(Promise.resolve(true)); useLedgerBluetooth.mockReturnValue({ isSendingLedgerCommands: true, isAppLaunchConfirmationNeeded: false, @@ -359,11 +354,10 @@ describe('LedgerConfirmationModal', () => { // eslint-disable-next-line @typescript-eslint/no-empty-function await act(async () => {}); - expect(unlockLedgerDefaultAccount).toHaveBeenCalled(); expect(onConfirmation).toHaveBeenCalled(); }); - it('logs LEDGER_HARDWARE_WALLET_ERROR thrown by unlockLedgerDefaultAccount', async () => { + it('logs LEDGER_HARDWARE_WALLET_ERROR event when the ledger error occurs', async () => { const onConfirmation = jest.fn(); const ledgerLogicToRun = jest.fn(); @@ -400,7 +394,7 @@ describe('LedgerConfirmationModal', () => { 1, MetaMetricsEvents.LEDGER_HARDWARE_WALLET_ERROR, { - device_type: 'Ledger', + device_type: HardwareDeviceTypes.LEDGER, error: 'LEDGER_ETH_APP_NOT_INSTALLED', }, ); diff --git a/app/components/UI/LedgerModals/LedgerConfirmationModal.tsx b/app/components/UI/LedgerModals/LedgerConfirmationModal.tsx index d6bc54187f6..5a765624e04 100644 --- a/app/components/UI/LedgerModals/LedgerConfirmationModal.tsx +++ b/app/components/UI/LedgerModals/LedgerConfirmationModal.tsx @@ -10,13 +10,13 @@ import ConfirmationStep from './Steps/ConfirmationStep'; import ErrorStep from './Steps/ErrorStep'; import OpenETHAppStep from './Steps/OpenETHAppStep'; import SearchingForDeviceStep from './Steps/SearchingForDeviceStep'; -import { unlockLedgerDefaultAccount } from '../../../core/Ledger/Ledger'; import { MetaMetricsEvents } from '../../../core/Analytics'; import { useMetrics } from '../../../components/hooks/useMetrics'; import { BluetoothPermissionErrors, LedgerCommunicationErrors, } from '../../../core/Ledger/ledgerErrors'; +import { HardwareDeviceTypes } from '../../../constants/keyringTypes'; const createStyles = (colors: Colors) => StyleSheet.create({ @@ -71,14 +71,13 @@ const LedgerConfirmationModal = ({ const connectLedger = () => { try { ledgerLogicToRun(async () => { - await unlockLedgerDefaultAccount(false); await onConfirmation(); }); } catch (_e) { // 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(MetaMetricsEvents.LEDGER_HARDWARE_WALLET_ERROR, { - device_type: 'Ledger', + device_type: HardwareDeviceTypes.LEDGER, error: 'LEDGER_ETH_APP_NOT_INSTALLED', }); } @@ -90,7 +89,7 @@ const LedgerConfirmationModal = ({ onRejection(); } finally { trackEvent(MetaMetricsEvents.LEDGER_HARDWARE_TRANSACTION_CANCELLED, { - device_type: 'Ledger', + device_type: HardwareDeviceTypes.LEDGER, }); } }; @@ -179,7 +178,7 @@ const LedgerConfirmationModal = ({ } if (ledgerError !== LedgerCommunicationErrors.UserRefusedConfirmation) { trackEvent(MetaMetricsEvents.LEDGER_HARDWARE_WALLET_ERROR, { - device_type: 'Ledger', + device_type: HardwareDeviceTypes.LEDGER, error: `${ledgerError}`, }); } @@ -208,7 +207,7 @@ const LedgerConfirmationModal = ({ } setPermissionErrorShown(true); trackEvent(MetaMetricsEvents.LEDGER_HARDWARE_WALLET_ERROR, { - device_type: 'Ledger', + device_type: HardwareDeviceTypes.LEDGER, error: 'LEDGER_BLUETOOTH_PERMISSION_ERR', }); } @@ -219,7 +218,7 @@ const LedgerConfirmationModal = ({ subtitle: strings('ledger.bluetooth_off_message'), }); trackEvent(MetaMetricsEvents.LEDGER_HARDWARE_WALLET_ERROR, { - device_type: 'Ledger', + device_type: HardwareDeviceTypes.LEDGER, error: 'LEDGER_BLUETOOTH_CONNECTION_ERR', }); } diff --git a/app/components/Views/AccountActions/AccountActions.styles.ts b/app/components/Views/AccountActions/AccountActions.styles.ts index 153fbd3857f..ae01e15c2f8 100644 --- a/app/components/Views/AccountActions/AccountActions.styles.ts +++ b/app/components/Views/AccountActions/AccountActions.styles.ts @@ -1,18 +1,25 @@ // Third party dependencies. import { StyleSheet } from 'react-native'; +import { fontStyles } from '../../../styles/common'; +import { Colors } from '../../../util/theme/models'; /** * Style sheet function for AccountActions component. * * @returns StyleSheet object. */ -const styleSheet = () => +const styleSheet = (colors: Colors) => StyleSheet.create({ actionsContainer: { alignItems: 'flex-start', justifyContent: 'center', paddingVertical: 16, }, + text: { + color: colors.text.default, + fontSize: 14, + ...fontStyles.normal, + }, }); export default styleSheet; diff --git a/app/components/Views/AccountActions/AccountActions.test.tsx b/app/components/Views/AccountActions/AccountActions.test.tsx index 891f077f7a4..b4b12204bee 100644 --- a/app/components/Views/AccountActions/AccountActions.test.tsx +++ b/app/components/Views/AccountActions/AccountActions.test.tsx @@ -1,7 +1,9 @@ import React from 'react'; import Share from 'react-native-share'; -import { fireEvent } from '@testing-library/react-native'; +import { Alert } from 'react-native'; + +import { fireEvent, waitFor } from '@testing-library/react-native'; import renderWithProvider from '../../../util/test/renderWithProvider'; @@ -10,13 +12,10 @@ import Routes from '../../../constants/navigation/Routes'; import AccountActions from './AccountActions'; import { AccountActionsModalSelectorsIDs } from '../../../../e2e/selectors/Modals/AccountActionsModal.selectors'; import { backgroundState } from '../../../util/test/initial-root-state'; -import { - MOCK_ACCOUNTS_CONTROLLER_STATE, - MOCK_ADDRESS_2, -} from '../../../util/test/accountsControllerTestUtils'; -import { toChecksumHexAddress } from '@metamask/controller-utils'; +import { MOCK_ACCOUNTS_CONTROLLER_STATE } from '../../../util/test/accountsControllerTestUtils'; -const mockEngine = Engine; +import { strings } from '../../../../locales/i18n'; +import { act } from '@testing-library/react-hooks'; const initialState = { swaps: { '0x1': { isLive: true }, hasOnboarded: false, isLive: true }, @@ -29,9 +28,33 @@ const initialState = { }; jest.mock('../../../core/Engine', () => ({ - init: () => mockEngine.init({}), + ...jest.requireActual('../../../core/Engine'), + context: { + PreferencesController: { + selectedAddress: `0xC4966c0D659D99699BFD7EB54D8fafEE40e4a756`, + }, + KeyringController: { + state: { + keyrings: [ + { + type: 'Ledger Hardware', + accounts: ['0xC4966c0D659D99699BFD7EB54D8fafEE40e4a756'], + }, + { + type: 'HD Key Tree', + accounts: ['0xa1e359811322d97991e03f863a0c30c2cf029cd'], + }, + ], + }, + getAccounts: jest.fn(), + removeAccount: jest.fn(), + }, + }, + setSelectedAddress: jest.fn(), })); +const mockEngine = jest.mocked(Engine); + const mockNavigate = jest.fn(); const mockGoBack = jest.fn(); @@ -63,10 +86,19 @@ jest.mock('react-native-share', () => ({ open: jest.fn(() => Promise.resolve()), })); +jest.mock('../../../core/Permissions', () => ({ + removeAccountsFromPermissions: jest.fn().mockResolvedValue(true), +})); + describe('AccountActions', () => { - afterEach(() => { - mockNavigate.mockClear(); + const mockKeyringController = mockEngine.context.KeyringController; + + beforeEach(() => { + jest.clearAllMocks(); + + jest.spyOn(Alert, 'alert'); }); + it('renders all actions', () => { const { getByTestId } = renderWithProvider(, { state: initialState, @@ -98,7 +130,7 @@ describe('AccountActions', () => { expect(mockNavigate).toHaveBeenCalledWith('Webview', { screen: 'SimpleWebview', params: { - url: 'https://etherscan.io/address/0xC4966c0D659D99699BFD7EB54D8fafEE40e4a756', + url: 'https://etherscan.io/address/0xc4966c0d659d99699bfd7eb54d8fafee40e4a756', title: 'etherscan.io', }, }); @@ -112,7 +144,7 @@ describe('AccountActions', () => { fireEvent.press(getByTestId(AccountActionsModalSelectorsIDs.SHARE_ADDRESS)); expect(Share.open).toHaveBeenCalledWith({ - message: toChecksumHexAddress(MOCK_ADDRESS_2), + message: '0xc4966c0d659d99699bfd7eb54d8fafee40e4a756', }); }); @@ -133,4 +165,52 @@ describe('AccountActions', () => { }, ); }); + + it('clicks edit account', () => { + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + fireEvent.press(getByTestId(AccountActionsModalSelectorsIDs.EDIT_ACCOUNT)); + + expect(mockNavigate).toHaveBeenCalledWith('EditAccountName'); + }); + + describe('clicks remove account', () => { + it('clicks remove button after popup shows to trigger the remove account process', async () => { + mockKeyringController.getAccounts.mockResolvedValue([ + '0xa1e359811322d97991e03f863a0c30c2cf029cd', + ]); + + const { getByTestId, getByText } = renderWithProvider( + , + { + state: initialState, + }, + ); + + fireEvent.press( + getByTestId(AccountActionsModalSelectorsIDs.REMOVE_HARDWARE_ACCOUNT), + ); + + expect(Alert.alert).toHaveBeenCalled(); + + //Check Alert title and description match. + expect(Alert.alert.mock.calls[0][0]).toBe( + strings('accounts.remove_hardware_account'), + ); + expect(Alert.alert.mock.calls[0][1]).toBe( + strings('accounts.remove_hw_account_alert_description'), + ); + + //Click remove button + await act(async () => { + Alert.alert.mock.calls[0][2][1].onPress(); + }); + + await waitFor(() => { + expect(getByText(strings('common.please_wait'))).toBeDefined(); + }); + }); + }); }); diff --git a/app/components/Views/AccountActions/AccountActions.tsx b/app/components/Views/AccountActions/AccountActions.tsx index 6b0f0312877..22feb6a46b9 100644 --- a/app/components/Views/AccountActions/AccountActions.tsx +++ b/app/components/Views/AccountActions/AccountActions.tsx @@ -1,6 +1,6 @@ // Third party dependencies. -import React, { useMemo, useRef } from 'react'; -import { View } from 'react-native'; +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import { Alert, View, Text } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { useDispatch, useSelector } from 'react-redux'; import Share from 'react-native-share'; @@ -9,7 +9,6 @@ import Share from 'react-native-share'; import BottomSheet, { BottomSheetRef, } from '../../../component-library/components/BottomSheets/BottomSheet'; -import { useStyles } from '../../../component-library/hooks'; import AccountAction from '../AccountAction/AccountAction'; import { IconName } from '../../../component-library/components/Icons/Icon'; import { @@ -26,9 +25,8 @@ import { selectNetworkConfigurations, selectProviderConfig, } from '../../../selectors/networkController'; -import { selectSelectedInternalAccountChecksummedAddress } from '../../../selectors/accountsController'; +import { selectSelectedInternalAccount } from '../../../selectors/accountsController'; import { strings } from '../../../../locales/i18n'; - // Internal dependencies import styleSheet from './AccountActions.styles'; import Logger from '../../../util/Logger'; @@ -36,19 +34,38 @@ import { protectWalletModalVisible } from '../../../actions/user'; import Routes from '../../../constants/navigation/Routes'; import { AccountActionsModalSelectorsIDs } from '../../../../e2e/selectors/Modals/AccountActionsModal.selectors'; import { useMetrics } from '../../../components/hooks/useMetrics'; +import { isHardwareAccount } from '../../../util/address'; +import { removeAccountsFromPermissions } from '../../../core/Permissions'; +import ExtendedKeyringTypes, { + HardwareDeviceTypes, +} from '../../../constants/keyringTypes'; +import { forgetLedger } from '../../../core/Ledger/Ledger'; +import Engine from '../../../core/Engine'; +import BlockingActionModal from '../../UI/BlockingActionModal'; +import { useTheme } from '../../../util/theme'; +import { Hex } from '@metamask/utils'; const AccountActions = () => { - const { styles } = useStyles(styleSheet, {}); + const { colors } = useTheme(); + const styles = styleSheet(colors); const sheetRef = useRef(null); const { navigate } = useNavigation(); const dispatch = useDispatch(); const { trackEvent } = useMetrics(); + const [blockingModalVisible, setBlockingModalVisible] = useState(false); + + const controllers = useMemo(() => { + const { KeyringController, PreferencesController } = Engine.context; + return { KeyringController, PreferencesController }; + }, []); + const providerConfig = useSelector(selectProviderConfig); - const selectedAddress = useSelector( - selectSelectedInternalAccountChecksummedAddress, - ); + const selectedAccount = useSelector(selectSelectedInternalAccount); + const selectedAddress = selectedAccount?.address; + const keyring = selectedAccount?.metadata.keyring; + const networkConfigurations = useSelector(selectNetworkConfigurations); const blockExplorer = useMemo(() => { @@ -122,6 +139,123 @@ const AccountActions = () => { }); }; + const showRemoveHWAlert = useCallback(() => { + Alert.alert( + strings('accounts.remove_hardware_account'), + strings('accounts.remove_hw_account_alert_description'), + [ + { + text: strings('accounts.remove_account_alert_cancel_btn'), + style: 'cancel', + }, + { + text: strings('accounts.remove_account_alert_remove_btn'), + onPress: async () => { + setBlockingModalVisible(true); + }, + }, + ], + ); + }, []); + + /** + * Remove the hardware account from the keyring + * @param keyring - The keyring object + * @param address - The address to remove + */ + const removeHardwareAccount = useCallback(async () => { + if (selectedAddress) { + await controllers.KeyringController.removeAccount(selectedAddress as Hex); + await removeAccountsFromPermissions([selectedAddress]); + trackEvent(MetaMetricsEvents.ACCOUNT_REMOVED, { + accountType: keyring?.type, + selectedAddress, + }); + } + }, [ + controllers.KeyringController, + keyring?.type, + selectedAddress, + trackEvent, + ]); + + /** + * Selects the first account after removing the previous selected account + */ + const selectFirstAccount = useCallback(async () => { + const accounts = await controllers.KeyringController.getAccounts(); + if (accounts && accounts.length > 0) { + Engine.setSelectedAddress(accounts[0]); + } + }, [controllers.KeyringController]); + + /** + * Forget the device if there are no more accounts in the keyring + * @param keyringType - The keyring type + */ + const forgetDeviceIfRequired = useCallback(async () => { + // re-fetch the latest keyrings from KeyringController state. + const { keyrings } = controllers.KeyringController.state; + const keyringType = keyring?.type; + const updatedKeyring = keyrings.find((kr) => kr.type === keyringType); + + // If there are no more accounts in the keyring, forget the device + let requestForgetDevice = false; + + if (updatedKeyring) { + if (updatedKeyring.accounts.length === 0) { + requestForgetDevice = true; + } + } else { + requestForgetDevice = true; + } + if (requestForgetDevice) { + switch (keyringType) { + case ExtendedKeyringTypes.ledger: + await forgetLedger(); + trackEvent(MetaMetricsEvents.HARDWARE_WALLET_FORGOTTEN, { + device_type: HardwareDeviceTypes.LEDGER, + }); + break; + case ExtendedKeyringTypes.qr: + await controllers.KeyringController.forgetQRDevice(); + trackEvent(MetaMetricsEvents.HARDWARE_WALLET_FORGOTTEN, { + device_type: HardwareDeviceTypes.QR, + }); + break; + default: + break; + } + } + }, [controllers.KeyringController, keyring?.type, trackEvent]); + + /** + * Trigger the remove hardware account action when user click on the remove account button + */ + const triggerRemoveHWAccount = useCallback(async () => { + if (blockingModalVisible && selectedAddress) { + if (!keyring) { + console.error('Keyring not found for address:', selectedAddress); + return; + } + + await removeHardwareAccount(); + + await selectFirstAccount(); + + await forgetDeviceIfRequired(); + + setBlockingModalVisible(false); + } + }, [ + blockingModalVisible, + forgetDeviceIfRequired, + keyring, + removeHardwareAccount, + selectFirstAccount, + selectedAddress, + ]); + const goToEditAccountName = () => { navigate('EditAccountName'); }; @@ -164,7 +298,22 @@ const AccountActions = () => { onPress={goToExportPrivateKey} testID={AccountActionsModalSelectorsIDs.SHOW_PRIVATE_KEY} /> + {selectedAddress && isHardwareAccount(selectedAddress) && ( + + )} + + {strings('common.please_wait')} + ); }; diff --git a/app/components/Views/ConnectHardware/SelectHardware/index.tsx b/app/components/Views/ConnectHardware/SelectHardware/index.tsx index 54769552fab..91a1ad19309 100644 --- a/app/components/Views/ConnectHardware/SelectHardware/index.tsx +++ b/app/components/Views/ConnectHardware/SelectHardware/index.tsx @@ -16,7 +16,6 @@ import Text, { } from '../../../../component-library/components/Texts/Text'; import Routes from '../../../../constants/navigation/Routes'; import { MetaMetricsEvents } from '../../../../core/Analytics'; -import { withLedgerKeyring } from '../../../../core/Ledger/Ledger'; import { fontStyles } from '../../../../styles/common'; import { mockTheme, @@ -25,7 +24,7 @@ import { } from '../../../../util/theme'; import { getNavigationOptionsTitle } from '../../../UI/Navbar'; import { useMetrics } from '../../../../components/hooks/useMetrics'; -import type LedgerKeyring from '@consensys/ledgerhq-metamask-keyring'; +import { HardwareDeviceTypes } from '../../../../constants/keyringTypes'; // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -108,24 +107,11 @@ const SelectHardwareWallet = () => { }; const navigateToConnectLedger = async () => { - const accounts = await withLedgerKeyring(async (keyring: LedgerKeyring) => - keyring.getAccounts(), - ); - trackEvent(MetaMetricsEvents.CONNECT_LEDGER, { - device_type: 'Ledger', + device_type: HardwareDeviceTypes.LEDGER, }); - if (accounts.length === 0) { - navigation.navigate(Routes.HW.CONNECT_LEDGER); - } else { - navigation.navigate(Routes.HW.LEDGER_ACCOUNT, { - screen: Routes.HW.LEDGER_ACCOUNT, - params: { - accounts, - }, - }); - } + navigation.navigate(Routes.HW.CONNECT_LEDGER); }; // TODO: Replace "any" with type diff --git a/app/components/Views/ConnectQRHardware/index.tsx b/app/components/Views/ConnectQRHardware/index.tsx index 19f8a1711fc..09507a2e0e1 100644 --- a/app/components/Views/ConnectQRHardware/index.tsx +++ b/app/components/Views/ConnectQRHardware/index.tsx @@ -29,6 +29,7 @@ import { safeToChecksumAddress } from '../../../util/address'; import { useMetrics } from '../../../components/hooks/useMetrics'; import type { MetaMaskKeyring as QRKeyring } from '@keystonehq/metamask-airgapped-keyring'; import { KeyringTypes } from '@metamask/keyring-controller'; +import { HardwareDeviceTypes } from '../../../constants/keyringTypes'; interface IConnectQRHardwareProps { // TODO: Replace "any" with type @@ -214,7 +215,7 @@ const ConnectQRHardware = ({ navigation }: IConnectQRHardwareProps) => { const onConnectHardware = useCallback(async () => { trackEvent(MetaMetricsEvents.CONTINUE_QR_HARDWARE_WALLET, { - device_type: 'QR Hardware', + device_type: HardwareDeviceTypes.QR, }); resetError(); const [qrInteractions, connectQRHardwarePromise] = @@ -231,7 +232,7 @@ const ConnectQRHardware = ({ navigation }: IConnectQRHardwareProps) => { (ur: UR) => { hideScanner(); trackEvent(MetaMetricsEvents.CONNECT_HARDWARE_WALLET_SUCCESS, { - device_type: 'QR Hardware', + device_type: HardwareDeviceTypes.QR, }); if (!qrInteractionsRef.current) { const errorMessage = 'Missing QR keyring interactions'; @@ -274,7 +275,7 @@ const ConnectQRHardware = ({ navigation }: IConnectQRHardwareProps) => { const prevPage = useCallback(async () => { resetError(); const [qrInteractions, connectQRHardwarePromise] = - await initiateQRHardwareConnection(1); + await initiateQRHardwareConnection(-1); qrInteractionsRef.current = qrInteractions; const previousPageAccounts = await connectQRHardwarePromise; @@ -283,7 +284,7 @@ const ConnectQRHardware = ({ navigation }: IConnectQRHardwareProps) => { setAccounts(previousPageAccounts); }, [resetError]); - const onToggle = useCallback(() => { + const onCheck = useCallback(() => { resetError(); }, [resetError]); @@ -353,7 +354,7 @@ const ConnectQRHardware = ({ navigation }: IConnectQRHardwareProps) => { selectedAccounts={existingAccounts} nextPage={nextPage} prevPage={prevPage} - toggleAccount={onToggle} + onCheck={onCheck} onUnlock={onUnlock} onForget={onForget} title={strings('connect_qr_hardware.select_accounts')} @@ -368,9 +369,7 @@ const ConnectQRHardware = ({ navigation }: IConnectQRHardwareProps) => { hideModal={hideScanner} /> - - {strings('connect_qr_hardware.please_wait')} - + {strings('common.please_wait')} ); diff --git a/app/components/Views/LedgerAccountInfo/index.tsx b/app/components/Views/LedgerAccountInfo/index.tsx deleted file mode 100644 index ec0f27d3b5a..00000000000 --- a/app/components/Views/LedgerAccountInfo/index.tsx +++ /dev/null @@ -1,207 +0,0 @@ -/* eslint-disable @typescript-eslint/no-require-imports */ -/* eslint-disable @typescript-eslint/no-var-requires */ -/* eslint-disable import/no-commonjs */ -import { StackActions, useNavigation } from '@react-navigation/native'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { - Image, - SafeAreaView, - StyleSheet, - TouchableOpacity, - View, -} from 'react-native'; -import { useDispatch, useSelector } from 'react-redux'; -import { strings } from '../../../../locales/i18n'; -import { setReloadAccounts } from '../../../actions/accounts'; -import { NO_RPC_BLOCK_EXPLORER, RPC } from '../../../constants/network'; -import Engine from '../../../core/Engine'; -import { forgetLedger, withLedgerKeyring } from '../../../core/Ledger/Ledger'; -import Device from '../../../util/device'; -import { getEtherscanAddressUrl } from '../../../util/etherscan'; -import { findBlockExplorerForRpc } from '../../../util/networks'; -import { - mockTheme, - useAppThemeFromContext, - useAssetFromTheme, -} from '../../../util/theme'; -import Text from '../../Base/Text'; -import { getNavigationOptionsTitle } from '../../UI/Navbar'; -import AccountDetails from '../../../components/UI/HardwareWallet/AccountDetails'; - -import ledgerDeviceDarkImage from '../../../images/ledger-device-dark.png'; -import ledgerDeviceLightImage from '../../../images/ledger-device-light.png'; -import { MetaMetricsEvents } from '../../../core/Analytics'; -import { useMetrics } from '../../../components/hooks/useMetrics'; -import type LedgerKeyring from '@consensys/ledgerhq-metamask-keyring'; - -// TODO: Replace "any" with type -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const createStyles = (colors: any) => - StyleSheet.create({ - container: { - flex: 1, - backgroundColor: colors.background.default, - }, - imageWrapper: { - alignSelf: 'flex-start', - marginLeft: Device.getDeviceWidth() * 0.07, - }, - textWrapper: { - alignItems: 'center', - marginHorizontal: Device.getDeviceWidth() * 0.07, - }, - accountCountText: { - fontSize: 24, - }, - accountsContainer: { - flexDirection: 'row', - marginTop: 20, - marginLeft: Device.getDeviceWidth() * 0.02, - marginRight: Device.getDeviceWidth() * 0.07, - }, - textContainer: { - flex: 0.7, - }, - etherscanContainer: { - flex: 0.3, - justifyContent: 'center', - }, - etherscanImage: { - width: 30, - height: 30, - }, - forgetLedgerContainer: { - flex: 1, - flexDirection: 'column', - justifyContent: 'flex-end', - alignItems: 'center', - padding: 20, - }, - }); - -const LedgerAccountInfo = () => { - const dispatch = useDispatch(); - const navigation = useNavigation(); - const { trackEvent } = useMetrics(); - const [account, setAccount] = useState(''); - const [accountBalance, setAccountBalance] = useState('0'); - const { colors } = useAppThemeFromContext() ?? mockTheme; - const styles = useMemo(() => createStyles(colors), [colors]); - const ledgerThemedImage = useAssetFromTheme( - ledgerDeviceLightImage, - ledgerDeviceDarkImage, - ); - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const { AccountTrackerController } = Engine.context as any; - const provider = useSelector( - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (state: any) => - state.engine.backgroundState.NetworkController.providerConfig, - ); - const frequentRpcList = useSelector( - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (state: any) => - state.engine.backgroundState.PreferencesController.frequentRpcList, - ); - - useEffect(() => { - navigation.setOptions( - getNavigationOptionsTitle('', navigation, true, colors), - ); - }, [navigation, colors]); - - useEffect(() => { - const getAccount = async () => { - const accounts = await withLedgerKeyring(async (keyring: LedgerKeyring) => - keyring.getAccounts(), - ); - - setAccount(accounts[0]); - }; - - getAccount(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const onForgetDevice = async () => { - await forgetLedger(); - dispatch(setReloadAccounts(true)); - trackEvent(MetaMetricsEvents.LEDGER_HARDWARE_WALLET_FORGOTTEN, { - device_type: 'Ledger', - }); - navigation.dispatch(StackActions.pop(2)); - }; - - const getEthAmountForAccount = async (ledgerAccount: string) => { - if (ledgerAccount) { - const ethValue = await AccountTrackerController.syncBalanceWithAddresses([ - ledgerAccount, - ]); - - setAccountBalance(ethValue[ledgerAccount]?.balance); - } - }; - - useEffect(() => { - if (account) { - getEthAmountForAccount(account); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [account]); - - const toBlockExplorer = useCallback( - (address: string) => { - const { type, rpcUrl } = provider; - let accountLink: string; - - if (type === RPC) { - const blockExplorer = - findBlockExplorerForRpc(rpcUrl, frequentRpcList) || - NO_RPC_BLOCK_EXPLORER; - accountLink = `${blockExplorer}/address/${address}`; - } else { - accountLink = getEtherscanAddressUrl(type, address); - } - - navigation.navigate('Webview', { - screen: 'SimpleWebview', - params: { - url: accountLink, - }, - }); - }, - [frequentRpcList, navigation, provider], - ); - - return ( - - - - - - - {strings('ledger.ledger_account_count')} - - - - - - - - {strings('ledger.forget_device')} - - - - ); -}; - -export default React.memo(LedgerAccountInfo); diff --git a/app/components/Views/LedgerConnect/__snapshots__/index.test.tsx.snap b/app/components/Views/LedgerConnect/__snapshots__/index.test.tsx.snap index d9227ecbdec..201ac2e5c74 100644 --- a/app/components/Views/LedgerConnect/__snapshots__/index.test.tsx.snap +++ b/app/components/Views/LedgerConnect/__snapshots__/index.test.tsx.snap @@ -19,20 +19,66 @@ exports[`LedgerConnect render matches latest snapshot 1`] = ` } } > - + > + + + +  + + + + StyleSheet.create({ + container: { + position: 'relative', + flex: 1, + backgroundColor: colors.background.default, + alignItems: 'center', + }, + connectLedgerWrapper: { + marginLeft: Device.getDeviceWidth() * 0.07, + marginRight: Device.getDeviceWidth() * 0.07, + }, + header: { + marginTop: Device.isIphoneX() ? 30 : 20, + flexDirection: 'row', + width: '100%', + alignItems: 'center', + }, + navbarRightButton: { + flexDirection: 'row', + justifyContent: 'flex-end', + height: 48, + width: 48, + flex: 1, + }, + closeIcon: { + fontSize: 28, + color: colors.text.default, + }, + ledgerImage: { + width: 68, + height: 68, + }, + coverImage: { + resizeMode: 'contain', + width: Device.getDeviceWidth() * 0.6, + height: 64, + overflow: 'visible', + }, + connectLedgerText: { + ...(fontStyles.normal as TextStyle), + fontSize: 24, + }, + bodyContainer: { + flex: 1, + marginTop: Device.getDeviceHeight() * 0.025, + }, + textContainer: { + marginTop: Device.getDeviceHeight() * 0.05, + }, + + instructionsText: { + marginTop: Device.getDeviceHeight() * 0.02, + }, + imageContainer: { + alignItems: 'center', + marginTop: Device.getDeviceHeight() * 0.08, + }, + buttonContainer: { + position: 'absolute', + display: 'flex', + bottom: Device.getDeviceHeight() * 0.025, + left: 0, + width: '100%', + }, + lookingForDeviceContainer: { + flexDirection: 'row', + }, + lookingForDeviceText: { + fontSize: 18, + }, + activityIndicatorStyle: { + marginLeft: 10, + }, + ledgerInstructionText: { + paddingLeft: 7, + }, + howToInstallEthAppText: { + marginTop: Device.getDeviceHeight() * 0.025, + }, + openEthAppMessage: { + marginTop: Device.getDeviceHeight() * 0.025, + }, + loader: { + color: colors.background.default, + }, + }); + +export default createStyles; diff --git a/app/components/Views/LedgerConnect/index.test.tsx b/app/components/Views/LedgerConnect/index.test.tsx index 27d86e2318d..80f0ca8c344 100644 --- a/app/components/Views/LedgerConnect/index.test.tsx +++ b/app/components/Views/LedgerConnect/index.test.tsx @@ -26,14 +26,11 @@ jest.mock('../../../util/device', () => ({ ...jest.requireActual('../../../util/device'), isAndroid: jest.fn(), isIos: jest.fn(), + isIphoneX: jest.fn(), getDeviceWidth: jest.fn(), getDeviceHeight: jest.fn(), })); -jest.mock('../../../core/Ledger/Ledger', () => ({ - unlockLedgerDefaultAccount: jest.fn(), -})); - jest.mock('../../../core/Engine', () => ({ context: { KeyringController: { @@ -72,6 +69,8 @@ jest.mock('react-native-permissions', () => ({ })); describe('LedgerConnect', () => { + const onConfirmationComplete = jest.fn(); + const checkLedgerCommunicationErrorFlow = function ( ledgerCommunicationError: LedgerCommunicationErrors, expectedTitle: string, @@ -84,7 +83,9 @@ describe('LedgerConnect', () => { error: ledgerCommunicationError, }); - const { getByText } = renderWithProvider(); + const { getByText } = renderWithProvider( + , + ); expect(getByText(expectedTitle)).toBeTruthy(); expect(getByText(expectedErrorBody)).toBeTruthy(); @@ -125,12 +126,15 @@ describe('LedgerConnect', () => { getSystemVersion.mockReturnValue('13'); Device.isAndroid.mockReturnValue(true); Device.isIos.mockReturnValue(false); + Device.isIphoneX.mockReturnValue(false); Device.getDeviceWidth.mockReturnValue(50); Device.getDeviceHeight.mockReturnValue(50); }); it('render matches latest snapshot', () => { - const wrapper = renderWithProvider(); + const wrapper = renderWithProvider( + , + ); expect(wrapper).toMatchSnapshot(); }); @@ -146,7 +150,9 @@ describe('LedgerConnect', () => { ledgerLogicToRun.mockImplementation((callback) => callback()); - const { getByTestId } = renderWithProvider(); + const { getByTestId } = renderWithProvider( + , + ); const continueButton = getByTestId('add-network-button'); fireEvent.press(continueButton); @@ -201,14 +207,18 @@ describe('LedgerConnect', () => { error: LedgerCommunicationErrors.LedgerHasPendingConfirmation, }); - renderWithProvider(); + renderWithProvider( + , + ); expect(navigate).toHaveBeenNthCalledWith(1, 'SelectHardwareWallet'); }); it('displays android 12+ permission text on android 12+ device', () => { getSystemVersion.mockReturnValue('13'); - const { getByText } = renderWithProvider(); + const { getByText } = renderWithProvider( + , + ); expect( getByText( strings('ledger.ledger_reminder_message_step_four_Androidv12plus'), @@ -218,7 +228,9 @@ describe('LedgerConnect', () => { it('displays android 11 permission text on android 11 device', () => { getSystemVersion.mockReturnValue('11'); - const { getByText } = renderWithProvider(); + const { getByText } = renderWithProvider( + , + ); expect( getByText(strings('ledger.ledger_reminder_message_step_four')), ).toBeTruthy(); @@ -232,7 +244,9 @@ describe('LedgerConnect', () => { dispatch: jest.fn(), }); - const { getByText } = renderWithProvider(); + const { getByText } = renderWithProvider( + , + ); const installInstructionsLink = getByText( strings('ledger.how_to_install_eth_app'), ); @@ -257,9 +271,13 @@ describe('LedgerConnect', () => { error: LedgerCommunicationErrors.FailedToOpenApp, }); - renderWithProvider(); + renderWithProvider( + , + ); - const { getByTestId } = renderWithProvider(); + const { getByTestId } = renderWithProvider( + , + ); const retryButton = getByTestId('add-network-button'); fireEvent.press(retryButton); diff --git a/app/components/Views/LedgerConnect/index.tsx b/app/components/Views/LedgerConnect/index.tsx index 5b304f397db..166061d09d9 100644 --- a/app/components/Views/LedgerConnect/index.tsx +++ b/app/components/Views/LedgerConnect/index.tsx @@ -1,17 +1,15 @@ import React, { useEffect, useMemo, useState } from 'react'; import { - View, - StyleSheet, + ActivityIndicator, Image, SafeAreaView, - TextStyle, - ActivityIndicator, + TouchableOpacity, + View, } from 'react-native'; -import { StackActions, useNavigation } from '@react-navigation/native'; +import { useNavigation } from '@react-navigation/native'; import { Device as NanoDevice } from '@ledgerhq/react-native-hw-transport-ble/lib/types'; import { useDispatch } from 'react-redux'; import { strings } from '../../../../locales/i18n'; -import Engine from '../../../core/Engine'; import StyledButton from '../../../components/UI/StyledButton'; import Text from '../../../components/Base/Text'; import { @@ -20,7 +18,6 @@ import { useAssetFromTheme, } from '../../../util/theme'; import Device from '../../../util/device'; -import { fontStyles } from '../../../styles/common'; import Scan from './Scan'; import useLedgerBluetooth from '../../hooks/Ledger/useLedgerBluetooth'; import { showSimpleNotification } from '../../../actions/notification'; @@ -28,99 +25,25 @@ import LedgerConnectionError, { LedgerConnectionErrorProps, } from './LedgerConnectionError'; import { getNavigationOptionsTitle } from '../../UI/Navbar'; -import { unlockLedgerDefaultAccount } from '../../../core/Ledger/Ledger'; -import { MetaMetricsEvents } from '../../../core/Analytics'; import { LEDGER_SUPPORT_LINK } from '../../../constants/urls'; import ledgerDeviceDarkImage from '../../../images/ledger-device-dark.png'; import ledgerDeviceLightImage from '../../../images/ledger-device-light.png'; import ledgerConnectLightImage from '../../../images/ledger-connect-light.png'; import ledgerConnectDarkImage from '../../../images/ledger-connect-dark.png'; -import { useMetrics } from '../../../components/hooks/useMetrics'; import { getSystemVersion } from 'react-native-device-info'; import { LedgerCommunicationErrors } from '../../../core/Ledger/ledgerErrors'; +import MaterialIcon from 'react-native-vector-icons/MaterialIcons'; +import createStyles from './index.styles'; -// TODO: Replace "any" with type -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const createStyles = (theme: any) => - StyleSheet.create({ - container: { - position: 'relative', - flex: 1, - backgroundColor: theme.colors.background.default, - alignItems: 'center', - }, - connectLedgerWrapper: { - marginLeft: Device.getDeviceWidth() * 0.07, - marginRight: Device.getDeviceWidth() * 0.07, - }, - ledgerImage: { - width: 68, - height: 68, - }, - coverImage: { - resizeMode: 'contain', - width: Device.getDeviceWidth() * 0.6, - height: 64, - overflow: 'visible', - }, - connectLedgerText: { - ...(fontStyles.normal as TextStyle), - fontSize: 24, - }, - bodyContainer: { - flex: 1, - marginTop: Device.getDeviceHeight() * 0.025, - }, - textContainer: { - marginTop: Device.getDeviceHeight() * 0.05, - }, - - instructionsText: { - marginTop: Device.getDeviceHeight() * 0.02, - }, - imageContainer: { - alignItems: 'center', - marginTop: Device.getDeviceHeight() * 0.08, - }, - buttonContainer: { - position: 'absolute', - display: 'flex', - bottom: Device.getDeviceHeight() * 0.025, - left: 0, - width: '100%', - }, - lookingForDeviceContainer: { - flexDirection: 'row', - }, - lookingForDeviceText: { - fontSize: 18, - }, - activityIndicatorStyle: { - marginLeft: 10, - }, - ledgerInstructionText: { - paddingLeft: 7, - }, - howToInstallEthAppText: { - marginTop: Device.getDeviceHeight() * 0.025, - }, - openEthAppMessage: { - marginTop: Device.getDeviceHeight() * 0.025, - }, - loader: { - color: theme.brandColors.white, - }, - }); +interface LedgerConnectProps { + onConnectLedger: () => void; +} -const LedgerConnect = () => { - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const { AccountTrackerController } = Engine.context as any; +const LedgerConnect = ({ onConnectLedger }: LedgerConnectProps) => { const theme = useAppThemeFromContext() ?? mockTheme; - const { trackEvent } = useMetrics(); const navigation = useNavigation(); - const styles = useMemo(() => createStyles(theme), [theme]); + const styles = useMemo(() => createStyles(theme.colors), [theme]); const [selectedDevice, setSelectedDevice] = useState(null); const [errorDetail, setErrorDetails] = useState(); const [loading, setLoading] = useState(false); @@ -144,16 +67,8 @@ const LedgerConnect = () => { const connectLedger = () => { setLoading(true); - trackEvent(MetaMetricsEvents.CONTINUE_LEDGER_HARDWARE_WALLET, { - device_type: 'Ledger', - }); ledgerLogicToRun(async () => { - const account = await unlockLedgerDefaultAccount(true); - await AccountTrackerController.syncBalanceWithAddresses([account]); - trackEvent(MetaMetricsEvents.CONNECT_LEDGER_SUCCESS, { - device_type: 'Ledger', - }); - navigation.dispatch(StackActions.pop(2)); + onConnectLedger(); }); }; @@ -254,14 +169,22 @@ const LedgerConnect = () => { return ( - + + + + + + {strings('ledger.connect_ledger')} diff --git a/app/components/Views/LedgerSelectAccount/__snapshots__/index.test.tsx.snap b/app/components/Views/LedgerSelectAccount/__snapshots__/index.test.tsx.snap new file mode 100644 index 00000000000..be0f72c7ffc --- /dev/null +++ b/app/components/Views/LedgerSelectAccount/__snapshots__/index.test.tsx.snap @@ -0,0 +1,983 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`LedgerSelectAccount renders correctly to match snapshot 1`] = ` + + + + + + +  + + + + + Connect Ledger + + + + + + + + Looking for device + + + + + Please make sure your Ledger Nano X is: + + + 1. Unlock your Ledger Nano X + + + 2. Install and open the Ethereum app + + + 3. Enable Bluetooth + + + 5. Do not disturb must be turned off + + + How to install the Ethereum app on a Ledger device + + + + + + + +`; + +exports[`LedgerSelectAccount renders correctly to match snapshot when getAccounts return valid accounts 1`] = ` + + + + + + +  + + + + + Connect Ledger + + + + + + + + Looking for device + + + + + Please make sure your Ledger Nano X is: + + + 1. Unlock your Ledger Nano X + + + 2. Install and open the Ethereum app + + + 3. Enable Bluetooth + + + 5. Do not disturb must be turned off + + + How to install the Ethereum app on a Ledger device + + + + + + + +`; diff --git a/app/components/Views/LedgerSelectAccount/index.styles.ts b/app/components/Views/LedgerSelectAccount/index.styles.ts new file mode 100644 index 00000000000..c56ad035164 --- /dev/null +++ b/app/components/Views/LedgerSelectAccount/index.styles.ts @@ -0,0 +1,47 @@ +import { Colors } from '../../../util/theme/models'; +import { StyleSheet } from 'react-native'; +import Device from '../../../util/device'; +import { fontStyles } from '../../../styles/common'; + +const createStyles = (colors: Colors) => + StyleSheet.create({ + container: { + flex: 1, + flexDirection: 'column', + alignItems: 'center', + }, + ledgerIcon: { + width: 60, + height: 60, + }, + header: { + marginTop: Device.isIphoneX() ? 50 : 20, + flexDirection: 'row', + width: '100%', + paddingHorizontal: 32, + alignItems: 'center', + }, + navbarRightButton: { + flexDirection: 'row', + justifyContent: 'flex-end', + height: 48, + width: 48, + flex: 1, + }, + closeIcon: { + fontSize: 28, + color: colors.text.default, + }, + error: { + ...fontStyles.normal, + fontSize: 14, + color: colors.error, + }, + text: { + color: colors.text.default, + fontSize: 14, + ...fontStyles.normal, + }, + }); + +export default createStyles; diff --git a/app/components/Views/LedgerSelectAccount/index.test.tsx b/app/components/Views/LedgerSelectAccount/index.test.tsx new file mode 100644 index 00000000000..e777de406af --- /dev/null +++ b/app/components/Views/LedgerSelectAccount/index.test.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import LedgerSelectAccount from './index'; +import renderWithProvider from '../../../util/test/renderWithProvider'; +import Engine from '../../../core/Engine'; + +const mockedNavigate = jest.fn(); + +jest.mock('@react-navigation/native', () => { + const actualNav = jest.requireActual('@react-navigation/native'); + return { + ...actualNav, + useNavigation: () => ({ + navigate: mockedNavigate, + setOptions: jest.fn(), + }), + }; +}); + +jest.mock('../../../core/Engine', () => ({ + context: { + KeyringController: { + state: { + keyrings: [], + }, + getAccounts: jest.fn(), + }, + }, +})); +const MockEngine = jest.mocked(Engine); + +describe('LedgerSelectAccount', () => { + const mockKeyringController = MockEngine.context.KeyringController; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders correctly to match snapshot', () => { + mockKeyringController.getAccounts.mockResolvedValue([]); + const wrapper = renderWithProvider(); + + expect(wrapper).toMatchSnapshot(); + }); + + it('renders correctly to match snapshot when getAccounts return valid accounts', () => { + mockKeyringController.getAccounts.mockResolvedValue([ + '0xd0a1e359811322d97991e03f863a0c30c2cf029c', + '0xa1e359811322d97991e03f863a0c30c2cf029cd', + ]); + const wrapper = renderWithProvider(); + + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/app/components/Views/LedgerSelectAccount/index.tsx b/app/components/Views/LedgerSelectAccount/index.tsx new file mode 100644 index 00000000000..3e495e93bdf --- /dev/null +++ b/app/components/Views/LedgerSelectAccount/index.tsx @@ -0,0 +1,191 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { Image, Text, TouchableOpacity, View } from 'react-native'; +import Engine from '../../../core/Engine'; +import AccountSelector from '../../UI/HardwareWallet/AccountSelector'; +import BlockingActionModal from '../../UI/BlockingActionModal'; +import { strings } from '../../../../locales/i18n'; +import { MetaMetricsEvents } from '../../../core/Analytics'; +import { useAssetFromTheme, useTheme } from '../../../util/theme'; +import useMetrics from '../../hooks/useMetrics/useMetrics'; +import ledgerDeviceLightImage from 'images/ledger-device-light.png'; +import ledgerDeviceDarkImage from 'images/ledger-device-dark.png'; +import { + forgetLedger, + getLedgerAccountsByOperation, + unlockLedgerWalletAccount, +} from '../../../core/Ledger/Ledger'; +import LedgerConnect from '../LedgerConnect'; +import { setReloadAccounts } from '../../../actions/accounts'; +import { StackActions, useNavigation } from '@react-navigation/native'; +import { useDispatch } from 'react-redux'; +import { KeyringController } from '@metamask/keyring-controller'; +import { StackNavigationProp } from '@react-navigation/stack'; +import createStyles from './index.styles'; +import OperationTypes from '../../../core/Ledger/types'; +import { HardwareDeviceTypes } from '../../../constants/keyringTypes'; +import MaterialIcon from 'react-native-vector-icons/MaterialIcons'; + +const LedgerSelectAccount = () => { + const navigation = useNavigation>(); + const dispatch = useDispatch(); + const { colors } = useTheme(); + const { trackEvent } = useMetrics(); + const styles = createStyles(colors); + const ledgerThemedImage = useAssetFromTheme( + ledgerDeviceLightImage, + ledgerDeviceDarkImage, + ); + + const keyringController = useMemo(() => { + const { KeyringController: controller } = Engine.context as { + KeyringController: KeyringController; + }; + return controller; + }, []); + + const [blockingModalVisible, setBlockingModalVisible] = useState(false); + const [accounts, setAccounts] = useState< + { address: string; index: number; balance: string }[] + >([]); + + const [unlockAccounts, setUnlockAccounts] = useState({ + trigger: false, + accountIndexes: [] as number[], + }); + + const [forgetDevice, setForgetDevice] = useState(false); + + const [existingAccounts, setExistingAccounts] = useState([]); + + useEffect(() => { + keyringController.getAccounts().then((value: string[]) => { + setExistingAccounts(value); + }); + }, [keyringController]); + + const onConnectHardware = useCallback(async () => { + trackEvent(MetaMetricsEvents.CONTINUE_LEDGER_HARDWARE_WALLET, { + device_type: HardwareDeviceTypes.LEDGER, + }); + const _accounts = await getLedgerAccountsByOperation( + OperationTypes.GET_FIRST_PAGE, + ); + setAccounts(_accounts); + }, [trackEvent]); + + const nextPage = useCallback(async () => { + const _accounts = await getLedgerAccountsByOperation( + OperationTypes.GET_NEXT_PAGE, + ); + setAccounts(_accounts); + }, []); + + const prevPage = useCallback(async () => { + const _accounts = await getLedgerAccountsByOperation( + OperationTypes.GET_PREVIOUS_PAGE, + ); + setAccounts(_accounts); + }, []); + + const onUnlock = useCallback( + async (accountIndexes: number[]) => { + setBlockingModalVisible(true); + + try { + for (const index of accountIndexes) { + await unlockLedgerWalletAccount(index); + } + } catch (err) { + // Do nothing + } + setBlockingModalVisible(false); + + trackEvent(MetaMetricsEvents.CONNECT_LEDGER_SUCCESS, { + device_type: HardwareDeviceTypes.LEDGER, + }); + navigation.pop(2); + }, + [navigation, trackEvent], + ); + + const onForget = useCallback(async () => { + setBlockingModalVisible(true); + await forgetLedger(); + dispatch(setReloadAccounts(true)); + trackEvent(MetaMetricsEvents.HARDWARE_WALLET_FORGOTTEN, { + device_type: HardwareDeviceTypes.LEDGER, + }); + setBlockingModalVisible(false); + navigation.dispatch(StackActions.pop(2)); + }, [dispatch, navigation, trackEvent]); + + const onAnimationCompleted = useCallback(async () => { + if (!blockingModalVisible) { + return; + } + + if (forgetDevice) { + await onForget(); + setBlockingModalVisible(false); + setForgetDevice(false); + } else if (unlockAccounts.trigger) { + await onUnlock(unlockAccounts.accountIndexes); + setBlockingModalVisible(false); + setUnlockAccounts({ trigger: false, accountIndexes: [] }); + } + }, [ + blockingModalVisible, + forgetDevice, + onForget, + onUnlock, + unlockAccounts.accountIndexes, + unlockAccounts.trigger, + ]); + + return accounts.length <= 0 ? ( + + ) : ( + <> + + + + + + + + + { + setUnlockAccounts({ trigger: true, accountIndexes: accountIndex }); + setBlockingModalVisible(true); + }} + onForget={() => { + setForgetDevice(true); + setBlockingModalVisible(true); + }} + title={strings('connect_qr_hardware.select_accounts')} + /> + + + {strings('common.please_wait')} + + + ); +}; + +export default LedgerSelectAccount; diff --git a/app/components/hooks/Ledger/useLedgerBluetooth.ts b/app/components/hooks/Ledger/useLedgerBluetooth.ts index 02b5330e91e..59a40bd2dcc 100644 --- a/app/components/hooks/Ledger/useLedgerBluetooth.ts +++ b/app/components/hooks/Ledger/useLedgerBluetooth.ts @@ -33,7 +33,7 @@ const RESTART_LIMIT = 5; // Assumptions // 1. One big code block - logic all encapsulated in logicToRun // 2. logicToRun calls setUpBluetoothConnection -function useLedgerBluetooth(deviceId?: string): UseLedgerBluetoothHook { +function useLedgerBluetooth(deviceId: string): UseLedgerBluetoothHook { // This is to track if we are expecting code to run or connection operational const [isSendingLedgerCommands, setIsSendingLedgerCommands] = useState(false); @@ -130,7 +130,7 @@ function useLedgerBluetooth(deviceId?: string): UseLedgerBluetoothHook { // Must do this at start of every code block to run to ensure transport is set await setUpBluetoothConnection(); - if (!transportRef.current || !deviceId) { + if (!transportRef.current) { throw new Error('transportRef.current is undefined'); } // Initialise the keyring and check for pre-conditions (is the correct app running?) diff --git a/app/constants/keyringTypes.ts b/app/constants/keyringTypes.ts index 45f2975a1ba..19811d67c29 100644 --- a/app/constants/keyringTypes.ts +++ b/app/constants/keyringTypes.ts @@ -6,3 +6,8 @@ enum ExtendedKeyringTypes { } export default ExtendedKeyringTypes; + +export enum HardwareDeviceTypes { + LEDGER = 'Ledger', + QR = 'QR Hardware', +} diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts index 81b2182ba31..21393b7ef06 100644 --- a/app/constants/navigation/Routes.ts +++ b/app/constants/navigation/Routes.ts @@ -29,7 +29,6 @@ const Routes = { SELECT_DEVICE: 'SelectHardwareWallet', CONNECT_QR_DEVICE: 'ConnectQRHardwareFlow', CONNECT_LEDGER: 'ConnectLedgerFlow', - LEDGER_ACCOUNT: 'LedgerAccountInfo', LEDGER_CONNECT: 'LedgerConnect', }, LEDGER_MESSAGE_SIGN_MODAL: 'LedgerMessageSignModal', diff --git a/app/core/Analytics/MetaMetrics.events.ts b/app/core/Analytics/MetaMetrics.events.ts index 769ae8fd8a8..b45c55f0bc6 100644 --- a/app/core/Analytics/MetaMetrics.events.ts +++ b/app/core/Analytics/MetaMetrics.events.ts @@ -353,7 +353,12 @@ enum EVENT_NAME { 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', - LEDGER_HARDWARE_WALLET_FORGOTTEN = 'Ledger hardware wallet forgotten', + + // common hardware wallet + HARDWARE_WALLET_FORGOTTEN = 'Hardware wallet forgotten', + + // Remove an account + ACCOUNT_REMOVED = 'Account removed', //Notifications ALL_NOTIFICATIONS = 'All Notifications', @@ -842,9 +847,10 @@ const events = { LEDGER_HARDWARE_WALLET_ERROR: generateOpt( EVENT_NAME.LEDGER_HARDWARE_WALLET_ERROR, ), - LEDGER_HARDWARE_WALLET_FORGOTTEN: generateOpt( - EVENT_NAME.LEDGER_HARDWARE_WALLET_FORGOTTEN, - ), + HARDWARE_WALLET_FORGOTTEN: generateOpt(EVENT_NAME.HARDWARE_WALLET_FORGOTTEN), + + // Remove an account + ACCOUNT_REMOVED: generateOpt(EVENT_NAME.ACCOUNT_REMOVED), // Smart transactions SMART_TRANSACTION_OPT_IN: generateOpt(EVENT_NAME.SMART_TRANSACTION_OPT_IN), @@ -925,6 +931,7 @@ enum DESCRIPTION { WALLET_QR_SCANNER = 'QR scanner', WALLET_COPIED_ADDRESS = 'Copied Address', WALLET_ADD_COLLECTIBLES = 'Add Collectibles', + // Transactions TRANSACTIONS_CONFIRM_STARTED = 'Confirm Started', TRANSACTIONS_EDIT_TRANSACTION = 'Edit Transaction', @@ -1176,6 +1183,7 @@ const legacyMetaMetricsEvents = { ACTIONS.WALLET_VIEW, DESCRIPTION.WALLET_ADD_COLLECTIBLES, ), + // Transactions TRANSACTIONS_CONFIRM_STARTED: generateOpt( EVENT_NAME.TRANSACTIONS, diff --git a/app/core/Engine.ts b/app/core/Engine.ts index 487b01dbad0..f9644279e9b 100644 --- a/app/core/Engine.ts +++ b/app/core/Engine.ts @@ -129,7 +129,11 @@ import { LoggingControllerState, LoggingControllerActions, } from '@metamask/logging-controller'; -import LedgerKeyring from '@consensys/ledgerhq-metamask-keyring'; +import { + LedgerKeyring, + LedgerMobileBridge, + LedgerTransportMiddleware, +} from '@metamask/eth-ledger-bridge-keyring'; import { Encryptor, LEGACY_DERIVATION_OPTIONS } from './Encryptor'; import { isMainnetByChainId, @@ -667,7 +671,8 @@ class Engine { }; qrKeyringBuilder.type = QRHardwareKeyring.type; - const ledgerKeyringBuilder = () => new LedgerKeyring(); + const bridge = new LedgerMobileBridge(new LedgerTransportMiddleware()); + const ledgerKeyringBuilder = () => new LedgerKeyring({ bridge }); ledgerKeyringBuilder.type = LedgerKeyring.type; const keyringController = new KeyringController({ diff --git a/app/core/Ledger/Ledger.test.ts b/app/core/Ledger/Ledger.test.ts index 798ebe8d4c0..e078666d087 100644 --- a/app/core/Ledger/Ledger.test.ts +++ b/app/core/Ledger/Ledger.test.ts @@ -1,20 +1,17 @@ -import LedgerKeyring from '@consensys/ledgerhq-metamask-keyring'; import { - connectLedgerHardware, - openEthereumAppOnLedger, closeRunningAppOnLedger, + connectLedgerHardware, forgetLedger, - ledgerSignTypedMessage, - unlockLedgerDefaultAccount, getDeviceId, - withLedgerKeyring, + getLedgerAccountsByOperation, + ledgerSignTypedMessage, + openEthereumAppOnLedger, + unlockLedgerWalletAccount, } from './Ledger'; import Engine from '../../core/Engine'; -import { - KeyringTypes, - SignTypedDataVersion, -} from '@metamask/keyring-controller'; +import { SignTypedDataVersion } from '@metamask/keyring-controller'; import type BleTransport from '@ledgerhq/react-native-hw-transport-ble'; +import OperationTypes from './types'; jest.mock('../../core/Engine', () => ({ context: { @@ -26,27 +23,86 @@ jest.mock('../../core/Engine', () => ({ })); const MockEngine = jest.mocked(Engine); +interface mockKeyringType { + addAccounts: jest.Mock; + bridge: { + getAppNameAndVersion: jest.Mock; + updateTransportMethod: jest.Mock; + openEthApp: jest.Mock; + closeApps: jest.Mock; + }; + deserialize: jest.Mock; + forgetDevice: jest.Mock; + getDeviceId: jest.Mock; + getFirstPage: jest.Mock; + getNextPage: jest.Mock; + getPreviousPage: jest.Mock; + setDeviceId: jest.Mock; + setHdPath: jest.Mock; + setAccountToUnlock: jest.Mock; +} + describe('Ledger core', () => { - let ledgerKeyring: LedgerKeyring; + let ledgerKeyring: mockKeyringType; beforeEach(() => { jest.resetAllMocks(); - // @ts-expect-error This is a partial mock, not completely identical - // TODO: Replace this with a type-safe mock + const mockKeyringController = MockEngine.context.KeyringController; + ledgerKeyring = { addAccounts: jest.fn(), - setTransport: jest.fn(), - getAppAndVersion: jest.fn().mockResolvedValue({ appName: 'appName' }), - getDefaultAccount: jest.fn().mockResolvedValue('defaultAccount'), - openEthApp: jest.fn(), - quitApp: jest.fn(), - forgetDevice: jest.fn(), + bridge: { + getAppNameAndVersion: jest + .fn() + .mockResolvedValue({ appName: 'appName' }), + updateTransportMethod: jest.fn(), + openEthApp: jest.fn(), + closeApps: jest.fn(), + }, deserialize: jest.fn(), - deviceId: 'deviceId', - getName: jest.fn().mockResolvedValue('name'), + forgetDevice: jest.fn(), + getDeviceId: jest.fn().mockReturnValue('deviceId'), + getFirstPage: jest.fn().mockResolvedValue([ + { + balance: '0', + address: '0x49b6FFd1BD9d1c64EEf400a64a1e4bBC33E2CAB2', + index: 0, + }, + { + balance: '1', + address: '0x49b6FFd1BD9d1c64EEf400a64a1e4bBC33E2CAB3', + index: 1, + }, + ]), + getNextPage: jest.fn().mockResolvedValue([ + { + balance: '4', + address: '0x49b6FFd1BD9d1c64EEf400a64a1e4bBC33E2CAB4', + index: 4, + }, + { + balance: '5', + address: '0x49b6FFd1BD9d1c64EEf400a64a1e4bBC33E2CAB5', + index: 5, + }, + ]), + getPreviousPage: jest.fn().mockResolvedValue([ + { + balance: '2', + address: '0x49b6FFd1BD9d1c64EEf400a64a1e4bBC33E2CAB6', + index: 2, + }, + { + balance: '3', + address: '0x49b6FFd1BD9d1c64EEf400a64a1e4bBC33E2CAB7', + index: 3, + }, + ]), + setDeviceId: jest.fn(), + setHdPath: jest.fn(), + setAccountToUnlock: jest.fn(), }; - const mockKeyringController = MockEngine.context.KeyringController; mockKeyringController.withKeyring.mockImplementation( // @ts-expect-error The Ledger keyring is not compatible with our keyring type yet @@ -57,99 +113,73 @@ describe('Ledger core', () => { describe('connectLedgerHardware', () => { const mockTransport = 'foo' as unknown as BleTransport; - it('should call keyring.setTransport', async () => { + it('calls keyring.setTransport', async () => { await connectLedgerHardware(mockTransport, 'bar'); - expect(ledgerKeyring.setTransport).toHaveBeenCalled(); + expect(ledgerKeyring.bridge.updateTransportMethod).toHaveBeenCalled(); }); - it('should call keyring.getAppAndVersion', async () => { + it('calls keyring.getAppAndVersion', async () => { await connectLedgerHardware(mockTransport, 'bar'); - expect(ledgerKeyring.getAppAndVersion).toHaveBeenCalled(); + expect(ledgerKeyring.bridge.getAppNameAndVersion).toHaveBeenCalled(); }); - it('should return app name', async () => { + it('returns app name correctly', async () => { const value = await connectLedgerHardware(mockTransport, 'bar'); expect(value).toBe('appName'); }); - }); - - describe('withLedgerKeyring', () => { - it('runs the operation with a Ledger keyring', async () => { - const mockOperation = jest.fn(); - const mockLedgerKeyring = {}; - MockEngine.context.KeyringController.withKeyring.mockImplementation( - async ( - selector: Record, - operation: Parameters< - typeof MockEngine.context.KeyringController.withKeyring - >[1], - options?: Record, - ) => { - expect(selector).toStrictEqual({ type: KeyringTypes.ledger }); - expect(options).toStrictEqual({ createIfMissing: true }); - // @ts-expect-error This mock keyring is not type compatible - await operation(mockLedgerKeyring); - }, - ); - - await withLedgerKeyring(mockOperation); - - expect(mockOperation).toHaveBeenCalledWith(mockLedgerKeyring); - }); - }); - - describe('unlockLedgerDefaultAccount', () => { - it('should not call KeyringController.addNewAccountForKeyring if isAccountImportReq is false', async () => { - const account = await unlockLedgerDefaultAccount(false); - - expect(ledgerKeyring.getDefaultAccount).toHaveBeenCalled(); - expect(account).toEqual({ - address: 'defaultAccount', - balance: '0x0', - }); - }); - it('should call KeyringController.addNewAccountForKeyring if isAccountImportReq is true', async () => { - const account = await unlockLedgerDefaultAccount(true); - - expect(ledgerKeyring.getDefaultAccount).toHaveBeenCalled(); - expect(account).toEqual({ - address: 'defaultAccount', - balance: '0x0', - }); + it('calls keyring.setHdPath and keyring.setDeviceId if deviceId is different', async () => { + await connectLedgerHardware(mockTransport, 'bar'); + expect(ledgerKeyring.setHdPath).toHaveBeenCalled(); + expect(ledgerKeyring.setDeviceId).toHaveBeenCalled(); }); }); describe('openEthereumAppOnLedger', () => { - it('should call keyring.openEthApp', async () => { + it('calls keyring.openEthApp', async () => { await openEthereumAppOnLedger(); - expect(ledgerKeyring.openEthApp).toHaveBeenCalled(); + expect(ledgerKeyring.bridge.openEthApp).toHaveBeenCalled(); }); }); describe('closeRunningAppOnLedger', () => { - it('should call keyring.quitApp', async () => { + it('calls keyring.quitApp', async () => { await closeRunningAppOnLedger(); - expect(ledgerKeyring.quitApp).toHaveBeenCalled(); + expect(ledgerKeyring.bridge.closeApps).toHaveBeenCalled(); }); }); describe('forgetLedger', () => { - it('should call keyring.forgetDevice', async () => { + it('calls keyring.forgetDevice', async () => { await forgetLedger(); expect(ledgerKeyring.forgetDevice).toHaveBeenCalled(); }); }); describe('getDeviceId', () => { - it('should return deviceId', async () => { + it('returns deviceId', async () => { const value = await getDeviceId(); expect(value).toBe('deviceId'); }); }); + describe('getLedgerAccountsByOperation', () => { + it('calls ledgerKeyring.getNextPage on ledgerKeyring', async () => { + await getLedgerAccountsByOperation(OperationTypes.GET_NEXT_PAGE); + expect(ledgerKeyring.getNextPage).toHaveBeenCalled(); + }); + it('calls getPreviousPage on ledgerKeyring', async () => { + await getLedgerAccountsByOperation(OperationTypes.GET_PREVIOUS_PAGE); + expect(ledgerKeyring.getPreviousPage).toHaveBeenCalled(); + }); + it('calls getFirstPage on ledgerKeyring', async () => { + await getLedgerAccountsByOperation(OperationTypes.GET_FIRST_PAGE); + expect(ledgerKeyring.getFirstPage).toHaveBeenCalled(); + }); + }); + describe('ledgerSignTypedMessage', () => { - it('should call signTypedMessage from keyring controller and return correct signature', async () => { + it('calls signTypedMessage from keyring controller and return correct signature', async () => { const expectedArg = { from: '0x49b6FFd1BD9d1c64EEf400a64a1e4bBC33E2CAB2', data: 'data', @@ -164,4 +194,12 @@ describe('Ledger core', () => { expect(value).toBe('signature'); }); }); + + describe(`unlockLedgerWalletAccount`, () => { + it(`calls keyring.setAccountToUnlock and addAccounts`, async () => { + await unlockLedgerWalletAccount(1); + expect(ledgerKeyring.setAccountToUnlock).toHaveBeenCalled(); + expect(ledgerKeyring.addAccounts).toHaveBeenCalledWith(1); + }); + }); }); diff --git a/app/core/Ledger/Ledger.ts b/app/core/Ledger/Ledger.ts index 3e584ff7d06..d5ca3aacc5f 100644 --- a/app/core/Ledger/Ledger.ts +++ b/app/core/Ledger/Ledger.ts @@ -1,8 +1,13 @@ -import LedgerKeyring from '@consensys/ledgerhq-metamask-keyring'; import type BleTransport from '@ledgerhq/react-native-hw-transport-ble'; import { SignTypedDataVersion } from '@metamask/keyring-controller'; import ExtendedKeyringTypes from '../../constants/keyringTypes'; import Engine from '../Engine'; +import { + LedgerKeyring, + LedgerMobileBridge, +} from '@metamask/eth-ledger-bridge-keyring'; +import LEDGER_HD_PATH from './constants'; +import OperationTypes from './types'; /** * Perform an operation with the Ledger keyring. @@ -43,46 +48,25 @@ export const connectLedgerHardware = async ( ): Promise => { const appAndVersion = await withLedgerKeyring( async (keyring: LedgerKeyring) => { - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - keyring.setTransport(transport as unknown as any, deviceId); - return await keyring.getAppAndVersion(); + keyring.setHdPath(LEDGER_HD_PATH); + keyring.setDeviceId(deviceId); + + const bridge = keyring.bridge as LedgerMobileBridge; + await bridge.updateTransportMethod(transport); + return await bridge.getAppNameAndVersion(); }, ); return appAndVersion.appName; }; -/** - * Retrieve the first account from the Ledger device. - * @param isAccountImportReq - Whether we need to import a ledger account by calling addNewAccountForKeyring - * @returns The default (first) account on the device - */ -export const unlockLedgerDefaultAccount = async ( - isAccountImportReq: boolean, -): Promise<{ - address: string; - balance: string; -}> => { - const address = await withLedgerKeyring(async (keyring: LedgerKeyring) => { - if (isAccountImportReq) { - await keyring.addAccounts(1); - } - return await keyring.getDefaultAccount(); - }); - - return { - address, - balance: `0x0`, - }; -}; - /** * Automatically opens the Ethereum app on the Ledger device. */ export const openEthereumAppOnLedger = async (): Promise => { await withLedgerKeyring(async (keyring: LedgerKeyring) => { - await keyring.openEthApp(); + const bridge = keyring.bridge as LedgerMobileBridge; + await bridge.openEthApp(); }); }; @@ -91,7 +75,8 @@ export const openEthereumAppOnLedger = async (): Promise => { */ export const closeRunningAppOnLedger = async (): Promise => { await withLedgerKeyring(async (keyring: LedgerKeyring) => { - await keyring.quitApp(); + const bridge = keyring.bridge as LedgerMobileBridge; + await bridge.closeApps(); }); }; @@ -100,7 +85,7 @@ export const closeRunningAppOnLedger = async (): Promise => { */ export const forgetLedger = async (): Promise => { await withLedgerKeyring(async (keyring: LedgerKeyring) => { - await keyring.forgetDevice(); + keyring.forgetDevice(); }); }; @@ -110,7 +95,39 @@ export const forgetLedger = async (): Promise => { * @returns The DeviceId */ export const getDeviceId = async (): Promise => - await withLedgerKeyring(async (keyring: LedgerKeyring) => keyring.deviceId); + await withLedgerKeyring(async (keyring: LedgerKeyring) => + keyring.getDeviceId(), + ); + +/** + * Unlock Ledger Accounts by page + * @param operation - the operation number,
0: Get First Page
1: Get Next Page
-1: Get Previous Page + * @return The Ledger Accounts + */ +export const getLedgerAccountsByOperation = async ( + operation: number, +): Promise<{ balance: string; address: string; index: number }[]> => { + try { + const accounts = await withLedgerKeyring(async (keyring: LedgerKeyring) => { + switch (operation) { + case OperationTypes.GET_PREVIOUS_PAGE: + return await keyring.getPreviousPage(); + case OperationTypes.GET_NEXT_PAGE: + return await keyring.getNextPage(); + default: + return await keyring.getFirstPage(); + } + }); + + return accounts.map((account) => ({ + ...account, + balance: '0x0', + })); + } catch (e) { + /* istanbul ignore next */ + throw new Error(`Unspecified error when connect Ledger Hardware, ${e}`); + } +}; /** * signTypedMessage from Ledger Keyring @@ -137,3 +154,15 @@ export const ledgerSignTypedMessage = async ( version, ); }; + +/** + * Unlock Ledger Wallet Account with index, and add it that account to metamask + * + * @param index - The index of the account to unlock + */ +export const unlockLedgerWalletAccount = async (index: number) => { + await withLedgerKeyring(async (keyring: LedgerKeyring) => { + keyring.setAccountToUnlock(index); + await keyring.addAccounts(1); + }); +}; diff --git a/app/core/Ledger/constants.ts b/app/core/Ledger/constants.ts new file mode 100644 index 00000000000..f9bb42547fe --- /dev/null +++ b/app/core/Ledger/constants.ts @@ -0,0 +1,3 @@ +const LEDGER_HD_PATH = `m/44'/60'/0'/0`; + +export default LEDGER_HD_PATH; diff --git a/app/core/Ledger/types.ts b/app/core/Ledger/types.ts new file mode 100644 index 00000000000..c3f62ae8d93 --- /dev/null +++ b/app/core/Ledger/types.ts @@ -0,0 +1,7 @@ +enum OperationTypes { + GET_FIRST_PAGE = 0, + GET_NEXT_PAGE = 1, + GET_PREVIOUS_PAGE = -1, +} + +export default OperationTypes; diff --git a/e2e/selectors/Modals/AccountActionsModal.selectors.js b/e2e/selectors/Modals/AccountActionsModal.selectors.js index 69127fb8436..f958cf5dd77 100644 --- a/e2e/selectors/Modals/AccountActionsModal.selectors.js +++ b/e2e/selectors/Modals/AccountActionsModal.selectors.js @@ -4,4 +4,5 @@ export const AccountActionsModalSelectorsIDs = { VIEW_ETHERSCAN: 'view-etherscan-action', SHARE_ADDRESS: 'share-address-action', SHOW_PRIVATE_KEY: 'show-private-key-action', + REMOVE_HARDWARE_ACCOUNT: 'remove-hardward-account-action', }; diff --git a/locales/languages/en.json b/locales/languages/en.json index 97751030500..afc9e7819cd 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -611,6 +611,10 @@ "remove_account_message": "Do you really want to remove this account?", "no": "No", "yes_remove_it": "Yes, remove it", + "remove_hardware_account": "Remove hardware account", + "remove_hw_account_alert_description": "Are you sure you want to remove this hardware wallet account? You’ll have to resync your hardware wallet if you want to use this account again with MetaMask Mobile.", + "remove_account_alert_remove_btn": "Remove", + "remove_account_alert_cancel_btn": "Nevermind", "accounts_title": "Accounts", "connect_account_title": "Connect account", "connect_accounts_title": "Connect accounts", @@ -666,8 +670,7 @@ "hint_text": "Scan your Keystone wallet to ", "purpose_connect": "connect", "purpose_sign": "confirm the transaction", - "select_accounts": "Select an Account", - "please_wait": "Please wait" + "select_accounts": "Select an Account" }, "data_collection_modal": { "accept": "Okay", @@ -3182,5 +3185,8 @@ "title": "Estimated changes", "tooltip_description": "Estimated changes are what might happen if you go through with this transaction. This is just a prediction, not a guarantee.", "total_fiat": "Total = {{currency}}" + }, + "common": { + "please_wait": "Please wait" } } \ No newline at end of file diff --git a/package.json b/package.json index c7c5a0ef115..ff33efda1a6 100644 --- a/package.json +++ b/package.json @@ -137,7 +137,6 @@ "socket.io-client/engine.io-client/ws": "^8.17.1" }, "dependencies": { - "@consensys/ledgerhq-metamask-keyring": "0.0.9", "@consensys/on-ramp-sdk": "1.28.1", "@eth-optimism/contracts": "0.0.0-2021919175625", "@ethereumjs/tx": "^3.2.1", @@ -155,6 +154,7 @@ "@metamask/contract-metadata": "^2.1.0", "@metamask/controller-utils": "^10.0.0", "@metamask/design-tokens": "^4.0.0", + "@metamask/eth-ledger-bridge-keyring": "^4.1.0", "@metamask/eth-sig-util": "^7.0.2", "@metamask/etherscan-link": "^2.0.0", "@metamask/gas-fee-controller": "^18.0.0", diff --git a/yarn.lock b/yarn.lock index 9e4d9b0f8fa..8450848903d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1310,17 +1310,6 @@ resolved "https://registry.yarnpkg.com/@colors/colors/-/colors-1.6.0.tgz#ec6cd237440700bc23ca23087f513c75508958b0" integrity sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA== -"@consensys/ledgerhq-metamask-keyring@0.0.9": - version "0.0.9" - resolved "https://registry.yarnpkg.com/@consensys/ledgerhq-metamask-keyring/-/ledgerhq-metamask-keyring-0.0.9.tgz#f824381c9cf55c6e6aad5693263dee77a17d2ac2" - integrity sha512-o/wdUU/7s8fIZxP0CcKjaWQaoJ1bz2+3BgeQSsizR2WLqZJF8phYF6t9hwfZeFFgM1FMZDuanVapCvOi13QjiA== - dependencies: - "@ethereumjs/tx" "^4.2.0" - "@ledgerhq/hw-app-eth" "6.26.1" - "@metamask/eth-sig-util" "^7.0.0" - buffer "^6.0.3" - ethereumjs-util "^7.1.5" - "@consensys/on-ramp-sdk@1.28.1": version "1.28.1" resolved "https://registry.yarnpkg.com/@consensys/on-ramp-sdk/-/on-ramp-sdk-1.28.1.tgz#bcc5c06a20256b471d3943bfe02819edb9cd7212" @@ -1883,7 +1872,7 @@ dependencies: "@ethereumjs/util" "^9.0.3" -"@ethereumjs/rlp@^4.0.1": +"@ethereumjs/rlp@^4.0.0", "@ethereumjs/rlp@^4.0.1": version "4.0.1" resolved "https://registry.yarnpkg.com/@ethereumjs/rlp/-/rlp-4.0.1.tgz#626fabfd9081baab3d0a3074b0c7ecaf674aaa41" integrity sha512-tqsQiBQDQdmPWE1xkkBq4rlSW5QZpLOUJ5RJh2/9fug+q9tnUhuZoVLk7s0scUIKTOzEtR72DFBXI4WiZcMpvw== @@ -3476,12 +3465,12 @@ rxjs "6" semver "^7.3.5" -"@ledgerhq/devices@^8.2.1", "@ledgerhq/devices@^8.2.2": - version "8.2.2" - resolved "https://registry.yarnpkg.com/@ledgerhq/devices/-/devices-8.2.2.tgz#d6d758182d690ad66e14f88426c448e8c54d259d" - integrity sha512-SKahGA4p0mZ3ovypOJ2wa5mUvUkArE3HBrwWKYf+cRs+t/Licp3OJfhj+DHIxP3AfyH2xR6CFFWECYHeKwGsDQ== +"@ledgerhq/devices@^8.2.1", "@ledgerhq/devices@^8.4.0": + version "8.4.0" + resolved "https://registry.yarnpkg.com/@ledgerhq/devices/-/devices-8.4.0.tgz#f3a03576d4a53d731bdaa212a00bd0adbfb86fb1" + integrity sha512-TUrMlWZJ+5AFp2lWMw4rGQoU+WtjIqlFX5SzQDL9phaUHrt4TFierAGHsaj5+tUHudhD4JhIaLI2cn1NOyq5NQ== dependencies: - "@ledgerhq/errors" "^6.16.3" + "@ledgerhq/errors" "^6.17.0" "@ledgerhq/logs" "^6.12.0" rxjs "^7.8.1" semver "^7.3.5" @@ -3491,10 +3480,10 @@ resolved "https://registry.yarnpkg.com/@ledgerhq/errors/-/errors-5.50.0.tgz#e3a6834cb8c19346efca214c1af84ed28e69dad9" integrity sha512-gu6aJ/BHuRlpU7kgVpy2vcYk6atjB4iauP2ymF7Gk0ez0Y/6VSMVSJvubeEQN+IV60+OBK0JgeIZG7OiHaw8ow== -"@ledgerhq/errors@^6.10.0", "@ledgerhq/errors@^6.16.2", "@ledgerhq/errors@^6.16.3": - version "6.16.3" - resolved "https://registry.yarnpkg.com/@ledgerhq/errors/-/errors-6.16.3.tgz#646f68cc7e6e8d5126bce1ca06140c5ad963bee8" - integrity sha512-3w7/SJVXOPa9mpzyll7VKoKnGwDD3BzWgN1Nom8byR40DiQvOKjHX+kKQausCedTHVNBn9euzPCNsftZ9+mxfw== +"@ledgerhq/errors@^6.10.0", "@ledgerhq/errors@^6.16.2", "@ledgerhq/errors@^6.17.0": + version "6.17.0" + resolved "https://registry.yarnpkg.com/@ledgerhq/errors/-/errors-6.17.0.tgz#0d56361fe6eb7de3b239e661710679f933f1fcca" + integrity sha512-xnOVpy/gUUkusEORdr2Qhw3Vd0MGfjyVGgkGR9Ck6FXE26OIdIQ3tNmG5BdZN+gwMMFJJVxxS4/hr0taQfZ43w== "@ledgerhq/hw-app-eth@5.27.2": version "5.27.2" @@ -3575,12 +3564,12 @@ events "^3.3.0" "@ledgerhq/hw-transport@^6.24.1", "@ledgerhq/hw-transport@^6.30.4": - version "6.30.5" - resolved "https://registry.yarnpkg.com/@ledgerhq/hw-transport/-/hw-transport-6.30.5.tgz#841c9e4bb3849536db110ca2894d693d55bf54fd" - integrity sha512-JMl//7BgPBvWxrWyMu82jj6JEYtsQyOyhYtonWNgtxn6KUZWht3gU4gxmLpeIRr+DiS7e50mW7m3GA+EudZmmA== + version "6.31.0" + resolved "https://registry.yarnpkg.com/@ledgerhq/hw-transport/-/hw-transport-6.31.0.tgz#82d8154bbcec8dc0104009a646159190fba5ae76" + integrity sha512-BY1poLk8vlJdIYngp8Zfaa/V9n14dqgt1G7iNetVRhJVFEKp9EYONeC3x6q/N7x81LUpzBk6M+T+s46Z4UiXHw== dependencies: - "@ledgerhq/devices" "^8.2.2" - "@ledgerhq/errors" "^6.16.3" + "@ledgerhq/devices" "^8.4.0" + "@ledgerhq/errors" "^6.17.0" "@ledgerhq/logs" "^6.12.0" events "^3.3.0" @@ -4003,6 +3992,18 @@ "@metamask/safe-event-emitter" "^3.0.0" "@metamask/utils" "^8.3.0" +"@metamask/eth-ledger-bridge-keyring@^4.1.0": + version "4.1.0" + resolved "https://registry.yarnpkg.com/@metamask/eth-ledger-bridge-keyring/-/eth-ledger-bridge-keyring-4.1.0.tgz#90bb94b931ecba5c8ed9f0023b35f32f4ed8ac5a" + integrity sha512-ZNNV6zLwyEbzIAN8WHdTA372xst7/ajX/lvafbZDrSiiA+UuC0CfRSDOS+NOyCNnP+3NRBcJlo1ilDRYRe3ZZg== + dependencies: + "@ethereumjs/rlp" "^4.0.0" + "@ethereumjs/tx" "^4.2.0" + "@ethereumjs/util" "^8.0.0" + "@ledgerhq/hw-app-eth" "6.26.1" + "@metamask/eth-sig-util" "^7.0.1" + hdkey "^2.1.0" + "@metamask/eth-query@^3.0.1": version "3.0.1" resolved "https://registry.yarnpkg.com/@metamask/eth-query/-/eth-query-3.0.1.tgz#3439eb6c7d5ccff1d6a66df1d1802bae0c890444" @@ -18248,12 +18249,13 @@ hastscript@^5.0.0: property-information "^5.0.0" space-separated-tokens "^1.0.0" -hdkey@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/hdkey/-/hdkey-2.0.1.tgz#0a211d0c510bfc44fa3ec9d44b13b634641cad74" - integrity sha512-c+tl9PHG9/XkGgG0tD7CJpRVaE0jfZizDNmnErUAKQ4EjQSOcOUcV3EN9ZEZS8pZ4usaeiiK0H7stzuzna8feA== +hdkey@^2.0.1, hdkey@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/hdkey/-/hdkey-2.1.0.tgz#755b30b73f54e93c31919c1b2f19205a8e57cb92" + integrity sha512-i9Wzi0Dy49bNS4tXXeGeu0vIcn86xXdPQUpEYg+SO1YiO8HtomjmmRMaRyqL0r59QfcD4PfVbSF3qmsWFwAemA== dependencies: bs58check "^2.1.2" + ripemd160 "^2.0.2" safe-buffer "^5.1.1" secp256k1 "^4.0.0" @@ -25835,7 +25837,7 @@ rimraf@~2.6.2: dependencies: glob "^7.1.3" -ripemd160@^2.0.0, ripemd160@^2.0.1: +ripemd160@^2.0.0, ripemd160@^2.0.1, ripemd160@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/ripemd160/-/ripemd160-2.0.2.tgz#a1c1a6f624751577ba5d07914cbc92850585890c" integrity sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA==