diff --git a/app/components/Snaps/SnapInterfaceContext.tsx b/app/components/Snaps/SnapInterfaceContext.tsx index e36c25715e2..f0a417fc382 100644 --- a/app/components/Snaps/SnapInterfaceContext.tsx +++ b/app/components/Snaps/SnapInterfaceContext.tsx @@ -93,8 +93,8 @@ export const SnapInterfaceContextProvider: FunctionComponent< params: { event: { type: event, - ...(name !== undefined && name !== null ? { name } : {}), - ...(value !== undefined && value !== null ? { value } : {}), + ...(name === undefined ? {} : { name }), + ...(value === undefined ? {} : { value }), }, id: interfaceId, }, diff --git a/app/components/Snaps/SnapUIDateTimePicker/SnapUIDateTimePicker.styles.ts b/app/components/Snaps/SnapUIDateTimePicker/SnapUIDateTimePicker.styles.ts new file mode 100644 index 00000000000..00f5d5d9293 --- /dev/null +++ b/app/components/Snaps/SnapUIDateTimePicker/SnapUIDateTimePicker.styles.ts @@ -0,0 +1,35 @@ +import { StyleSheet } from 'react-native'; +import { Theme } from '../../../util/theme/models'; +import Device from '../../../util/device'; + +/** + * Generates the style sheet for the SnapUIDateTimePicker component. + * + * @param params Style sheet params. + * @param params.theme App theme from ThemeContext. + * @returns StyleSheet object. + */ +const styleSheet = (params: { + theme: Theme; + vars: { + selected?: boolean; + compact?: boolean; + }; +}) => { + const { theme } = params; + const { colors } = theme; + return StyleSheet.create({ + modal: { + backgroundColor: colors.background.default, + borderTopLeftRadius: 24, + borderTopRightRadius: 24, + paddingBottom: Device.isIphoneX() ? 20 : 0, + alignItems: 'center', + gap: 16, + paddingLeft: 16, + paddingRight: 16, + }, + }); +}; + +export default styleSheet; diff --git a/app/components/Snaps/SnapUIDateTimePicker/SnapUIDateTimePicker.tsx b/app/components/Snaps/SnapUIDateTimePicker/SnapUIDateTimePicker.tsx new file mode 100644 index 00000000000..a265328159a --- /dev/null +++ b/app/components/Snaps/SnapUIDateTimePicker/SnapUIDateTimePicker.tsx @@ -0,0 +1,379 @@ +import React, { FunctionComponent, useEffect, useRef, useState } from 'react'; +import { useSnapInterfaceContext } from '../SnapInterfaceContext'; +import { DateTime } from 'luxon'; +import { Box } from '@metamask/design-system-react-native'; +import Label from '../../../component-library/components/Form/Label'; +import HelpText, { + HelpTextSeverity, +} from '../../../component-library/components/Form/HelpText'; +import { TextVariant } from '../../../component-library/components/Texts/Text'; +import DateTimePicker, { + DateTimePickerEvent, +} from '@react-native-community/datetimepicker'; +import TextField, { + TextFieldSize, +} from '../../../component-library/components/Form/TextField'; +import { Platform, TextInput, TouchableOpacity, View } from 'react-native'; +import stylesheet from './SnapUIDateTimePicker.styles'; +import ApprovalModal from '../../Approvals/ApprovalModal'; +import BottomSheetFooter from '../../../component-library/components/BottomSheets/BottomSheetFooter'; +import { ButtonVariants } from '../../../component-library/components/Buttons/Button'; +import { useStyles } from '../../hooks/useStyles'; +import Input from '../../../component-library/components/Form/TextField/foundation/Input'; +import { strings } from '../../../../locales/i18n'; + +/** + * The type of the SnapUIDateTimePicker. + */ +enum SnapUIDateTimePickerType { + Date = 'date', + Time = 'time', + DateTime = 'datetime', +} + +/** + * The props for the SnapUIDateTimePicker component. + */ +export interface SnapUIDateTimePickerProps { + name: string; + type: SnapUIDateTimePickerType; + label?: string; + error?: string; + placeholder?: string; + form?: string; + disablePast?: boolean; + disableFuture?: boolean; + disabled?: boolean; +} + +/** + * Formats the date for display based on the picker type. + * @param date - The date to format. + * @param type - The type of the picker (date, time, datetime). + * @returns The formatted date string. + */ +function formatDateForDisplay( + date: Date | null, + type: SnapUIDateTimePickerType, +) { + if (!date) { + return undefined; + } + switch (type) { + case SnapUIDateTimePickerType.Date: + return DateTime.fromJSDate(date).toLocaleString(DateTime.DATE_SHORT); + case SnapUIDateTimePickerType.Time: + return DateTime.fromJSDate(date).toLocaleString(DateTime.TIME_SIMPLE); + case SnapUIDateTimePickerType.DateTime: + return DateTime.fromJSDate(date).toLocaleString(DateTime.DATETIME_SHORT); + } +} + +/** + * Normalizes the date based on the picker type. + * + * @param date - The date to normalize. + * @param type - The type of the picker (date, time, datetime). + * @returns The normalized date. + */ +function normalizeDate(date: Date, type: SnapUIDateTimePickerType): Date { + switch (type) { + case SnapUIDateTimePickerType.Date: + date.setHours(0, 0, 0, 0); + break; + case SnapUIDateTimePickerType.Time: + date.setSeconds(0, 0); + break; + case SnapUIDateTimePickerType.DateTime: + date.setSeconds(0, 0); + break; + default: + break; + } + + return date; +} + +/** + * The SnapUIDateTimePicker component. + * + * @param props - The component props. + * @param props.name - The name of the input. + * @param props.type - The type of the picker (date, time, datetime). + * @param props.label - The label for the picker. + * @param props.form - The form identifier. + * @param props.disabled - Whether the picker is disabled. + * @param props.error - The error message to display. + * @param props.disablePast - Whether to disable past dates (only for date and datetime types). + * @param props.disableFuture - Whether to disable future dates (only for date and datetime types). + * @param props.placeholder - The placeholder text for the picker. + * @returns The DateTimePicker component. + */ +export const SnapUIDateTimePicker: FunctionComponent< + SnapUIDateTimePickerProps +> = ({ + type = SnapUIDateTimePickerType.DateTime, + label, + placeholder, + name, + form, + disabled, + error, + disablePast = false, + disableFuture = false, +}) => { + const { handleInputChange, getValue, setCurrentFocusedInput } = + useSnapInterfaceContext(); + + const { styles } = useStyles(stylesheet, {}); + + const inputRef = useRef(null); + + const initialValue = getValue(name, form) as string; + + const [value, setValue] = useState( + initialValue ? new Date(initialValue) : null, + ); + + // Internal state to manage the picker value before submission. + const [internalValue, setInternalValue] = useState(value ?? new Date()); + + // Android mode state to handle the two-step process for datetime type. + // First step is date selection, second step is time selection. + // We have to manage this manually as there is no native datetime picker on Android. + const [androidMode, setAndroidMode] = useState< + SnapUIDateTimePickerType.Date | SnapUIDateTimePickerType.Time + >( + type === SnapUIDateTimePickerType.DateTime + ? SnapUIDateTimePickerType.Date + : type, + ); + + const [showDatePicker, setShowDatePicker] = useState(false); + + useEffect(() => { + if (initialValue !== undefined && initialValue !== null) { + setValue(new Date(initialValue)); + } + }, [initialValue]); + + /** + * Handles internal value change for iOS picker. + * Since iOS picker is displayed in a bottom sheet, + * we only submit the value when the user confirms. + * @param _event - The date time picker event. + * @param date - The selected date. + */ + const handleIosChange = ( + _event: DateTimePickerEvent, + date: Date | undefined, + ) => { + if (!date) { + return; + } + + setInternalValue(date); + }; + + /** + * Submits the internal value to the snap. + * + * @param date - The date to submit. + */ + const submitInternalValue = (date: Date) => { + const normalizedDate = normalizeDate(date, type); + const isoString = DateTime.fromJSDate(normalizedDate).toISO(); + + setValue(normalizedDate); + handleInputChange(name, isoString, form); + setShowDatePicker(false); + }; + + /** + * Handles submission for iOS picker. + */ + const handleIosSubmit = () => { + submitInternalValue(internalValue); + }; + + /** + * Handles value change for Android picker. + * Handles the two-step process for datetime type as no native datetime picker is available on Android. + * @param event - The date time picker event. + * @param date - The selected date. + * @returns void. + */ + const handleAndroidChange = ( + event: DateTimePickerEvent, + date: Date | undefined, + ) => { + if (!date) { + return; + } + + // Handle the first of two-step process for datetime type. (date selection) + if ( + type === SnapUIDateTimePickerType.DateTime && + androidMode === SnapUIDateTimePickerType.Date && + event.type === 'set' + ) { + setInternalValue(date); + setAndroidMode(SnapUIDateTimePickerType.Time); + return; + } + + // Handle the second of two-step process for datetime type. (time selection) + if ( + type === SnapUIDateTimePickerType.DateTime && + androidMode === SnapUIDateTimePickerType.Time && + event.type === 'set' + ) { + setInternalValue(date); + submitInternalValue(date); + + setAndroidMode(SnapUIDateTimePickerType.Date); + return; + } + + // Handle dismissal for datetime type when selecting a date. + if ( + event.type === 'dismissed' && + type === SnapUIDateTimePickerType.DateTime && + androidMode === SnapUIDateTimePickerType.Date + ) { + setShowDatePicker(false); + setInternalValue(value ?? new Date()); + return; + } + + // Handle dismissal for datetime type when selecting a time. + // This resets the mode back to date for next opening. + if ( + event.type === 'dismissed' && + type === SnapUIDateTimePickerType.DateTime && + androidMode === SnapUIDateTimePickerType.Time + ) { + setAndroidMode(SnapUIDateTimePickerType.Date); + return; + } + // Handle single step date or time selection. + if (event.type === 'set') { + setInternalValue(date); + submitInternalValue(date); + } + + setShowDatePicker(false); + }; + + /** + * Handles opening the picker. + */ + const handleOpenPicker = () => { + setShowDatePicker(true); + }; + + /** + * Handles closing the picker. + * Submits the internal value before closing. + */ + const handleClosePicker = () => { + setShowDatePicker(false); + + // Revert to initial value if the user cancels. + setInternalValue(value ?? new Date()); + }; + + /** + * Handles focus event on the input. + */ + const handleFocus = () => setCurrentFocusedInput(name); + + /** + * Handles blur event on the input. + */ + const handleBlur = () => setCurrentFocusedInput(null); + + return ( + + {label && } + + + + } + // We set a max height of 58px and let the input grow to fill the rest of the height next to a taller sibling element. + // eslint-disable-next-line react-native/no-inline-styles + style={{ maxHeight: 58, flexGrow: 1 }} + /> + + {Platform.OS === 'android' && showDatePicker && ( + + )} + + {Platform.OS === 'ios' && ( + + + + + + + )} + {error && {error}} + + ); +}; diff --git a/app/components/Snaps/SnapUIRenderer/__snapshots__/SnapUIRenderer.test.ts.snap b/app/components/Snaps/SnapUIRenderer/__snapshots__/SnapUIRenderer.test.ts.snap index c6617fa1f66..5d036c9b0e8 100644 --- a/app/components/Snaps/SnapUIRenderer/__snapshots__/SnapUIRenderer.test.ts.snap +++ b/app/components/Snaps/SnapUIRenderer/__snapshots__/SnapUIRenderer.test.ts.snap @@ -29,6 +29,7 @@ exports[`SnapUIRenderer adds a footer if required 1`] = ` "marginBottom": 80, } } + testID="snap-ui-renderer__scrollview" > @@ -188,6 +189,7 @@ exports[`SnapUIRenderer prefills interactive inputs with existing state 1`] = ` "marginBottom": 0, } } + testID="snap-ui-renderer__scrollview" > @@ -346,6 +348,7 @@ exports[`SnapUIRenderer re-renders when the interface changes 1`] = ` "marginBottom": 0, } } + testID="snap-ui-renderer__scrollview" > @@ -600,6 +603,7 @@ exports[`SnapUIRenderer re-syncs state when the interface changes 1`] = ` "marginBottom": 0, } } + testID="snap-ui-renderer__scrollview" > @@ -852,6 +856,7 @@ exports[`SnapUIRenderer renders basic UI 1`] = ` "marginBottom": 0, } } + testID="snap-ui-renderer__scrollview" > @@ -947,6 +952,7 @@ exports[`SnapUIRenderer renders complex nested components 1`] = ` "marginBottom": 80, } } + testID="snap-ui-renderer__scrollview" > @@ -1465,6 +1471,7 @@ exports[`SnapUIRenderer renders footers 1`] = ` "marginBottom": 80, } } + testID="snap-ui-renderer__scrollview" > @@ -1724,6 +1731,7 @@ exports[`SnapUIRenderer supports fields with multiple components 1`] = ` "marginBottom": 0, } } + testID="snap-ui-renderer__scrollview" > @@ -2040,6 +2048,7 @@ exports[`SnapUIRenderer supports interactive inputs 1`] = ` "marginBottom": 0, } } + testID="snap-ui-renderer__scrollview" > @@ -2198,6 +2207,7 @@ exports[`SnapUIRenderer supports the onCancel prop 1`] = ` "marginBottom": 80, } } + testID="snap-ui-renderer__scrollview" > diff --git a/app/components/Snaps/SnapUIRenderer/components/__snapshots__/account-selector.test.ts.snap b/app/components/Snaps/SnapUIRenderer/components/__snapshots__/account-selector.test.ts.snap index bab4c838680..fc3a1e76757 100644 --- a/app/components/Snaps/SnapUIRenderer/components/__snapshots__/account-selector.test.ts.snap +++ b/app/components/Snaps/SnapUIRenderer/components/__snapshots__/account-selector.test.ts.snap @@ -29,6 +29,7 @@ exports[`SnapUIAccountSelector renders an account selector 1`] = ` "marginBottom": 0, } } + testID="snap-ui-renderer__scrollview" > @@ -150,6 +151,7 @@ exports[`SnapUIAccountSelector renders inside a field 1`] = ` "marginBottom": 0, } } + testID="snap-ui-renderer__scrollview" > diff --git a/app/components/Snaps/SnapUIRenderer/components/__snapshots__/address.test.ts.snap b/app/components/Snaps/SnapUIRenderer/components/__snapshots__/address.test.ts.snap index 0cdc9c2c9b6..ea026da5af1 100644 --- a/app/components/Snaps/SnapUIRenderer/components/__snapshots__/address.test.ts.snap +++ b/app/components/Snaps/SnapUIRenderer/components/__snapshots__/address.test.ts.snap @@ -29,6 +29,7 @@ exports[`SnapUIAddress renders Bitcoin address 1`] = ` "marginBottom": 0, } } + testID="snap-ui-renderer__scrollview" > @@ -123,6 +124,7 @@ exports[`SnapUIAddress renders Bitcoin address with blockie 1`] = ` "marginBottom": 0, } } + testID="snap-ui-renderer__scrollview" > @@ -226,6 +228,7 @@ exports[`SnapUIAddress renders Cosmos address 1`] = ` "marginBottom": 0, } } + testID="snap-ui-renderer__scrollview" > @@ -320,6 +323,7 @@ exports[`SnapUIAddress renders Cosmos address with blockie 1`] = ` "marginBottom": 0, } } + testID="snap-ui-renderer__scrollview" > @@ -423,6 +427,7 @@ exports[`SnapUIAddress renders Ethereum address 1`] = ` "marginBottom": 0, } } + testID="snap-ui-renderer__scrollview" > @@ -517,6 +522,7 @@ exports[`SnapUIAddress renders Ethereum address with blockie 1`] = ` "marginBottom": 0, } } + testID="snap-ui-renderer__scrollview" > @@ -620,6 +626,7 @@ exports[`SnapUIAddress renders Hedera address 1`] = ` "marginBottom": 0, } } + testID="snap-ui-renderer__scrollview" > @@ -714,6 +721,7 @@ exports[`SnapUIAddress renders Hedera address with blockie 1`] = ` "marginBottom": 0, } } + testID="snap-ui-renderer__scrollview" > @@ -817,6 +825,7 @@ exports[`SnapUIAddress renders Polkadot address 1`] = ` "marginBottom": 0, } } + testID="snap-ui-renderer__scrollview" > @@ -911,6 +920,7 @@ exports[`SnapUIAddress renders Polkadot address with blockie 1`] = ` "marginBottom": 0, } } + testID="snap-ui-renderer__scrollview" > @@ -1014,6 +1024,7 @@ exports[`SnapUIAddress renders Starknet address 1`] = ` "marginBottom": 0, } } + testID="snap-ui-renderer__scrollview" > @@ -1108,6 +1119,7 @@ exports[`SnapUIAddress renders Starknet address with blockie 1`] = ` "marginBottom": 0, } } + testID="snap-ui-renderer__scrollview" > @@ -1211,6 +1223,7 @@ exports[`SnapUIAddress renders legacy Ethereum address 1`] = ` "marginBottom": 0, } } + testID="snap-ui-renderer__scrollview" > diff --git a/app/components/Snaps/SnapUIRenderer/components/__snapshots__/asset-selector.test.ts.snap b/app/components/Snaps/SnapUIRenderer/components/__snapshots__/asset-selector.test.ts.snap index 1f349483e56..3c7f8357b45 100644 --- a/app/components/Snaps/SnapUIRenderer/components/__snapshots__/asset-selector.test.ts.snap +++ b/app/components/Snaps/SnapUIRenderer/components/__snapshots__/asset-selector.test.ts.snap @@ -29,6 +29,7 @@ exports[`SnapUIAssetSelector should render 1`] = ` "marginBottom": 0, } } + testID="snap-ui-renderer__scrollview" > diff --git a/app/components/Snaps/SnapUIRenderer/components/__snapshots__/container.test.ts.snap b/app/components/Snaps/SnapUIRenderer/components/__snapshots__/container.test.ts.snap new file mode 100644 index 00000000000..3bde037a4a8 --- /dev/null +++ b/app/components/Snaps/SnapUIRenderer/components/__snapshots__/container.test.ts.snap @@ -0,0 +1,87 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`container add footer button when useFooter is true and onCancel is provided 1`] = ` +{ + "children": { + "children": { + "children": { + "children": "navigation.close", + "element": "SnapUIFooterButton", + "key": "default-button", + "props": { + "isSnapAction": false, + "onCancel": [MockFunction], + "testID": "default-snap-footer-button", + "variant": "Secondary", + }, + }, + "element": "Box", + "key": "default-footer", + "props": { + "flexDirection": "row", + "gap": 16, + "padding": 16, + "style": { + "alignItems": "center", + "bottom": 0, + "gap": 16, + "height": 80, + "justifyContent": "space-evenly", + "margin": 16, + "paddingVertical": 16, + "position": "absolute", + "width": "100%", + }, + }, + }, + "element": "TouchableHighlight", + }, + "element": "ScrollView", + "key": "default-scrollview", + "props": { + "style": { + "marginBottom": 0, + }, + "testID": "snap-ui-renderer__scrollview", + }, +} +`; + +exports[`container render basic container with single child 1`] = ` +{ + "children": [ + { + "children": { + "children": { + "children": [ + "Hello", + ], + "element": "text", + "props": { + "style": { + "gap": 16, + "margin": 16, + }, + }, + }, + "element": "TouchableHighlight", + }, + "element": "ScrollView", + "key": "default-scrollview", + "props": { + "style": { + "marginBottom": 0, + }, + "testID": "snap-ui-renderer__scrollview", + }, + }, + ], + "element": "Box", + "props": { + "style": { + "flexDirection": "column", + "flexGrow": 1, + }, + }, +} +`; diff --git a/app/components/Snaps/SnapUIRenderer/components/__snapshots__/date-time-picker.test.tsx.snap b/app/components/Snaps/SnapUIRenderer/components/__snapshots__/date-time-picker.test.tsx.snap new file mode 100644 index 00000000000..20b4e271dfd --- /dev/null +++ b/app/components/Snaps/SnapUIRenderer/components/__snapshots__/date-time-picker.test.tsx.snap @@ -0,0 +1,876 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SnapUIDateTimePicker can show an error 1`] = ` + + + + + + + + + Select date and time + + + + + + + + + + + This is an error + + + + + + + + + +`; + +exports[`SnapUIDateTimePicker renders a date picker 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`SnapUIDateTimePicker renders a date time picker 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`SnapUIDateTimePicker renders a time picker 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`SnapUIDateTimePicker renders inside a field 1`] = ` + + + + + + + + + Select date and time + + + + + + + + + + + + + + + + + +`; diff --git a/app/components/Snaps/SnapUIRenderer/components/__snapshots__/form.test.ts.snap b/app/components/Snaps/SnapUIRenderer/components/__snapshots__/form.test.ts.snap index 03be006e040..ab8108d924a 100644 --- a/app/components/Snaps/SnapUIRenderer/components/__snapshots__/form.test.ts.snap +++ b/app/components/Snaps/SnapUIRenderer/components/__snapshots__/form.test.ts.snap @@ -29,6 +29,7 @@ exports[`SnapUIForm will render 1`] = ` "marginBottom": 0, } } + testID="snap-ui-renderer__scrollview" > @@ -229,6 +230,7 @@ exports[`SnapUIForm will render with fields 1`] = ` "marginBottom": 0, } } + testID="snap-ui-renderer__scrollview" > diff --git a/app/components/Snaps/SnapUIRenderer/components/__snapshots__/input.test.ts.snap b/app/components/Snaps/SnapUIRenderer/components/__snapshots__/input.test.ts.snap index bfd631dc075..7f51af1a051 100644 --- a/app/components/Snaps/SnapUIRenderer/components/__snapshots__/input.test.ts.snap +++ b/app/components/Snaps/SnapUIRenderer/components/__snapshots__/input.test.ts.snap @@ -29,6 +29,7 @@ exports[`SnapUIInput handles disabled input 1`] = ` "marginBottom": 0, } } + testID="snap-ui-renderer__scrollview" > @@ -187,6 +188,7 @@ exports[`SnapUIInput renders with initial value 1`] = ` "marginBottom": 0, } } + testID="snap-ui-renderer__scrollview" > diff --git a/app/components/Snaps/SnapUIRenderer/components/container.test.ts b/app/components/Snaps/SnapUIRenderer/components/container.test.ts index 48e029500d5..bb3587e91e8 100644 --- a/app/components/Snaps/SnapUIRenderer/components/container.test.ts +++ b/app/components/Snaps/SnapUIRenderer/components/container.test.ts @@ -43,43 +43,7 @@ describe('container', () => { theme: mockTheme, }); - expect(result).toMatchInlineSnapshot(` - { - "children": [ - { - "children": { - "children": { - "children": [ - "Hello", - ], - "element": "text", - "props": { - "style": { - "gap": 16, - "margin": 16, - }, - }, - }, - "element": "TouchableHighlight", - }, - "element": "ScrollView", - "key": "default-scrollview", - "props": { - "style": { - "marginBottom": 0, - }, - }, - }, - ], - "element": "Box", - "props": { - "style": { - "flexDirection": "column", - "flexGrow": 1, - }, - }, - } - `); + expect(result).toMatchSnapshot(); }); it('add footer button when useFooter is true and onCancel is provided', () => { @@ -97,50 +61,6 @@ describe('container', () => { expect(Array.isArray(result.children)).toBe(true); // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect((result.children as any[])[0]).toMatchInlineSnapshot(` - { - "children": { - "children": { - "children": { - "children": "navigation.close", - "element": "SnapUIFooterButton", - "key": "default-button", - "props": { - "isSnapAction": false, - "onCancel": [MockFunction], - "testID": "default-snap-footer-button", - "variant": "Secondary", - }, - }, - "element": "Box", - "key": "default-footer", - "props": { - "flexDirection": "row", - "gap": 16, - "padding": 16, - "style": { - "alignItems": "center", - "bottom": 0, - "gap": 16, - "height": 80, - "justifyContent": "space-evenly", - "margin": 16, - "paddingVertical": 16, - "position": "absolute", - "width": "100%", - }, - }, - }, - "element": "TouchableHighlight", - }, - "element": "ScrollView", - "key": "default-scrollview", - "props": { - "style": { - "marginBottom": 0, - }, - }, - } - `); + expect((result.children as any[])[0]).toMatchSnapshot(); }); }); diff --git a/app/components/Snaps/SnapUIRenderer/components/container.ts b/app/components/Snaps/SnapUIRenderer/components/container.ts index 4d43459b20e..5c92acc8679 100644 --- a/app/components/Snaps/SnapUIRenderer/components/container.ts +++ b/app/components/Snaps/SnapUIRenderer/components/container.ts @@ -74,6 +74,7 @@ export const container: UIComponentFactory = ({ children: styledContent, }, props: { + testID: 'snap-ui-renderer__scrollview', style: { marginBottom: useFooter && footer ? 80 : 0, }, diff --git a/app/components/Snaps/SnapUIRenderer/components/date-time-picker.test.tsx b/app/components/Snaps/SnapUIRenderer/components/date-time-picker.test.tsx new file mode 100644 index 00000000000..7ff64d9425d --- /dev/null +++ b/app/components/Snaps/SnapUIRenderer/components/date-time-picker.test.tsx @@ -0,0 +1,242 @@ +import { Box, DateTimePicker, Field } from '@metamask/snaps-sdk/jsx'; +import { renderInterface } from '../testUtils'; +import { act, fireEvent, waitFor } from '@testing-library/react-native'; + +import RNDateTimePicker from '@react-native-community/datetimepicker'; +import { DateTime } from 'luxon'; + +jest.mock('../../../../core/Engine/Engine', () => ({ + controllerMessenger: { + call: jest.fn(), + }, + context: { + SnapInterfaceController: { + updateInterfaceState: jest.fn(), + }, + }, +})); + +describe('SnapUIDateTimePicker', () => { + it('renders a date time picker', () => { + const { toJSON, getByTestId } = renderInterface( + Box({ + children: DateTimePicker({ + name: 'date-time-picker', + }), + }), + ); + + expect(getByTestId('snap-ui-renderer__date-time-picker')).toBeTruthy(); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders a date picker', () => { + const { toJSON, getByTestId } = renderInterface( + Box({ + children: DateTimePicker({ + name: 'date-picker', + type: 'date', + }), + }), + ); + + expect(getByTestId('snap-ui-renderer__date-time-picker')).toBeTruthy(); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders a time picker', () => { + const { toJSON, getByTestId } = renderInterface( + Box({ + children: DateTimePicker({ + name: 'time-picker', + type: 'time', + }), + }), + ); + + expect(getByTestId('snap-ui-renderer__date-time-picker')).toBeTruthy(); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('can select a date and time', async () => { + const { getByTestId, UNSAFE_getByType, getByText } = renderInterface( + Box({ + children: DateTimePicker({ + name: 'date-time-picker', + type: 'datetime', + }), + }), + ); + + act(() => { + fireEvent.press( + getByTestId('snap-ui-renderer__date-time-picker--datetime-touchable'), + ); + }); + + await waitFor(() => UNSAFE_getByType(RNDateTimePicker)); + + const date = new Date('2024-12-25T15:30:00Z'); + + act(() => { + fireEvent( + UNSAFE_getByType(RNDateTimePicker), + 'onChange', + { + type: 'set', + nativeEvent: { + timestamp: date.getTime(), + utcOffset: 0, + }, + }, + date, + ); + }); + + act(() => { + fireEvent.press(getByText('OK')); + }); + + expect( + getByTestId('snap-ui-renderer__date-time-picker--datetime-input').props + .value, + ).toEqual( + DateTime.fromJSDate(date).toLocaleString(DateTime.DATETIME_SHORT), + ); + }); + + it('can select a date', async () => { + const { getByTestId, UNSAFE_getByType, getByText } = renderInterface( + Box({ + children: DateTimePicker({ + name: 'date-picker', + type: 'date', + }), + }), + ); + + act(() => { + fireEvent.press( + getByTestId('snap-ui-renderer__date-time-picker--date-touchable'), + ); + }); + + await waitFor(() => UNSAFE_getByType(RNDateTimePicker)); + + const date = new Date('2024-12-25T15:30:00Z'); + + act(() => { + fireEvent( + UNSAFE_getByType(RNDateTimePicker), + 'onChange', + { + type: 'set', + nativeEvent: { + timestamp: date.getTime(), + utcOffset: 0, + }, + }, + date, + ); + }); + + act(() => { + fireEvent.press(getByText('OK')); + }); + + expect( + getByTestId('snap-ui-renderer__date-time-picker--date-input').props.value, + ).toEqual( + DateTime.fromJSDate(date) + .set({ hour: 0, minute: 0, second: 0, millisecond: 0 }) + .toLocaleString(DateTime.DATE_SHORT), + ); + }); + + it('can select a time', async () => { + const { getByTestId, UNSAFE_getByType, getByText } = renderInterface( + Box({ + children: DateTimePicker({ + name: 'time-picker', + type: 'time', + }), + }), + ); + + act(() => { + fireEvent.press( + getByTestId('snap-ui-renderer__date-time-picker--time-touchable'), + ); + }); + + await waitFor(() => UNSAFE_getByType(RNDateTimePicker)); + + const date = new Date('2024-12-25T15:30:00Z'); + + act(() => { + fireEvent( + UNSAFE_getByType(RNDateTimePicker), + 'onChange', + { + type: 'set', + nativeEvent: { + timestamp: date.getTime(), + utcOffset: 0, + }, + }, + date, + ); + }); + + act(() => { + fireEvent.press(getByText('OK')); + }); + + expect( + getByTestId('snap-ui-renderer__date-time-picker--time-input').props.value, + ).toEqual( + DateTime.fromJSDate(date) + .set({ second: 0, millisecond: 0 }) + .toLocaleString(DateTime.TIME_SIMPLE), + ); + }); + + it('renders inside a field', () => { + const { toJSON, getByText, getByTestId } = renderInterface( + Box({ + children: Field({ + label: 'Select date and time', + children: DateTimePicker({ + name: 'date-time-picker', + }), + }), + }), + ); + + expect(getByText('Select date and time')).toBeTruthy(); + expect(getByTestId('snap-ui-renderer__date-time-picker')).toBeTruthy(); + expect(toJSON()).toMatchSnapshot(); + }); + + it('can show an error', () => { + const { toJSON, getByText, getByTestId } = renderInterface( + Box({ + children: Field({ + label: 'Select date and time', + error: 'This is an error', + children: DateTimePicker({ + name: 'date-time-picker', + }), + }), + }), + ); + + expect(getByText('Select date and time')).toBeTruthy(); + expect(getByText('This is an error')).toBeTruthy(); + expect(getByTestId('snap-ui-renderer__date-time-picker')).toBeTruthy(); + expect(toJSON()).toMatchSnapshot(); + }); +}); diff --git a/app/components/Snaps/SnapUIRenderer/components/date-time-picker.ts b/app/components/Snaps/SnapUIRenderer/components/date-time-picker.ts new file mode 100644 index 00000000000..c19a7d71b7f --- /dev/null +++ b/app/components/Snaps/SnapUIRenderer/components/date-time-picker.ts @@ -0,0 +1,18 @@ +import { DateTimePickerElement } from '@metamask/snaps-sdk/jsx'; +import { UIComponentFactory } from './types'; + +export const dateTimePicker: UIComponentFactory = ({ + element: e, + form, +}) => ({ + element: 'SnapUIDateTimePicker', + props: { + form, + type: e.props.type, + name: e.props.name, + placeholder: e.props.placeholder, + disabled: e.props.disabled, + disablePast: e.props.disablePast, + disableFuture: e.props.disableFuture, + }, +}); diff --git a/app/components/Snaps/SnapUIRenderer/components/field.ts b/app/components/Snaps/SnapUIRenderer/components/field.ts index eebeb019081..1ce317ecb7e 100644 --- a/app/components/Snaps/SnapUIRenderer/components/field.ts +++ b/app/components/Snaps/SnapUIRenderer/components/field.ts @@ -9,6 +9,7 @@ import { AccountSelectorElement, DropdownElement, RadioGroupElement, + DateTimePickerElement, } from '@metamask/snaps-sdk/jsx'; import { getJsxChildren } from '@metamask/snaps-utils'; import { getPrimaryChildElementIndex, mapToTemplate } from '../utils'; @@ -20,6 +21,7 @@ import { assetSelector as assetSelectorFn } from './asset-selector'; import { accountSelector as accountSelectorFn } from './account-selector'; import { dropdown as dropdownFn } from './dropdown'; import { radioGroup as radioGroupFn } from './radioGroup'; +import { dateTimePicker as dateTimePickerFn } from './date-time-picker'; export const field: UIComponentFactory = ({ element: e, @@ -243,6 +245,24 @@ export const field: UIComponentFactory = ({ }; } + case 'DateTimePicker': { + const dateTimePicker = child as DateTimePickerElement; + const dateTimePickerMapped = dateTimePickerFn({ + element: dateTimePicker, + } as UIComponentParams); + return { + ...dateTimePickerMapped, + element: 'SnapUIDateTimePicker', + props: { + ...dateTimePickerMapped.props, + label: e.props.label, + form, + error: e.props.error, + disabled: child.props.disabled, + }, + }; + } + default: throw new Error(`Invalid Field child: ${child.type}`); } diff --git a/app/components/Snaps/SnapUIRenderer/components/index.ts b/app/components/Snaps/SnapUIRenderer/components/index.ts index d94db2748a7..7a9d9ef2025 100644 --- a/app/components/Snaps/SnapUIRenderer/components/index.ts +++ b/app/components/Snaps/SnapUIRenderer/components/index.ts @@ -28,6 +28,7 @@ import { copyable } from './copyable'; import { accountSelector } from './account-selector'; import { dropdown } from './dropdown'; import { radioGroup } from './radioGroup'; +import { dateTimePicker } from './date-time-picker'; export const COMPONENT_MAPPING = { Box: box, @@ -60,4 +61,5 @@ export const COMPONENT_MAPPING = { AccountSelector: accountSelector, Dropdown: dropdown, RadioGroup: radioGroup, + DateTimePicker: dateTimePicker, }; diff --git a/app/components/Snaps/SnapUIRenderer/utils.ts b/app/components/Snaps/SnapUIRenderer/utils.ts index 905f64063e3..618131e3ae8 100644 --- a/app/components/Snaps/SnapUIRenderer/utils.ts +++ b/app/components/Snaps/SnapUIRenderer/utils.ts @@ -46,6 +46,7 @@ export const FIELD_ELEMENT_TYPES = [ 'AddressInput', 'AssetSelector', 'AccountSelector', + 'DateTimePicker', ]; /** diff --git a/app/components/UI/NetworkModal/index.tsx b/app/components/UI/NetworkModal/index.tsx index 95f90c9207d..bbe5f8ecf88 100644 --- a/app/components/UI/NetworkModal/index.tsx +++ b/app/components/UI/NetworkModal/index.tsx @@ -335,13 +335,14 @@ const NetworkModals = (props: NetworkProps) => { const addNetwork = async () => { const isValidUrl = validateRpcUrl(rpcUrl); if (showPopularNetworkModal) { - // track popular network + // track popular network - first RPC endpoint added (index 0) trackEvent( - createEventBuilder(MetaMetricsEvents.NETWORK_ADDED) + createEventBuilder(MetaMetricsEvents.RPC_ADDED) .addProperties({ chain_id: toHex(chainId), source: 'Popular network list', symbol: ticker, + rpc_url_index: 0, }) .build(), ); @@ -350,13 +351,14 @@ const NetworkModals = (props: NetworkProps) => { rpcUrl, safeChains, ); - // track custom network, this shouldn't be in popular networks modal + // track custom network - first RPC endpoint added (index 0) trackEvent( - createEventBuilder(MetaMetricsEvents.NETWORK_ADDED) + createEventBuilder(MetaMetricsEvents.RPC_ADDED) .addProperties({ chain_id: toHex(safeChain.chainId), source: { anonymous: true, value: 'Custom Network Added' }, symbol: safeChain.nativeCurrency.symbol, + rpc_url_index: 0, }) .addSensitiveProperties({ rpcUrl: safeRPCUrl }) .build(), diff --git a/app/components/UI/TemplateRenderer/SafeComponentList.ts b/app/components/UI/TemplateRenderer/SafeComponentList.ts index d017d2a7d75..22ef8fa024d 100644 --- a/app/components/UI/TemplateRenderer/SafeComponentList.ts +++ b/app/components/UI/TemplateRenderer/SafeComponentList.ts @@ -38,6 +38,7 @@ import { SnapUIAssetSelector } from '../../Snaps/SnapUIAssetSelector/SnapUIAsset import { SnapUICopyable } from '../../Snaps/SnapUICopyable/SnapUICopyable'; import { SnapUIAccountSelector } from '../../Snaps/SnapUIAccountSelector/SnapUIAccountSelector'; import { SnapUIRadioGroup } from '../../Snaps/SnapUIRadioGroup/SnapUIRadioGroup'; +import { SnapUIDateTimePicker } from '../../Snaps/SnapUIDateTimePicker/SnapUIDateTimePicker'; export const safeComponentList = { BottomSheetFooter, @@ -72,6 +73,7 @@ export const safeComponentList = { SnapUISpinner, SnapUIInfoRow, SnapUIAccountSelector, + SnapUIDateTimePicker, RNText, ScrollView, SnapUITooltip, diff --git a/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.js b/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.js index 2fa2c91110a..08bcebbe43d 100644 --- a/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.js +++ b/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.js @@ -14,6 +14,7 @@ import Networks, { getDecimalChainId, isWhitelistedSymbol, } from '../../../../../util/networks'; +import { PopularList } from '../../../../../util/networks/customNetworks'; import Engine from '../../../../../core/Engine'; import URL from 'url-parse'; import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view'; @@ -48,7 +49,11 @@ import { import { selectIsRpcFailoverEnabled } from '../../../../../selectors/featureFlagController/walletFramework'; import { regex } from '../../../../../../app/util/regex'; import { NetworksViewSelectorsIDs } from '../../../../../../e2e/selectors/Settings/NetworksView.selectors'; -import { isSafeChainId, toHex } from '@metamask/controller-utils'; +import { + BlockExplorerUrl, + isSafeChainId, + toHex, +} from '@metamask/controller-utils'; import { hexToNumber } from '@metamask/utils'; import { CustomDefaultNetworkIDs } from '../../../../../../e2e/selectors/Onboarding/CustomDefaultNetwork.selectors'; import { updateIncomingTransactions } from '../../../../../util/transaction-controller'; @@ -86,6 +91,7 @@ import { CellComponentSelectorsIDs } from '../../../../../../e2e/selectors/walle import stripProtocol from '../../../../../util/stripProtocol'; import stripKeyFromInfuraUrl from '../../../../../util/stripKeyFromInfuraUrl'; import { MetaMetrics, MetaMetricsEvents } from '../../../../../core/Analytics'; +import { MetricsEventBuilder } from '../../../../../core/Analytics/MetricsEventBuilder'; import { addItemToChainIdList, removeItemFromChainIdList, @@ -102,6 +108,31 @@ const allNetworks = getAllNetworks(); const InfuraKey = process.env.MM_INFURA_PROJECT_ID; const infuraProjectId = InfuraKey === 'null' ? '' : InfuraKey; +/** + * Get block explorer URL as fallback when networkConfiguration doesn't have blockExplorerUrls. + * Checks PopularList first (by chainId), then falls back to BlockExplorerUrl (by networkType). + * + * @param {string} chainId - The chain ID (hex string) + * @param {string} [networkType] - Optional network type (e.g., 'mainnet', 'linea-mainnet') + * @returns {string | undefined} The block explorer URL or undefined + */ +const getDefaultBlockExplorerUrl = (chainId, networkType) => { + // First check PopularList by chainId + const popularNetwork = PopularList.find( + (network) => network.chainId === chainId, + ); + if (popularNetwork?.rpcPrefs?.blockExplorerUrl) { + return popularNetwork.rpcPrefs.blockExplorerUrl; + } + + // Fall back to BlockExplorerUrl for built-in networks (mainnet, linea, etc.) + if (networkType && BlockExplorerUrl[networkType]) { + return BlockExplorerUrl[networkType]; + } + + return undefined; +}; + /** * Main view for app configurations */ @@ -275,11 +306,15 @@ export class NetworkSettings extends PureComponent { nickname = networkConfiguration?.name; editable = false; - blockExplorerUrl = networkConfiguration - ? networkConfiguration.blockExplorerUrls[ - networkConfiguration.defaultBlockExplorerUrlIndex - ] - : undefined; + // Use networkTypeOrRpcUrl as the network type for built-in networks (e.g., 'mainnet', 'linea-mainnet') + const fallbackExplorerUrl = getDefaultBlockExplorerUrl( + chainId, + networkTypeOrRpcUrl, + ); + blockExplorerUrl = + networkConfiguration?.blockExplorerUrls?.[ + networkConfiguration.defaultBlockExplorerUrlIndex + ] ?? fallbackExplorerUrl; rpcUrl = defaultRpcEndpoint?.url; failoverRpcUrls = defaultRpcEndpoint?.failoverUrls; rpcName = defaultRpcEndpoint @@ -288,7 +323,13 @@ export class NetworkSettings extends PureComponent { : defaultRpcEndpoint.name : undefined; rpcUrls = networkConfiguration?.rpcEndpoints; - blockExplorerUrls = networkConfiguration?.blockExplorerUrls; + // Use fallback if blockExplorerUrls is undefined/null OR empty array + const configBlockExplorerUrls = networkConfiguration?.blockExplorerUrls; + if (configBlockExplorerUrls?.length > 0) { + blockExplorerUrls = configBlockExplorerUrls; + } else { + blockExplorerUrls = fallbackExplorerUrl ? [fallbackExplorerUrl] : []; + } ticker = networkConfiguration?.nativeCurrency; } else { @@ -305,16 +346,28 @@ export class NetworkSettings extends PureComponent { : undefined; nickname = networkConfiguration?.name; chainId = networkConfiguration?.chainId; + // Use networkClientId from the RPC endpoint for built-in networks + const networkClientId = defaultRpcEndpoint?.networkClientId; + const fallbackExplorerUrl = getDefaultBlockExplorerUrl( + chainId, + networkClientId, + ); blockExplorerUrl = - networkConfiguration?.blockExplorerUrls[ + networkConfiguration?.blockExplorerUrls?.[ networkConfiguration?.defaultBlockExplorerUrlIndex - ]; + ] ?? fallbackExplorerUrl; ticker = networkConfiguration?.nativeCurrency; editable = true; rpcUrl = defaultRpcEndpoint?.url; failoverRpcUrls = defaultRpcEndpoint?.failoverUrls; rpcUrls = networkConfiguration?.rpcEndpoints; - blockExplorerUrls = networkConfiguration?.blockExplorerUrls; + // Use fallback if blockExplorerUrls is undefined/null OR empty array + const configBlockExplorerUrls = networkConfiguration?.blockExplorerUrls; + if (configBlockExplorerUrls?.length > 0) { + blockExplorerUrls = configBlockExplorerUrls; + } else { + blockExplorerUrls = fallbackExplorerUrl ? [fallbackExplorerUrl] : []; + } rpcName = defaultRpcEndpoint ? defaultRpcEndpoint.type === 'infura' ? 'Infura' @@ -978,6 +1031,10 @@ export class NetworkSettings extends PureComponent { type: RpcEndpointType.Custom, }; + // Calculate the index of the newly added RPC endpoint + // rpc_url_index: 0 means there is only 1 RPC URL for this network + const rpcUrlIndex = this.state.rpcUrls.length; + await this.setState((prevState) => ({ rpcUrls: [...prevState.rpcUrls, newRpcUrl], })); @@ -988,6 +1045,20 @@ export class NetworkSettings extends PureComponent { rpcName: newRpcUrl.name, }); + // Track RPC Added event + if (this.state.chainId) { + MetaMetrics.getInstance().trackEvent( + MetricsEventBuilder.createEventBuilder(MetaMetricsEvents.RPC_ADDED) + .addProperties({ + chain_id: toHex(this.state.chainId), + source: 'Network Settings', + symbol: this.state.ticker, + rpc_url_index: rpcUrlIndex, + }) + .build(), + ); + } + this.closeAddRpcForm(); this.closeRpcModal(); this.getCurrentState(); @@ -1083,10 +1154,29 @@ export class NetworkSettings extends PureComponent { }; onRpcUrlDelete = async (url) => { - const { addMode } = this.state; + const { addMode, rpcUrls, chainId } = this.state; + + // Find the index of the RPC being deleted before removal + const rpcUrlIndex = rpcUrls.findIndex((rpcUrl) => rpcUrl.url === url); + await this.setState((prevState) => ({ rpcUrls: prevState.rpcUrls.filter((rpcUrl) => rpcUrl.url !== url), })); + + // Track RPC Removed event + if (chainId && rpcUrlIndex !== -1) { + MetaMetrics.getInstance().trackEvent( + MetricsEventBuilder.createEventBuilder(MetaMetricsEvents.RPC_REMOVED) + .addProperties({ + chain_id: toHex(chainId), + source: 'Network Settings', + symbol: this.state.ticker, + rpc_url_index: rpcUrlIndex, + }) + .build(), + ); + } + this.validateName(); if (addMode) { this.validateChainId(); diff --git a/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.test.tsx b/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.test.tsx index be2de00f4dd..74da8a9fac6 100644 --- a/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.test.tsx +++ b/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.test.tsx @@ -1,15 +1,28 @@ // Mock the Analytics module BEFORE any imports +const mockTrackEvent = jest.fn(); +const mockAddProperties = jest.fn().mockReturnThis(); +const mockBuild = jest.fn().mockReturnValue({ name: 'test-event' }); +const mockCreateEventBuilder = jest.fn().mockReturnValue({ + addProperties: mockAddProperties, + build: mockBuild, +}); + jest.mock('../../../../../core/Analytics', () => ({ MetaMetrics: { getInstance: jest.fn(() => ({ addTraitsToUser: jest.fn(), + trackEvent: mockTrackEvent, })), }, MetaMetricsEvents: { NETWORK_REMOVED: 'Network Removed', + RPC_ADDED: { category: 'RPC Added' }, + RPC_REMOVED: { category: 'RPC Removed' }, }, })); +jest.mock('../../../../../core/Analytics/MetricsEventBuilder'); + import React from 'react'; import { shallow } from 'enzyme'; import { RpcEndpointType } from '@metamask/network-controller'; @@ -24,8 +37,12 @@ import * as jsonRequest from '../../../../../util/jsonRpcRequest'; import Logger from '../../../../../util/Logger'; import Engine from '../../../../../core/Engine'; import { MetaMetricsEvents } from '../../../../../core/Analytics'; +import { MetricsEventBuilder } from '../../../../../core/Analytics/MetricsEventBuilder'; const { PreferencesController } = Engine.context; +// Set up MetricsEventBuilder mock after import +MetricsEventBuilder.createEventBuilder = mockCreateEventBuilder; + jest.mock( '../../../../../util/metrics/MultichainAPI/networkMetricUtils', () => ({ @@ -38,24 +55,6 @@ jest.mock( }), ); -const mockTrackEvent = jest.fn(); - -const mockCreateEventBuilder = jest.fn((eventName) => { - let properties = {}; - return { - addProperties(props: Record) { - properties = { ...properties, ...props }; - return this; - }, - build() { - return { - name: eventName, - properties, - }; - }, - }; -}); - jest.mock('../../../../../components/hooks/useMetrics', () => ({ useMetrics: () => ({ trackEvent: mockTrackEvent, @@ -854,6 +853,209 @@ describe('NetworkSettings', () => { expect(wrapper.state('rpcUrls').length).toBe(0); }); + describe('RPC event tracking', () => { + beforeEach(() => { + mockTrackEvent.mockClear(); + mockCreateEventBuilder.mockClear(); + mockAddProperties.mockClear(); + mockBuild.mockClear(); + }); + + it('tracks RPC_ADDED event when adding RPC URL with chainId set', async () => { + const instance = wrapper.instance(); + const chainId = '0x1'; + const ticker = 'ETH'; + + wrapper.setState({ + chainId, + ticker, + rpcUrls: [], + }); + + await instance.onRpcItemAdd('https://new-rpc-url.com', 'New RPC'); + + // Verify RPC_ADDED event was tracked + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + expect.objectContaining({ category: 'RPC Added' }), + ); + expect(mockAddProperties).toHaveBeenCalledWith({ + chain_id: '0x1', + source: 'Network Settings', + symbol: 'ETH', + rpc_url_index: 0, + }); + expect(mockTrackEvent).toHaveBeenCalled(); + }); + + it('tracks RPC_ADDED event with correct rpc_url_index when adding multiple RPC URLs', async () => { + const instance = wrapper.instance(); + const chainId = '0x64'; + const ticker = 'xDai'; + + wrapper.setState({ + chainId, + ticker, + rpcUrls: [ + { + url: 'https://first-rpc-url.com', + name: 'First RPC', + type: RpcEndpointType.Custom, + }, + ], + }); + + // Add second RPC URL + await instance.onRpcItemAdd('https://second-rpc-url.com', 'Second RPC'); + + // Verify RPC_ADDED event was tracked with index 1 + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + expect.objectContaining({ category: 'RPC Added' }), + ); + expect(mockAddProperties).toHaveBeenCalledWith({ + chain_id: '0x64', + source: 'Network Settings', + symbol: 'xDai', + rpc_url_index: 1, + }); + expect(mockTrackEvent).toHaveBeenCalled(); + }); + + it('does not track RPC_ADDED event when chainId is not set', async () => { + const instance = wrapper.instance(); + + wrapper.setState({ + chainId: undefined, + rpcUrls: [], + }); + + await instance.onRpcItemAdd('https://new-rpc-url.com', 'New RPC'); + + // Verify RPC_ADDED event was NOT tracked + expect(mockCreateEventBuilder).not.toHaveBeenCalled(); + expect(mockTrackEvent).not.toHaveBeenCalled(); + }); + + it('tracks RPC_REMOVED event when deleting RPC URL with chainId set', async () => { + const instance = wrapper.instance(); + const chainId = '0x2'; + const ticker = 'TST'; + const rpcUrlToDelete = 'https://to-delete-url.com'; + + wrapper.setState({ + chainId, + ticker, + rpcUrls: [ + { + url: 'https://first-rpc-url.com', + name: 'First RPC', + type: RpcEndpointType.Custom, + }, + { + url: rpcUrlToDelete, + name: 'RPC to delete', + type: RpcEndpointType.Custom, + }, + ], + }); + + await instance.onRpcUrlDelete(rpcUrlToDelete); + + // Verify RPC_REMOVED event was tracked + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + expect.objectContaining({ category: 'RPC Removed' }), + ); + expect(mockAddProperties).toHaveBeenCalledWith({ + chain_id: '0x2', + source: 'Network Settings', + symbol: 'TST', + rpc_url_index: 1, // Second RPC URL (index 1) + }); + expect(mockTrackEvent).toHaveBeenCalled(); + }); + + it('tracks RPC_REMOVED event with correct rpc_url_index when deleting first RPC URL', async () => { + const instance = wrapper.instance(); + const chainId = '0x3'; + const ticker = 'ABC'; + const rpcUrlToDelete = 'https://first-rpc-url.com'; + + wrapper.setState({ + chainId, + ticker, + rpcUrls: [ + { + url: rpcUrlToDelete, + name: 'First RPC', + type: RpcEndpointType.Custom, + }, + { + url: 'https://second-rpc-url.com', + name: 'Second RPC', + type: RpcEndpointType.Custom, + }, + ], + }); + + await instance.onRpcUrlDelete(rpcUrlToDelete); + + // Verify RPC_REMOVED event was tracked with index 0 + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + expect.objectContaining({ category: 'RPC Removed' }), + ); + expect(mockAddProperties).toHaveBeenCalledWith({ + chain_id: '0x3', + source: 'Network Settings', + symbol: 'ABC', + rpc_url_index: 0, // First RPC URL (index 0) + }); + expect(mockTrackEvent).toHaveBeenCalled(); + }); + + it('does not track RPC_REMOVED event when chainId is not set', async () => { + const instance = wrapper.instance(); + const rpcUrlToDelete = 'https://to-delete-url.com'; + + wrapper.setState({ + chainId: undefined, + rpcUrls: [ + { + url: rpcUrlToDelete, + name: 'RPC to delete', + type: RpcEndpointType.Custom, + }, + ], + }); + + await instance.onRpcUrlDelete(rpcUrlToDelete); + + // Verify RPC_REMOVED event was NOT tracked + expect(mockCreateEventBuilder).not.toHaveBeenCalled(); + expect(mockTrackEvent).not.toHaveBeenCalled(); + }); + + it('does not track RPC_REMOVED event when RPC URL is not found', async () => { + const instance = wrapper.instance(); + const chainId = '0x4'; + + wrapper.setState({ + chainId, + rpcUrls: [ + { + url: 'https://existing-rpc-url.com', + name: 'Existing RPC', + type: RpcEndpointType.Custom, + }, + ], + }); + + await instance.onRpcUrlDelete('https://non-existent-url.com'); + + // Verify RPC_REMOVED event was NOT tracked (rpcUrlIndex would be -1) + expect(mockCreateEventBuilder).not.toHaveBeenCalled(); + expect(mockTrackEvent).not.toHaveBeenCalled(); + }); + }); + it('should correctly delete a Block Explorer URL and update state', async () => { const instance = wrapper.instance(); @@ -1331,6 +1533,57 @@ describe('NetworkSettings', () => { expect(updateNavBarSpy).toHaveBeenCalled(); expect(validateRpcAndChainIdSpy).toHaveBeenCalled(); }); + + it('uses fallback block explorer URL when blockExplorerUrls is empty', () => { + const propsWithEmptyBlockExplorerUrls = { + route: { + params: { + network: 'mainnet', + }, + }, + navigation: { + setOptions: jest.fn(), + navigate: jest.fn(), + goBack: jest.fn(), + }, + networkConfigurations: { + '0x1': { + blockExplorerUrls: [], // Empty array - should trigger fallback + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + chainId: '0x1', + rpcEndpoints: [ + { + networkClientId: 'mainnet', + type: 'Infura', + url: 'https://mainnet.infura.io/v3/', + }, + ], + name: 'Ethereum Main Network', + nativeCurrency: 'ETH', + }, + }, + }; + + const wrapperWithFallback = shallow( + + + , + ) + .find(NetworkSettings) + .dive(); + + const instanceWithFallback = wrapperWithFallback.instance(); + instanceWithFallback.componentDidMount?.(); + + // Fallback should use BlockExplorerUrl['mainnet'] = 'https://etherscan.io' + expect(wrapperWithFallback.state('blockExplorerUrl')).toBe( + 'https://etherscan.io', + ); + expect(wrapperWithFallback.state('blockExplorerUrls')).toEqual([ + 'https://etherscan.io', + ]); + }); }); describe('NetworkSettings - handleNetworkUpdate', () => { diff --git a/app/components/Views/confirmations/components/send/recipient/recipient.test.tsx b/app/components/Views/confirmations/components/send/recipient/recipient.test.tsx index 4cb72917e74..b86c0184617 100644 --- a/app/components/Views/confirmations/components/send/recipient/recipient.test.tsx +++ b/app/components/Views/confirmations/components/send/recipient/recipient.test.tsx @@ -167,6 +167,14 @@ const mockUseRecipientSelectionMetrics = jest.mocked( const mockUseSendActions = jest.mocked(useSendActions); const mockUseSendType = jest.mocked(useSendType); +function createMockUseSendType( + returnValues: Partial>, +) { + mockUseSendType.mockReturnValue( + returnValues as ReturnType, + ); +} + describe('Recipient', () => { const mockUpdateTo = jest.fn(); const mockHandleSubmitPress = jest.fn(); @@ -212,14 +220,8 @@ describe('Recipient', () => { }); mockDoENSLookup.mockReturnValue(Promise.resolve('')); - mockUseSendType.mockReturnValue({ + createMockUseSendType({ isEvmSendType: true, - isEvmNativeSendType: false, - isNonEvmSendType: false, - isNonEvmNativeSendType: false, - isSolanaSendType: false, - isBitcoinSendType: false, - isTronSendType: false, }); }); diff --git a/app/components/Views/confirmations/hooks/send/useAccounts.test.ts b/app/components/Views/confirmations/hooks/send/useAccounts.test.ts index 16a232a540e..bfcda6f6e74 100644 --- a/app/components/Views/confirmations/hooks/send/useAccounts.test.ts +++ b/app/components/Views/confirmations/hooks/send/useAccounts.test.ts @@ -61,6 +61,14 @@ const mockUseSendContext = useSendContext as jest.MockedFunction< typeof useSendContext >; +function createMockUseSendType( + returnValues: Partial>, +) { + mockUseSendType.mockReturnValue( + returnValues as ReturnType, + ); +} + describe('useAccounts', () => { const mockEvmAccount = { id: 'evm-account-1', @@ -166,14 +174,9 @@ describe('useAccounts', () => { return []; }); - mockUseSendType.mockReturnValue({ + createMockUseSendType({ isEvmSendType: true, - isSolanaSendType: false, - isEvmNativeSendType: false, - isNonEvmSendType: false, - isNonEvmNativeSendType: false, - isBitcoinSendType: false, - isTronSendType: false, + isPredefinedEvm: true, }); mockUseSendContext.mockReturnValue({ @@ -200,6 +203,10 @@ describe('useAccounts', () => { isNonEvmNativeSendType: false, isBitcoinSendType: false, isTronSendType: false, + isPredefinedEvm: true, + isPredefinedSolana: false, + isPredefinedBitcoin: false, + isPredefinedTron: false, }); mockIsEvmAccountType.mockImplementation( (accountType) => accountType === 'eip155:eoa', @@ -254,14 +261,9 @@ describe('useAccounts', () => { describe('when isSolanaSendType is true', () => { beforeEach(() => { - mockUseSendType.mockReturnValue({ - isEvmSendType: false, + createMockUseSendType({ isSolanaSendType: true, - isEvmNativeSendType: false, - isNonEvmSendType: false, - isNonEvmNativeSendType: false, - isBitcoinSendType: false, - isTronSendType: false, + isPredefinedSolana: true, }); mockIsEvmAccountType.mockReturnValue(false); mockIsSolanaAccount.mockImplementation( @@ -298,14 +300,9 @@ describe('useAccounts', () => { describe('when isBitcoinSendType is true', () => { beforeEach(() => { - mockUseSendType.mockReturnValue({ - isEvmSendType: false, - isSolanaSendType: false, - isEvmNativeSendType: false, - isNonEvmSendType: false, - isNonEvmNativeSendType: false, + createMockUseSendType({ isBitcoinSendType: true, - isTronSendType: false, + isPredefinedBitcoin: true, }); mockIsEvmAccountType.mockReturnValue(false); mockIsSolanaAccount.mockReturnValue(false); @@ -359,14 +356,9 @@ describe('useAccounts', () => { describe('when isTronSendType is true', () => { beforeEach(() => { - mockUseSendType.mockReturnValue({ - isEvmSendType: false, - isSolanaSendType: false, - isEvmNativeSendType: false, - isNonEvmSendType: false, - isNonEvmNativeSendType: false, - isBitcoinSendType: false, + createMockUseSendType({ isTronSendType: true, + isPredefinedTron: true, }); mockIsEvmAccountType.mockReturnValue(false); mockIsSolanaAccount.mockReturnValue(false); @@ -421,15 +413,7 @@ describe('useAccounts', () => { describe('when neither EVM nor Solana send type is active', () => { beforeEach(() => { - mockUseSendType.mockReturnValue({ - isEvmSendType: false, - isSolanaSendType: false, - isEvmNativeSendType: false, - isNonEvmSendType: false, - isNonEvmNativeSendType: false, - isBitcoinSendType: false, - isTronSendType: false, - }); + createMockUseSendType({}); }); it('returns empty array', () => { @@ -472,14 +456,9 @@ describe('useAccounts', () => { }); it('handles wallet with no compatible accounts', () => { - mockUseSendType.mockReturnValue({ + createMockUseSendType({ isEvmSendType: true, - isSolanaSendType: false, - isEvmNativeSendType: false, - isNonEvmSendType: false, - isNonEvmNativeSendType: false, - isBitcoinSendType: false, - isTronSendType: false, + isPredefinedEvm: true, }); mockIsEvmAccountType.mockReturnValue(false); @@ -568,14 +547,9 @@ describe('useAccounts', () => { }; beforeEach(() => { - mockUseSendType.mockReturnValue({ + createMockUseSendType({ isEvmSendType: true, - isSolanaSendType: false, - isEvmNativeSendType: false, - isNonEvmSendType: false, - isNonEvmNativeSendType: false, - isBitcoinSendType: false, - isTronSendType: false, + isPredefinedEvm: true, }); mockIsEvmAccountType.mockImplementation( (accountType) => accountType === 'eip155:eoa', @@ -623,14 +597,9 @@ describe('useAccounts', () => { }); it('calls isEvmAccountType for each account when isEvmSendType is true', () => { - mockUseSendType.mockReturnValue({ + createMockUseSendType({ isEvmSendType: true, - isSolanaSendType: false, - isEvmNativeSendType: false, - isNonEvmSendType: false, - isNonEvmNativeSendType: false, - isBitcoinSendType: false, - isTronSendType: false, + isPredefinedEvm: true, }); renderHook(() => useAccounts()); @@ -640,14 +609,9 @@ describe('useAccounts', () => { }); it('calls isSolanaAccount for each account when isSolanaSendType is true', () => { - mockUseSendType.mockReturnValue({ - isEvmSendType: false, + createMockUseSendType({ isSolanaSendType: true, - isEvmNativeSendType: false, - isNonEvmSendType: false, - isNonEvmNativeSendType: false, - isBitcoinSendType: false, - isTronSendType: false, + isPredefinedSolana: true, }); renderHook(() => useAccounts()); diff --git a/app/components/Views/confirmations/hooks/send/useContacts.test.ts b/app/components/Views/confirmations/hooks/send/useContacts.test.ts index c059c565f4f..9c62a6f5fe9 100644 --- a/app/components/Views/confirmations/hooks/send/useContacts.test.ts +++ b/app/components/Views/confirmations/hooks/send/useContacts.test.ts @@ -20,6 +20,14 @@ import { selectAddressBook } from '../../../../../selectors/addressBookControlle const mockUseSelector = useSelector as jest.MockedFunction; const mockUseSendType = useSendType as jest.MockedFunction; +function createMockUseSendType( + returnValues: Partial>, +) { + mockUseSendType.mockReturnValue( + returnValues as ReturnType, + ); +} + describe('useContacts', () => { const mockEvmContact1 = { name: 'John Doe', @@ -81,14 +89,8 @@ describe('useContacts', () => { return {}; }); - mockUseSendType.mockReturnValue({ + createMockUseSendType({ isEvmSendType: true, - isSolanaSendType: false, - isEvmNativeSendType: false, - isNonEvmSendType: false, - isNonEvmNativeSendType: false, - isBitcoinSendType: false, - isTronSendType: false, }); }); @@ -102,6 +104,10 @@ describe('useContacts', () => { isNonEvmNativeSendType: false, isBitcoinSendType: false, isTronSendType: false, + isPredefinedEvm: true, + isPredefinedSolana: false, + isPredefinedBitcoin: false, + isPredefinedTron: false, }); }); @@ -150,15 +156,7 @@ describe('useContacts', () => { describe('when neither EVM nor Solana send type is active', () => { beforeEach(() => { - mockUseSendType.mockReturnValue({ - isEvmSendType: false, - isSolanaSendType: false, - isEvmNativeSendType: false, - isNonEvmSendType: false, - isNonEvmNativeSendType: false, - isBitcoinSendType: false, - isTronSendType: false, - }); + createMockUseSendType({}); }); it('returns all contacts without filtering', () => { @@ -202,14 +200,9 @@ describe('useContacts', () => { }); it('returns empty array when isNonEvmSendType is true', () => { - mockUseSendType.mockReturnValue({ + createMockUseSendType({ isEvmSendType: true, - isSolanaSendType: false, - isEvmNativeSendType: false, isNonEvmSendType: true, - isNonEvmNativeSendType: false, - isBitcoinSendType: false, - isTronSendType: false, }); const { result } = renderHook(() => useContacts()); expect(result.current).toEqual([]); @@ -289,14 +282,9 @@ describe('useContacts', () => { }); it('filters addresses correctly for EVM when addresses have different lengths', () => { - mockUseSendType.mockReturnValue({ + createMockUseSendType({ isEvmSendType: true, - isSolanaSendType: false, - isEvmNativeSendType: false, - isNonEvmSendType: false, - isNonEvmNativeSendType: false, - isBitcoinSendType: false, - isTronSendType: false, + isPredefinedEvm: true, }); const { result } = renderHook(() => useContacts()); diff --git a/app/components/Views/confirmations/hooks/send/useSendTokens.test.ts b/app/components/Views/confirmations/hooks/send/useSendTokens.test.ts index 40b5feb62cd..b5d771a8a98 100644 --- a/app/components/Views/confirmations/hooks/send/useSendTokens.test.ts +++ b/app/components/Views/confirmations/hooks/send/useSendTokens.test.ts @@ -104,9 +104,21 @@ describe('useSendTokens', () => { isSolanaSendType: undefined, isBitcoinSendType: undefined, isTronSendType: undefined, + isPredefinedEvm: false, + isPredefinedSolana: false, + isPredefinedBitcoin: false, + isPredefinedTron: false, }); }); + function createMockUseSendType( + returnValues: Partial>, + ) { + mockUseSendType.mockReturnValue( + returnValues as ReturnType, + ); + } + it('returns all tokens when no send type is set', () => { mockUseAccountTokens.mockReturnValue([ mockEvmToken, @@ -123,15 +135,12 @@ describe('useSendTokens', () => { expect(result.current).toHaveLength(4); }); - it('filters to EVM tokens when isEvmSendType is true', () => { - mockUseSendType.mockReturnValue({ - isEvmSendType: true, - isEvmNativeSendType: undefined, - isNonEvmSendType: undefined, - isNonEvmNativeSendType: undefined, - isSolanaSendType: undefined, - isBitcoinSendType: undefined, - isTronSendType: undefined, + it('filters to EVM tokens when isPredefinedEvm is true', () => { + createMockUseSendType({ + isPredefinedEvm: true, + isPredefinedSolana: false, + isPredefinedTron: false, + isPredefinedBitcoin: false, }); mockUseAccountTokens.mockReturnValue([ mockEvmToken, @@ -149,15 +158,12 @@ describe('useSendTokens', () => { expect(result.current[0]).toEqual(mockEvmToken); }); - it('filters to Solana tokens when isSolanaSendType is true', () => { - mockUseSendType.mockReturnValue({ - isEvmSendType: undefined, - isEvmNativeSendType: undefined, - isNonEvmSendType: true, - isNonEvmNativeSendType: undefined, - isSolanaSendType: true, - isBitcoinSendType: undefined, - isTronSendType: undefined, + it('filters to Solana tokens when isPredefinedSolana is true', () => { + createMockUseSendType({ + isPredefinedEvm: false, + isPredefinedSolana: true, + isPredefinedTron: false, + isPredefinedBitcoin: false, }); mockUseAccountTokens.mockReturnValue([ mockEvmToken, @@ -175,15 +181,12 @@ describe('useSendTokens', () => { expect(result.current[0]).toEqual(mockSolanaToken); }); - it('filters to Tron tokens when isTronSendType is true', () => { - mockUseSendType.mockReturnValue({ - isEvmSendType: undefined, - isEvmNativeSendType: undefined, - isNonEvmSendType: true, - isNonEvmNativeSendType: undefined, - isSolanaSendType: undefined, - isBitcoinSendType: undefined, - isTronSendType: true, + it('filters to Tron tokens when isPredefinedTron is true', () => { + createMockUseSendType({ + isPredefinedEvm: false, + isPredefinedSolana: false, + isPredefinedTron: true, + isPredefinedBitcoin: false, }); mockUseAccountTokens.mockReturnValue([ mockEvmToken, @@ -201,15 +204,12 @@ describe('useSendTokens', () => { expect(result.current[0]).toEqual(mockTronToken); }); - it('filters to Bitcoin tokens when isBitcoinSendType is true', () => { - mockUseSendType.mockReturnValue({ - isEvmSendType: undefined, - isEvmNativeSendType: undefined, - isNonEvmSendType: true, - isNonEvmNativeSendType: undefined, - isSolanaSendType: undefined, - isBitcoinSendType: true, - isTronSendType: undefined, + it('filters to Bitcoin tokens when isPredefinedBitcoin is true', () => { + createMockUseSendType({ + isPredefinedEvm: false, + isPredefinedSolana: false, + isPredefinedTron: false, + isPredefinedBitcoin: true, }); mockUseAccountTokens.mockReturnValue([ mockEvmToken, @@ -238,14 +238,11 @@ describe('useSendTokens', () => { }); it('prioritizes first matching account type when multiple are true', () => { - mockUseSendType.mockReturnValue({ - isEvmSendType: true, - isEvmNativeSendType: undefined, - isNonEvmSendType: true, - isNonEvmNativeSendType: undefined, - isSolanaSendType: true, - isBitcoinSendType: undefined, - isTronSendType: undefined, + createMockUseSendType({ + isPredefinedEvm: true, + isPredefinedSolana: true, + isPredefinedTron: false, + isPredefinedBitcoin: false, }); mockUseAccountTokens.mockReturnValue([ mockEvmToken, diff --git a/app/components/Views/confirmations/hooks/send/useSendTokens.ts b/app/components/Views/confirmations/hooks/send/useSendTokens.ts index 8ca012ef860..be099484229 100644 --- a/app/components/Views/confirmations/hooks/send/useSendTokens.ts +++ b/app/components/Views/confirmations/hooks/send/useSendTokens.ts @@ -8,16 +8,20 @@ export function useSendTokens({ }: { includeNoBalance?: boolean; } = {}): AssetType[] { - const { isEvmSendType, isSolanaSendType, isTronSendType, isBitcoinSendType } = - useSendType(); + const { + isPredefinedTron, + isPredefinedBitcoin, + isPredefinedSolana, + isPredefinedEvm, + } = useSendType(); const allTokens = useAccountTokens({ includeNoBalance }); return useMemo(() => { const accountTypeMap: Record = { - eip155: !!isEvmSendType, - solana: !!isSolanaSendType, - tron: !!isTronSendType, - bip122: !!isBitcoinSendType, + eip155: !!isPredefinedEvm, + solana: !!isPredefinedSolana, + tron: !!isPredefinedTron, + bip122: !!isPredefinedBitcoin, }; const matchedAccountType = Object.entries(accountTypeMap).find( @@ -33,9 +37,9 @@ export function useSendTokens({ ); }, [ allTokens, - isEvmSendType, - isSolanaSendType, - isTronSendType, - isBitcoinSendType, + isPredefinedEvm, + isPredefinedSolana, + isPredefinedTron, + isPredefinedBitcoin, ]); } diff --git a/app/components/Views/confirmations/hooks/send/useSendType.test.ts b/app/components/Views/confirmations/hooks/send/useSendType.test.ts index 5f9de050566..6de174bb05f 100644 --- a/app/components/Views/confirmations/hooks/send/useSendType.test.ts +++ b/app/components/Views/confirmations/hooks/send/useSendType.test.ts @@ -52,6 +52,12 @@ describe('useSendType', () => { isNonEvmNativeSendType: undefined, isNonEvmSendType: undefined, isSolanaSendType: undefined, + isBitcoinSendType: undefined, + isTronSendType: undefined, + isPredefinedEvm: false, + isPredefinedSolana: false, + isPredefinedBitcoin: false, + isPredefinedTron: false, }); }); }); diff --git a/app/components/Views/confirmations/hooks/send/useSendType.ts b/app/components/Views/confirmations/hooks/send/useSendType.ts index b4cb0cbf51f..fb067facc21 100644 --- a/app/components/Views/confirmations/hooks/send/useSendType.ts +++ b/app/components/Views/confirmations/hooks/send/useSendType.ts @@ -81,27 +81,35 @@ export const useSendType = () => { return useMemo( () => ({ isEvmSendType, + isPredefinedEvm, isEvmNativeSendType: isEvmSendType && assetIsNative, isNonEvmNativeSendType: isNonEvmSendType && assetIsNative, isNonEvmSendType, isSolanaSendType, + isPredefinedSolana, /// BEGIN:ONLY_INCLUDE_IF(bitcoin) isBitcoinSendType, + isPredefinedBitcoin, /// END:ONLY_INCLUDE_IF /// BEGIN:ONLY_INCLUDE_IF(tron) isTronSendType, + isPredefinedTron, /// END:ONLY_INCLUDE_IF }), [ isEvmSendType, + isPredefinedEvm, isNonEvmSendType, assetIsNative, isSolanaSendType, + isPredefinedSolana, /// BEGIN:ONLY_INCLUDE_IF(bitcoin) isBitcoinSendType, + isPredefinedBitcoin, /// END:ONLY_INCLUDE_IF /// BEGIN:ONLY_INCLUDE_IF(tron) isTronSendType, + isPredefinedTron, /// END:ONLY_INCLUDE_IF ], ); diff --git a/app/core/Analytics/MetaMetrics.events.ts b/app/core/Analytics/MetaMetrics.events.ts index da59e018514..d2d37547fa5 100644 --- a/app/core/Analytics/MetaMetrics.events.ts +++ b/app/core/Analytics/MetaMetrics.events.ts @@ -81,7 +81,8 @@ enum EVENT_NAME { // Network NETWORK_SWITCHED = 'Network Switched', - NETWORK_ADDED = 'Network Added', + RPC_ADDED = 'RPC Added', + RPC_REMOVED = 'RPC Removed', NETWORK_REQUESTED = 'Network Requested', NETWORK_REQUEST_REJECTED = 'Network Request Rejected', NETWORK_SELECTOR = 'Network Menu Opened', @@ -714,7 +715,8 @@ const events = { VIEW_ALL_ASSETS_CLICKED: generateOpt(EVENT_NAME.VIEW_ALL_ASSETS_CLICKED), CURRENCY_CHANGED: generateOpt(EVENT_NAME.CURRENCY_CHANGED), NETWORK_SWITCHED: generateOpt(EVENT_NAME.NETWORK_SWITCHED), - NETWORK_ADDED: generateOpt(EVENT_NAME.NETWORK_ADDED), + RPC_ADDED: generateOpt(EVENT_NAME.RPC_ADDED), + RPC_REMOVED: generateOpt(EVENT_NAME.RPC_REMOVED), NETWORK_REQUESTED: generateOpt(EVENT_NAME.NETWORK_REQUESTED), NETWORK_REQUEST_REJECTED: generateOpt(EVENT_NAME.NETWORK_REQUEST_REJECTED), SEND_TRANSACTION_STARTED: generateOpt(EVENT_NAME.SEND_TRANSACTION_STARTED), diff --git a/app/core/Engine/controllers/keyring-controller-init.ts b/app/core/Engine/controllers/keyring-controller-init.ts index 1c77799fc92..6addc10902c 100644 --- a/app/core/Engine/controllers/keyring-controller-init.ts +++ b/app/core/Engine/controllers/keyring-controller-init.ts @@ -10,6 +10,7 @@ import { LedgerTransportMiddleware, } from '@metamask/eth-ledger-bridge-keyring'; import { HdKeyring } from '@metamask/eth-hd-keyring'; +import { hmacSha512 } from '@metamask/native-utils'; import { Encryptor, LEGACY_DERIVATION_OPTIONS, pbkdf2 } from '../../Encryptor'; const encryptor = new Encryptor({ @@ -56,7 +57,10 @@ export const keyringControllerInit: ControllerInitFunction< const hdKeyringBuilder = () => new HdKeyring({ - cryptographicFunctions: { pbkdf2Sha512: pbkdf2 }, + cryptographicFunctions: { + pbkdf2Sha512: pbkdf2, + hmacSha512: async (key, data) => hmacSha512(key, data), + }, }); hdKeyringBuilder.type = HdKeyring.type; diff --git a/app/core/Engine/controllers/snaps/execution-service-init.test.ts b/app/core/Engine/controllers/snaps/execution-service-init.test.ts index 21fc1acf371..70d970c6748 100644 --- a/app/core/Engine/controllers/snaps/execution-service-init.test.ts +++ b/app/core/Engine/controllers/snaps/execution-service-init.test.ts @@ -45,7 +45,6 @@ describe('ExecutionServiceInit', () => { expect(controllerMock).toHaveBeenCalledWith({ messenger: expect.any(Object), setupSnapProvider: expect.any(Function), - pingTimeout: expect.any(Number), }); }); diff --git a/app/core/Engine/controllers/snaps/execution-service-init.ts b/app/core/Engine/controllers/snaps/execution-service-init.ts index d86d4530f7a..23bc3b12254 100644 --- a/app/core/Engine/controllers/snaps/execution-service-init.ts +++ b/app/core/Engine/controllers/snaps/execution-service-init.ts @@ -8,7 +8,6 @@ import { createWebView, removeWebView } from '../../../../lib/snaps'; import Logger from '../../../../util/Logger'; import { SnapBridge } from '../../../Snaps'; import getRpcMethodMiddleware from '../../../RPCMethods/RPCMethodMiddleware'; -import { Duration, inMilliseconds } from '@metamask/utils'; import { SnapId } from '@metamask/snaps-sdk'; /** @@ -69,7 +68,6 @@ export const executionServiceInit: ControllerInitFunction< setupSnapProvider, createWebView, removeWebView, - pingTimeout: inMilliseconds(5, Duration.Second), }), }; }; diff --git a/app/core/Engine/controllers/snaps/snap-controller-init.test.ts b/app/core/Engine/controllers/snaps/snap-controller-init.test.ts index 326790bd522..4bd4b56d90d 100644 --- a/app/core/Engine/controllers/snaps/snap-controller-init.test.ts +++ b/app/core/Engine/controllers/snaps/snap-controller-init.test.ts @@ -64,6 +64,7 @@ describe('SnapControllerInit', () => { state: undefined, clientCryptography: { pbkdf2Sha512: expect.any(Function), + hmacSha512: expect.any(Function), }, detectSnapLocation: expect.any(Function), encryptor: expect.any(Object), diff --git a/app/core/Engine/controllers/snaps/snap-controller-init.ts b/app/core/Engine/controllers/snaps/snap-controller-init.ts index f54ac617e3b..4192f1f022f 100644 --- a/app/core/Engine/controllers/snaps/snap-controller-init.ts +++ b/app/core/Engine/controllers/snaps/snap-controller-init.ts @@ -1,5 +1,6 @@ import { SnapController } from '@metamask/snaps-controllers'; import { Duration, hasProperty, inMilliseconds } from '@metamask/utils'; +import { hmacSha512 } from '@metamask/native-utils'; import { ControllerInitFunction } from '../../types'; import { SnapControllerInitMessenger, @@ -165,6 +166,7 @@ export const snapControllerInit: ControllerInitFunction< detectSnapLocation, clientCryptography: { pbkdf2Sha512: pbkdf2, + hmacSha512: async (key, data) => hmacSha512(key, data), }, trackEvent: (params: { event: string; diff --git a/app/core/RPCMethods/wallet_addEthereumChain.js b/app/core/RPCMethods/wallet_addEthereumChain.js index f5b366c118b..b205681314a 100644 --- a/app/core/RPCMethods/wallet_addEthereumChain.js +++ b/app/core/RPCMethods/wallet_addEthereumChain.js @@ -1,6 +1,6 @@ import { equal } from 'uri-js'; import { InteractionManager } from 'react-native'; -import { ChainId } from '@metamask/controller-utils'; +import { ChainId, toHex } from '@metamask/controller-utils'; import Engine from '../Engine'; import { providerErrors, rpcErrors } from '@metamask/rpc-errors'; import { MetaMetricsEvents, MetaMetrics } from '../../core/Analytics'; @@ -194,6 +194,24 @@ export const wallet_addEthereumChain = async ({ } : undefined, ); + + // Track RPC Added event if a new RPC endpoint was added (not just updated) + // rpcResult.index === original array length means a new RPC was added + const wasNewRpcAdded = + rpcResult.index === existingNetworkConfiguration.rpcEndpoints.length; + if (wasNewRpcAdded) { + MetaMetrics.getInstance().trackEvent( + MetricsEventBuilder.createEventBuilder(MetaMetricsEvents.RPC_ADDED) + .addProperties({ + chain_id: toHex(chainId), + source: 'Custom Network API', + symbol: ticker, + rpc_url_index: rpcResult.index, + ...analytics, + }) + .build(), + ); + } } else { updatedNetworkConfiguration = NetworkController.addNetwork({ chainId, @@ -210,18 +228,20 @@ export const wallet_addEthereumChain = async ({ }, ], }); - } - MetaMetrics.getInstance().trackEvent( - MetricsEventBuilder.createEventBuilder(MetaMetricsEvents.NETWORK_ADDED) - .addProperties({ - chain_id: getDecimalChainId(chainId), - source: 'Custom Network API', - symbol: ticker, - ...analytics, - }) - .build(), - ); + // Track RPC Added event for new networks - first RPC endpoint is at index 0 + MetaMetrics.getInstance().trackEvent( + MetricsEventBuilder.createEventBuilder(MetaMetricsEvents.RPC_ADDED) + .addProperties({ + chain_id: toHex(chainId), + source: 'Custom Network API', + symbol: ticker, + rpc_url_index: 0, + ...analytics, + }) + .build(), + ); + } MetaMetrics.getInstance().addTraitsToUser(addItemToChainIdList(chainId)); } diff --git a/app/core/RPCMethods/wallet_addEthereumChain.test.js b/app/core/RPCMethods/wallet_addEthereumChain.test.js index e60190bd1b4..f3bd7a2413b 100644 --- a/app/core/RPCMethods/wallet_addEthereumChain.test.js +++ b/app/core/RPCMethods/wallet_addEthereumChain.test.js @@ -3,6 +3,8 @@ import { wallet_addEthereumChain } from './wallet_addEthereumChain'; import Engine from '../Engine'; import { mockNetworkState } from '../../util/test/network'; import MetaMetrics from '../Analytics/MetaMetrics'; +import { MetaMetricsEvents } from '../Analytics/MetaMetrics.events'; +import { MetricsEventBuilder } from '../Analytics/MetricsEventBuilder'; import { flushPromises } from '../../util/test/utils'; jest.mock('../../util/metrics/MultichainAPI/networkMetricUtils', () => ({ @@ -80,20 +82,24 @@ jest.mock('../../store', () => ({ })); jest.mock('../Analytics/MetaMetrics'); +jest.mock('../Analytics/MetricsEventBuilder'); const mockTrackEvent = jest.fn(); +const mockAddProperties = jest.fn().mockReturnThis(); +const mockBuild = jest.fn().mockReturnValue({ name: 'test-event' }); const mockCreateEventBuilder = jest.fn().mockReturnValue({ - addProperties: jest.fn().mockReturnThis(), - build: jest.fn().mockReturnThis(), + addProperties: mockAddProperties, + build: mockBuild, }); const mockAddTraitsToUser = jest.fn(); MetaMetrics.getInstance = jest.fn().mockReturnValue({ trackEvent: mockTrackEvent, - createEventBuilder: mockCreateEventBuilder, addTraitsToUser: mockAddTraitsToUser, }); +MetricsEventBuilder.createEventBuilder = mockCreateEventBuilder; + const correctParams = { chainId: '0x64', chainName: 'xDai', @@ -158,7 +164,7 @@ describe('RPC Method - wallet_addEthereumChain', () => { global.fetch.mockClear(); }); - it('should report missing params', async () => { + it('reports missing params', async () => { try { await wallet_addEthereumChain({ req: { @@ -171,7 +177,7 @@ describe('RPC Method - wallet_addEthereumChain', () => { } }); - it('should report extra keys', async () => { + it('reports extra keys', async () => { try { await wallet_addEthereumChain({ req: { @@ -186,7 +192,7 @@ describe('RPC Method - wallet_addEthereumChain', () => { } }); - it('should report invalid rpc url', async () => { + it('reports invalid rpc url', async () => { try { await wallet_addEthereumChain({ req: { @@ -201,7 +207,7 @@ describe('RPC Method - wallet_addEthereumChain', () => { } }); - it('should report invalid block explorer url', async () => { + it('reports invalid block explorer url', async () => { try { await wallet_addEthereumChain({ req: { @@ -216,7 +222,7 @@ describe('RPC Method - wallet_addEthereumChain', () => { } }); - it('should report invalid chainId', async () => { + it('reports invalid chainId', async () => { try { await wallet_addEthereumChain({ req: { @@ -231,7 +237,7 @@ describe('RPC Method - wallet_addEthereumChain', () => { } }); - it('should report unsafe chainId', async () => { + it('reports unsafe chainId', async () => { try { await wallet_addEthereumChain({ req: { @@ -246,7 +252,7 @@ describe('RPC Method - wallet_addEthereumChain', () => { } }); - it('should report chainId not matching rpcUrl returned chainId', async () => { + it('reports chainId not matching rpcUrl returned chainId', async () => { try { await wallet_addEthereumChain({ req: { @@ -259,7 +265,7 @@ describe('RPC Method - wallet_addEthereumChain', () => { } }); - it('should report invalid chain name', async () => { + it('reports invalid chain name', async () => { try { await wallet_addEthereumChain({ req: { @@ -272,7 +278,7 @@ describe('RPC Method - wallet_addEthereumChain', () => { } }); - it('should report invalid native currency', async () => { + it('reports invalid native currency', async () => { try { await wallet_addEthereumChain({ req: { @@ -287,7 +293,7 @@ describe('RPC Method - wallet_addEthereumChain', () => { } }); - it('should report invalid native currency decimals', async () => { + it('reports invalid native currency decimals', async () => { try { await wallet_addEthereumChain({ req: { @@ -307,7 +313,7 @@ describe('RPC Method - wallet_addEthereumChain', () => { } }); - it('should report missing native currency symbol', async () => { + it('reports missing native currency symbol', async () => { try { await wallet_addEthereumChain({ req: { @@ -327,7 +333,7 @@ describe('RPC Method - wallet_addEthereumChain', () => { } }); - it('should report native currency symbol length being too long', async () => { + it('reports native currency symbol length exceeds maximum', async () => { const symbol = 'aaaaaaaaaaaaaaa'; await expect( wallet_addEthereumChain({ @@ -346,7 +352,7 @@ describe('RPC Method - wallet_addEthereumChain', () => { ); }); - it('should allow 1 letter native currency symbols', async () => { + it('allows 1 letter native currency symbols', async () => { jest.mock('./networkChecker.util'); jest .spyOn(Engine.context.NetworkController, 'addNetwork') @@ -380,153 +386,344 @@ describe('RPC Method - wallet_addEthereumChain', () => { }); }); - it('should grant permissions when chain is not already permitted', async () => { - jest - .spyOn(Engine.context.NetworkController, 'addNetwork') - .mockReturnValue(networkConfigurationResult); - jest.spyOn(otherOptions.hooks, 'getCaveat').mockReturnValue({ - value: { - optionalScopes: {}, - requiredScopes: {}, - isMultichainOrigin: false, - sessionProperties: {}, - }, - }); - const spyOnGrantPermissionsIncremental = jest.spyOn( - otherOptions.hooks, - 'requestPermittedChainsPermissionIncrementalForOrigin', - ); + describe('permissions', () => { + it('grants permissions when chain is not already permitted', async () => { + jest + .spyOn(Engine.context.NetworkController, 'addNetwork') + .mockReturnValue(networkConfigurationResult); + jest.spyOn(otherOptions.hooks, 'getCaveat').mockReturnValue({ + value: { + optionalScopes: {}, + requiredScopes: {}, + isMultichainOrigin: false, + sessionProperties: {}, + }, + }); + const spyOnGrantPermissionsIncremental = jest.spyOn( + otherOptions.hooks, + 'requestPermittedChainsPermissionIncrementalForOrigin', + ); - await wallet_addEthereumChain({ - req: { - params: [correctParams], - origin: 'https://example.com', - }, - ...otherOptions, + await wallet_addEthereumChain({ + req: { + params: [correctParams], + origin: 'https://example.com', + }, + ...otherOptions, + }); + + expect(spyOnGrantPermissionsIncremental).toHaveBeenCalledTimes(1); + expect(spyOnGrantPermissionsIncremental).toHaveBeenCalledWith({ + autoApprove: true, + chainId: '0x64', + metadata: { + rpcUrl: 'https://rpc.gnosischain.com', + }, + }); }); - expect(spyOnGrantPermissionsIncremental).toHaveBeenCalledTimes(1); - expect(spyOnGrantPermissionsIncremental).toHaveBeenCalledWith({ - autoApprove: true, - chainId: '0x64', - metadata: { - rpcUrl: 'https://rpc.gnosischain.com', - }, + it('does not grant permissions when chain is already permitted', async () => { + jest + .spyOn(Engine.context.NetworkController, 'addNetwork') + .mockReturnValue(networkConfigurationResult); + + const spyOnGrantPermissionsIncremental = jest.spyOn( + Engine.context.PermissionController, + 'grantPermissionsIncremental', + ); + jest.spyOn(otherOptions.hooks, 'getCaveat').mockReturnValue({ + value: { + optionalScopes: { 'eip155:100': { accounts: [] } }, + requiredScopes: {}, + isMultichainOrigin: false, + sessionProperties: {}, + }, + }); + await wallet_addEthereumChain({ + req: { + params: [correctParams], + origin: 'https://example.com', + }, + ...otherOptions, + }); + + expect(spyOnGrantPermissionsIncremental).toHaveBeenCalledTimes(0); }); }); - it('should not grant permissions when chain is already permitted', async () => { - jest - .spyOn(Engine.context.NetworkController, 'addNetwork') - .mockReturnValue(networkConfigurationResult); + describe('adding new rpc endpoint without existing network configuration', () => { + it('adds and switches to the new network', async () => { + const spyOnAddNetwork = jest + .spyOn(Engine.context.NetworkController, 'addNetwork') + .mockReturnValue(networkConfigurationResult); - const spyOnGrantPermissionsIncremental = jest.spyOn( - Engine.context.PermissionController, - 'grantPermissionsIncremental', - ); - jest.spyOn(otherOptions.hooks, 'getCaveat').mockReturnValue({ - value: { - optionalScopes: { 'eip155:100': { accounts: [] } }, - requiredScopes: {}, - isMultichainOrigin: false, - sessionProperties: {}, - }, + const spyOnSetNetworkClientIdForDomain = jest.spyOn( + Engine.context.SelectedNetworkController, + 'setNetworkClientIdForDomain', + ); + + await wallet_addEthereumChain({ + req: { + params: [correctParams], + origin: 'https://example.com', + }, + ...otherOptions, + }); + await flushPromises(); + + expect(spyOnAddNetwork).toHaveBeenCalledTimes(1); + expect(spyOnAddNetwork).toHaveBeenCalledWith( + expect.objectContaining({ + chainId: correctParams.chainId, + blockExplorerUrls: correctParams.blockExplorerUrls, + nativeCurrency: correctParams.nativeCurrency.symbol, + name: correctParams.chainName, + }), + ); + + expect(spyOnSetNetworkClientIdForDomain).toHaveBeenCalledTimes(1); }); - await wallet_addEthereumChain({ - req: { - params: [correctParams], - origin: 'https://example.com', - }, - ...otherOptions, + + it('calls addTraitsToUser with chain ID list', async () => { + jest + .spyOn(Engine.context.NetworkController, 'addNetwork') + .mockReturnValue(networkConfigurationResult); + + await wallet_addEthereumChain({ + req: { + params: [correctParams], + origin: 'https://example.com', + }, + ...otherOptions, + }); + await flushPromises(); + + expect(mockAddTraitsToUser).toHaveBeenCalledWith({ + chain_id_list: ['eip155:1', 'eip155:100'], + }); }); - expect(spyOnGrantPermissionsIncremental).toHaveBeenCalledTimes(0); - }); + it('tracks RPC_ADDED event with rpc_url_index: 0', async () => { + jest + .spyOn(Engine.context.NetworkController, 'addNetwork') + .mockReturnValue(networkConfigurationResult); - it('should correctly add and switch to a new chain when chain is not already in wallet state ', async () => { - const spyOnAddNetwork = jest - .spyOn(Engine.context.NetworkController, 'addNetwork') - .mockReturnValue(networkConfigurationResult); + mockTrackEvent.mockClear(); + mockCreateEventBuilder.mockClear(); + mockAddProperties.mockClear(); + mockBuild.mockClear(); - const spyOnSetNetworkClientIdForDomain = jest.spyOn( - Engine.context.SelectedNetworkController, - 'setNetworkClientIdForDomain', - ); + await wallet_addEthereumChain({ + req: { + params: [correctParams], + origin: 'https://example.com', + }, + ...otherOptions, + }); + await flushPromises(); - await wallet_addEthereumChain({ - req: { - params: [correctParams], - origin: 'https://example.com', - }, - ...otherOptions, + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.RPC_ADDED, + ); + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ + chain_id: '0x64', + source: 'Custom Network API', + symbol: 'xDai', + rpc_url_index: 0, + }), + ); + expect(mockTrackEvent).toHaveBeenCalled(); }); - await flushPromises(); - - expect(spyOnAddNetwork).toHaveBeenCalledTimes(1); - expect(spyOnAddNetwork).toHaveBeenCalledWith( - expect.objectContaining({ - chainId: correctParams.chainId, - blockExplorerUrls: correctParams.blockExplorerUrls, - nativeCurrency: correctParams.nativeCurrency.symbol, - name: correctParams.chainName, - }), - ); - - expect(spyOnSetNetworkClientIdForDomain).toHaveBeenCalledTimes(1); }); - it('should call addTraitsToUser with chain ID list when adding a new network', async () => { - const spyOnAddNetwork = jest - .spyOn(Engine.context.NetworkController, 'addNetwork') - .mockReturnValue(networkConfigurationResult); + describe('adding new rpc endpoint to existing network configuration', () => { + const existingNetworkWithRpc = { + id: 'test-network-configuration-id', + chainId: '0x2', + name: 'Test Chain', + ticker: 'TST', + rpcEndpoints: [ + { + url: 'https://rpc.test-chain.com', + networkClientId: '1', + }, + ], + defaultRpcEndpointIndex: 0, + blockExplorerUrls: ['https://explorer.test-chain.com'], + defaultBlockExplorerUrlIndex: 0, + }; - await wallet_addEthereumChain({ - req: { - params: [correctParams], - origin: 'https://example.com', + const updatedNetworkResult = { + ...existingNetworkWithRpc, + rpcEndpoints: [ + ...existingNetworkWithRpc.rpcEndpoints, + { + url: 'https://different-rpc-url.com', + networkClientId: '2', + }, + ], + defaultRpcEndpointIndex: 1, + }; + + const existingNetworkParams = { + chainId: existingNetworkWithRpc.chainId, + rpcUrls: ['https://different-rpc-url.com'], + chainName: existingNetworkWithRpc.name, + nativeCurrency: { + name: existingNetworkWithRpc.ticker, + symbol: existingNetworkWithRpc.ticker, + decimals: 18, }, - ...otherOptions, + }; + + it('updates network configuration and switches to new RPC endpoint', async () => { + jest + .spyOn(Engine.context.NetworkController, 'updateNetwork') + .mockReturnValue(updatedNetworkResult); + + otherOptions.hooks.getNetworkConfigurationByChainId = jest + .fn() + .mockReturnValue(existingNetworkWithRpc); + + const spyOnSetNetworkClientIdForDomain = jest.spyOn( + Engine.context.SelectedNetworkController, + 'setNetworkClientIdForDomain', + ); + + await wallet_addEthereumChain({ + req: { + params: [existingNetworkParams], + origin: 'https://example.com', + }, + ...otherOptions, + }); + await flushPromises(); + + expect( + Engine.context.NetworkController.updateNetwork, + ).toHaveBeenCalledTimes(1); + expect(spyOnSetNetworkClientIdForDomain).toHaveBeenCalledTimes(1); }); - await flushPromises(); - expect(spyOnAddNetwork).toHaveBeenCalledTimes(1); + it('tracks RPC_ADDED event with rpc_url_index matching new endpoint position', async () => { + jest + .spyOn(Engine.context.NetworkController, 'updateNetwork') + .mockReturnValue(updatedNetworkResult); + + otherOptions.hooks.getNetworkConfigurationByChainId = jest + .fn() + .mockReturnValue(existingNetworkWithRpc); + + mockTrackEvent.mockClear(); + mockCreateEventBuilder.mockClear(); + mockAddProperties.mockClear(); + mockBuild.mockClear(); + + await wallet_addEthereumChain({ + req: { + params: [existingNetworkParams], + origin: 'https://example.com', + }, + ...otherOptions, + }); + await flushPromises(); - expect(mockAddTraitsToUser).toHaveBeenCalledWith({ - chain_id_list: ['eip155:1', 'eip155:100'], + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.RPC_ADDED, + ); + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ + chain_id: '0x2', + source: 'Custom Network API', + symbol: 'TST', + rpc_url_index: 1, // Second RPC endpoint (index 1) + }), + ); + expect(mockTrackEvent).toHaveBeenCalled(); }); }); - it('should not update the networkConfiguration that has a chainId that already exists in wallet state, but should switch to the existing network', async () => { - const spyOnUpdateNetwork = jest - .spyOn(Engine.context.NetworkController, 'updateNetwork') - .mockReturnValue(networkConfigurationResult); - - const spyOnSetNetworkClientIdForDomain = jest.spyOn( - Engine.context.SelectedNetworkController, - 'setNetworkClientIdForDomain', - ); + describe('adding existing rpc endpoint', () => { + const existingNetworkWithRpc = { + id: 'test-network-configuration-id', + chainId: '0x2', + name: 'Test Chain', + ticker: 'TST', + rpcEndpoints: [ + { + url: 'https://rpc.test-chain.com', + networkClientId: '1', + }, + ], + defaultRpcEndpointIndex: 0, + blockExplorerUrls: ['https://explorer.test-chain.com'], + defaultBlockExplorerUrlIndex: 0, + }; - const existingParams = { - chainId: existingNetworkConfiguration.chainId, - rpcUrls: ['https://different-rpc-url.com'], - chainName: existingNetworkConfiguration.nickname, + const existingRpcParams = { + chainId: existingNetworkWithRpc.chainId, + rpcUrls: ['https://rpc.test-chain.com'], // Same URL that already exists + chainName: existingNetworkWithRpc.name, nativeCurrency: { - name: existingNetworkConfiguration.ticker, - symbol: existingNetworkConfiguration.ticker, + name: existingNetworkWithRpc.ticker, + symbol: existingNetworkWithRpc.ticker, decimals: 18, }, }; - await wallet_addEthereumChain({ - req: { - params: [existingParams], - origin: 'https://example.com', - }, - ...otherOptions, + it('switches to existing network without updating configuration', async () => { + const spyOnUpdateNetwork = jest + .spyOn(Engine.context.NetworkController, 'updateNetwork') + .mockReturnValue(existingNetworkWithRpc); + + otherOptions.hooks.getNetworkConfigurationByChainId = jest + .fn() + .mockReturnValue(existingNetworkWithRpc); + + const spyOnSetNetworkClientIdForDomain = jest.spyOn( + Engine.context.SelectedNetworkController, + 'setNetworkClientIdForDomain', + ); + + await wallet_addEthereumChain({ + req: { + params: [existingRpcParams], + origin: 'https://example.com', + }, + ...otherOptions, + }); + await flushPromises(); + + // Network is updated but RPC endpoints remain the same + expect(spyOnSetNetworkClientIdForDomain).toHaveBeenCalledTimes(1); }); - await flushPromises(); - expect(spyOnUpdateNetwork).not.toHaveBeenCalled(); - expect(spyOnSetNetworkClientIdForDomain).toHaveBeenCalledTimes(1); + it('does not track RPC_ADDED event', async () => { + jest + .spyOn(Engine.context.NetworkController, 'updateNetwork') + .mockReturnValue(existingNetworkWithRpc); + + otherOptions.hooks.getNetworkConfigurationByChainId = jest + .fn() + .mockReturnValue(existingNetworkWithRpc); + + mockTrackEvent.mockClear(); + mockCreateEventBuilder.mockClear(); + + await wallet_addEthereumChain({ + req: { + params: [existingRpcParams], + origin: 'https://example.com', + }, + ...otherOptions, + }); + await flushPromises(); + + const rpcAddedCalls = mockCreateEventBuilder.mock.calls.filter( + (call) => call[0] === MetaMetricsEvents.RPC_ADDED, + ); + expect(rpcAddedCalls).toHaveLength(0); + }); }); }); diff --git a/app/core/Snaps/permissions/specifications.ts b/app/core/Snaps/permissions/specifications.ts index 14cad903b58..300b2664612 100644 --- a/app/core/Snaps/permissions/specifications.ts +++ b/app/core/Snaps/permissions/specifications.ts @@ -34,6 +34,7 @@ import { } from '@metamask/approval-controller'; import Logger from '../../../util/Logger'; import { HasPermission } from '@metamask/permission-controller'; +import { hmacSha512 } from '@metamask/native-utils'; import { pbkdf2 } from '../../Encryptor'; import I18n from '../../../../locales/i18n'; import { @@ -249,7 +250,11 @@ export const getSnapPermissionSpecifications = ( messenger.call('ApprovalController:addRequest', opts, true), hasPermission: (origin: string, target: string) => messenger.call('PermissionController:hasPermission', origin, target), - getClientCryptography: () => ({ pbkdf2Sha512: pbkdf2 }), + getClientCryptography: () => ({ + pbkdf2Sha512: pbkdf2, + hmacSha512: async (key: Uint8Array, data: Uint8Array) => + hmacSha512(key, data), + }), getPreferences: () => { const { securityAlertsEnabled, diff --git a/e2e/pages/Browser/TestSnaps.ts b/e2e/pages/Browser/TestSnaps.ts index 05f6e24ab64..08d9d3e1feb 100644 --- a/e2e/pages/Browser/TestSnaps.ts +++ b/e2e/pages/Browser/TestSnaps.ts @@ -24,7 +24,7 @@ import { RetryOptions } from '../../framework'; import { Json } from '@metamask/utils'; export const TEST_SNAPS_URL = - 'https://metamask.github.io/snaps/test-snaps/2.28.1/'; + 'https://metamask.github.io/snaps/test-snaps/3.1.0/'; class TestSnaps { get getConnectSnapButton(): DetoxElement { @@ -61,6 +61,32 @@ class TestSnaps { return Matchers.getElementByID('snap-ui-renderer__checkbox'); } + get dateTimePickerTouchable(): DetoxElement { + return Matchers.getElementByID( + 'snap-ui-renderer__date-time-picker--datetime-touchable', + ); + } + + get datePickerTouchable(): DetoxElement { + return Matchers.getElementByID( + 'snap-ui-renderer__date-time-picker--date-touchable', + ); + } + + get timePickerTouchable(): DetoxElement { + return Matchers.getElementByID( + 'snap-ui-renderer__date-time-picker--time-touchable', + ); + } + + get dateTimePickerOkButton(): DetoxElement { + return Matchers.getElementByText('OK'); + } + + get snapUIRendererScrollView(): Promise { + return Matchers.getIdentifier('snap-ui-renderer__scrollview'); + } + async checkResultSpan( selector: keyof typeof TestSnapResultSelectorWebIDS, expectedMessage: string, @@ -322,6 +348,34 @@ class TestSnaps { await Gestures.tap(this.checkboxElement); } + async selectDateInDateTimePicker() { + await Gestures.scrollToElement( + this.timePickerTouchable, + this.snapUIRendererScrollView, + ); + + await Gestures.waitAndTap(this.dateTimePickerTouchable); + + await Gestures.waitAndTap(this.dateTimePickerOkButton); + + // Android date and time picker is a two-step process, so we need to tap OK again + if (device.getPlatform() === 'android') { + await Gestures.waitAndTap(this.dateTimePickerOkButton); + } + } + + async selectDateInDatePicker() { + await Gestures.waitAndTap(this.datePickerTouchable); + + await Gestures.waitAndTap(this.dateTimePickerOkButton); + } + + async selectTimeInTimePicker() { + await Gestures.waitAndTap(this.timePickerTouchable); + + await Gestures.waitAndTap(this.dateTimePickerOkButton); + } + async installSnap( buttonLocator: keyof typeof TestSnapViewSelectorWebIDS, ): Promise { diff --git a/e2e/specs/snaps/test-snap-interactive-ui.spec.ts b/e2e/specs/snaps/test-snap-interactive-ui.spec.ts index 342eef3e8e6..ff1d61cfb03 100644 --- a/e2e/specs/snaps/test-snap-interactive-ui.spec.ts +++ b/e2e/specs/snaps/test-snap-interactive-ui.spec.ts @@ -6,6 +6,7 @@ import TabBarComponent from '../../pages/wallet/TabBarComponent'; import TestSnaps from '../../pages/Browser/TestSnaps'; import { Assertions } from '../../framework'; import Matchers from '../../framework/Matchers'; +import { DateTime } from 'luxon'; jest.setTimeout(150_000); @@ -36,11 +37,18 @@ describe(FlaskBuildTests('Interactive UI Snap Tests'), () => { async () => { await TestSnaps.tapButton('createDialogButton'); + // Create the expected date when the pickers are mounted. + // This is needed to compare later with the values submitted by the snap. + const dateTimePickerDate = DateTime.now(); + await TestSnaps.fillInput('example-input', 'foo bar'); await TestSnaps.selectInNativeDropdown('snapUIDropdown', 'Option 2'); await TestSnaps.selectRadioButton('Option 1'); await TestSnaps.tapCheckbox(); await TestSnaps.selectInNativeDropdown('snapUISelector', 'Option 3'); + await TestSnaps.selectDateInDateTimePicker(); + await TestSnaps.selectDateInDatePicker(); + await TestSnaps.selectTimeInTimePicker(); await TestSnaps.tapSubmitButton(); await Assertions.expectTextDisplayed('foo bar'); @@ -48,6 +56,17 @@ describe(FlaskBuildTests('Interactive UI Snap Tests'), () => { await Assertions.expectTextDisplayed('option1'); await Assertions.expectTextDisplayed('true'); await Assertions.expectTextDisplayed('option3'); + await Assertions.expectTextDisplayed( + dateTimePickerDate.set({ second: 0, millisecond: 0 }).toISO(), + ); + await Assertions.expectTextDisplayed( + dateTimePickerDate + .set({ hour: 0, minute: 0, second: 0, millisecond: 0 }) + .toISO(), + ); + await Assertions.expectTextDisplayed( + dateTimePickerDate.set({ second: 0, millisecond: 0 }).toISO(), + ); await TestSnaps.tapOkButton(); }, @@ -79,6 +98,11 @@ describe(FlaskBuildTests('Interactive UI Snap Tests'), () => { await Assertions.checkIfDisabled( Matchers.getElementByID('snap-ui-renderer__selector'), ); + + await Assertions.checkIfDisabled(TestSnaps.dateTimePickerTouchable); + await Assertions.checkIfDisabled(TestSnaps.datePickerTouchable); + await Assertions.checkIfDisabled(TestSnaps.timePickerTouchable); + await Assertions.checkIfDisabled(Matchers.getElementByText('Submit')); await TestSnaps.tapCancelButton(); diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 1660c02e910..577069312f1 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -2529,7 +2529,7 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga - - RNDateTimePicker (8.3.0): + - RNDateTimePicker (8.5.1): - DoubleConversion - glog - hermes-engine @@ -3572,7 +3572,7 @@ SPEC CHECKSUMS: RNCCheckbox: 33b44487ca8008394ce658cc32b26eab04f426ef RNCClipboard: f1fe5a8005c651c204e60b43cf0cd67e2244ab70 RNCMaskedView: b3aee2f4fa81807ec035be51c057aa2eed166173 - RNDateTimePicker: 96559f666636a8326e862024103eab77a6950625 + RNDateTimePicker: fe098f01de623ed82e4e2f47bf33ec3ead309f53 RNDefaultPreference: 36fe31684af1f2d14e0664aa9a816d0ec6149cc1 RNDeviceInfo: e5219d380b51ddb7f97e650ab99a518476b90203 RNFBApp: 0e66b9f844efdf2ac3fa2b30e64c9db41a263b3d diff --git a/package.json b/package.json index b4083ca3ef3..64233d94ef4 100644 --- a/package.json +++ b/package.json @@ -169,7 +169,7 @@ "appwright@^0.1.45": "patch:appwright@npm%3A0.1.45#./.yarn/patches/appwright-npm-0.1.45-f282bc1c1b.patch", "@scure/bip32": "1.7.0", "js-sha3": "0.9.3", - "@metamask/snaps-sdk": "^10.0.0", + "@metamask/snaps-sdk": "^10.2.0", "react-native@0.76.9": "patch:react-native@npm%3A0.76.9#./.yarn/patches/react-native-npm-0.76.9-1c25352097.patch", "@ethereumjs/util@npm:^9.0.3": "patch:@ethereumjs/util@npm%3A9.1.0#~/.yarn/patches/@ethereumjs-util-npm-9.1.0-7e85509408.patch", "@ethereumjs/util@npm:^9.1.0": "patch:@ethereumjs/util@npm%3A9.1.0#~/.yarn/patches/@ethereumjs-util-npm-9.1.0-7e85509408.patch", @@ -277,11 +277,11 @@ "@metamask/signature-controller": "^35.0.0", "@metamask/slip44": "^4.2.0", "@metamask/smart-transactions-controller": "^20.1.0", - "@metamask/snaps-controllers": "^17.0.0", - "@metamask/snaps-execution-environments": "^10.2.3", + "@metamask/snaps-controllers": "^17.1.1", + "@metamask/snaps-execution-environments": "^10.3.0", "@metamask/snaps-rpc-methods": "^14.1.1", - "@metamask/snaps-sdk": "^10.1.0", - "@metamask/snaps-utils": "^11.6.1", + "@metamask/snaps-sdk": "^10.2.0", + "@metamask/snaps-utils": "^11.6.3", "@metamask/solana-wallet-snap": "^2.5.1", "@metamask/solana-wallet-standard": "^0.6.0", "@metamask/stake-sdk": "^3.4.0", @@ -304,7 +304,7 @@ "@react-native-community/cli-platform-android": "15.0.1", "@react-native-community/cli-platform-ios": "15.0.1", "@react-native-community/cli-server-api": "^17.0.0", - "@react-native-community/datetimepicker": "^8.3.0", + "@react-native-community/datetimepicker": "^8.5.1", "@react-native-community/netinfo": "^11.4.1", "@react-native-community/slider": "^4.4.3", "@react-native-cookies/cookies": "^6.2.1", diff --git a/shim.js b/shim.js index 0a4f52e66dc..80d68197147 100644 --- a/shim.js +++ b/shim.js @@ -1,6 +1,10 @@ /* eslint-disable import/no-nodejs-modules */ import { Platform } from 'react-native'; -import { getRandomValues, randomUUID } from 'react-native-quick-crypto'; +import { + getRandomValues, + randomUUID, + subtle as quickCryptoSubtle, +} from 'react-native-quick-crypto'; import { LaunchArguments } from 'react-native-launch-arguments'; import { FALLBACK_FIXTURE_SERVER_PORT, @@ -96,6 +100,12 @@ global.crypto = { ...crypto, randomUUID, getRandomValues, + subtle: { + ...global.crypto.subtle, + ...crypto.subtle, + // Shimming just digest as it has been fully implemented. + digest: quickCryptoSubtle.digest, + }, }; process.browser = false; diff --git a/yarn.lock b/yarn.lock index c1e09248db8..2981f21b3df 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9259,9 +9259,9 @@ __metadata: languageName: node linkType: hard -"@metamask/snaps-controllers@npm:^17.0.0": - version: 17.0.0 - resolution: "@metamask/snaps-controllers@npm:17.0.0" +"@metamask/snaps-controllers@npm:^17.1.1": + version: 17.1.1 + resolution: "@metamask/snaps-controllers@npm:17.1.1" dependencies: "@metamask/approval-controller": "npm:^8.0.0" "@metamask/base-controller": "npm:^9.0.0" @@ -9270,14 +9270,14 @@ __metadata: "@metamask/key-tree": "npm:^10.1.1" "@metamask/messenger": "npm:^0.3.0" "@metamask/object-multiplex": "npm:^2.1.0" - "@metamask/permission-controller": "npm:^12.1.0" + "@metamask/permission-controller": "npm:^12.1.1" "@metamask/phishing-controller": "npm:^15.0.0" "@metamask/post-message-stream": "npm:^10.0.0" "@metamask/rpc-errors": "npm:^7.0.3" - "@metamask/snaps-registry": "npm:^3.3.0" + "@metamask/snaps-registry": "npm:^4.0.0" "@metamask/snaps-rpc-methods": "npm:^14.1.1" - "@metamask/snaps-sdk": "npm:^10.1.0" - "@metamask/snaps-utils": "npm:^11.6.1" + "@metamask/snaps-sdk": "npm:^10.2.0" + "@metamask/snaps-utils": "npm:^11.6.3" "@metamask/utils": "npm:^11.8.1" "@xstate/fsm": "npm:^2.0.0" async-mutex: "npm:^0.5.0" @@ -9293,33 +9293,33 @@ __metadata: semver: "npm:^7.5.4" tar-stream: "npm:^3.1.7" peerDependencies: - "@metamask/snaps-execution-environments": ^10.2.3 + "@metamask/snaps-execution-environments": ^10.3.0 peerDependenciesMeta: "@metamask/snaps-execution-environments": optional: true - checksum: 10/3976a532a71b3d20a2b63690c5464028a0e165e5916eaf889a0681dfc8467546b8225f886b515f1d26d67068ec365e8962c160f0185e8451fbc5392eb53dc694 + checksum: 10/2eba643b9693ec2b28e4aba7dc9a481698d57827bf9b345665d4a1773b6cfa0c8fcd5215a52de0e11d8a85aa917dcc8a023e92e4cb00b1e72b0dec7104d3064e languageName: node linkType: hard -"@metamask/snaps-execution-environments@npm:^10.2.3": - version: 10.2.3 - resolution: "@metamask/snaps-execution-environments@npm:10.2.3" +"@metamask/snaps-execution-environments@npm:^10.3.0": + version: 10.3.0 + resolution: "@metamask/snaps-execution-environments@npm:10.3.0" dependencies: "@metamask/json-rpc-engine": "npm:^10.1.0" "@metamask/object-multiplex": "npm:^2.1.0" "@metamask/post-message-stream": "npm:^10.0.0" "@metamask/providers": "npm:^22.1.1" "@metamask/rpc-errors": "npm:^7.0.3" - "@metamask/snaps-sdk": "npm:^10.1.0" - "@metamask/snaps-utils": "npm:^11.6.1" + "@metamask/snaps-sdk": "npm:^10.2.0" + "@metamask/snaps-utils": "npm:^11.6.2" "@metamask/superstruct": "npm:^3.2.1" "@metamask/utils": "npm:^11.8.1" readable-stream: "npm:^3.6.2" - checksum: 10/da92c33942e1422de8a24b1f5885a037d3d4cbd33f802a7493c0f05b1ad2cce3721576288e2caa7a2369a7348d6b031b24f47087a04f2a7c0b0ac6eb5b01cc27 + checksum: 10/25056a1051a451c276d8253168d4d8b590d30d37935b8bf8957b77ef0b7f8728c6bf1ac2f66aae2d3cb2c1a80f69c374e981654bcca65675d3d98c8d9bfca407 languageName: node linkType: hard -"@metamask/snaps-registry@npm:^3.2.3, @metamask/snaps-registry@npm:^3.3.0": +"@metamask/snaps-registry@npm:^3.2.3": version: 3.3.0 resolution: "@metamask/snaps-registry@npm:3.3.0" dependencies: @@ -9331,6 +9331,18 @@ __metadata: languageName: node linkType: hard +"@metamask/snaps-registry@npm:^4.0.0": + version: 4.0.0 + resolution: "@metamask/snaps-registry@npm:4.0.0" + dependencies: + "@metamask/superstruct": "npm:^3.2.1" + "@metamask/utils": "npm:^11.4.0" + "@noble/curves": "npm:^1.2.0" + "@noble/hashes": "npm:^1.3.2" + checksum: 10/62387701d0433402bbe93495f90e24f2df3bdff2b063402b029294fada62c9f389048a2c68a1e51b260cb10cec949dd0c80596bf6295abc4175f5039c486a108 + languageName: node + linkType: hard + "@metamask/snaps-rpc-methods@npm:^13.5.0": version: 13.5.3 resolution: "@metamask/snaps-rpc-methods@npm:13.5.3" @@ -9364,32 +9376,33 @@ __metadata: languageName: node linkType: hard -"@metamask/snaps-sdk@npm:^10.0.0": - version: 10.0.0 - resolution: "@metamask/snaps-sdk@npm:10.0.0" +"@metamask/snaps-sdk@npm:^10.2.0": + version: 10.2.0 + resolution: "@metamask/snaps-sdk@npm:10.2.0" dependencies: "@metamask/key-tree": "npm:^10.1.1" "@metamask/providers": "npm:^22.1.1" "@metamask/rpc-errors": "npm:^7.0.3" "@metamask/superstruct": "npm:^3.2.1" "@metamask/utils": "npm:^11.8.1" - checksum: 10/f753e92b2d4b06aae448beabc891de9be913c6c42ffbbd38039196982c19eafa3cb9fd158a7717f2d0692eeba2e4714a3620d3b19ce40550db75b1e8c5470da3 + luxon: "npm:^3.5.0" + checksum: 10/fcbd532ce73745c07ee3dbbe64644aba1369235e67ec66bc91ff62db4e47307f0c0c4a4e61d3edf43464da82f2385595a75af7280d8c39246ac66fcc16936427 languageName: node linkType: hard -"@metamask/snaps-utils@npm:^11.0.0, @metamask/snaps-utils@npm:^11.5.0, @metamask/snaps-utils@npm:^11.6.0, @metamask/snaps-utils@npm:^11.6.1": - version: 11.6.1 - resolution: "@metamask/snaps-utils@npm:11.6.1" +"@metamask/snaps-utils@npm:^11.0.0, @metamask/snaps-utils@npm:^11.5.0, @metamask/snaps-utils@npm:^11.6.0, @metamask/snaps-utils@npm:^11.6.1, @metamask/snaps-utils@npm:^11.6.2, @metamask/snaps-utils@npm:^11.6.3": + version: 11.6.3 + resolution: "@metamask/snaps-utils@npm:11.6.3" dependencies: "@babel/core": "npm:^7.23.2" "@babel/types": "npm:^7.23.0" "@metamask/key-tree": "npm:^10.1.1" "@metamask/messenger": "npm:^0.3.0" - "@metamask/permission-controller": "npm:^12.0.0" + "@metamask/permission-controller": "npm:^12.1.1" "@metamask/rpc-errors": "npm:^7.0.3" "@metamask/slip44": "npm:^4.3.0" - "@metamask/snaps-registry": "npm:^3.2.3" - "@metamask/snaps-sdk": "npm:^10.0.0" + "@metamask/snaps-registry": "npm:^4.0.0" + "@metamask/snaps-sdk": "npm:^10.2.0" "@metamask/superstruct": "npm:^3.2.1" "@metamask/utils": "npm:^11.8.1" "@noble/hashes": "npm:^1.7.1" @@ -9405,7 +9418,7 @@ __metadata: semver: "npm:^7.5.4" ses: "npm:^1.14.0" validate-npm-package-name: "npm:^5.0.0" - checksum: 10/ad694a02aeea9ab8dc159a032681b5eb57b75debf636ac229669ece479fc38deda423b3e79a1ef2af30ef69be627ad5fcd7852a24448eeb58799f510d07b8216 + checksum: 10/6bd471fafacbebb287240a740c32bddcfabb4060df7c6980ee92ab3cd2ee6162825bc8119611c25126d8f891a107c822b2af69e97c0a8b055432df3b06a91378 languageName: node linkType: hard @@ -11779,13 +11792,13 @@ __metadata: languageName: node linkType: hard -"@react-native-community/datetimepicker@npm:^8.3.0": - version: 8.3.0 - resolution: "@react-native-community/datetimepicker@npm:8.3.0" +"@react-native-community/datetimepicker@npm:^8.5.1": + version: 8.5.1 + resolution: "@react-native-community/datetimepicker@npm:8.5.1" dependencies: invariant: "npm:^2.2.4" peerDependencies: - expo: ">=50.0.0" + expo: ">=52.0.0" react: "*" react-native: "*" react-native-windows: "*" @@ -11794,7 +11807,7 @@ __metadata: optional: true react-native-windows: optional: true - checksum: 10/69adaaadbd8b57d1c049d0ca92dddad62b5c8dfba50e4660303c30746d39e9e976d6d61f59f001d5df15f25832dc740225fe394c667bd3a76dc42b66efc061d9 + checksum: 10/6d28e8fc00cbc240b676c6c036e1c0c5be16d5dd91e18cff0d703dcc9219abdeab09d031f5c60204c2c3e96f70a194f1316006690990e44ed79f8a2b4ffebfe0 languageName: node linkType: hard @@ -34292,11 +34305,11 @@ __metadata: "@metamask/signature-controller": "npm:^35.0.0" "@metamask/slip44": "npm:^4.2.0" "@metamask/smart-transactions-controller": "npm:^20.1.0" - "@metamask/snaps-controllers": "npm:^17.0.0" - "@metamask/snaps-execution-environments": "npm:^10.2.3" + "@metamask/snaps-controllers": "npm:^17.1.1" + "@metamask/snaps-execution-environments": "npm:^10.3.0" "@metamask/snaps-rpc-methods": "npm:^14.1.1" - "@metamask/snaps-sdk": "npm:^10.1.0" - "@metamask/snaps-utils": "npm:^11.6.1" + "@metamask/snaps-sdk": "npm:^10.2.0" + "@metamask/snaps-utils": "npm:^11.6.3" "@metamask/solana-wallet-snap": "npm:^2.5.1" "@metamask/solana-wallet-standard": "npm:^0.6.0" "@metamask/stake-sdk": "npm:^3.4.0" @@ -34327,7 +34340,7 @@ __metadata: "@react-native-community/cli-platform-android": "npm:15.0.1" "@react-native-community/cli-platform-ios": "npm:15.0.1" "@react-native-community/cli-server-api": "npm:^17.0.0" - "@react-native-community/datetimepicker": "npm:^8.3.0" + "@react-native-community/datetimepicker": "npm:^8.5.1" "@react-native-community/netinfo": "npm:^11.4.1" "@react-native-community/slider": "npm:^4.4.3" "@react-native-cookies/cookies": "npm:^6.2.1"