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==