diff --git a/.github/workflows/run-e2e-workflow.yml b/.github/workflows/run-e2e-workflow.yml index 715ca7b0c00..47ac303e090 100644 --- a/.github/workflows/run-e2e-workflow.yml +++ b/.github/workflows/run-e2e-workflow.yml @@ -34,7 +34,6 @@ jobs: test-e2e-mobile: name: ${{ inputs.test-suite-name }} runs-on: ${{ inputs.platform == 'ios' && 'macos-latest-xlarge' || 'gha-mmsdk-scale-set-ubuntu-22.04-amd64-xl' }} - continue-on-error: true concurrency: group: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.platform }}-${{ inputs.test-suite-name }}-${{ inputs.split_number }} cancel-in-progress: ${{ !(contains(github.ref, 'refs/heads/main') || contains(github.ref, 'refs/heads/stable')) }} diff --git a/app/components/Nav/App/App.tsx b/app/components/Nav/App/App.tsx index 29deb07e6ec..70d042ce473 100644 --- a/app/components/Nav/App/App.tsx +++ b/app/components/Nav/App/App.tsx @@ -158,7 +158,8 @@ import SolanaNewFeatureContent from '../../UI/SolanaNewFeatureContent'; import { DeepLinkModal } from '../../UI/DeepLinkModal'; import { checkForDeeplink } from '../../../actions/user'; import { WalletDetails } from '../../Views/MultichainAccounts/WalletDetails/WalletDetails'; -import { AddressList as AccountAddressList } from '../../Views/MultichainAccounts/AddressList'; +import { AddressList as MultichainAccountAddressList } from '../../Views/MultichainAccounts/AddressList'; +import { PrivateKeyList as MultichainAccountPrivateKeyList } from '../../Views/MultichainAccounts/PrivateKeyList'; import MultichainAccountActions from '../../Views/MultichainAccounts/sheets/MultichainAccountActions/MultichainAccountActions'; import useInterval from '../../hooks/useInterval'; import { Duration } from '@metamask/utils'; @@ -745,7 +746,21 @@ const MultichainAddressList = () => { > + + ); +}; + +const MultichainPrivateKeyList = () => { + const route = useRoute(); + + return ( + + @@ -897,6 +912,10 @@ const AppFlow = () => { name={Routes.MULTICHAIN_ACCOUNTS.ADDRESS_LIST} component={MultichainAddressList} /> + { label={strings('login.cancel')} width={ButtonWidthTypes.Full} testID={ForgotPasswordModalSelectorsIDs.CANCEL_BUTTON} + isDisabled={isDeletingWallet} /> diff --git a/app/components/Views/AccountBackupStep1/index.js b/app/components/Views/AccountBackupStep1/index.js index 41d281e3c99..d93db27c04c 100644 --- a/app/components/Views/AccountBackupStep1/index.js +++ b/app/components/Views/AccountBackupStep1/index.js @@ -25,7 +25,7 @@ import { ManualBackUpStepsSelectorsIDs } from '../../../../e2e/selectors/Onboard import trackOnboarding from '../../../util/metrics/TrackOnboarding/trackOnboarding'; import Routes from '../../../../app/constants/navigation/Routes'; import { MetricsEventBuilder } from '../../../core/Analytics/MetricsEventBuilder'; -import SRPDesignLight from '../../../images/secure_wallet.png'; +import SRPDesignLight from '../../../images/secure_wallet_light.png'; import SRPDesignDark from '../../../images/secure_wallet_dark.png'; import Button, { ButtonVariants, diff --git a/app/components/Views/AccountStatus/__snapshots__/index.test.tsx.snap b/app/components/Views/AccountStatus/__snapshots__/index.test.tsx.snap index 844dc4a0d5f..8ddcb286a35 100644 --- a/app/components/Views/AccountStatus/__snapshots__/index.test.tsx.snap +++ b/app/components/Views/AccountStatus/__snapshots__/index.test.tsx.snap @@ -44,13 +44,15 @@ exports[`AccountStatus Snapshots android renders correctly with accountName in r Wallet already exists @@ -215,13 +217,15 @@ exports[`AccountStatus Snapshots android renders correctly with type="found and Wallet already exists @@ -386,13 +390,15 @@ exports[`AccountStatus Snapshots android renders correctly with type="not_exist" Wallet not found @@ -557,13 +563,15 @@ exports[`AccountStatus Snapshots iOS renders correctly with accountName in route Wallet already exists @@ -728,13 +736,15 @@ exports[`AccountStatus Snapshots iOS renders correctly with type="found" 1`] = ` Wallet already exists @@ -899,13 +909,15 @@ exports[`AccountStatus Snapshots iOS renders correctly with type="not_exist" 1`] Wallet not found diff --git a/app/components/Views/AccountStatus/index.styles.ts b/app/components/Views/AccountStatus/index.styles.ts index cfec14245cb..50af705d907 100644 --- a/app/components/Views/AccountStatus/index.styles.ts +++ b/app/components/Views/AccountStatus/index.styles.ts @@ -1,4 +1,10 @@ -import { StyleSheet, Platform, StatusBar } from 'react-native'; +import { StyleSheet, Platform, StatusBar, Dimensions } from 'react-native'; + +const IMAGE_MAX_WIDTH = 343; +const IMAGE_ASPECT_RATIO = 343 / 302; +const HORIZONTAL_PADDING = 16; +const CONTAINER_WIDTH = Dimensions.get('window').width - HORIZONTAL_PADDING * 2; +const WALLET_IMAGE_WIDTH = Math.min(CONTAINER_WIDTH, IMAGE_MAX_WIDTH); const styles = StyleSheet.create({ scrollView: { @@ -19,6 +25,8 @@ const styles = StyleSheet.create({ marginHorizontal: 'auto', alignSelf: 'center', marginVertical: 16, + width: WALLET_IMAGE_WIDTH, + height: Math.round(WALLET_IMAGE_WIDTH / IMAGE_ASPECT_RATIO), }, description: { fontSize: 14, diff --git a/app/components/Views/AccountStatus/index.tsx b/app/components/Views/AccountStatus/index.tsx index d363f646553..96bca6f15f2 100644 --- a/app/components/Views/AccountStatus/index.tsx +++ b/app/components/Views/AccountStatus/index.tsx @@ -143,7 +143,7 @@ const AccountStatus = ({ diff --git a/app/components/Views/ChoosePassword/index.js b/app/components/Views/ChoosePassword/index.js index f2a31857b97..5d1856ea403 100644 --- a/app/components/Views/ChoosePassword/index.js +++ b/app/components/Views/ChoosePassword/index.js @@ -476,7 +476,6 @@ class ChoosePassword extends PureComponent { this.props.passwordSet(); this.props.setLockTime(AppConstants.DEFAULT_LOCK_TIMEOUT); - this.setState({ loading: false }); if (authType.oauth2Login) { endTrace({ name: TraceName.OnboardingNewSocialCreateWallet }); diff --git a/app/components/Views/Login/__snapshots__/index.test.tsx.snap b/app/components/Views/Login/__snapshots__/index.test.tsx.snap index 2e2513c3612..bc91516f98a 100644 --- a/app/components/Views/Login/__snapshots__/index.test.tsx.snap +++ b/app/components/Views/Login/__snapshots__/index.test.tsx.snap @@ -286,24 +286,28 @@ exports[`Login renders matching snapshot 1`] = ` Unlock - Forgot password? - + @@ -628,24 +632,28 @@ exports[`Login renders matching snapshot when password input is focused 1`] = ` Unlock - Forgot password? - + diff --git a/app/components/Views/Login/index.tsx b/app/components/Views/Login/index.tsx index 798a1c1c848..7d643b8359c 100644 --- a/app/components/Views/Login/index.tsx +++ b/app/components/Views/Login/index.tsx @@ -771,6 +771,8 @@ const Login: React.FC = ({ saveOnboardingEvent }) => { onPress={toggleWarningModal} testID={LoginViewSelectors.RESET_WALLET} label={strings('login.forgot_password')} + isDisabled={loading} + size={ButtonSize.Lg} /> )} @@ -783,6 +785,9 @@ const Login: React.FC = ({ saveOnboardingEvent }) => { onPress={handleUseOtherMethod} testID={LoginViewSelectors.OTHER_METHODS_BUTTON} label={strings('login.other_methods')} + loading={loading} + isDisabled={loading} + size={ButtonSize.Lg} /> )} diff --git a/app/components/Views/Login/styles.ts b/app/components/Views/Login/styles.ts index d694c5fdf5d..184955f0c1e 100644 --- a/app/components/Views/Login/styles.ts +++ b/app/components/Views/Login/styles.ts @@ -69,6 +69,7 @@ const styleSheet = (params: { theme: Theme }) => { }, goBack: { marginVertical: 14, + alignSelf: 'center', }, biometrics: { flexDirection: 'row', diff --git a/app/components/Views/MultichainAccounts/AccountGroupDetails/AccountGroupDetails.tsx b/app/components/Views/MultichainAccounts/AccountGroupDetails/AccountGroupDetails.tsx index d29214c54bb..98677a5da4f 100644 --- a/app/components/Views/MultichainAccounts/AccountGroupDetails/AccountGroupDetails.tsx +++ b/app/components/Views/MultichainAccounts/AccountGroupDetails/AccountGroupDetails.tsx @@ -34,6 +34,7 @@ import { AccountGroupType } from '@metamask/account-api'; import { isHDOrFirstPartySnapAccount } from '../../../../util/address'; import { selectInternalAccountsById } from '../../../../selectors/accountsController'; import { SecretRecoveryPhrase, Wallet, RemoveAccount } from './components'; +import { createPrivateKeyListNavigationDetails } from '../PrivateKeyList/PrivateKeyList'; interface AccountGroupDetailsProps { route: { @@ -142,6 +143,16 @@ export const AccountGroupDetails = (props: AccountGroupDetailsProps) => { { + navigation.navigate( + ...createPrivateKeyListNavigationDetails({ + groupId: id, + title: strings( + 'multichain_accounts.account_details.private_keys', + ), + }), + ); + }} > {strings('multichain_accounts.account_details.private_keys')} diff --git a/app/components/Views/MultichainAccounts/PrivateKeyList/PrivateKeyList.test.tsx b/app/components/Views/MultichainAccounts/PrivateKeyList/PrivateKeyList.test.tsx new file mode 100644 index 00000000000..955cd5b56ac --- /dev/null +++ b/app/components/Views/MultichainAccounts/PrivateKeyList/PrivateKeyList.test.tsx @@ -0,0 +1,221 @@ +import React from 'react'; +import { fireEvent } from '@testing-library/react-native'; +import { AccountGroupId, AccountWalletId } from '@metamask/account-api'; +import { SolAccountType, EthScope, SolScope } from '@metamask/keyring-api'; + +import { createMockInternalAccount } from '../../../../util/test/accountsControllerTestUtils'; +import { renderScreen } from '../../../../util/test/renderWithProvider'; +import { PrivateKeyListIds } from '../../../../../e2e/selectors/MultichainAccounts/PrivateKeyList.selectors'; +import { strings } from '../../../../../locales/i18n'; + +import { PrivateKeyList } from './PrivateKeyList'; + +const ACCOUNT_WALLET_ID = 'entropy:wallet-id-1' as AccountWalletId; +const ACCOUNT_GROUP_ID = 'entropy:wallet-id-1/1' as AccountGroupId; + +const TITLE = 'Private Key List'; +const shortenedEthAddress = '0x4FeC2...fdcB5'; + +const mockNavigate = jest.fn(); +const mockGoBack = jest.fn(); +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: () => ({ + navigate: mockNavigate, + goBack: mockGoBack, + }), +})); + +jest.mock('../../../../util/navigation/navUtils', () => ({ + useParams: jest.fn().mockReturnValue({ + title: TITLE, + groupId: ACCOUNT_GROUP_ID, + }), + useRoute: jest.fn(), + createNavigationDetails: jest.fn(), +})); + +jest.mock('../../../../core/Engine', () => ({ + context: { + KeyringController: { + verifyPassword: (password: string) => { + if (password === 'correct-password') { + return Promise.resolve(); + } + return Promise.reject(new Error('Wrong password')); + }, + exportAccount: jest.fn().mockImplementation((password, address) => { + if (password === 'correct-password') { + return Promise.resolve(`mock-private-key-for-${address}`); + } + return Promise.reject(new Error('Wrong password')); + }), + state: { + keyrings: [], + }, + }, + }, +})); + +const mockEthEoaAccount = { + ...createMockInternalAccount( + '0x4fec2622fb662e892dd0e5060b91fa49ddcfdcb5', + 'Eth Account 1', + ), + id: 'mock-eth-account-1', + scopes: [EthScope.Eoa], +}; + +const mockSolAccount = { + ...createMockInternalAccount( + 'FcdCd3moFy29rZDxjt9jhT5HpFB8VssD6c79g4UGPZgj', + 'Sol Account 1', + ), + id: 'mock-eth-account-2', + scopes: [SolScope.Mainnet, SolScope.Testnet, SolScope.Devnet], + type: SolAccountType.DataAccount, +}; + +const renderWithPrivateKeyList = () => { + const mockAccountsControllerState = { + internalAccounts: { + accounts: { + [mockEthEoaAccount.id]: mockEthEoaAccount, + [mockSolAccount.id]: mockSolAccount, + }, + }, + }; + + const mockAccountTreeControllerState = { + accountTree: { + wallets: { + [ACCOUNT_WALLET_ID]: { + id: ACCOUNT_WALLET_ID, + metadata: { name: 'Mock Wallet' }, + groups: { + [ACCOUNT_GROUP_ID]: { + accounts: [mockEthEoaAccount.id, mockSolAccount.id], + id: ACCOUNT_GROUP_ID, + }, + }, + }, + }, + }, + }; + + const mockNetworkControllerState = { + networkConfigurationsByChainId: { + 0x1: { + chainId: '0x1', + name: 'Ethereum', + }, + 0xaa36a7: { + chainId: '0xaa36a7', + name: 'Sepolia Test Network', + }, + 0x2105: { + chainId: '0x2105', + name: 'Base', + }, + 0xa4b1: { + chainId: '0xa4b1', + name: 'Arbitrum One', + }, + }, + }; + + const mockMultichainNetworkController = { + multichainNetworkConfigurationsByChainId: { + [SolScope.Mainnet]: { + name: 'Solana Mainnet', + chainId: SolScope.Mainnet, + isTestnet: false, + }, + [SolScope.Testnet]: { + name: 'Solana Testnet', + chainId: SolScope.Testnet, + isTestnet: true, + }, + [SolScope.Devnet]: { + name: 'Solana Devnet', + chainId: SolScope.Devnet, + isTestnet: true, + }, + }, + }; + + const mockState = { + engine: { + backgroundState: { + AccountsController: mockAccountsControllerState, + AccountTreeController: mockAccountTreeControllerState, + NetworkController: mockNetworkControllerState, + MultichainNetworkController: mockMultichainNetworkController, + }, + }, + }; + + return renderScreen( + () => , + { + name: 'PrivateKeyList', + }, + { + state: mockState, + }, + ); +}; + +describe('PrivateKeyList', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders password input box correctly', () => { + const { getByTestId } = renderWithPrivateKeyList(); + + expect(getByTestId(PrivateKeyListIds.PASSWORD_TITLE)).toBeDefined(); + expect(getByTestId(PrivateKeyListIds.BANNER)).toBeDefined(); + expect(getByTestId(PrivateKeyListIds.PASSWORD_INPUT)).toBeDefined(); + expect(getByTestId(PrivateKeyListIds.CONTINUE_BUTTON)).toBeDefined(); + expect(getByTestId(PrivateKeyListIds.CANCEL_BUTTON)).toBeDefined(); + }); + + it('shows an error message for an incorrect password', async () => { + const { getByTestId, findByTestId, queryByTestId } = + renderWithPrivateKeyList(); + + fireEvent.changeText( + getByTestId(PrivateKeyListIds.PASSWORD_INPUT), + 'wrong-password', + ); + fireEvent.press(getByTestId(PrivateKeyListIds.CONTINUE_BUTTON)); + + const errorMessage = await findByTestId(PrivateKeyListIds.PASSWORD_ERROR); + expect(errorMessage).toBeOnTheScreen(); + expect(errorMessage).toHaveTextContent( + strings('multichain_accounts.private_key_list.wrong_password'), + ); + expect(queryByTestId(PrivateKeyListIds.LIST)).toBeNull(); + }); + + it('reveals private key list for correct password and filters accounts', async () => { + const { getByTestId, getByText, findByTestId, getAllByText } = + renderWithPrivateKeyList(); + + fireEvent.changeText( + getByTestId(PrivateKeyListIds.PASSWORD_INPUT), + 'correct-password', + ); + fireEvent.press(getByTestId(PrivateKeyListIds.CONTINUE_BUTTON)); + + await findByTestId(PrivateKeyListIds.LIST); + + expect(getByText(TITLE)).toBeDefined(); + + expect(getAllByText(shortenedEthAddress).length).toBe(3); + expect(getByText('Ethereum')).toBeDefined(); + expect(getByText('Base')).toBeDefined(); + expect(getByText('Arbitrum One')).toBeDefined(); + }); +}); diff --git a/app/components/Views/MultichainAccounts/PrivateKeyList/PrivateKeyList.tsx b/app/components/Views/MultichainAccounts/PrivateKeyList/PrivateKeyList.tsx new file mode 100644 index 00000000000..32e8f3aeff4 --- /dev/null +++ b/app/components/Views/MultichainAccounts/PrivateKeyList/PrivateKeyList.tsx @@ -0,0 +1,277 @@ +import React, { + Fragment, + useState, + useEffect, + useCallback, + useRef, +} from 'react'; +import { View, TextInput } from 'react-native'; +import { useSelector } from 'react-redux'; +import { FlashList } from '@shopify/flash-list'; +import { KeyringTypes } from '@metamask/keyring-controller'; +import { InternalAccount } from '@metamask/keyring-internal-api'; +// Core +import Engine from '../../../../core/Engine'; +import ClipboardManager from '../../../../core/ClipboardManager'; +// Hooks +import { useStyles } from '../../../hooks/useStyles'; +// Selectors +import { + selectInternalAccountListSpreadByScopesByGroupId, + selectInternalAccountsByGroupId, +} from '../../../../selectors/multichainAccounts/accounts'; +// Components +import MultichainAddressRow from '../../../../component-library/components-temp/MultichainAccounts/MultichainAddressRow'; +import SheetHeader from '../../../../component-library/components/Sheet/SheetHeader'; +import BottomSheet, { + BottomSheetRef, +} from '../../../../component-library/components/BottomSheets/BottomSheet'; +import Banner, { + BannerVariant, + BannerAlertSeverity, +} from '../../../../component-library/components/Banners/Banner'; +import { strings } from '../../../../../locales/i18n'; +import Text, { + TextVariant, + TextColor, +} from '../../../../component-library/components/Texts/Text'; +import Button, { + ButtonSize, + ButtonVariants, +} from '../../../../component-library/components/Buttons/Button'; +import { + useParams, + createNavigationDetails, +} from '../../../../util/navigation/navUtils'; +import Routes from '../../../../constants/navigation/Routes'; +import { PrivateKeyListIds } from '../../../../../e2e/selectors/MultichainAccounts/PrivateKeyList.selectors'; + +import styleSheet from './styles'; +import type { Params as PrivateKeyListParams, AddressItem } from './types'; + +export const createPrivateKeyListNavigationDetails = + createNavigationDetails( + Routes.MULTICHAIN_ACCOUNTS.PRIVATE_KEY_LIST, + ); + +/** + * Check if the account has the private key available according to its keyring type. + * TODO: Add support for KeyringTypes.snap + * + * @param account - The internal account to check. + * @returns True if the private key is available, false otherwise. + */ +const hasPrivateKeyAvailable = (account: InternalAccount) => + account.metadata.keyring.type === KeyringTypes.hd || + account.metadata.keyring.type === KeyringTypes.simple; + +/** + * AddressList component displays a list of addresses spread by scopes. + * + * @param props - Component properties. + * @returns {JSX.Element} The rendered component. + */ +export const PrivateKeyList = () => { + const { groupId, title } = useParams(); + + const { styles } = useStyles(styleSheet, {}); + const sheetRef = useRef(null); + const [password, setPassword] = useState(''); + const [wrongPassword, setWrongPassword] = useState(false); + const [reveal, setReveal] = useState(false); + const [privateKeys, setPrivateKeys] = useState>({}); + + const getInternalAccountsByGroupId = useSelector( + selectInternalAccountsByGroupId, + ); + const accounts = getInternalAccountsByGroupId(groupId); + + const selectInternalAccountsSpreadByScopes = useSelector( + selectInternalAccountListSpreadByScopesByGroupId, + ); + const internalAccountsSpreadByScopes = + selectInternalAccountsSpreadByScopes(groupId); + + useEffect( + () => () => { + // Clean state variables on unmount + setPassword(''); + setWrongPassword(false); + setReveal(false); + setPrivateKeys({}); + }, + [], + ); + + const onPasswordChange = useCallback((pswd: string) => { + setPassword(pswd); + }, []); + + const unlockPrivateKeys = useCallback(async () => { + const { KeyringController } = Engine.context; + const pkAccounts = accounts.filter((account: InternalAccount) => + hasPrivateKeyAvailable(account), + ); + + const privateKeyMap: Record = {}; + await Promise.all( + pkAccounts.map(async (account: InternalAccount) => { + const pk = await KeyringController.exportAccount( + password, + account.address, + ); + privateKeyMap[account.address] = pk; + }), + ); + + setPrivateKeys(privateKeyMap); + }, [accounts, password]); + + const verifyPasswordAndUnlockKeys = useCallback(async () => { + const { KeyringController } = Engine.context; + try { + await KeyringController.verifyPassword(password); + } catch (error) { + setWrongPassword(true); + setReveal(false); + setPrivateKeys({}); + return; + } + + await unlockPrivateKeys(); + setWrongPassword(false); + setReveal(true); + }, [password, unlockPrivateKeys]); + + const onCancel = useCallback(() => { + if (sheetRef.current) { + sheetRef.current.onCloseBottomSheet(); + } + }, []); + + const filteredAccounts = useCallback( + () => + internalAccountsSpreadByScopes.filter((item: AddressItem) => + hasPrivateKeyAvailable(item.account), + ), + [internalAccountsSpreadByScopes], + ); + + const renderAddressItem = useCallback( + ({ item }: { item: AddressItem }) => ( + { + await ClipboardManager.setStringExpire( + privateKeys[item.account.address], + ); + }, + }} + /> + ), + [privateKeys], + ); + + const renderPassword = useCallback( + () => ( + <> + + + {strings('multichain_accounts.private_key_list.enter_password')} + + + + + {wrongPassword && ( + + {strings('multichain_accounts.private_key_list.wrong_password')} + + )} + + +