diff --git a/.storybook/storybook.requires.js b/.storybook/storybook.requires.js
index a1ca7774ac8..c08c085d9a9 100644
--- a/.storybook/storybook.requires.js
+++ b/.storybook/storybook.requires.js
@@ -126,7 +126,7 @@ const getStories = () => {
"./app/components/Views/confirmations/components/UI/InfoRow/InfoRow.stories.tsx": require("../app/components/Views/confirmations/components/UI/InfoRow/InfoRow.stories.tsx"),
"./app/components/Views/confirmations/components/UI/ExpandableSection/ExpandableSection.stories.tsx": require("../app/components/Views/confirmations/components/UI/ExpandableSection/ExpandableSection.stories.tsx"),
"./app/components/Views/confirmations/components/UI/Tooltip/Tooltip.stories.tsx": require("../app/components/Views/confirmations/components/UI/Tooltip/Tooltip.stories.tsx"),
- "./app/components/Views/confirmations/components/UI/CopyButton/CopyButton.stories.tsx": require("../app/components/Views/confirmations/components/UI/CopyButton/CopyButton.stories.tsx"),
+ "./app/component-library/components/Texts/SensitiveText/SensitiveText.stories.tsx": require("../app/component-library/components/Texts/SensitiveText/SensitiveText.stories.tsx"),
};
};
diff --git a/app/component-library/components/Pickers/PickerBase/PickerBase.stories.tsx b/app/component-library/components/Pickers/PickerBase/PickerBase.stories.tsx
new file mode 100644
index 00000000000..e5a705d98dd
--- /dev/null
+++ b/app/component-library/components/Pickers/PickerBase/PickerBase.stories.tsx
@@ -0,0 +1,57 @@
+/* eslint-disable react/display-name */
+/* eslint-disable react-native/no-inline-styles */
+// External dependencies.
+import React from 'react';
+import { View, Text } from 'react-native';
+
+// Internal dependencies.
+import PickerBase from './PickerBase';
+import { IconSize } from '../../Icons/Icon';
+
+const PickerBaseMeta = {
+ title: 'Component Library / Pickers',
+ component: PickerBase,
+ argTypes: {
+ children: {
+ control: { type: 'text' },
+ defaultValue: 'Select an option',
+ },
+ iconSize: {
+ options: Object.values(IconSize),
+ control: { type: 'select' },
+ defaultValue: IconSize.Md,
+ },
+ },
+};
+
+export default PickerBaseMeta;
+
+export const Default = {
+ render: ({
+ children,
+ iconSize,
+ }: {
+ children: string;
+ iconSize: IconSize;
+ }) => (
+
+ null} iconSize={iconSize}>
+ {children}
+
+
+ ),
+};
+
+export const WithCustomStyles = {
+ render: () => (
+
+ null}
+ style={{ width: 200 }}
+ dropdownIconStyle={{ marginLeft: 20 }}
+ >
+ Custom Styled Picker
+
+
+ ),
+};
diff --git a/app/component-library/components/Pickers/PickerBase/PickerBase.styles.ts b/app/component-library/components/Pickers/PickerBase/PickerBase.styles.ts
index 475e466e6b1..cad2a763c44 100644
--- a/app/component-library/components/Pickers/PickerBase/PickerBase.styles.ts
+++ b/app/component-library/components/Pickers/PickerBase/PickerBase.styles.ts
@@ -21,7 +21,8 @@ const styleSheet = (params: {
}) => {
const { vars, theme } = params;
const { colors } = theme;
- const { style } = vars;
+ const { style, dropdownIconStyle } = vars;
+
return StyleSheet.create({
base: Object.assign(
{
@@ -35,9 +36,12 @@ const styleSheet = (params: {
} as ViewStyle,
style,
) as ViewStyle,
- dropdownIcon: {
- marginLeft: 16,
- },
+ dropdownIcon: Object.assign(
+ {
+ marginLeft: 16,
+ } as ViewStyle,
+ dropdownIconStyle,
+ ),
});
};
diff --git a/app/component-library/components/Pickers/PickerBase/PickerBase.test.tsx b/app/component-library/components/Pickers/PickerBase/PickerBase.test.tsx
index 662319adb46..94ec1e73255 100644
--- a/app/component-library/components/Pickers/PickerBase/PickerBase.test.tsx
+++ b/app/component-library/components/Pickers/PickerBase/PickerBase.test.tsx
@@ -1,18 +1,78 @@
// Third party dependencies.
import React from 'react';
-import { View } from 'react-native';
-import { render } from '@testing-library/react-native';
+import { Text } from 'react-native';
+import { render, fireEvent } from '@testing-library/react-native';
// Internal dependencies.
import PickerBase from './PickerBase';
+import { IconName, IconSize } from '../../Icons/Icon';
describe('PickerBase', () => {
it('should render correctly', () => {
const { toJSON } = render(
-
-
+
+ Test Content
,
);
expect(toJSON()).toMatchSnapshot();
});
+
+ it('should call onPress when pressed', () => {
+ const onPressMock = jest.fn();
+ const { getByText } = render(
+
+ Test Content
+ ,
+ );
+
+ fireEvent.press(getByText('Test Content'));
+ expect(onPressMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('should render children correctly', () => {
+ const { getByText } = render(
+
+ Child Component
+ ,
+ );
+
+ expect(getByText('Child Component')).toBeTruthy();
+ });
+
+ it('should render dropdown icon', () => {
+ const { UNSAFE_getByProps } = render(
+
+ Test Content
+ ,
+ );
+
+ const icon = UNSAFE_getByProps({ name: IconName.ArrowDown });
+ expect(icon).toBeTruthy();
+ });
+
+ it('should apply custom icon size', () => {
+ const { UNSAFE_getByProps } = render(
+
+ Test Content
+ ,
+ );
+
+ const icon = UNSAFE_getByProps({
+ name: IconName.ArrowDown,
+ size: IconSize.Lg,
+ });
+ expect(icon).toBeTruthy();
+ });
+
+ it('should apply custom dropdown icon style', () => {
+ const customStyle = { marginLeft: 20 };
+ const { UNSAFE_getByProps } = render(
+
+ Test Content
+ ,
+ );
+
+ const icon = UNSAFE_getByProps({ name: IconName.ArrowDown });
+ expect(icon.props.style).toEqual(expect.objectContaining(customStyle));
+ });
});
diff --git a/app/component-library/components/Pickers/PickerBase/PickerBase.tsx b/app/component-library/components/Pickers/PickerBase/PickerBase.tsx
index 5488138d687..10d39f7be7e 100644
--- a/app/component-library/components/Pickers/PickerBase/PickerBase.tsx
+++ b/app/component-library/components/Pickers/PickerBase/PickerBase.tsx
@@ -15,15 +15,18 @@ import styleSheet from './PickerBase.styles';
const PickerBase: React.ForwardRefRenderFunction<
TouchableOpacity,
PickerBaseProps
-> = ({ style, children, ...props }, ref) => {
- const { styles, theme } = useStyles(styleSheet, { style });
+> = (
+ { iconSize = IconSize.Md, style, dropdownIconStyle, children, ...props },
+ ref,
+) => {
+ const { styles, theme } = useStyles(styleSheet, { style, dropdownIconStyle });
const { colors } = theme;
return (
{children}
;
+export type PickerBaseStyleSheetVars = Pick<
+ PickerBaseProps,
+ 'style' | 'dropdownIconStyle'
+>;
diff --git a/app/component-library/components/Pickers/PickerBase/README.md b/app/component-library/components/Pickers/PickerBase/README.md
index 74ec00f6ba7..ea5b298becd 100644
--- a/app/component-library/components/Pickers/PickerBase/README.md
+++ b/app/component-library/components/Pickers/PickerBase/README.md
@@ -1,10 +1,10 @@
# PickerBase
-PickerBase is a **wrapper** component used for providing a dropdown icon next to wrapped content.
+PickerBase is a **wrapper** component used for providing a dropdown icon next to wrapped content. It's designed to be a flexible base for various picker-style components.
## Props
-This component extends `TouchableOpacityProps` from React Native's [TouchableOpacityProps](https://reactnative.dev/docs/touchableOpacity) opacity.
+This component extends `TouchableOpacityProps` from React Native's [TouchableOpacity](https://reactnative.dev/docs/touchableopacity).
### `onPress`
@@ -22,11 +22,55 @@ Content to wrap in PickerBase.
| :-------------------------------------------------- | :------------------------------------------------------ |
| ReactNode | Yes |
+### `iconSize`
+
+Size of the dropdown icon.
+
+| TYPE | REQUIRED | DEFAULT |
+| :-------------------------------------------------- | :------------------------------------------------------ | :----------------------------------------------------- |
+| IconSize | No | IconSize.Md |
+
+### `dropdownIconStyle`
+
+Custom styles for the dropdown icon.
+
+| TYPE | REQUIRED |
+| :-------------------------------------------------- | :------------------------------------------------------ |
+| ViewStyle | No |
+
+### `style`
+
+Custom styles for the main container.
+
+| TYPE | REQUIRED |
+| :-------------------------------------------------- | :------------------------------------------------------ |
+| ViewStyle | No |
+
+## Usage
+
```javascript
-// Replace import with relative path.
+import React from 'react';
+import { Text } from 'react-native';
import PickerBase from 'app/component-library/components/Pickers/PickerBase';
+import { IconSize } from 'app/component-library/components/Icons/Icon';
+
+const ExampleComponent = () => (
+ console.log('Picker pressed')}
+ iconSize={IconSize.Lg}
+ dropdownIconStyle={{ marginLeft: 20 }}
+ style={{ backgroundColor: 'lightgray' }}
+ >
+ Select an option
+
+);
-
-
-;
+export default ExampleComponent;
```
+
+## Notes
+
+- The component uses a `TouchableOpacity` as its base, providing press feedback.
+- It automatically includes a dropdown icon (ArrowDown) to the right of the content.
+- The component is designed to be flexible and can be customized using the `style` and `dropdownIconStyle` props.
+- The dropdown icon color is determined by the theme's `colors.icon.default`.
\ No newline at end of file
diff --git a/app/component-library/components/Pickers/PickerBase/__snapshots__/PickerBase.test.tsx.snap b/app/component-library/components/Pickers/PickerBase/__snapshots__/PickerBase.test.tsx.snap
index f93ccf2a438..21db5a5774a 100644
--- a/app/component-library/components/Pickers/PickerBase/__snapshots__/PickerBase.test.tsx.snap
+++ b/app/component-library/components/Pickers/PickerBase/__snapshots__/PickerBase.test.tsx.snap
@@ -2,7 +2,7 @@
exports[`PickerBase should render correctly 1`] = `
-
+
+ Test Content
+
TYPE | REQUIRED | DEFAULT |
+| :-------------------------------------------------- | :------------------------------------------------------ | :----------------------------------------------------- |
+| boolean | Yes | false |
+
+### `length`
+
+Determines the length of the hidden text (number of dots). Can be a predefined SensitiveTextLength or a custom string number.
+
+| TYPE | REQUIRED | DEFAULT |
+| :-------------------------------------------------- | :------------------------------------------------------ | :----------------------------------------------------- |
+| [SensitiveTextLengthType](./SensitiveText.types.ts#L14) \| [CustomLength](./SensitiveText.types.ts#L19) | No | SensitiveTextLength.Short |
+
+### `children`
+
+The text content to be displayed or hidden.
+
+| TYPE | REQUIRED | DEFAULT |
+| :-------------------------------------------------- | :------------------------------------------------------ | :----------------------------------------------------- |
+| string | Yes | - |
+
+## Usage
+
+```javascript
+import SensitiveText from 'app/component-library/components/Texts/SensitiveText';
+import { TextVariant } from 'app/component-library/components/Texts/Text';
+import { SensitiveTextLength } from 'app/component-library/components/Texts/SensitiveText/SensitiveText.types';
+
+
+ Sensitive Information
+
+
+
+ Custom Length Hidden Text
+
+```
+
+This will render a Text component with dots instead of the actual text when `isHidden` is true, and the original text when `isHidden` is false. The number of asterisks is determined by the `length` prop.
diff --git a/app/component-library/components/Texts/SensitiveText/SensitiveText.stories.tsx b/app/component-library/components/Texts/SensitiveText/SensitiveText.stories.tsx
new file mode 100644
index 00000000000..c881014ed43
--- /dev/null
+++ b/app/component-library/components/Texts/SensitiveText/SensitiveText.stories.tsx
@@ -0,0 +1,89 @@
+// Third party dependencies
+import React from 'react';
+
+// External dependencies
+import { TextVariant, TextColor } from '../Text/Text.types';
+
+// Internal dependencies
+import SensitiveText from './SensitiveText';
+import { SensitiveTextProps, SensitiveTextLength } from './SensitiveText.types';
+
+const SensitiveTextMeta = {
+ title: 'Component Library / Texts',
+ component: SensitiveText,
+ argTypes: {
+ isHidden: {
+ control: 'boolean',
+ },
+ length: {
+ options: SensitiveTextLength,
+ control: {
+ type: 'select',
+ },
+ },
+ variant: {
+ options: TextVariant,
+ control: {
+ type: 'select',
+ },
+ },
+ color: {
+ options: TextColor,
+ control: {
+ type: 'select',
+ },
+ },
+ children: {
+ control: { type: 'text' },
+ },
+ },
+};
+export default SensitiveTextMeta;
+
+export const SensitiveTextExample = {
+ args: {
+ isHidden: false,
+ length: SensitiveTextLength.Short,
+ variant: TextVariant.BodyMD,
+ color: TextColor.Default,
+ children: 'Sensitive Information',
+ },
+};
+
+export const SensitiveTextVariants = (
+ args: React.JSX.IntrinsicAttributes &
+ SensitiveTextProps & { children?: React.ReactNode | undefined },
+) => (
+ <>
+
+ Visible Sensitive Text
+
+ {Object.values(SensitiveTextLength).map((length) => (
+
+ {`Hidden (${length})`}
+
+ ))}
+ >
+);
+SensitiveTextVariants.argTypes = {
+ isHidden: { control: false },
+ length: { control: false },
+ children: { control: false },
+};
+SensitiveTextVariants.args = {
+ variant: TextVariant.BodyMD,
+ color: TextColor.Default,
+};
diff --git a/app/component-library/components/Texts/SensitiveText/SensitiveText.test.tsx b/app/component-library/components/Texts/SensitiveText/SensitiveText.test.tsx
new file mode 100644
index 00000000000..2e5b158263c
--- /dev/null
+++ b/app/component-library/components/Texts/SensitiveText/SensitiveText.test.tsx
@@ -0,0 +1,116 @@
+// Third party dependencies
+import React from 'react';
+import { render } from '@testing-library/react-native';
+
+// External dependencies
+import { mockTheme } from '../../../../util/theme';
+
+// Internal dependencies
+import SensitiveText from './SensitiveText';
+import { SensitiveTextLength } from './SensitiveText.types';
+import { TextVariant, TextColor } from '../Text/Text.types';
+
+describe('SensitiveText', () => {
+ const testProps = {
+ isHidden: false,
+ length: SensitiveTextLength.Short,
+ variant: TextVariant.BodyMD,
+ color: TextColor.Default,
+ children: 'Sensitive Information',
+ };
+
+ it('should render correctly', () => {
+ const wrapper = render();
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ it('should display the text when isHidden is false', () => {
+ const { getByText } = render();
+ expect(getByText('Sensitive Information')).toBeTruthy();
+ });
+
+ it('should hide the text when isHidden is true', () => {
+ const { queryByText, getByText } = render(
+ ,
+ );
+ expect(queryByText('Sensitive Information')).toBeNull();
+ expect(getByText('••••••')).toBeTruthy();
+ });
+
+ it('should render the correct number of asterisks for different lengths', () => {
+ const { getByText: getShort } = render(
+ ,
+ );
+ expect(getShort('••••••')).toBeTruthy();
+
+ const { getByText: getMedium } = render(
+ ,
+ );
+ expect(getMedium('•••••••••')).toBeTruthy();
+
+ const { getByText: getLong } = render(
+ ,
+ );
+ expect(getLong('••••••••••••')).toBeTruthy();
+
+ const { getByText: getExtraLong } = render(
+ ,
+ );
+ expect(getExtraLong('••••••••••••••••••••')).toBeTruthy();
+ });
+
+ it('should apply the correct text color', () => {
+ const { getByText } = render(
+ ,
+ );
+ const textElement = getByText('Sensitive Information');
+ expect(textElement.props.style.color).toBe(mockTheme.colors.text.default);
+ });
+ it('should handle all predefined SensitiveTextLength values', () => {
+ Object.entries(SensitiveTextLength).forEach(([_, value]) => {
+ const { getByText } = render(
+ ,
+ );
+ expect(getByText('•'.repeat(Number(value)))).toBeTruthy();
+ });
+ });
+
+ it('should handle custom length as a string', () => {
+ const { getByText } = render(
+ ,
+ );
+ expect(getByText('•••••••••••••••')).toBeTruthy();
+ });
+
+ it('should fall back to Short length for invalid custom length', () => {
+ const { getByText } = render(
+ ,
+ );
+ expect(getByText('••••••')).toBeTruthy();
+ });
+
+ it('should log a warning for invalid custom length', () => {
+ const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();
+ render();
+ expect(consoleSpy).toHaveBeenCalledWith(
+ 'Invalid length provided: abc. Falling back to Short.',
+ );
+ consoleSpy.mockRestore();
+ });
+});
diff --git a/app/component-library/components/Texts/SensitiveText/SensitiveText.tsx b/app/component-library/components/Texts/SensitiveText/SensitiveText.tsx
new file mode 100644
index 00000000000..6e1f512c35b
--- /dev/null
+++ b/app/component-library/components/Texts/SensitiveText/SensitiveText.tsx
@@ -0,0 +1,39 @@
+// external dependencies
+import React, { useMemo } from 'react';
+import Text from '../Text/Text';
+
+// internal dependencies
+import { SensitiveTextProps, SensitiveTextLength } from './SensitiveText.types';
+
+const SensitiveText: React.FC = ({
+ isHidden = false,
+ children = '',
+ length = SensitiveTextLength.Short,
+ ...props
+}) => {
+ const getFallbackLength = useMemo(
+ () => (len: string) => {
+ const numLength = Number(len);
+ return Number.isNaN(numLength) ? 0 : numLength;
+ },
+ [],
+ );
+
+ const isValidCustomLength = (value: string): boolean => {
+ const num = Number(value);
+ return !Number.isNaN(num) && num > 0;
+ };
+
+ if (!(length in SensitiveTextLength) && !isValidCustomLength(length)) {
+ console.warn(`Invalid length provided: ${length}. Falling back to Short.`);
+ length = SensitiveTextLength.Short;
+ }
+
+ const fallback = useMemo(
+ () => '•'.repeat(getFallbackLength(length)),
+ [length, getFallbackLength],
+ );
+ return {isHidden ? fallback : children};
+};
+
+export default SensitiveText;
diff --git a/app/component-library/components/Texts/SensitiveText/SensitiveText.types.ts b/app/component-library/components/Texts/SensitiveText/SensitiveText.types.ts
new file mode 100644
index 00000000000..1c6f4688b78
--- /dev/null
+++ b/app/component-library/components/Texts/SensitiveText/SensitiveText.types.ts
@@ -0,0 +1,46 @@
+// External dependencies.
+import { TextProps } from '../Text/Text.types';
+
+/**
+ * SensitiveText length options.
+ */
+export const SensitiveTextLength = {
+ Short: '6',
+ Medium: '9',
+ Long: '12',
+ ExtraLong: '20',
+} as const;
+
+/**
+ * Type for SensitiveTextLength values.
+ */
+export type SensitiveTextLengthType =
+ (typeof SensitiveTextLength)[keyof typeof SensitiveTextLength];
+
+/**
+ * Type for custom length values.
+ */
+export type CustomLength = string;
+
+/**
+ * SensitiveText component props.
+ */
+export interface SensitiveTextProps extends TextProps {
+ /**
+ * Boolean to determine whether the text should be hidden or visible.
+ *
+ * @default false
+ */
+ isHidden?: boolean;
+ /**
+ * Determines the length of the hidden text (number of asterisks).
+ * Can be a predefined SensitiveTextLength or a custom string number.
+ *
+ * @default SensitiveTextLength.Short
+ */
+ length?: SensitiveTextLengthType | CustomLength;
+ /**
+ * The text content to be displayed or hidden.
+ */
+ children: string;
+}
diff --git a/app/component-library/components/Texts/SensitiveText/__snapshots__/SensitiveText.test.tsx.snap b/app/component-library/components/Texts/SensitiveText/__snapshots__/SensitiveText.test.tsx.snap
new file mode 100644
index 00000000000..baa2e5148bf
--- /dev/null
+++ b/app/component-library/components/Texts/SensitiveText/__snapshots__/SensitiveText.test.tsx.snap
@@ -0,0 +1,19 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`SensitiveText should render correctly 1`] = `
+
+ Sensitive Information
+
+`;
diff --git a/app/component-library/components/Texts/SensitiveText/index.ts b/app/component-library/components/Texts/SensitiveText/index.ts
new file mode 100644
index 00000000000..4ea8f25dad4
--- /dev/null
+++ b/app/component-library/components/Texts/SensitiveText/index.ts
@@ -0,0 +1,3 @@
+export { default } from './SensitiveText';
+export { SensitiveTextLength } from './SensitiveText.types';
+export type { SensitiveTextProps } from './SensitiveText.types';
diff --git a/app/components/Views/AccountActions/AccountActions.tsx b/app/components/Views/AccountActions/AccountActions.tsx
index 22feb6a46b9..99547d6cfda 100644
--- a/app/components/Views/AccountActions/AccountActions.tsx
+++ b/app/components/Views/AccountActions/AccountActions.tsx
@@ -34,7 +34,12 @@ 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 {
+ isHardwareAccount,
+ ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps)
+ isSnapAccount,
+ ///: END:ONLY_INCLUDE_IF
+} from '../../../util/address';
import { removeAccountsFromPermissions } from '../../../core/Permissions';
import ExtendedKeyringTypes, {
HardwareDeviceTypes,
@@ -189,6 +194,49 @@ const AccountActions = () => {
}
}, [controllers.KeyringController]);
+ ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps)
+
+ /**
+ * Remove the snap account from the keyring
+ */
+ const removeSnapAccount = 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,
+ ]);
+
+ const showRemoveSnapAccountAlert = useCallback(() => {
+ Alert.alert(
+ strings('accounts.remove_snap_account'),
+ strings('accounts.remove_snap_account_alert_description'),
+ [
+ {
+ text: strings('accounts.remove_account_alert_cancel_btn'),
+ style: 'cancel',
+ },
+ {
+ text: strings('accounts.remove_account_alert_remove_btn'),
+ onPress: async () => {
+ sheetRef.current?.onCloseBottomSheet(async () => {
+ await removeSnapAccount();
+ });
+ },
+ },
+ ],
+ );
+ }, [removeSnapAccount]);
+ ///: END:ONLY_INCLUDE_IF
+
/**
* Forget the device if there are no more accounts in the keyring
* @param keyringType - The keyring type
@@ -306,6 +354,18 @@ const AccountActions = () => {
testID={AccountActionsModalSelectorsIDs.REMOVE_HARDWARE_ACCOUNT}
/>
)}
+ {
+ ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps)
+ selectedAddress && isSnapAccount(selectedAddress) && (
+
+ )
+ ///: END:ONLY_INCLUDE_IF
+ }
{
+ const { theme } = params;
+ const { colors } = theme;
+
+ return StyleSheet.create({
+ bottomSheet: {
+ flex: 1,
+ },
+ container: {
+ paddingHorizontal: 16,
+ },
+ description: {
+ paddingVertical: 8,
+ },
+ buttonContainer: {
+ paddingTop: 16,
+ },
+ input: {
+ borderWidth: 1,
+ borderColor: colors.border.default,
+ borderRadius: 4,
+ padding: 10,
+ marginVertical: 10,
+ },
+ errorText: {
+ color: colors.error.default,
+ },
+ placeholderText: {
+ color: colors.text.muted,
+ },
+ scrollView: {
+ flexGrow: 1,
+ maxHeight: 300,
+ },
+ });
+};
+
+export default styleSheet;
+///: END:ONLY_INCLUDE_IF
diff --git a/app/components/Views/Snaps/KeyringSnapRemovalWarning/KeyringSnapRemovalWarning.tsx b/app/components/Views/Snaps/KeyringSnapRemovalWarning/KeyringSnapRemovalWarning.tsx
new file mode 100644
index 00000000000..b2ec560b5dd
--- /dev/null
+++ b/app/components/Views/Snaps/KeyringSnapRemovalWarning/KeyringSnapRemovalWarning.tsx
@@ -0,0 +1,226 @@
+///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps)
+import React, {
+ useEffect,
+ useRef,
+ useState,
+ useCallback,
+ useMemo,
+} from 'react';
+import { View, TextInput, ScrollView } from 'react-native';
+import { NativeViewGestureHandler } from 'react-native-gesture-handler';
+import { Snap } from '@metamask/snaps-utils';
+import BottomSheet, {
+ BottomSheetRef,
+} from '../../../../component-library/components/BottomSheets/BottomSheet';
+import Text, {
+ TextVariant,
+} from '../../../../component-library/components/Texts/Text';
+import { InternalAccount } from '@metamask/keyring-api';
+import BannerAlert from '../../../../component-library/components/Banners/Banner/variants/BannerAlert';
+import { BannerAlertSeverity } from '../../../../component-library/components/Banners/Banner';
+import BottomSheetHeader from '../../../../component-library/components/BottomSheets/BottomSheetHeader';
+import { useStyles } from '../../../hooks/useStyles';
+import stylesheet from './KeyringSnapRemovalWarning.styles';
+import { strings } from '../../../../../locales/i18n';
+import { KeyringAccountListItem } from '../components/KeyringAccountListItem';
+import { getAccountLink } from '@metamask/etherscan-link';
+import { useSelector } from 'react-redux';
+import { selectProviderConfig } from '../../../../selectors/networkController';
+import BottomSheetFooter, {
+ ButtonsAlignment,
+} from '../../../../component-library/components/BottomSheets/BottomSheetFooter';
+import {
+ ButtonProps,
+ ButtonSize,
+ ButtonVariants,
+} from '../../../../component-library/components/Buttons/Button/Button.types';
+import {
+ KEYRING_SNAP_REMOVAL_WARNING,
+ KEYRING_SNAP_REMOVAL_WARNING_CANCEL,
+ KEYRING_SNAP_REMOVAL_WARNING_CONTINUE,
+ KEYRING_SNAP_REMOVAL_WARNING_TEXT_INPUT,
+} from './KeyringSnapRemovalWarning.constants';
+import Logger from '../../../../util/Logger';
+
+interface KeyringSnapRemovalWarningProps {
+ snap: Snap;
+ keyringAccounts: InternalAccount[];
+ onCancel: () => void;
+ onClose: () => void;
+ onSubmit: () => void;
+}
+
+export default function KeyringSnapRemovalWarning({
+ snap,
+ keyringAccounts,
+ onCancel,
+ onClose,
+ onSubmit,
+}: KeyringSnapRemovalWarningProps) {
+ const [showConfirmation, setShowConfirmation] = useState(false);
+ const [confirmedRemoval, setConfirmedRemoval] = useState(false);
+ const [confirmationInput, setConfirmationInput] = useState('');
+ const [error, setError] = useState(false);
+ const { chainId } = useSelector(selectProviderConfig);
+ const { styles } = useStyles(stylesheet, {});
+ const bottomSheetRef = useRef(null);
+
+ useEffect(() => {
+ setShowConfirmation(keyringAccounts.length === 0);
+ }, [keyringAccounts]);
+
+ const validateConfirmationInput = useCallback(
+ (input: string): boolean => input === snap.manifest.proposedName,
+ [snap.manifest.proposedName],
+ );
+
+ const handleConfirmationInputChange = useCallback(
+ (text: string) => {
+ setConfirmationInput(text);
+ setConfirmedRemoval(validateConfirmationInput(text));
+ },
+ [validateConfirmationInput],
+ );
+
+ const handleContinuePress = useCallback(() => {
+ if (!showConfirmation) {
+ setShowConfirmation(true);
+ } else if (confirmedRemoval) {
+ try {
+ onSubmit();
+ } catch (e) {
+ Logger.error(
+ e as Error,
+ 'KeyringSnapRemovalWarning: error while removing snap',
+ );
+ setError(true);
+ }
+ }
+ }, [showConfirmation, confirmedRemoval, onSubmit]);
+
+ const cancelButtonProps: ButtonProps = useMemo(
+ () => ({
+ variant: ButtonVariants.Secondary,
+ label: strings(
+ 'app_settings.snaps.snap_settings.remove_account_snap_warning.cancel_button',
+ ),
+ size: ButtonSize.Lg,
+ onPress: onCancel,
+ testID: KEYRING_SNAP_REMOVAL_WARNING_CANCEL,
+ }),
+ [onCancel],
+ );
+
+ const continueButtonProps: ButtonProps = useMemo(
+ () => ({
+ variant: ButtonVariants.Primary,
+ label: showConfirmation
+ ? strings(
+ 'app_settings.snaps.snap_settings.remove_account_snap_warning.remove_snap_button',
+ )
+ : strings(
+ 'app_settings.snaps.snap_settings.remove_account_snap_warning.continue_button',
+ ),
+ size: ButtonSize.Lg,
+ onPress: handleContinuePress,
+ isDisabled: showConfirmation && !confirmedRemoval,
+ isDanger: showConfirmation,
+ testID: KEYRING_SNAP_REMOVAL_WARNING_CONTINUE,
+ }),
+ [showConfirmation, confirmedRemoval, handleContinuePress],
+ );
+
+ const buttonPropsArray = useMemo(
+ () => [cancelButtonProps, continueButtonProps],
+ [cancelButtonProps, continueButtonProps],
+ );
+
+ const accountListItems = useMemo(
+ () =>
+ keyringAccounts.map((account, index) => (
+
+ )),
+ [keyringAccounts, chainId],
+ );
+
+ return (
+
+
+
+
+ {strings(
+ 'app_settings.snaps.snap_settings.remove_account_snap_warning.title',
+ )}
+
+
+
+ {showConfirmation ? (
+ <>
+
+ {`${strings(
+ 'app_settings.snaps.snap_settings.remove_account_snap_warning.remove_account_snap_alert_description_1',
+ )} `}
+
+ {snap.manifest.proposedName}
+
+ {` ${strings(
+ 'app_settings.snaps.snap_settings.remove_account_snap_warning.remove_account_snap_alert_description_2',
+ )}`}
+
+
+ {error && (
+
+ {strings(
+ 'app_settings.snaps.snap_settings.remove_account_snap_warning.remove_snap_error',
+ {
+ snapName: snap.manifest.proposedName,
+ },
+ )}
+
+ )}
+ >
+ ) : (
+ <>
+
+ {strings(
+ 'app_settings.snaps.snap_settings.remove_account_snap_warning.description',
+ )}
+
+
+
+ {accountListItems}
+
+
+ >
+ )}
+
+
+
+ );
+}
+///: END:ONLY_INCLUDE_IF
diff --git a/app/components/Views/Snaps/KeyringSnapRemovalWarning/index.ts b/app/components/Views/Snaps/KeyringSnapRemovalWarning/index.ts
new file mode 100644
index 00000000000..94d58b3d412
--- /dev/null
+++ b/app/components/Views/Snaps/KeyringSnapRemovalWarning/index.ts
@@ -0,0 +1,3 @@
+///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps)
+export { default as KeyringSnapRemovalWarning } from './KeyringSnapRemovalWarning';
+///: END:ONLY_INCLUDE_IF
diff --git a/app/components/Views/Snaps/KeyringSnapRemovalWarning/test/KeyringSnapRemovalWarning.test.tsx b/app/components/Views/Snaps/KeyringSnapRemovalWarning/test/KeyringSnapRemovalWarning.test.tsx
new file mode 100644
index 00000000000..6359b5a7b3d
--- /dev/null
+++ b/app/components/Views/Snaps/KeyringSnapRemovalWarning/test/KeyringSnapRemovalWarning.test.tsx
@@ -0,0 +1,337 @@
+import React from 'react';
+import { fireEvent, waitFor } from '@testing-library/react-native';
+import KeyringSnapRemovalWarning from '../KeyringSnapRemovalWarning';
+import {
+ KEYRING_SNAP_REMOVAL_WARNING,
+ KEYRING_SNAP_REMOVAL_WARNING_CANCEL,
+ KEYRING_SNAP_REMOVAL_WARNING_CONTINUE,
+ KEYRING_SNAP_REMOVAL_WARNING_TEXT_INPUT,
+} from '../KeyringSnapRemovalWarning.constants';
+import { useSelector } from 'react-redux';
+import renderWithProvider from '../../../../../util/test/renderWithProvider';
+import { Snap, SnapStatus } from '@metamask/snaps-utils';
+import { SnapId } from '@metamask/snaps-sdk';
+import { SemVerVersion } from '@metamask/utils';
+import { createMockSnapInternalAccount } from '../../../../../util/test/accountsControllerTestUtils';
+import { KEYRING_ACCOUNT_LIST_ITEM } from '../../components/KeyringAccountListItem/KeyringAccountListItem.constants';
+
+jest.mock('react-redux', () => ({
+ ...jest.requireActual('react-redux'),
+ useSelector: jest.fn(),
+}));
+
+jest.mock('react-native-safe-area-context', () => {
+ // using disting digits for mock rects to make sure they are not mixed up
+ const inset = { top: 1, right: 2, bottom: 3, left: 4 };
+ const frame = { width: 5, height: 6, x: 7, y: 8 };
+ return {
+ SafeAreaProvider: jest.fn().mockImplementation(({ children }) => children),
+ SafeAreaConsumer: jest
+ .fn()
+ .mockImplementation(({ children }) => children(inset)),
+ useSafeAreaInsets: jest.fn().mockImplementation(() => inset),
+ useSafeAreaFrame: jest.fn().mockImplementation(() => frame),
+ };
+});
+
+jest.mock('@react-navigation/native', () => {
+ const actualNav = jest.requireActual('@react-navigation/native');
+ return {
+ ...actualNav,
+ useNavigation: () => ({
+ navigate: jest.fn(),
+ }),
+ };
+});
+
+describe('KeyringSnapRemovalWarning', () => {
+ const mockSnapName = 'MetaMask Simple Snap Keyring';
+ const mockSnap: Snap = {
+ blocked: false,
+ enabled: true,
+ id: 'npm:@metamask/snap-simple-keyring-snap' as SnapId,
+ initialPermissions: {
+ 'endowment:keyring': {
+ allowedOrigins: ['https://metamask.github.io', 'metamask.github.io'],
+ },
+ 'endowment:rpc': {
+ dapps: true,
+ },
+ snap_manageAccounts: {},
+ snap_manageState: {},
+ },
+ manifest: {
+ version: '1.1.6' as SemVerVersion,
+ description: 'An example of a key management snap for a simple keyring.',
+ proposedName: mockSnapName,
+ repository: {
+ type: 'git',
+ url: 'git+https://github.com/MetaMask/snap-simple-keyring.git',
+ },
+ source: {
+ shasum: 'P2BbaJn6jb7+ecBF6mJJnheQ4j8dtEZ8O4FLqLv8e8M=',
+ location: {
+ npm: {
+ filePath: 'dist/bundle.js',
+ iconPath: 'images/icon.svg',
+ packageName: '@metamask/snap-simple-keyring-snap',
+ registry: 'https://registry.npmjs.org/',
+ },
+ },
+ },
+ initialPermissions: {
+ 'endowment:keyring': {
+ allowedOrigins: ['https://metamask.github.io', 'metamask.github.io'],
+ },
+ 'endowment:rpc': {
+ dapps: true,
+ },
+ snap_manageAccounts: {},
+ snap_manageState: {},
+ },
+ manifestVersion: '0.1',
+ },
+ status: 'stopped' as SnapStatus,
+ sourceCode: '',
+ version: '1.1.6' as SemVerVersion,
+ versionHistory: [
+ {
+ version: '1.1.6',
+ date: 1727403640652,
+ origin: 'https://metamask.github.io',
+ },
+ ],
+ auxiliaryFiles: [],
+ localizationFiles: [],
+ };
+
+ const MOCK_ADDRESS_1 = '0xC4955C0d639D99699Bfd7Ec54d9FaFEe40e4D272';
+ const MOCK_ADDRESS_2 = '0xA7E9922b0e7DB390c3B108127739eFebe4d6293E';
+
+ const mockKeyringAccount1 = createMockSnapInternalAccount(
+ MOCK_ADDRESS_1,
+ 'Snap Account 1',
+ );
+ const mockKeyringAccount2 = createMockSnapInternalAccount(
+ MOCK_ADDRESS_2,
+ 'Snap Account 2',
+ );
+
+ const mockKeyringAccounts = [mockKeyringAccount1, mockKeyringAccount2];
+ const onCancelMock = jest.fn();
+ const onCloseMock = jest.fn();
+ const onSubmitMock = jest.fn();
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (useSelector as jest.Mock).mockReturnValue({ chainId: '1' });
+ });
+
+ it('renders correctly with initial props', () => {
+ const { getByTestId, queryByText } = renderWithProvider(
+ ,
+ );
+
+ const continueButton = getByTestId(KEYRING_SNAP_REMOVAL_WARNING_CONTINUE);
+ expect(continueButton).toBeTruthy();
+ expect(continueButton.props.children[1].props.children).toBe('Continue');
+
+ const cancelButton = getByTestId(KEYRING_SNAP_REMOVAL_WARNING_CANCEL);
+ expect(cancelButton).toBeTruthy();
+ expect(cancelButton.props.children[1].props.children).toBe('Cancel');
+
+ const warningBannerTitle = queryByText(
+ 'Be sure you can access any accounts created by this Snap on your own before removing it',
+ );
+ expect(warningBannerTitle).toBeTruthy();
+ });
+
+ it('renders the correct number of keyring account list items', () => {
+ const { getAllByTestId } = renderWithProvider(
+ ,
+ );
+
+ const accountListItems = getAllByTestId(KEYRING_ACCOUNT_LIST_ITEM);
+ expect(accountListItems).toHaveLength(mockKeyringAccounts.length);
+ });
+ it('shows confirmation input when keyringAccounts is empty', () => {
+ const { getByTestId } = renderWithProvider(
+ ,
+ );
+ const continueButton = getByTestId(KEYRING_SNAP_REMOVAL_WARNING_CONTINUE);
+ expect(continueButton).toBeTruthy();
+ expect(continueButton.props.disabled).toBe(true);
+ expect(continueButton.props.children[1].props.children).toBe('Remove Snap');
+
+ const textInput = getByTestId(KEYRING_SNAP_REMOVAL_WARNING_TEXT_INPUT);
+ expect(textInput).toBeTruthy();
+ });
+
+ it('enables continue button when correct snap name is entered', async () => {
+ const { getByTestId } = renderWithProvider(
+ ,
+ );
+
+ const continueButton = getByTestId(KEYRING_SNAP_REMOVAL_WARNING_CONTINUE);
+ expect(continueButton.props.disabled).toBe(true);
+
+ const textInput = getByTestId(KEYRING_SNAP_REMOVAL_WARNING_TEXT_INPUT);
+ expect(textInput).toBeTruthy();
+ fireEvent.changeText(textInput, mockSnapName);
+
+ await waitFor(() => {
+ expect(continueButton.props.disabled).toBe(false);
+ });
+ });
+
+ it('does not enable continue button when incorrect snap name is entered', async () => {
+ const { getByTestId } = renderWithProvider(
+ ,
+ );
+
+ const continueButton = getByTestId(KEYRING_SNAP_REMOVAL_WARNING_CONTINUE);
+ expect(continueButton.props.disabled).toBe(true);
+
+ const textInput = getByTestId(KEYRING_SNAP_REMOVAL_WARNING_TEXT_INPUT);
+ expect(textInput).toBeTruthy();
+ fireEvent.changeText(textInput, 'Wrong snap name');
+
+ await waitFor(() => {
+ expect(continueButton.props.disabled).toBe(true);
+ });
+ });
+
+ it('calls onSubmit when confirmed and continue is pressed', async () => {
+ const { getByTestId } = renderWithProvider(
+ ,
+ );
+
+ const textInput = getByTestId(KEYRING_SNAP_REMOVAL_WARNING_TEXT_INPUT);
+ expect(textInput).toBeTruthy();
+ fireEvent.changeText(textInput, mockSnapName);
+
+ const continueButton = getByTestId(KEYRING_SNAP_REMOVAL_WARNING_CONTINUE);
+ fireEvent.press(continueButton);
+ expect(onSubmitMock).toHaveBeenCalled();
+ });
+
+ it('displays error when onSubmit throws', async () => {
+ onSubmitMock.mockImplementation(() => {
+ throw new Error('Error');
+ });
+
+ const { getByTestId, getByText } = renderWithProvider(
+ ,
+ );
+
+ const textInput = getByTestId(KEYRING_SNAP_REMOVAL_WARNING_TEXT_INPUT);
+ fireEvent.changeText(textInput, mockSnapName);
+
+ const continueButton = getByTestId(KEYRING_SNAP_REMOVAL_WARNING_CONTINUE);
+ fireEvent.press(continueButton);
+
+ await waitFor(() => {
+ expect(
+ getByText(
+ `Failed to remove ${mockSnapName}`,
+ ),
+ ).toBeTruthy();
+ });
+ });
+
+ it('calls onCancel when cancel button is pressed', () => {
+ const { getByTestId } = renderWithProvider(
+ ,
+ );
+
+ const cancelButton = getByTestId(KEYRING_SNAP_REMOVAL_WARNING_CANCEL);
+ fireEvent.press(cancelButton);
+
+ expect(onCancelMock).toHaveBeenCalled();
+ });
+
+ it('calls onClose when BottomSheet is closed', () => {
+ const { getByTestId } = renderWithProvider(
+ ,
+ );
+
+ const bottomSheet = getByTestId(KEYRING_SNAP_REMOVAL_WARNING);
+ fireEvent(bottomSheet, 'onClose');
+
+ expect(onCloseMock).toHaveBeenCalled();
+ });
+ it('allows removal of snaps with empty names and keeps the continue button enabled', () => {
+ const { getByTestId } = renderWithProvider(
+ ,
+ );
+ const textInput = getByTestId(KEYRING_SNAP_REMOVAL_WARNING_TEXT_INPUT);
+ fireEvent.changeText(textInput, '');
+ const continueButton = getByTestId(KEYRING_SNAP_REMOVAL_WARNING_CONTINUE);
+ expect(continueButton.props.disabled).toBe(false);
+ expect(textInput.props.value).toBe('');
+ expect(continueButton.props.children[1].props.children).toBe('Remove Snap');
+ });
+});
diff --git a/app/components/Views/Snaps/SnapSettings/SnapSettings.tsx b/app/components/Views/Snaps/SnapSettings/SnapSettings.tsx
index 19f739400eb..d56c1d0b200 100644
--- a/app/components/Views/Snaps/SnapSettings/SnapSettings.tsx
+++ b/app/components/Views/Snaps/SnapSettings/SnapSettings.tsx
@@ -1,5 +1,5 @@
-///: BEGIN:ONLY_INCLUDE_IF(external-snaps)
-import React, { useCallback, useEffect } from 'react';
+///: BEGIN:ONLY_INCLUDE_IF(external-snaps,keyring-snaps)
+import React, { useCallback, useEffect, useState } from 'react';
import { View, ScrollView, SafeAreaView } from 'react-native';
import Engine from '../../../../core/Engine';
@@ -28,7 +28,11 @@ import { useStyles } from '../../../hooks/useStyles';
import { useSelector } from 'react-redux';
import SNAP_SETTINGS_REMOVE_BUTTON from './SnapSettings.constants';
import { selectPermissionControllerState } from '../../../../selectors/snaps/permissionController';
-
+import KeyringSnapRemovalWarning from '../KeyringSnapRemovalWarning/KeyringSnapRemovalWarning';
+import { getAccountsBySnapId } from '../../../../core/SnapKeyring/utils/getAccountsBySnapId';
+import { selectInternalAccounts } from '../../../../selectors/accountsController';
+import { InternalAccount } from '@metamask/keyring-api';
+import Logger from '../../../../util/Logger';
interface SnapSettingsProps {
snap: Snap;
}
@@ -42,9 +46,16 @@ const SnapSettings = () => {
const navigation = useNavigation();
const { snap } = useParams();
-
const permissionsState = useSelector(selectPermissionControllerState);
+ const [
+ isShowingSnapKeyringRemoveWarning,
+ setIsShowingSnapKeyringRemoveWarning,
+ ] = useState(false);
+
+ const [keyringAccounts, setKeyringAccounts] = useState([]);
+ const internalAccounts = useSelector(selectInternalAccounts);
+
// TODO: Replace "any" with type
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function getPermissionSubjects(state: any) {
@@ -70,53 +81,118 @@ const SnapSettings = () => {
);
}, [colors, navigation, snap.manifest.proposedName]);
+ const isKeyringSnap = Boolean(permissionsFromController?.snap_manageAccounts);
+
+ useEffect(() => {
+ if (isKeyringSnap) {
+ (async () => {
+ const addresses = await getAccountsBySnapId(snap.id);
+ const snapIdentities = Object.values(internalAccounts).filter(
+ (internalAccount) =>
+ addresses.includes(internalAccount.address.toLowerCase()),
+ );
+ setKeyringAccounts(snapIdentities);
+ })();
+ }
+ }, [snap?.id, internalAccounts, isKeyringSnap]);
+
+ const handleKeyringSnapRemovalWarningClose = useCallback(() => {
+ setIsShowingSnapKeyringRemoveWarning(false);
+ }, []);
+
+
const removeSnap = useCallback(async () => {
- // TODO: Replace "any" with type
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const { SnapController } = Engine.context as any;
+ const { SnapController } = Engine.context;
await SnapController.removeSnap(snap.id);
+
+ if (isKeyringSnap && keyringAccounts.length > 0) {
+ try {
+ for (const keyringAccount of keyringAccounts) {
+ await Engine.removeAccount(keyringAccount.address);
+ }
+ } catch(error) {
+ Logger.error(error as Error, 'SnapSettings: failed to remove snap accounts when calling Engine.removeAccount');
+ }
+ }
navigation.goBack();
- }, [navigation, snap.id]);
+ }, [isKeyringSnap, keyringAccounts, navigation, snap.id]);
+
+ const handleRemoveSnap = useCallback(() => {
+ if (isKeyringSnap && keyringAccounts.length > 0) {
+ setIsShowingSnapKeyringRemoveWarning(true);
+ } else {
+ removeSnap();
+ }
+ }, [isKeyringSnap, keyringAccounts.length, removeSnap]);
+
+
+ const handleRemoveSnapKeyring = useCallback(() => {
+ try {
+ setIsShowingSnapKeyringRemoveWarning(true);
+ removeSnap();
+ setIsShowingSnapKeyringRemoveWarning(false);
+ } catch {
+ setIsShowingSnapKeyringRemoveWarning(false);
+ } finally {
+ setIsShowingSnapKeyringRemoveWarning(false);
+ }
+ }, [removeSnap]);
+
+ const shouldRenderRemoveSnapAccountWarning =
+ isShowingSnapKeyringRemoveWarning &&
+ isKeyringSnap &&
+ keyringAccounts.length > 0;
return (
-
-
-
-
-
-
-
-
-
-
-
- {strings(
- 'app_settings.snaps.snap_settings.remove_snap_section_title',
- )}
-
-
- {strings(
- 'app_settings.snaps.snap_settings.remove_snap_section_description',
- )}
-
-
-
-
-
+ <>
+
+
+
+
+
+
+
+
+
+
+
+ {strings(
+ 'app_settings.snaps.snap_settings.remove_snap_section_title',
+ )}
+
+
+ {strings(
+ 'app_settings.snaps.snap_settings.remove_snap_section_description',
+ )}
+
+
+
+
+
+ {shouldRenderRemoveSnapAccountWarning && (
+
+ )}
+ >
);
};
diff --git a/app/components/Views/Snaps/SnapSettings/test/SnapSettings.test.tsx b/app/components/Views/Snaps/SnapSettings/test/SnapSettings.test.tsx
index d011006ffc4..fb9fda5d713 100644
--- a/app/components/Views/Snaps/SnapSettings/test/SnapSettings.test.tsx
+++ b/app/components/Views/Snaps/SnapSettings/test/SnapSettings.test.tsx
@@ -1,8 +1,6 @@
-// eslint-disable-next-line @typescript-eslint/ban-ts-comment
-// @ts-nocheck - Snaps team directory
import React from 'react';
-import { fireEvent, waitFor } from '@testing-library/react-native';
-import { Status } from '@metamask/snaps-utils';
+import { act, fireEvent, waitFor } from '@testing-library/react-native';
+import { Snap, SnapStatus, Status } from '@metamask/snaps-utils';
import SnapSettings from '../SnapSettings';
import Engine from '../../../../../core/Engine';
import renderWithProvider from '../../../../../util/test/renderWithProvider';
@@ -15,87 +13,42 @@ import SNAP_SETTINGS_REMOVE_BUTTON from '../SnapSettings.constants';
import { SNAP_DETAILS_CELL } from '../../components/SnapDetails/SnapDetails.constants';
import SNAP_PERMISSIONS from '../../components/SnapPermissions/SnapPermissions.contants';
import { SNAP_PERMISSION_CELL } from '../../components/SnapPermissionCell/SnapPermissionCell.constants';
+import { SnapId } from '@metamask/snaps-sdk';
+import {
+ createMockAccountsControllerState,
+ createMockAccountsControllerStateWithSnap,
+ MOCK_ADDRESS_1,
+ MOCK_ADDRESS_2,
+} from '../../../../../util/test/accountsControllerTestUtils';
+import { backgroundState } from '../../../../../util/test/initial-root-state';
+import {
+ KEYRING_SNAP_REMOVAL_WARNING,
+ KEYRING_SNAP_REMOVAL_WARNING_CONTINUE,
+ KEYRING_SNAP_REMOVAL_WARNING_TEXT_INPUT,
+} from '../../KeyringSnapRemovalWarning/KeyringSnapRemovalWarning.constants';
-jest.mock('../../../../../core/Engine', () => ({
- context: {
- SnapController: {
- removeSnap: jest.fn(),
- },
- },
-}));
+const MOCK_ACCOUNTS_CONTROLLER_STATE = createMockAccountsControllerState([
+ MOCK_ADDRESS_1,
+ MOCK_ADDRESS_2,
+]);
+
+jest.mock('react-native-safe-area-context', () => {
+ // using disting digits for mock rects to make sure they are not mixed up
+ const inset = { top: 1, right: 2, bottom: 3, left: 4 };
+ const frame = { width: 5, height: 6, x: 7, y: 8 };
+ return {
+ SafeAreaProvider: jest.fn().mockImplementation(({ children }) => children),
+ SafeAreaConsumer: jest
+ .fn()
+ .mockImplementation(({ children }) => children(inset)),
+ useSafeAreaInsets: jest.fn().mockImplementation(() => inset),
+ useSafeAreaFrame: jest.fn().mockImplementation(() => frame),
+ };
+});
+const mockUseParams = jest.fn();
jest.mock('../../../../../util/navigation/navUtils', () => ({
- useParams: () => ({
- snap: {
- blocked: false,
- enabled: true,
- permissionName: 'wallet_snap_npm:@chainsafe/filsnap',
- id: 'npm:@chainsafe/filsnap',
- initialPermissions: {
- 'endowment:network-access': {},
- 'endowment:rpc': {
- dapps: true,
- snaps: true,
- },
- snap_confirm: {},
- snap_getBip44Entropy: [
- {
- coinType: 1,
- },
- {
- coinType: 461,
- },
- ],
- snap_manageState: {},
- },
- manifest: {
- version: '2.3.13' as SemVerVersion,
- proposedName: 'Filsnap',
- description: 'The Filecoin snap.',
- repository: {
- type: 'git',
- url: 'https://github.com/Chainsafe/filsnap.git',
- },
- source: {
- shasum: 'Z7lh6iD1yjfKES/WutUyxepg5Dgp8Xjo3kivsz9vpwc=',
- location: {
- npm: {
- filePath: 'dist/bundle.js',
- packageName: '@chainsafe/filsnap',
- registry: 'https://registry.npmjs.org/',
- },
- },
- },
- initialPermissions: {
- 'endowment:network-access': {},
- 'endowment:rpc': {
- dapps: true,
- snaps: true,
- },
- snap_confirm: {},
- snap_getBip44Entropy: [
- {
- coinType: 1,
- },
- {
- coinType: 461,
- },
- ],
- snap_manageState: {},
- },
- manifestVersion: '0.1',
- },
- status: 'runing' as Status,
- version: '2.3.13' as SemVerVersion,
- versionHistory: [
- {
- version: '2.3.13',
- date: 1684964145490,
- origin: 'metamask-mobile',
- },
- ],
- },
- }),
+ useParams: () => mockUseParams(),
createNavigationDetails: jest.fn(),
}));
@@ -172,9 +125,11 @@ jest.mock('@react-navigation/native', () => {
};
});
-const engineState = {
+const initialState = {
+ settings: {},
engine: {
backgroundState: {
+ ...backgroundState,
PermissionController: {
subjects: {
'npm:@chainsafe/filsnap': {
@@ -182,16 +137,136 @@ const engineState = {
},
},
},
+ AccountsController: MOCK_ACCOUNTS_CONTROLLER_STATE,
},
},
};
-describe('SnapSettings', () => {
+const mockSnap = {
+ blocked: false,
+ enabled: true,
+ permissionName: 'wallet_snap_npm:@chainsafe/filsnap',
+ id: 'npm:@chainsafe/filsnap',
+ initialPermissions: {
+ 'endowment:network-access': {},
+ 'endowment:rpc': {
+ dapps: true,
+ snaps: true,
+ },
+ snap_confirm: {},
+ snap_getBip44Entropy: [
+ {
+ coinType: 1,
+ },
+ {
+ coinType: 461,
+ },
+ ],
+ snap_manageState: {},
+ },
+ manifest: {
+ version: '2.3.13' as SemVerVersion,
+ proposedName: 'Filsnap',
+ description: 'The Filecoin snap.',
+ repository: {
+ type: 'git',
+ url: 'https://github.com/Chainsafe/filsnap.git',
+ },
+ source: {
+ shasum: 'Z7lh6iD1yjfKES/WutUyxepg5Dgp8Xjo3kivsz9vpwc=',
+ location: {
+ npm: {
+ filePath: 'dist/bundle.js',
+ packageName: '@chainsafe/filsnap',
+ registry: 'https://registry.npmjs.org/',
+ },
+ },
+ },
+ initialPermissions: {
+ 'endowment:network-access': {},
+ 'endowment:rpc': {
+ dapps: true,
+ snaps: true,
+ },
+ snap_confirm: {},
+ snap_getBip44Entropy: [
+ {
+ coinType: 1,
+ },
+ {
+ coinType: 461,
+ },
+ ],
+ snap_manageState: {},
+ },
+ manifestVersion: '0.1',
+ },
+ status: 'runing' as Status,
+ version: '2.3.13' as SemVerVersion,
+ versionHistory: [
+ {
+ version: '2.3.13',
+ date: 1684964145490,
+ origin: 'metamask-mobile',
+ },
+ ],
+};
+
+jest.mock('../../../../../core/Engine', () => {
+ // eslint-disable-next-line @typescript-eslint/no-shadow
+ const { MOCK_ADDRESS_1, MOCK_ADDRESS_2 } = jest.requireActual(
+ '../../../../../util/test/accountsControllerTestUtils',
+ );
+ return {
+ getSnapKeyring: jest.fn().mockReturnValue({
+ type: 'Snap Keyring',
+ getAccountsBySnapId: jest
+ .fn()
+ .mockReturnValue([
+ MOCK_ADDRESS_1.toLowerCase(),
+ MOCK_ADDRESS_2.toLowerCase(),
+ ]),
+ }),
+ removeAccount: jest.fn(),
+ context: {
+ SnapController: {
+ removeSnap: jest.fn(),
+ },
+ KeyringController: {
+ state: {
+ keyrings: [
+ {
+ accounts: [MOCK_ADDRESS_1.toLowerCase()],
+ index: 0,
+ type: 'Snap Keyring',
+ },
+ {
+ accounts: [MOCK_ADDRESS_2.toLowerCase()],
+ index: 1,
+ type: 'Snap Keyring',
+ },
+ ],
+ },
+ },
+ AccountsController: MOCK_ACCOUNTS_CONTROLLER_STATE,
+ },
+ };
+});
+
+describe('SnapSettings with non keyring snap', () => {
+ beforeEach(() => {
+ jest.restoreAllMocks();
+ mockUseParams.mockReturnValue({
+ snap: mockSnap,
+ });
+ });
+
it('renders correctly', () => {
const { getAllByTestId, getByTestId } = renderWithProvider(
,
{
- state: engineState,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ state: initialState as any,
},
);
@@ -210,7 +285,8 @@ describe('SnapSettings', () => {
it('remove snap and goes back when Remove button is pressed', async () => {
const { getByTestId } = renderWithProvider(, {
- state: engineState,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ state: initialState as any,
});
const removeButton = getByTestId(SNAP_SETTINGS_REMOVE_BUTTON);
@@ -223,3 +299,174 @@ describe('SnapSettings', () => {
});
});
});
+
+describe('SnapSettings with keyring snap', () => {
+ const mockSnapName = 'MetaMask Simple Snap Keyring';
+ const mockKeyringSnapId = 'npm:@metamask/snap-simple-keyring-snap' as SnapId;
+ const mockKeyringSnap: Snap = {
+ blocked: false,
+ enabled: true,
+ id: mockKeyringSnapId,
+ initialPermissions: {
+ 'endowment:keyring': {
+ allowedOrigins: ['https://metamask.github.io', 'metamask.github.io'],
+ },
+ 'endowment:rpc': {
+ dapps: true,
+ },
+ snap_manageAccounts: {},
+ snap_manageState: {},
+ },
+ manifest: {
+ version: '1.1.6' as SemVerVersion,
+ description: 'An example of a key management snap for a simple keyring.',
+ proposedName: mockSnapName,
+ repository: {
+ type: 'git',
+ url: 'git+https://github.com/MetaMask/snap-simple-keyring.git',
+ },
+ source: {
+ shasum: 'P2BbaJn6jb7+ecBF6mJJnheQ4j8dtEZ8O4FLqLv8e8M=',
+ location: {
+ npm: {
+ filePath: 'dist/bundle.js',
+ iconPath: 'images/icon.svg',
+ packageName: '@metamask/snap-simple-keyring-snap',
+ registry: 'https://registry.npmjs.org/',
+ },
+ },
+ },
+ initialPermissions: {
+ 'endowment:keyring': {
+ allowedOrigins: ['https://metamask.github.io', 'metamask.github.io'],
+ },
+ 'endowment:rpc': {
+ dapps: true,
+ },
+ snap_manageAccounts: {},
+ snap_manageState: {},
+ },
+ manifestVersion: '0.1',
+ },
+ status: 'stopped' as SnapStatus,
+ sourceCode: '',
+ version: '1.1.6' as SemVerVersion,
+ versionHistory: [
+ {
+ version: '1.1.6',
+ date: 1727403640652,
+ origin: 'https://metamask.github.io',
+ },
+ ],
+ auxiliaryFiles: [],
+ localizationFiles: [],
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockUseParams.mockReturnValue({
+ snap: mockKeyringSnap,
+ });
+ });
+
+ const MOCK_ACCOUNTS_CONTROLLER_STATE_WITH_SNAP =
+ createMockAccountsControllerStateWithSnap([MOCK_ADDRESS_1, MOCK_ADDRESS_2]);
+
+ const mockKeyringSnapPermissions: SubjectPermissions = {
+ 'endowment:keyring': {
+ id: 'Bjj3InYtb6U4ak-uja0f_',
+ parentCapability: 'endowment:keyring',
+ invoker: 'npm:@chainsafe/filsnap',
+ caveats: null,
+ date: mockDate,
+ },
+ snap_manageAccounts: {
+ id: 'BKbg3uDSHHu0D1fCUTOmS',
+ parentCapability: 'snap_manageAccounts',
+ invoker: mockKeyringSnapId,
+ caveats: null,
+ date: mockDate2,
+ },
+ };
+
+ const initialStateWithKeyringSnap = {
+ settings: {},
+ engine: {
+ backgroundState: {
+ ...backgroundState,
+ PermissionController: {
+ subjects: {
+ [mockKeyringSnapId]: {
+ permissions: mockKeyringSnapPermissions,
+ },
+ },
+ },
+ AccountsController: MOCK_ACCOUNTS_CONTROLLER_STATE_WITH_SNAP,
+ },
+ },
+ };
+
+ it('renders KeyringSnapRemovalWarning when removing a keyring snap', async () => {
+ const { getByTestId } = renderWithProvider(, {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ state: initialStateWithKeyringSnap as any,
+ });
+
+ // Needed to allow for useEffect to run
+ // eslint-disable-next-line no-empty-function
+ await act(async () => {});
+
+ const removeButton = getByTestId(SNAP_SETTINGS_REMOVE_BUTTON);
+ fireEvent.press(removeButton);
+ await waitFor(() => {
+ expect(getByTestId(KEYRING_SNAP_REMOVAL_WARNING)).toBeTruthy();
+ });
+ });
+
+ it('calls Engine.context.SnapController and Engine.removeAccount when removing a keyring snap with accounts', async () => {
+ const { getByTestId } = renderWithProvider(, {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ state: initialStateWithKeyringSnap as any,
+ });
+
+ // Needed to allow for useEffect to run
+ // eslint-disable-next-line no-empty-function
+ await act(async () => {});
+
+ // Step 1: // Trigger the remove snap action
+ const removeButton = getByTestId(SNAP_SETTINGS_REMOVE_BUTTON);
+ fireEvent.press(removeButton);
+
+ // Step 2: Click continue on the warning modal
+ const keyringSnapRemovalWarningContinueButton = getByTestId(
+ KEYRING_SNAP_REMOVAL_WARNING_CONTINUE,
+ );
+ fireEvent.press(keyringSnapRemovalWarningContinueButton);
+
+
+ // Step 3: Wait for the warning modal to appear and enter the snap name
+ await waitFor(() => {
+ const inputField = getByTestId(KEYRING_SNAP_REMOVAL_WARNING_TEXT_INPUT);
+ expect(inputField).toBeTruthy();
+ });
+ const inputField = getByTestId(KEYRING_SNAP_REMOVAL_WARNING_TEXT_INPUT);
+ fireEvent.changeText(inputField, mockKeyringSnap.manifest.proposedName);
+
+ // Step 4: Click the continue button
+ fireEvent.press(keyringSnapRemovalWarningContinueButton);
+
+ // Step 5: Verify that the removal functions are called
+ await waitFor(() => {
+ expect(Engine.context.SnapController.removeSnap).toHaveBeenCalledWith(
+ mockKeyringSnapId,
+ );
+ expect(Engine.removeAccount).toHaveBeenCalledTimes(2);
+ expect(Engine.removeAccount).toHaveBeenCalledWith(
+ MOCK_ADDRESS_1.toLowerCase(),
+ );
+ expect(Engine.removeAccount).toHaveBeenCalledWith(
+ MOCK_ADDRESS_2.toLowerCase(),
+ );
+ });
+ });
+});
diff --git a/app/components/Views/Snaps/components/KeyringAccountListItem/KeyringAccountListItem.constants.ts b/app/components/Views/Snaps/components/KeyringAccountListItem/KeyringAccountListItem.constants.ts
new file mode 100644
index 00000000000..bd27e3902d6
--- /dev/null
+++ b/app/components/Views/Snaps/components/KeyringAccountListItem/KeyringAccountListItem.constants.ts
@@ -0,0 +1,4 @@
+///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps)
+export const KEYRING_ACCOUNT_LIST_ITEM = 'keyring-account-list-item';
+export const KEYRING_ACCOUNT_LIST_ITEM_BUTTON = 'keyring-account-list-item-button';
+///: END:ONLY_INCLUDE_IF
diff --git a/app/components/Views/Snaps/components/KeyringAccountListItem/KeyringAccountListItem.styles.ts b/app/components/Views/Snaps/components/KeyringAccountListItem/KeyringAccountListItem.styles.ts
new file mode 100644
index 00000000000..36250c03f1f
--- /dev/null
+++ b/app/components/Views/Snaps/components/KeyringAccountListItem/KeyringAccountListItem.styles.ts
@@ -0,0 +1,36 @@
+///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps)
+import { StyleSheet } from 'react-native';
+import { Theme } from '../../../../../util/theme/models';
+
+const styleSheet = (params: { theme: Theme }) => {
+ const { theme } = params;
+ const { colors } = theme;
+
+ return StyleSheet.create({
+ container: {
+ paddingTop: 16,
+ backgroundColor: colors.background.default,
+ },
+ content: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ borderWidth: 1,
+ borderColor: colors.border.default,
+ borderRadius: 8,
+ padding: 16,
+ },
+ textContent: {
+ flex: 1,
+ },
+ addressText: {
+ color: colors.text.default,
+ },
+ buttonContainer: {
+ paddingLeft: 16,
+ alignSelf: 'center',
+ },
+ });
+};
+
+export default styleSheet;
+///: END:ONLY_INCLUDE_IF
diff --git a/app/components/Views/Snaps/components/KeyringAccountListItem/KeyringAccountListItem.tsx b/app/components/Views/Snaps/components/KeyringAccountListItem/KeyringAccountListItem.tsx
new file mode 100644
index 00000000000..f0935d131b8
--- /dev/null
+++ b/app/components/Views/Snaps/components/KeyringAccountListItem/KeyringAccountListItem.tsx
@@ -0,0 +1,74 @@
+///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps)
+import React, { useCallback } from 'react';
+import { InternalAccount } from '@metamask/keyring-api';
+import { toChecksumHexAddress } from '@metamask/controller-utils';
+import ButtonIcon, {
+ ButtonIconSizes,
+} from '../../../../../component-library/components/Buttons/ButtonIcon';
+import {
+ IconColor,
+ IconName,
+} from '../../../../../component-library/components/Icons/Icon';
+import { Linking, View } from 'react-native';
+import { useStyles } from '../../../../hooks/useStyles';
+import stylesheet from './KeyringAccountListItem.styles';
+import Text, {
+ TextColor,
+ TextVariant,
+} from '../../../../../component-library/components/Texts/Text';
+import { strings } from '../../../../../../locales/i18n';
+import {
+ KEYRING_ACCOUNT_LIST_ITEM,
+ KEYRING_ACCOUNT_LIST_ITEM_BUTTON,
+} from './KeyringAccountListItem.constants';
+
+interface KeyringAccountListItemProps {
+ account: InternalAccount;
+ blockExplorerUrl: string;
+}
+
+const KeyringAccountListItem = ({
+ account,
+ blockExplorerUrl,
+}: KeyringAccountListItemProps) => {
+ const { styles } = useStyles(stylesheet, {});
+
+ const handlePress = useCallback(() => {
+ Linking.openURL(blockExplorerUrl);
+ }, [blockExplorerUrl]);
+
+ return (
+
+
+
+
+ {strings(
+ 'app_settings.snaps.keyring_account_list_item.account_name',
+ )}
+
+ {account.metadata.name}
+
+ {strings(
+ 'app_settings.snaps.keyring_account_list_item.public_address',
+ )}
+
+
+ {toChecksumHexAddress(account.address)}
+
+
+
+
+
+
+
+ );
+};
+
+export default React.memo(KeyringAccountListItem);
+///: END:ONLY_INCLUDE_IF
diff --git a/app/components/Views/Snaps/components/KeyringAccountListItem/index.ts b/app/components/Views/Snaps/components/KeyringAccountListItem/index.ts
new file mode 100644
index 00000000000..909d648dff4
--- /dev/null
+++ b/app/components/Views/Snaps/components/KeyringAccountListItem/index.ts
@@ -0,0 +1,3 @@
+///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps)
+export { default as KeyringAccountListItem } from './KeyringAccountListItem';
+///: END:ONLY_INCLUDE_IF
diff --git a/app/components/Views/Snaps/components/KeyringAccountListItem/test/KeyringAccountListItem.test.tsx b/app/components/Views/Snaps/components/KeyringAccountListItem/test/KeyringAccountListItem.test.tsx
new file mode 100644
index 00000000000..686872072fc
--- /dev/null
+++ b/app/components/Views/Snaps/components/KeyringAccountListItem/test/KeyringAccountListItem.test.tsx
@@ -0,0 +1,42 @@
+import React from 'react';
+import { render, fireEvent } from '@testing-library/react-native';
+import KeyringAccountListItem from '../KeyringAccountListItem';
+import { Linking } from 'react-native';
+import { toChecksumHexAddress } from '@metamask/controller-utils';
+import { KEYRING_ACCOUNT_LIST_ITEM, KEYRING_ACCOUNT_LIST_ITEM_BUTTON } from '../KeyringAccountListItem.constants';
+import { MOCK_ADDRESS_1, createMockSnapInternalAccount } from '../../../../../../util/test/accountsControllerTestUtils';
+
+jest.mock('react-native/Libraries/Linking/Linking', () => ({
+ openURL: jest.fn(),
+}));
+
+describe('KeyringAccountListItem', () => {
+
+ const mockInternalAccount = createMockSnapInternalAccount(
+ MOCK_ADDRESS_1,
+ 'Snap Account 1',
+ );
+
+ const mockBlockExplorerUrl = `https://etherscan.io/address/${MOCK_ADDRESS_1.toLowerCase()}`;
+
+ it('renders correctly', () => {
+ const { getByTestId, getByText } = render(
+ ,
+ );
+
+ expect(getByTestId(KEYRING_ACCOUNT_LIST_ITEM)).toBeTruthy();
+ expect(getByText('Snap Account 1')).toBeTruthy();
+ expect(getByText(toChecksumHexAddress(MOCK_ADDRESS_1))).toBeTruthy();
+ });
+
+ it('opens snap URL when export button is pressed', () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ const exportButton = getByTestId(KEYRING_ACCOUNT_LIST_ITEM_BUTTON);
+ fireEvent.press(exportButton);
+
+ expect(Linking.openURL).toHaveBeenCalledWith(mockBlockExplorerUrl);
+ });
+});
diff --git a/app/core/Engine.ts b/app/core/Engine.ts
index a5969f5edf3..684f12c6164 100644
--- a/app/core/Engine.ts
+++ b/app/core/Engine.ts
@@ -765,20 +765,6 @@ class Engine {
additionalKeyrings.push(ledgerKeyringBuilder);
///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps)
-
- /**
- * Removes an account from state / storage.
- *
- * @param {string} address - A hex address
- */
- const removeAccount = async (address: string) => {
- // Remove all associated permissions
- await removeAccountsFromPermissions([address]);
- // Remove account from the keyring
- await this.keyringController.removeAccount(address as Hex);
- return address;
- };
-
const snapKeyringBuildMessenger = this.controllerMessenger.getRestricted({
name: 'SnapKeyringBuilder',
allowedActions: [
@@ -812,7 +798,7 @@ class Engine {
snapKeyringBuildMessenger,
getSnapController,
persistAndUpdateAccounts,
- (address) => removeAccount(address),
+ (address) => this.removeAccount(address),
),
);
@@ -1341,7 +1327,7 @@ class Engine {
return Boolean(
hasProperty(showIncomingTransactions, currentChainId) &&
- showIncomingTransactions?.[currentHexChainId],
+ showIncomingTransactions?.[currentHexChainId],
);
},
updateTransactions: true,
@@ -1692,7 +1678,7 @@ class Engine {
(state: NetworkState) => {
if (
state.networksMetadata[state.selectedNetworkClientId].status ===
- NetworkStatus.Available &&
+ NetworkStatus.Available &&
networkController.getNetworkClientById(
networkController?.state.selectedNetworkClientId,
).configuration.chainId !== currentChainId
@@ -1717,10 +1703,9 @@ class Engine {
} catch (error) {
console.error(
error,
- `Network ID not changed, current chainId: ${
- networkController.getNetworkClientById(
- networkController?.state.selectedNetworkClientId,
- ).configuration.chainId
+ `Network ID not changed, current chainId: ${networkController.getNetworkClientById(
+ networkController?.state.selectedNetworkClientId,
+ ).configuration.chainId
}`,
);
}
@@ -1849,7 +1834,7 @@ class Engine {
const decimalsToShow = (currentCurrency === 'usd' && 2) || undefined;
if (
accountsByChainId?.[toHexadecimal(chainId)]?.[
- selectSelectedInternalAccountChecksummedAddress
+ selectSelectedInternalAccountChecksummedAddress
]
) {
ethFiat = weiToFiatNumber(
@@ -1881,9 +1866,9 @@ class Engine {
item.balance ||
(item.address in tokenBalances
? renderFromTokenMinimalUnit(
- tokenBalances[item.address],
- item.decimals,
- )
+ tokenBalances[item.address],
+ item.decimals,
+ )
: undefined);
const tokenBalanceFiat = balanceToFiatNumber(
// TODO: Fix this by handling or eliminating the undefined case
@@ -1936,6 +1921,20 @@ class Engine {
}
return snapKeyring;
};
+
+
+ /**
+ * Removes an account from state / storage.
+ *
+ * @param {string} address - A hex address
+ */
+ removeAccount = async (address: string) => {
+ // Remove all associated permissions
+ await removeAccountsFromPermissions([address]);
+ // Remove account from the keyring
+ await this.keyringController.removeAccount(address as Hex);
+ return address;
+ };
///: END:ONLY_INCLUDE_IF
/**
@@ -2292,4 +2291,14 @@ export default {
assertEngineExists(instance);
return instance.getGlobalEthQuery();
},
+ ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps)
+ getSnapKeyring: () => {
+ assertEngineExists(instance);
+ return instance.getSnapKeyring();
+ },
+ removeAccount: async (address: string) => {
+ assertEngineExists(instance);
+ return await instance.removeAccount(address);
+ }
+ ///: END:ONLY_INCLUDE_IF
};
diff --git a/app/core/SnapKeyring/utils/getAccountsBySnapId.ts b/app/core/SnapKeyring/utils/getAccountsBySnapId.ts
new file mode 100644
index 00000000000..e2080548060
--- /dev/null
+++ b/app/core/SnapKeyring/utils/getAccountsBySnapId.ts
@@ -0,0 +1,17 @@
+///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps)
+import { SnapKeyring } from '@metamask/eth-snap-keyring';
+import { SnapId } from '@metamask/snaps-sdk';
+import Engine from '../../../core/Engine';
+
+/**
+ * Get the addresses of the accounts managed by a given Snap.
+ *
+ * @param snapId - Snap ID to get accounts for.
+ * @returns The addresses of the accounts.
+ */
+export const getAccountsBySnapId = async (snapId: SnapId) => {
+ const snapKeyring: SnapKeyring =
+ (await Engine.getSnapKeyring()) as SnapKeyring;
+ return await snapKeyring.getAccountsBySnapId(snapId);
+};
+///: END:ONLY_INCLUDE_IF
diff --git a/app/selectors/accountTrackerController.ts b/app/selectors/accountTrackerController.ts
index d87dc1be939..681bd6f6adb 100644
--- a/app/selectors/accountTrackerController.ts
+++ b/app/selectors/accountTrackerController.ts
@@ -16,7 +16,7 @@ export const selectAccounts = createDeepEqualSelector(
(accountTrackerControllerState: AccountTrackerControllerState) =>
accountTrackerControllerState.accounts,
);
-export const selectAccountsByChainId = createSelector(
+export const selectAccountsByChainId = createDeepEqualSelector(
selectAccountTrackerControllerState,
(accountTrackerControllerState: AccountTrackerControllerState) =>
accountTrackerControllerState.accountsByChainId,
diff --git a/app/util/address/index.test.ts b/app/util/address/index.test.ts
index 18fc4147f8f..94912b1f8ab 100644
--- a/app/util/address/index.test.ts
+++ b/app/util/address/index.test.ts
@@ -14,6 +14,7 @@ import {
resemblesAddress,
getKeyringByAddress,
getLabelTextByAddress,
+ isSnapAccount,
} from '.';
import {
mockHDKeyringAddress,
@@ -385,3 +386,14 @@ describe('resemblesAddress', () => {
expect(resemblesAddress(mockHDKeyringAddress)).toBeTruthy();
});
});
+describe('isSnapAccount,', () => {
+ it('should return true if account is of type Snap Keyring', () => {
+ expect(isSnapAccount(snapAddress)).toBeTruthy();
+ });
+
+ it('should return false if account is not of type Snap Keyring', () => {
+ expect(
+ isSnapAccount('0xD5955C0d639D99699Bfd7Ec54d9FaFEe40e4D278'),
+ ).toBeFalsy();
+ });
+});
diff --git a/app/util/address/index.ts b/app/util/address/index.ts
index d34f848d195..34071c0579a 100644
--- a/app/util/address/index.ts
+++ b/app/util/address/index.ts
@@ -222,6 +222,17 @@ export function isHardwareAccount(
return keyring && accountTypes.includes(keyring.type as ExtendedKeyringTypes);
}
+/**
+ * Determines if an address belongs to a snap account
+ *
+ * @param {String} address - String corresponding to an address
+ * @returns {Boolean} - Returns a boolean
+ */
+export function isSnapAccount(address: string) {
+ const keyring = getKeyringByAddress(address);
+ return keyring && keyring.type === KeyringTypes.snap;
+}
+
/**
* judge address is a hardware account that require external operation or not
*
diff --git a/app/util/test/accountsControllerTestUtils.ts b/app/util/test/accountsControllerTestUtils.ts
index 983a0ba94f9..ebac7ed3214 100644
--- a/app/util/test/accountsControllerTestUtils.ts
+++ b/app/util/test/accountsControllerTestUtils.ts
@@ -39,6 +39,37 @@ export function createMockInternalAccount(
};
}
+export function createMockSnapInternalAccount(
+ address: string,
+ nickname: string,
+): InternalAccount {
+ return {
+ address,
+ id: createMockUuidFromAddress(address),
+ metadata: {
+ name: nickname,
+ importTime: 1684232000456,
+ keyring: {
+ type: 'Snap Keyring',
+ },
+ snap: {
+ id: 'npm:@metamask/snap-simple-keyring-snap',
+ name: 'MetaMask Simple Snap Keyring',
+ enabled: true,
+ },
+ },
+ options: {},
+ methods: [
+ EthMethod.PersonalSign,
+ EthMethod.SignTransaction,
+ EthMethod.SignTypedDataV1,
+ EthMethod.SignTypedDataV3,
+ EthMethod.SignTypedDataV4,
+ ],
+ type: 'eip155:eoa',
+ };
+}
+
// Mock checksummed addresses
export const MOCK_ADDRESS_1 = '0xC4955C0d639D99699Bfd7Ec54d9FaFEe40e4D272';
export const MOCK_ADDRESS_2 = '0xC4966c0D659D99699BFD7EB54D8fafEE40e4a756';
diff --git a/e2e/selectors/Modals/AccountActionsModal.selectors.js b/e2e/selectors/Modals/AccountActionsModal.selectors.js
index d41c40575b6..afa8c3cbf5c 100644
--- a/e2e/selectors/Modals/AccountActionsModal.selectors.js
+++ b/e2e/selectors/Modals/AccountActionsModal.selectors.js
@@ -3,5 +3,8 @@ 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',
+ REMOVE_HARDWARE_ACCOUNT: 'remove-hardware-account-action',
+ ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps)
+ REMOVE_SNAP_ACCOUNT: 'remove-snap-account-action',
+ ///: END:ONLY_INCLUDE_IF
};
diff --git a/locales/languages/en.json b/locales/languages/en.json
index 22e9e313922..9c7273b8fbf 100644
--- a/locales/languages/en.json
+++ b/locales/languages/en.json
@@ -695,6 +695,8 @@
"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_snap_account": "Remove snap account",
+ "remove_snap_account_alert_description": "This account will be removed from your wallet. Please make sure you have the original Secret Recovery Phrase or private key for this imported account before continuing. You can import or create accounts again from the account drop-down.",
"remove_account_alert_remove_btn": "Remove",
"remove_account_alert_cancel_btn": "Nevermind",
"accounts_title": "Accounts",
@@ -1059,7 +1061,19 @@
"snap_settings": {
"remove_snap_section_title": "Remove Snap",
"remove_snap_section_description": "This action will delete the snap, its data, and its granted permissions.",
- "remove_button_label": "Remove {{snapName}}"
+ "remove_button_label": "Remove {{snapName}}",
+ "remove_account_snap_warning": {
+ "title": "Remove Snap",
+ "description": "Removing this Snap removes these accounts from MetaMask:",
+ "remove_account_snap_alert_description_1": "Type",
+ "remove_account_snap_alert_description_2": "to confirm you want to remove this snap:",
+ "banner_title": "Be sure you can access any accounts created by this Snap on your own before removing it",
+ "cancel_button": "Cancel",
+ "continue_button": "Continue",
+ "remove_snap_button": "Remove Snap",
+ "remove_snap_error": "Failed to remove {{snapName}}",
+ "remove_snap_success": "{{snapName}} removed"
+ }
},
"snap_details": {
"install_date": "Installed on {{date}}",
@@ -1067,6 +1081,10 @@
"enabled": "Enabled",
"version": "Version"
},
+ "keyring_account_list_item": {
+ "account_name": "Account name",
+ "public_address": "Public Address"
+ },
"snap_permissions": {
"approved_date": "Approved on {{date}}",
"permission_section_title": "Permissions",