From 71b1e6ef08c005ccb9f5f47c33a16d13ba7d6c20 Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Thu, 11 Dec 2025 12:51:48 +0100 Subject: [PATCH 1/4] fix: fix default blockexplorer url for linea and mainnet (#23861) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Fix default blockexplorer for linea and mainnet. Also fixes showing the block explorer url for all our popular networks. ## **Changelog** CHANGELOG entry: Add default blockexplorer for linea and mainnet. ## **Related issues** Fixes: https://github.com/MetaMask/metamask-mobile/issues/15118 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Adds a fallback to resolve block explorer URLs from PopularList or BlockExplorerUrl when network configurations lack them, and updates NetworkSettings initialization and tests accordingly. > > - **Network Settings (UI/logic)**: > - Add `getDefaultBlockExplorerUrl(chainId, networkType)` using `PopularList` (by `chainId`) with fallback to `BlockExplorerUrl` (by network type/clientId). > - Use fallback when `networkConfiguration.blockExplorerUrls` is missing or empty and when selecting built-in networks via `networkTypeOrRpcUrl` or RPCs via `networkClientId`. > - Initialize `blockExplorerUrl` and `blockExplorerUrls` with fallback; keep existing behavior when config provides values. > - **Tests**: > - Add test asserting fallback when `blockExplorerUrls` is empty. > - Update/expand initialization and validation tests to cover new fallback and related flows. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 9364b61cdcccaf5c398934ff77d7fe15af441c2a. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../NetworksSettings/NetworkSettings/index.js | 72 ++++++++++++++++--- .../NetworkSettings/index.test.tsx | 51 +++++++++++++ 2 files changed, 113 insertions(+), 10 deletions(-) diff --git a/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.js b/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.js index 2fa2c91110a9..8e431fd12d43 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'; @@ -102,6 +107,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 +305,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 +322,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 +345,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' diff --git a/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.test.tsx b/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.test.tsx index be2de00f4ddf..45a343c4f4c3 100644 --- a/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.test.tsx +++ b/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.test.tsx @@ -1331,6 +1331,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', () => { From 56257920a3c01470a4d12e52dd1d4a98df205ac3 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Thu, 11 Dec 2025 13:46:19 +0100 Subject: [PATCH 2/4] chore: Bump Snaps packages (#23769) ## **Description** This bumps Snaps packages to the latest version. Notable changes include: - Fetching registry without caching - Add `DateTimePicker` component - Increase default ping timeout to prevent stability problems on low-end systems - Use `crypto.subtle.digest` for faster SHA-256 hashing when verifying the Snaps registry To support the `crypto.subtle.digest` change, this PR adds `react-native-quick-crypto` as a shim for the digest function, which has full parity. Additionally this PR adds usage of `hmacSha512` from `native-utils` to usages of key-tree. ## **Changelog** CHANGELOG entry: Improve Snaps registry verification speed. --- > [!NOTE] > Upgrades Snaps packages and RN datetime picker, introduces SnapUIDateTimePicker with renderer/field support and tests, adds crypto.subtle digest shim and hmacSha512 usage, and adjusts execution service/controller logic and E2E tests. > > - **UI/Renderer**: > - **New `SnapUIDateTimePicker`**: Component, styles, renderer mapping (`components/index.ts`, `components/date-time-picker.ts`), Field support (`field.ts`), and snapshots/tests. > - Add `testID` to container `ScrollView` and update snapshots. > - Tweak `SnapInterfaceContext` event payload to omit undefined fields. > - **Snaps Engine/Controllers**: > - Use `hmacSha512` from `@metamask/native-utils` in keyring, snap controller, and permissions. > - Remove explicit `pingTimeout` from execution service init and update tests. > - **Platform/Crypto**: > - Shim `crypto.subtle.digest` via `react-native-quick-crypto` in `shim.js`. > - **E2E/Tests**: > - Update Test Snaps URL to `3.1.0`; add E2E flows for date/time pickers; adjust disabled-state assertions. > - **Dependencies**: > - Bump `@metamask/snaps-*` and `@metamask/snaps-sdk` packages; upgrade `@react-native-community/datetimepicker` to `8.5.1` (Podfile.lock + yarn.lock). > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 37178ea6a21beda1496921023e37ba44688ef318. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: Guillaume Roux --- app/components/Snaps/SnapInterfaceContext.tsx | 4 +- .../SnapUIDateTimePicker.styles.ts | 35 + .../SnapUIDateTimePicker.tsx | 379 ++++++++ .../__snapshots__/SnapUIRenderer.test.ts.snap | 10 + .../account-selector.test.ts.snap | 2 + .../__snapshots__/address.test.ts.snap | 13 + .../__snapshots__/asset-selector.test.ts.snap | 1 + .../__snapshots__/container.test.ts.snap | 87 ++ .../date-time-picker.test.tsx.snap | 876 ++++++++++++++++++ .../__snapshots__/form.test.ts.snap | 2 + .../__snapshots__/input.test.ts.snap | 2 + .../components/container.test.ts | 84 +- .../SnapUIRenderer/components/container.ts | 1 + .../components/date-time-picker.test.tsx | 242 +++++ .../components/date-time-picker.ts | 18 + .../Snaps/SnapUIRenderer/components/field.ts | 20 + .../Snaps/SnapUIRenderer/components/index.ts | 2 + app/components/Snaps/SnapUIRenderer/utils.ts | 1 + .../UI/TemplateRenderer/SafeComponentList.ts | 2 + .../controllers/keyring-controller-init.ts | 6 +- .../snaps/execution-service-init.test.ts | 1 - .../snaps/execution-service-init.ts | 2 - .../snaps/snap-controller-init.test.ts | 1 + .../controllers/snaps/snap-controller-init.ts | 2 + app/core/Snaps/permissions/specifications.ts | 7 +- e2e/pages/Browser/TestSnaps.ts | 56 +- .../snaps/test-snap-interactive-ui.spec.ts | 24 + ios/Podfile.lock | 4 +- package.json | 12 +- shim.js | 12 +- yarn.lock | 87 +- 31 files changed, 1859 insertions(+), 136 deletions(-) create mode 100644 app/components/Snaps/SnapUIDateTimePicker/SnapUIDateTimePicker.styles.ts create mode 100644 app/components/Snaps/SnapUIDateTimePicker/SnapUIDateTimePicker.tsx create mode 100644 app/components/Snaps/SnapUIRenderer/components/__snapshots__/container.test.ts.snap create mode 100644 app/components/Snaps/SnapUIRenderer/components/__snapshots__/date-time-picker.test.tsx.snap create mode 100644 app/components/Snaps/SnapUIRenderer/components/date-time-picker.test.tsx create mode 100644 app/components/Snaps/SnapUIRenderer/components/date-time-picker.ts diff --git a/app/components/Snaps/SnapInterfaceContext.tsx b/app/components/Snaps/SnapInterfaceContext.tsx index e36c25715e23..f0a417fc3828 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 000000000000..00f5d5d92932 --- /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 000000000000..a265328159ae --- /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 c6617fa1f666..5d036c9b0e83 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 bab4c838680c..fc3a1e76757f 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 0cdc9c2c9b62..ea026da5af17 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 1f349483e56c..3c7f8357b457 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 000000000000..3bde037a4a8c --- /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 000000000000..20b4e271dfd2 --- /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 03be006e0408..ab8108d924a8 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 bfd631dc0758..7f51af1a0518 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 48e029500d5b..bb3587e91e88 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 4d43459b20ee..5c92acc86797 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 000000000000..7ff64d9425d4 --- /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 000000000000..c19a7d71b7f8 --- /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 eebeb0190814..1ce317ecb7e9 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 d94db2748a71..7a9d9ef20257 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 905f64063e32..618131e3ae8a 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/TemplateRenderer/SafeComponentList.ts b/app/components/UI/TemplateRenderer/SafeComponentList.ts index d017d2a7d752..22ef8fa024d9 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/core/Engine/controllers/keyring-controller-init.ts b/app/core/Engine/controllers/keyring-controller-init.ts index 1c77799fc92a..6addc10902c4 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 21fc1acf3718..70d970c67482 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 d86d4530f7ac..23bc3b122546 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 326790bd5226..4bd4b56d90dd 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 f54ac617e3bc..4192f1f022f8 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/Snaps/permissions/specifications.ts b/app/core/Snaps/permissions/specifications.ts index 14cad903b587..300b26646125 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 05f6e24ab649..08d9d3e1febb 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 342eef3e8e64..ff1d61cfb036 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 1660c02e910d..577069312f14 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 b4083ca3ef35..64233d94ef44 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 0a4f52e66dc0..80d68197147c 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 c1e09248db82..2981f21b3df9 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" From 0393d7ebcb962dcfadebccd5d1769953cb40b2bc Mon Sep 17 00:00:00 2001 From: OGPoyraz Date: Thu, 11 Dec 2025 14:08:07 +0100 Subject: [PATCH 3/4] fix: Fix send flow to not filter assets on back and forth navigation (#23860) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR aims to prevent send asset page to not filter assets when user picks nonEVM and then goes back to asset page. (or vice versa) Asset page now only filter if there is predefined network is marked. This issue introduced in 7.61 hence we don't need any changelog if we decide to CP. ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: https://github.com/MetaMask/metamask-mobile/issues/23859 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** https://github.com/user-attachments/assets/b40544af-3f63-45ed-b192-c8421d4f6b28 ### **After** https://github.com/user-attachments/assets/dcf9b56a-f970-4722-81ca-de2dd2957dec ## **Pre-merge author checklist** - [X] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [X] I've completed the PR template to the best of my ability - [X] I’ve included tests if applicable - [X] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [X] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > useSendTokens now filters tokens only when a predefined chain type is set via new isPredefined* flags from useSendType; tests updated accordingly. > > - **Hooks**: > - **useSendType**: Exposes new booleans `isPredefinedEvm`, `isPredefinedSolana`, `isPredefinedBitcoin`, `isPredefinedTron` derived from `predefinedRecipient.chainType`; returns them alongside existing send-type flags. > - **useSendTokens**: Uses `isPredefined*` flags to determine filtering (`eip155`, `solana`, `tron`, `bip122`); defaults to returning all tokens when no predefined type is set; memo deps updated. > - **Tests**: > - Updated `useSendTokens.test`, `useSendType.test`, `useAccounts.test`, `useContacts.test`, and `recipient.test` to reflect `isPredefined*` behavior and expectations. > - Added `createMockUseSendType` helpers in tests to simplify mocking return values. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 9127b8d964b252d15dbcdf8da0ab36676a77afe4. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../send/recipient/recipient.test.tsx | 16 ++-- .../hooks/send/useAccounts.test.ts | 94 ++++++------------- .../hooks/send/useContacts.test.ts | 46 ++++----- .../hooks/send/useSendTokens.test.ts | 85 ++++++++--------- .../confirmations/hooks/send/useSendTokens.ts | 24 +++-- .../hooks/send/useSendType.test.ts | 6 ++ .../confirmations/hooks/send/useSendType.ts | 8 ++ 7 files changed, 124 insertions(+), 155 deletions(-) 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 4cb72917e749..b86c0184617a 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 16a232a540ed..bfcda6f6e742 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 c059c565f4ff..9c62a6f5fe90 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 40b5feb62cdc..b5d771a8a98f 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 8ca012ef860a..be0994842292 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 5f9de0505661..6de174bb05fc 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 b4cb0cbf51f9..fb067facc21c 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 ], ); From 21652352a4c7dbc4acc0bf33a557faaffc70d3ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20=C5=81ucka?= <5708018+PatrykLucka@users.noreply.github.com> Date: Thu, 11 Dec 2025 17:12:20 +0100 Subject: [PATCH 4/4] refactor: update RPC event tracking and analytics for network management (#23246) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR implements analytics tracking for RPC endpoint management by renaming the `NETWORK_ADDED` event to `RPC_ADDED` and adding a new `RPC_DELETED` event, both with the `rpc_url_index` property to track which RPC endpoint index is being added or removed. **Reason for change:** Users can now add multiple RPC endpoints to a single network. We need to track which RPC endpoint index is being added or deleted to better understand user behavior and network configuration patterns. **Improvement/Solution:** - Renamed `NETWORK_ADDED` event to `RPC_ADDED` to better reflect that we're tracking RPC endpoint additions, not just network additions - Added new `RPC_DELETED` event to track when RPC endpoints are removed - Added `rpc_url_index` property to both events: - `0` indicates there is only 1 RPC URL for the network (first/only endpoint) - Higher values indicate additional RPC endpoints - Standardized all `chain_id` values to use `toHex()` format for consistency across all RPC tracking events - Updated tracking in: - `NetworkModal` - when networks are added via UI - `wallet_addEthereumChain` - when RPC endpoints are added via RPC method - `NetworkSettings` - when RPC endpoints are added/removed in network settings ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/TMCU-211 ## **Manual testing steps** ```gherkin Feature: RPC endpoint analytics tracking Scenario: user adds a network with first RPC endpoint Given the user is on the network settings screen When user adds a new network with an RPC URL Then RPC_ADDED event should be tracked with rpc_url_index: 0 and chain_id in hex format Scenario: user adds additional RPC endpoint to existing network Given the user has a network with at least one RPC endpoint configured When user adds an additional RPC endpoint to that network Then RPC_ADDED event should be tracked with rpc_url_index matching the new endpoint's index and chain_id in hex format Scenario: user removes an RPC endpoint from network Given the user has a network with multiple RPC endpoints configured When user deletes one of the RPC endpoints Then RPC_DELETED event should be tracked with rpc_url_index matching the deleted endpoint's index and chain_id in hex format Scenario: user adds network via wallet_addEthereumChain RPC method Given a dapp calls wallet_addEthereumChain with a new network When the network is successfully added Then RPC_ADDED event should be tracked with rpc_url_index: 0 and chain_id in hex format Scenario: user adds RPC endpoint to existing network via wallet_addEthereumChain Given a network already exists in the wallet When a dapp calls wallet_addEthereumChain with a different RPC URL for the same chainId Then RPC_ADDED event should be tracked with rpc_url_index matching the new endpoint's index and chain_id in hex format ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Replaces NETWORK_ADDED with RPC_ADDED and adds RPC_REMOVED, standardizing chain_id to hex and capturing rpc_url_index across UI flows and wallet_addEthereumChain, with comprehensive tests. > > - **Analytics**: > - Rename `NETWORK_ADDED` to `RPC_ADDED`; add `RPC_REMOVED` in `core/Analytics/MetaMetrics.events.ts` and exports. > - Standardize `chain_id` to hex via `toHex`; add `rpc_url_index` to RPC events. > - **UI Tracking**: > - `components/UI/NetworkModal/index.tsx`: Track `RPC_ADDED` for popular/custom adds with `rpc_url_index: 0`. > - `Views/Settings/NetworksSettings/NetworkSettings/index.js`: > - On RPC add: track `RPC_ADDED` with `chain_id`, `symbol`, and computed `rpc_url_index`. > - On RPC delete: track `RPC_REMOVED` with `rpc_url_index`. > - **RPC Method**: > - `core/RPCMethods/wallet_addEthereumChain.js`: When updating/adding networks, emit `RPC_ADDED`: > - New network: `rpc_url_index: 0`. > - Existing network: only when a new RPC endpoint is appended; include correct index. > - **Tests**: > - Add/expand unit tests to assert `RPC_ADDED`/`RPC_REMOVED` emissions, index accuracy, and hex `chain_id` in `NetworkSettings` and `wallet_addEthereumChain`. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit fd0489b2d12d8b646f0693d521249d64324eb404. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- app/components/UI/NetworkModal/index.tsx | 10 +- .../NetworksSettings/NetworkSettings/index.js | 40 +- .../NetworkSettings/index.test.tsx | 238 ++++++++- app/core/Analytics/MetaMetrics.events.ts | 6 +- .../RPCMethods/wallet_addEthereumChain.js | 44 +- .../wallet_addEthereumChain.test.js | 461 +++++++++++++----- 6 files changed, 630 insertions(+), 169 deletions(-) diff --git a/app/components/UI/NetworkModal/index.tsx b/app/components/UI/NetworkModal/index.tsx index 95f90c9207da..bbe5f8ecf88a 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/Views/Settings/NetworksSettings/NetworkSettings/index.js b/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.js index 8e431fd12d43..08bcebbe43d1 100644 --- a/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.js +++ b/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.js @@ -91,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, @@ -1030,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], })); @@ -1040,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(); @@ -1135,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 45a343c4f4c3..74da8a9fac63 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(); diff --git a/app/core/Analytics/MetaMetrics.events.ts b/app/core/Analytics/MetaMetrics.events.ts index da59e0185145..d2d37547fa5d 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/RPCMethods/wallet_addEthereumChain.js b/app/core/RPCMethods/wallet_addEthereumChain.js index f5b366c118b4..b205681314ab 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 e60190bd1b4c..f3bd7a2413b1 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); + }); }); });