['goToRamps'];
+ RampMode: typeof RampMode;
+ AggregatorRampType: typeof AggregatorRampType;
+}
+
+export function withRampNavigation(
+ Component: React.ComponentType
,
+): React.ComponentType> {
+ return function WithRampNavigationWrapper(
+ props: Omit,
+ ) {
+ const { goToRamps } = useRampNavigation();
+
+ return (
+
+ );
+ };
+}
diff --git a/app/components/UI/ReceiveRequest/index.js b/app/components/UI/ReceiveRequest/index.js
index e0fd54d10039..6e3b9ac5f52a 100644
--- a/app/components/UI/ReceiveRequest/index.js
+++ b/app/components/UI/ReceiveRequest/index.js
@@ -26,7 +26,7 @@ import ClipboardManager from '../../../core/ClipboardManager';
import { ThemeContext, mockTheme } from '../../../util/theme';
import { selectChainId } from '../../../selectors/networkController';
import { isNetworkRampSupported } from '../Ramp/Aggregator/utils';
-import { createBuyNavigationDetails } from '../Ramp/Aggregator/routes/utils';
+import { withRampNavigation } from '../Ramp/hooks/withRampNavigation';
import { selectSelectedInternalAccountFormattedAddress } from '../../../selectors/accountsController';
import {
getDetectedGeolocation,
@@ -77,6 +77,18 @@ class ReceiveRequest extends PureComponent {
/* Triggers global alert
*/
showAlert: PropTypes.func,
+ /**
+ * Function to navigate to ramp flows
+ */
+ goToRamps: PropTypes.func,
+ /**
+ * RampMode enum
+ */
+ RampMode: PropTypes.object,
+ /**
+ * AggregatorRampType enum
+ */
+ AggregatorRampType: PropTypes.object,
/**
* Network provider chain id
*/
@@ -144,14 +156,18 @@ class ReceiveRequest extends PureComponent {
* Shows an alert message with a coming soon message
*/
onBuy = async () => {
- const { navigation, isNetworkBuySupported } = this.props;
+ const { isNetworkBuySupported, goToRamps, RampMode, AggregatorRampType } =
+ this.props;
if (!isNetworkBuySupported) {
Alert.alert(
strings('fiat_on_ramp.network_not_supported'),
strings('fiat_on_ramp.switch_network'),
);
} else {
- navigation.navigate(...createBuyNavigationDetails());
+ goToRamps({
+ mode: RampMode.AGGREGATOR,
+ params: { rampType: AggregatorRampType.BUY },
+ });
this.props.metrics.trackEvent(
this.props.metrics
@@ -270,4 +286,4 @@ const mapDispatchToProps = (dispatch) => ({
export default connect(
mapStateToProps,
mapDispatchToProps,
-)(withMetricsAwareness(ReceiveRequest));
+)(withRampNavigation(withMetricsAwareness(ReceiveRequest)));
diff --git a/app/components/UI/Swaps/QuotesView.js b/app/components/UI/Swaps/QuotesView.js
index e15e204bbfdc..48f7a6247bad 100644
--- a/app/components/UI/Swaps/QuotesView.js
+++ b/app/components/UI/Swaps/QuotesView.js
@@ -110,7 +110,8 @@ import { selectAccounts } from '../../../selectors/accountTrackerController';
import { selectContractBalances } from '../../../selectors/tokenBalancesController';
import { selectSelectedInternalAccountFormattedAddress } from '../../../selectors/accountsController';
import { resetTransaction, setRecipient } from '../../../actions/transaction';
-import { createBuyNavigationDetails } from '../Ramp/Aggregator/routes/utils';
+import { useRampNavigation, RampMode } from '../Ramp/hooks/useRampNavigation';
+import { RampType as AggregatorRampType } from '../Ramp/Aggregator/types';
import { SwapsViewSelectorsIDs } from '../../../../e2e/selectors/swaps/SwapsView.selectors';
import { useMetrics } from '../../../components/hooks/useMetrics';
import { addTransaction } from '../../../util/transaction-controller';
@@ -415,6 +416,7 @@ function SwapsQuotesView({
/* Get params from navigation */
const route = useRoute();
const { trackEvent, createEventBuilder } = useMetrics();
+ const { goToRamps } = useRampNavigation();
const { colors } = useTheme();
const styles = createStyles(colors);
@@ -1503,7 +1505,10 @@ function SwapsQuotesView({
const buyEth = useCallback(() => {
try {
- navigation.navigate(...createBuyNavigationDetails());
+ goToRamps({
+ mode: RampMode.AGGREGATOR,
+ params: { rampType: AggregatorRampType.BUY },
+ });
} catch (error) {
Logger.error(error, 'Navigation: Error when navigating to buy ETH.');
}
@@ -1513,7 +1518,7 @@ function SwapsQuotesView({
MetaMetricsEvents.RECEIVE_OPTIONS_PAYMENT_REQUEST,
).build(),
);
- }, [navigation, trackEvent, createEventBuilder]);
+ }, [goToRamps, trackEvent, createEventBuilder]);
const handleTermsPress = useCallback(
() =>
diff --git a/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.test.ts b/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.test.ts
index 9714dacda147..570f71c8d645 100644
--- a/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.test.ts
+++ b/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.test.ts
@@ -15,6 +15,7 @@ import { useConfirmActions } from '../useConfirmActions';
import { useTransactionPayToken } from '../pay/useTransactionPayToken';
import { noop } from 'lodash';
import { useConfirmationContext } from '../../context/confirmation-context';
+import { useRampNavigation } from '../../../../UI/Ramp/hooks/useRampNavigation';
import { useIsGaslessSupported } from '../gas/useIsGaslessSupported';
jest.mock('../../../../../util/navigation/navUtils', () => ({
@@ -48,6 +49,10 @@ jest.mock('../../../../../reducers/transaction', () => ({
selectTransactionState: jest.fn(),
}));
jest.mock('../../context/confirmation-context');
+jest.mock('../../../../UI/Ramp/hooks/useRampNavigation', () => ({
+ useRampNavigation: jest.fn(),
+ RampMode: { AGGREGATOR: 'AGGREGATOR', DEPOSIT: 'DEPOSIT' },
+}));
jest.mock('../gas/useIsGaslessSupported');
describe('useInsufficientBalanceAlert', () => {
@@ -61,6 +66,8 @@ describe('useInsufficientBalanceAlert', () => {
);
const mockUseTransactionPayToken = jest.mocked(useTransactionPayToken);
const mockUseConfirmationContext = jest.mocked(useConfirmationContext);
+ const mockUseRampNavigation = jest.mocked(useRampNavigation);
+ const mockGoToRamps = jest.fn();
const useIsGaslessSupportedMock = jest.mocked(useIsGaslessSupported);
const mockChainId = '0x1';
@@ -116,6 +123,9 @@ describe('useInsufficientBalanceAlert', () => {
mockUseConfirmationContext.mockReturnValue({
isTransactionValueUpdating: false,
} as unknown as ReturnType);
+ mockUseRampNavigation.mockReturnValue({
+ goToRamps: mockGoToRamps,
+ });
});
it('return empty array when no transaction metadata is available', () => {
diff --git a/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.ts b/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.ts
index 7e268671c090..a0050f274e0c 100644
--- a/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.ts
+++ b/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.ts
@@ -1,7 +1,6 @@
import { useMemo } from 'react';
import { Hex, add0x } from '@metamask/utils';
import { BigNumber } from 'bignumber.js';
-import { useNavigation } from '@react-navigation/native';
import { useSelector } from 'react-redux';
import {
addHexes,
@@ -10,7 +9,11 @@ import {
} from '../../../../../util/conversions';
import { strings } from '../../../../../../locales/i18n';
import { selectNetworkConfigurations } from '../../../../../selectors/networkController';
-import { createBuyNavigationDetails } from '../../../../UI/Ramp/Aggregator/routes/utils';
+import {
+ useRampNavigation,
+ RampMode,
+} from '../../../../UI/Ramp/hooks/useRampNavigation';
+import { RampType as AggregatorRampType } from '../../../../UI/Ramp/Aggregator/types';
import { RowAlertKey } from '../../components/UI/info-row/alert-row/constants';
import { AlertKeys } from '../../constants/alerts';
import { Alert, Severity } from '../../types/alerts';
@@ -35,7 +38,7 @@ export const useInsufficientBalanceAlert = ({
}: {
ignoreGasFeeToken?: boolean;
} = {}): Alert[] => {
- const navigation = useNavigation();
+ const { goToRamps } = useRampNavigation();
const transactionMetadata = useTransactionMetadataRequest();
const networkConfigurations = useSelector(selectNetworkConfigurations);
const { balanceWeiInHex } = useAccountNativeBalance(
@@ -92,7 +95,10 @@ export const useInsufficientBalanceAlert = ({
nativeCurrency,
}),
callback: () => {
- navigation.navigate(...createBuyNavigationDetails());
+ goToRamps({
+ mode: RampMode.AGGREGATOR,
+ params: { rampType: AggregatorRampType.BUY },
+ });
onReject(undefined, true);
},
},
@@ -112,9 +118,9 @@ export const useInsufficientBalanceAlert = ({
ignoreGasFeeToken,
isGaslessSupported,
isTransactionValueUpdating,
- navigation,
networkConfigurations,
onReject,
transactionMetadata,
+ goToRamps,
]);
};
diff --git a/app/components/Views/confirmations/legacy/SendFlow/SendTo/index.js b/app/components/Views/confirmations/legacy/SendFlow/SendTo/index.js
index aca99e7e1b03..237e13581738 100644
--- a/app/components/Views/confirmations/legacy/SendFlow/SendTo/index.js
+++ b/app/components/Views/confirmations/legacy/SendFlow/SendTo/index.js
@@ -59,7 +59,7 @@ import {
} from '../../../../../../selectors/accountsController';
import AddToAddressBookWrapper from '../../../../../UI/AddToAddressBookWrapper';
import { isNetworkRampNativeTokenSupported } from '../../../../../UI/Ramp/Aggregator/utils';
-import { createBuyNavigationDetails } from '../../../../../UI/Ramp/Aggregator/routes/utils';
+import { withRampNavigation } from '../../../../../UI/Ramp/hooks/withRampNavigation';
import {
getDetectedGeolocation,
getRampNetworks,
@@ -101,6 +101,18 @@ class SendFlow extends PureComponent {
* Selected address as string
*/
selectedAddress: PropTypes.string,
+ /**
+ * Function to navigate to ramp flows
+ */
+ goToRamps: PropTypes.func,
+ /**
+ * RampMode enum
+ */
+ RampMode: PropTypes.object,
+ /**
+ * AggregatorRampType enum
+ */
+ AggregatorRampType: PropTypes.object,
/**
* List of accounts from the AccountsController
*/
@@ -335,7 +347,10 @@ class SendFlow extends PureComponent {
};
goToBuy = () => {
- this.props.navigation.navigate(...createBuyNavigationDetails());
+ this.props.goToRamps({
+ mode: this.props.RampMode.AGGREGATOR,
+ params: { rampType: this.props.AggregatorRampType.BUY },
+ });
this.props.metrics.trackEvent(
this.props.metrics
@@ -799,4 +814,4 @@ const mapDispatchToProps = (dispatch) => ({
export default connect(
mapStateToProps,
mapDispatchToProps,
-)(withMetricsAwareness(SendFlow));
+)(withRampNavigation(withMetricsAwareness(SendFlow)));
diff --git a/app/components/Views/confirmations/legacy/components/ApproveTransactionReview/index.js b/app/components/Views/confirmations/legacy/components/ApproveTransactionReview/index.js
index 085dc9746de0..066d4e10287a 100644
--- a/app/components/Views/confirmations/legacy/components/ApproveTransactionReview/index.js
+++ b/app/components/Views/confirmations/legacy/components/ApproveTransactionReview/index.js
@@ -97,7 +97,7 @@ import TransactionBlockaidBanner from '../TransactionBlockaidBanner/TransactionB
import { regex } from '../../../../../../util/regex';
import { withMetricsAwareness } from '../../../../../../components/hooks/useMetrics';
import { selectShouldUseSmartTransaction } from '../../../../../../selectors/smartTransactionsController';
-import { createBuyNavigationDetails } from '../../../../../UI/Ramp/Aggregator/routes/utils';
+import { withRampNavigation } from '../../../../../UI/Ramp/hooks/withRampNavigation';
import SDKConnect from '../../../../../../core/SDKConnect/SDKConnect';
import DevLogger from '../../../../../../core/SDKConnect/utils/DevLogger';
import { WC2Manager } from '../../../../../../core/WalletConnect/WalletConnectV2';
@@ -142,6 +142,18 @@ class ApproveTransactionReview extends PureComponent {
* Current provider ticker
*/
ticker: PropTypes.string,
+ /**
+ * Function to navigate to ramp flows
+ */
+ goToRamps: PropTypes.func,
+ /**
+ * RampMode enum
+ */
+ RampMode: PropTypes.object,
+ /**
+ * AggregatorRampType enum
+ */
+ AggregatorRampType: PropTypes.object,
/**
* Number of tokens
*/
@@ -1229,11 +1241,14 @@ class ApproveTransactionReview extends PureComponent {
};
buyEth = () => {
- const { navigation } = this.props;
+ const { goToRamps, RampMode, AggregatorRampType } = this.props;
/* this is kinda weird, we have to reject the transaction to collapse the modal */
this.onCancelPress();
try {
- navigation.navigate(...createBuyNavigationDetails());
+ goToRamps({
+ mode: RampMode.AGGREGATOR,
+ params: { rampType: AggregatorRampType.BUY },
+ });
} catch (error) {
Logger.error(error, 'Navigation: Error when navigating to buy ETH.');
}
@@ -1382,7 +1397,9 @@ export default connect(
mapStateToProps,
mapDispatchToProps,
)(
- withNavigation(
- withQRHardwareAwareness(withMetricsAwareness(ApproveTransactionReview)),
+ withRampNavigation(
+ withNavigation(
+ withQRHardwareAwareness(withMetricsAwareness(ApproveTransactionReview)),
+ ),
),
);
From c722d72610507139caa82fdcbb321965b7a4e137 Mon Sep 17 00:00:00 2001
From: Prithpal Sooriya
Date: Fri, 14 Nov 2025 15:37:11 +0000
Subject: [PATCH 15/17] refactor: remove dead code, and add/cleanup
notification tests (#22681)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
Removed dead notification code (e.g. using notifee badges), and improves
test suites
## **Changelog**
CHANGELOG entry: null
## **Related issues**
Fixes:
## **Manual testing steps**
N/A
## **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]
> Simplifies the notifications list to a single data source, removes
badge updates on item click, and replaces snapshots with data-driven
tests using shared mocks and selectors.
>
> - **Notifications UI**:
> - Simplifies `Notifications` props to only `allNotifications` and
`loading`; removes `walletNotifications`/`web3Notifications`.
> - Adds `TEST_IDS.loadingContainer` and uses test IDs for list states
and items.
> - Removes badge updates from `useNotificationOnClick`; now only marks
as read, tracks metrics, and conditionally navigates when a modal
exists.
> - **Notifications View**:
> - Consolidates filtering to return `allNotifications` only; updates
rendering accordingly.
> - Keeps "Mark all as read" flow; still sets badge count to 0.
> - **Tests**:
> - Deletes snapshot file; replaces with state-driven, parameterized
tests for loading/empty/data and item rendering.
> - Mocks `useMarkNotificationAsRead`; verifies metrics and conditional
navigation.
> - Updates notification-state and node-guard tests to use shared
processed mocks (`mockNotificationsWithMetaData`) and controller mocks.
> - **Mocks/Utilities**:
> - Introduces `mockNotificationsWithMetaData` (with `hasModal`) and
switches imports to `notification-services` module mocks.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
fa706304e5fbf94f9178d240d2f9e69f3f6aab68. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
.../List/__snapshots__/index.test.tsx.snap | 149 -----------
.../UI/Notification/List/index.test.tsx | 247 +++++++++---------
app/components/UI/Notification/List/index.tsx | 18 +-
.../__mocks__/mock_notifications.ts | 105 +++++++-
.../Views/Notifications/index.test.tsx | 4 +-
app/components/Views/Notifications/index.tsx | 35 +--
.../notification-states/index.test.tsx | 107 +-------
.../notification-states/node-guard.test.ts | 29 +-
8 files changed, 260 insertions(+), 434 deletions(-)
delete mode 100644 app/components/UI/Notification/List/__snapshots__/index.test.tsx.snap
diff --git a/app/components/UI/Notification/List/__snapshots__/index.test.tsx.snap b/app/components/UI/Notification/List/__snapshots__/index.test.tsx.snap
deleted file mode 100644
index aef0bac40de2..000000000000
--- a/app/components/UI/Notification/List/__snapshots__/index.test.tsx.snap
+++ /dev/null
@@ -1,149 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`NotificationsList renders correctly 1`] = `
-
-
-
-
-
-`;
-
-exports[`NotificationsList renders empty state 1`] = `
-
-
- }
- contentContainerStyle={
- {
- "flexGrow": 1,
- "paddingBottom": 100,
- }
- }
- data={[]}
- getItem={[Function]}
- getItemCount={[Function]}
- initialNumToRender={10}
- keyExtractor={[Function]}
- maxToRenderPerBatch={2}
- onContentSizeChange={[Function]}
- onEndReachedThreshold={0.5}
- onLayout={[Function]}
- onMomentumScrollBegin={[Function]}
- onMomentumScrollEnd={[Function]}
- onRefresh={[Function]}
- onScroll={[Function]}
- onScrollBeginDrag={[Function]}
- onScrollEndDrag={[Function]}
- refreshControl={
-
- }
- refreshing={false}
- removeClippedSubviews={false}
- renderItem={[Function]}
- scrollEventThrottle={0.0001}
- stickyHeaderIndices={[]}
- tabLabel=""
- testID="notification-menu-scroll-view"
- viewabilityConfigCallbackPairs={[]}
- >
-
-
-
-
-
- Nothing to see here
-
-
- This is where you can find notifications once there’s activity in your wallet.
-
-
-
-
-
-`;
diff --git a/app/components/UI/Notification/List/index.test.tsx b/app/components/UI/Notification/List/index.test.tsx
index a2b492c98747..e0ca70c05775 100644
--- a/app/components/UI/Notification/List/index.test.tsx
+++ b/app/components/UI/Notification/List/index.test.tsx
@@ -1,25 +1,20 @@
import React from 'react';
import { renderHook, act } from '@testing-library/react-hooks';
-import { processNotification } from '@metamask/notification-services-controller/notification-services';
-import { createMockNotificationEthSent } from '@metamask/notification-services-controller/notification-services/mocks';
+import { type INotification } from '@metamask/notification-services-controller/notification-services';
import NotificationsList, {
NotificationsListItem,
+ TEST_IDS,
useNotificationOnClick,
} from './';
-import NotificationsService from '../../../../util/notifications/services/NotificationService';
import renderWithProvider from '../../../../util/test/renderWithProvider';
-import MOCK_NOTIFICATIONS from '../__mocks__/mock_notifications';
+import { mockNotificationsWithMetaData } from '../__mocks__/mock_notifications';
import { createNavigationProps } from '../../../../util/testUtils';
-import {
- hasNotificationComponents,
- NotificationComponentState,
-} from '../../../../util/notifications/notification-states';
+import { NotificationsViewSelectorsIDs } from '../../../../../e2e/selectors/wallet/NotificationsView.selectors';
+import { NotificationMenuViewSelectorsIDs } from '../../../../../e2e/selectors/Notifications/NotificationMenuView.selectors';
// eslint-disable-next-line import/no-namespace
-import * as Actions from '../../../../actions/notification/helpers';
-import { NavigationProp, ParamListBase } from '@react-navigation/native';
+import * as UseNotificationsModule from '../../../../util/notifications/hooks/useNotifications';
const mockNavigation = createNavigationProps({});
-
const mockTrackEvent = jest.fn();
const mockCreateEventBuilder = jest.fn(() => ({
addProperties: jest.fn(() => ({
@@ -27,23 +22,6 @@ const mockCreateEventBuilder = jest.fn(() => ({
})),
}));
-jest.mock('../../../../util/notifications/constants', () => ({
- ...jest.requireActual('../../../../util/notifications/constants'),
- isNotificationsFeatureEnabled: () => true,
-}));
-
-jest.mock(
- '../../../../util/notifications/services/NotificationService',
- () => ({
- ...jest.requireActual(
- '../../../../util/notifications/services/NotificationService',
- ),
- getBadgeCount: jest.fn(),
- decrementBadgeCount: jest.fn(),
- setBadgeCount: jest.fn(),
- }),
-);
-
jest.mock('../../../hooks/useMetrics', () => ({
useMetrics: () => ({
trackEvent: mockTrackEvent,
@@ -54,96 +32,115 @@ jest.mock('../../../hooks/useMetrics', () => ({
},
}));
-jest.mock('../NotificationMenuItem', () => ({
- NotificationMenuItem: {
- Root: ({ children }: { children: React.ReactNode }) => (
- {children}
- ),
- Icon: jest.fn(({ isRead }: { isRead: boolean }) => (
- {isRead ? 'Read Icon' : 'Unread Icon'}
- )),
- Content: jest.fn(() => Mocked Content
),
- Cta: jest.fn(() => null),
- },
-}));
-
-function arrangeActions() {
- const mockMarkNotificationAsRead = jest
- .spyOn(Actions, 'markNotificationsAsRead')
- .mockResolvedValue(undefined);
-
- return {
- mockMarkNotificationAsRead,
- };
-}
-
-describe('NotificationsList', () => {
- it('renders correctly', () => {
- const { toJSON } = renderWithProvider(
- ,
- );
- expect(toJSON()).toMatchSnapshot();
- });
-
- it('renders empty state', () => {
- const { toJSON } = renderWithProvider(
- ,
- );
- expect(toJSON()).toMatchSnapshot();
- });
-
- it('derives notificationState correctly based on notification type', () => {
- const notification = MOCK_NOTIFICATIONS[2];
- if (!hasNotificationComponents(notification.type)) {
- throw new Error('Test Setup Failure - incorrect mock');
- }
-
- const notifState = NotificationComponentState[notification.type];
- const mockCreateMenuItem = jest.spyOn(notifState, 'createMenuItem');
+describe('NotificationsList States', () => {
+ const mockNotifSlice = mockNotificationsWithMetaData.slice(0, 1);
+ const itemIds = mockNotifSlice
+ .map(({ notification }) =>
+ NotificationMenuViewSelectorsIDs.ITEM(notification.id),
+ )
+ .slice(0, 1);
+ const statesTests = [
+ {
+ type: 'loading',
+ elemsRendered: [TEST_IDS.loadingContainer],
+ elemsNotRendered: [
+ NotificationsViewSelectorsIDs.NO_NOTIFICATIONS_CONTAINER,
+ ...itemIds,
+ ],
+ },
+ {
+ type: 'empty',
+ elemsRendered: [NotificationsViewSelectorsIDs.NO_NOTIFICATIONS_CONTAINER],
+ elemsNotRendered: [TEST_IDS.loadingContainer, ...itemIds],
+ },
+ {
+ type: 'data',
+ elemsRendered: [...itemIds],
+ elemsNotRendered: [
+ TEST_IDS.loadingContainer,
+ NotificationsViewSelectorsIDs.NO_NOTIFICATIONS_CONTAINER,
+ ],
+ },
+ ] as const;
+
+ it.each(statesTests)(
+ 'renders correct list state - $type',
+ ({ type, elemsRendered, elemsNotRendered }) => {
+ const getTestState = () => {
+ if (type === 'loading') {
+ return { allNotifications: [], loading: true };
+ }
+ if (type === 'empty') {
+ return { allNotifications: [], loading: false };
+ }
+ if (type === 'data') {
+ return {
+ allNotifications: mockNotifSlice.map((n) => n.notification),
+ loading: false,
+ };
+ }
+ throw new Error('TEST FAIL - NO TEST STATE FOUND');
+ };
+
+ const { getByTestId, queryByTestId } = renderWithProvider(
+ ,
+ );
+
+ elemsRendered.forEach((id) => {
+ expect(getByTestId(id)).toBeOnTheScreen();
+ });
+
+ elemsNotRendered.forEach((id) => {
+ expect(queryByTestId(id)).not.toBeOnTheScreen();
+ });
+ },
+ );
+});
- renderWithProvider(
+describe('NotificationsListItem', () => {
+ it('returns null on invalid notification', () => {
+ const { root } = renderWithProvider(
,
);
-
- expect(mockCreateMenuItem).toHaveBeenCalledWith(MOCK_NOTIFICATIONS[2]);
+ expect(root).toBeUndefined();
});
+
+ it.each(mockNotificationsWithMetaData)(
+ 'renders notification menu item - $type',
+ ({ notification }) => {
+ const { getByTestId } = renderWithProvider(
+ ,
+ );
+
+ expect(
+ getByTestId(NotificationMenuViewSelectorsIDs.ITEM(notification.id)),
+ ).toBeOnTheScreen();
+ },
+ );
});
describe('useNotificationOnClick', () => {
const arrangeMocks = () => {
- const { mockMarkNotificationAsRead } = arrangeActions();
- const mockGetBadgeCount = jest
- .mocked(NotificationsService.getBadgeCount)
- .mockResolvedValue(1);
- const mockDecrementBadgeCount = jest.mocked(
- NotificationsService.decrementBadgeCount,
- );
- const mockSetBadgeConut = jest.mocked(NotificationsService.setBadgeCount);
+ const mockMarkNotificationAsRead = jest.fn();
+ jest
+ .spyOn(UseNotificationsModule, 'useMarkNotificationAsRead')
+ .mockReturnValue({
+ loading: false,
+ markNotificationAsRead: mockMarkNotificationAsRead,
+ });
return {
mockMarkNotificationAsRead,
- mockGetBadgeCount,
- mockDecrementBadgeCount,
- mockSetBadgeConut,
mockTrackEvent,
- mockNavigation: createNavigationProps({}).navigation as jest.MockedObject<
- NavigationProp
- >,
+ mockNavigation: createNavigationProps({}).navigation,
};
};
@@ -151,28 +148,22 @@ describe('useNotificationOnClick', () => {
jest.clearAllMocks();
});
- it('call correct logic, and invoke navigation + events', async () => {
- const mocks = arrangeMocks();
- const hook = renderHook(() =>
- useNotificationOnClick({ navigation: mocks.mockNavigation }),
- );
- const notification = processNotification(createMockNotificationEthSent());
-
- await act(() => hook.result.current.onNotificationClick(notification));
-
- // Assert - Controller Action
- expect(mocks.mockMarkNotificationAsRead).toHaveBeenCalledWith([
- expect.objectContaining({ id: notification.id }),
- ]);
-
- // Assert - Page Navigation
- expect(mocks.mockNavigation.navigate).toHaveBeenCalled();
-
- // Assert - Badge Update
- expect(mocks.mockGetBadgeCount).toHaveBeenCalled();
- expect(mocks.mockDecrementBadgeCount).toHaveBeenCalled();
-
- // Assert - Event Fired
- expect(mocks.mockTrackEvent).toHaveBeenCalled();
- });
+ it.each(mockNotificationsWithMetaData)(
+ 'invokes click callback and attempts navigation for notification - $type',
+ async ({ notification, hasModal }) => {
+ const mocks = arrangeMocks();
+ const hook = renderHook(() =>
+ useNotificationOnClick({ navigation: mocks.mockNavigation }),
+ );
+ await act(() => hook.result.current.onNotificationClick(notification));
+ expect(mocks.mockMarkNotificationAsRead).toHaveBeenCalled();
+ expect(mocks.mockTrackEvent).toHaveBeenCalled();
+
+ if (hasModal) {
+ expect(mocks.mockNavigation.navigate).toHaveBeenCalled();
+ } else {
+ expect(mocks.mockNavigation.navigate).not.toHaveBeenCalled();
+ }
+ },
+ );
});
diff --git a/app/components/UI/Notification/List/index.tsx b/app/components/UI/Notification/List/index.tsx
index d9aae57bbc58..9dcda2cc9cc6 100644
--- a/app/components/UI/Notification/List/index.tsx
+++ b/app/components/UI/Notification/List/index.tsx
@@ -3,7 +3,6 @@ import { ActivityIndicator, FlatList, FlatListProps, View } from 'react-native';
import { NavigationProp, ParamListBase } from '@react-navigation/native';
import { Box } from '@metamask/design-system-react-native';
import { useTailwind } from '@metamask/design-system-twrnc-preset';
-import NotificationsService from '../../../../util/notifications/services/NotificationService';
import { NotificationsViewSelectorsIDs } from '../../../../../e2e/selectors/wallet/NotificationsView.selectors';
import {
hasNotificationComponents,
@@ -22,11 +21,14 @@ import Empty from '../Empty';
import { NotificationMenuItem } from '../NotificationMenuItem';
import useStyles from './useStyles';
import { NotificationMenuViewSelectorsIDs } from '../../../../../e2e/selectors/Notifications/NotificationMenuView.selectors';
+
+export const TEST_IDS = {
+ loadingContainer: 'notification-list-loading',
+};
+
interface NotificationsListProps {
navigation: NavigationProp;
allNotifications: INotification[];
- walletNotifications: INotification[];
- web3Notifications: INotification[];
loading: boolean;
}
@@ -46,7 +48,7 @@ function Loading() {
} = useStyles();
return (
-
+
);
@@ -91,14 +93,6 @@ export function useNotificationOnClick(
})
.build(),
);
-
- NotificationsService.getBadgeCount().then((count) => {
- if (count > 0) {
- NotificationsService.decrementBadgeCount(1);
- } else {
- NotificationsService.setBadgeCount(0);
- }
- });
},
[createEventBuilder, markNotificationAsRead, trackEvent],
);
diff --git a/app/components/UI/Notification/__mocks__/mock_notifications.ts b/app/components/UI/Notification/__mocks__/mock_notifications.ts
index 0751210873a5..92588632adff 100644
--- a/app/components/UI/Notification/__mocks__/mock_notifications.ts
+++ b/app/components/UI/Notification/__mocks__/mock_notifications.ts
@@ -1,9 +1,6 @@
-import { NotificationServicesController } from '@metamask/notification-services-controller';
-
-const {
- Processors: { processNotification },
- Mocks,
-} = NotificationServicesController;
+import { processNotification } from '@metamask/notification-services-controller/notification-services';
+// eslint-disable-next-line import/no-namespace
+import * as Mocks from '@metamask/notification-services-controller/notification-services/mocks';
export const MOCK_ON_CHAIN_NOTIFICATIONS = [
processNotification(Mocks.createMockNotificationEthSent()),
@@ -65,4 +62,100 @@ export const createMockNotificationLidoWithdrawalCompleted = () =>
export const createMockFeatureAnnouncementRaw = () =>
processNotification(Mocks.createMockFeatureAnnouncementRaw());
+export const mockNotificationsWithMetaData = [
+ {
+ notification: processNotification(Mocks.createMockNotificationEthSent()),
+ hasModal: true,
+ },
+ {
+ notification: processNotification(
+ Mocks.createMockNotificationEthReceived(),
+ ),
+ hasModal: true,
+ },
+ {
+ notification: processNotification(Mocks.createMockNotificationERC20Sent()),
+ hasModal: true,
+ },
+ {
+ notification: processNotification(
+ Mocks.createMockNotificationERC20Received(),
+ ),
+ hasModal: true,
+ },
+ {
+ notification: processNotification(Mocks.createMockNotificationERC721Sent()),
+ hasModal: true,
+ },
+ {
+ notification: processNotification(
+ Mocks.createMockNotificationERC721Received(),
+ ),
+ hasModal: true,
+ },
+ {
+ notification: processNotification(
+ Mocks.createMockNotificationERC1155Sent(),
+ ),
+ hasModal: true,
+ },
+ {
+ notification: processNotification(
+ Mocks.createMockNotificationERC1155Received(),
+ ),
+ hasModal: true,
+ },
+ {
+ notification: processNotification(
+ Mocks.createMockNotificationMetaMaskSwapsCompleted(),
+ ),
+ hasModal: true,
+ },
+ {
+ notification: processNotification(
+ Mocks.createMockNotificationRocketPoolStakeCompleted(),
+ ),
+ hasModal: true,
+ },
+ {
+ notification: processNotification(
+ Mocks.createMockNotificationRocketPoolUnStakeCompleted(),
+ ),
+ hasModal: true,
+ },
+ {
+ notification: processNotification(
+ Mocks.createMockNotificationLidoStakeCompleted(),
+ ),
+ hasModal: true,
+ },
+ {
+ notification: processNotification(
+ Mocks.createMockNotificationLidoWithdrawalRequested(),
+ ),
+ hasModal: true,
+ },
+ {
+ notification: processNotification(
+ Mocks.createMockNotificationLidoReadyToBeWithdrawn(),
+ ),
+ hasModal: true,
+ },
+ {
+ notification: processNotification(
+ Mocks.createMockNotificationLidoWithdrawalCompleted(),
+ ),
+ hasModal: true,
+ },
+ {
+ notification: processNotification(Mocks.createMockFeatureAnnouncementRaw()),
+ hasModal: true,
+ },
+ {
+ notification: processNotification(Mocks.createMockPlatformNotification()),
+ hasModal: false,
+ hasCta: true,
+ },
+].map((x) => ({ ...x, type: x.notification.type }));
+
export default MOCK_NOTIFICATIONS;
diff --git a/app/components/Views/Notifications/index.test.tsx b/app/components/Views/Notifications/index.test.tsx
index 5c65206529f2..49ee5276a278 100644
--- a/app/components/Views/Notifications/index.test.tsx
+++ b/app/components/Views/Notifications/index.test.tsx
@@ -182,11 +182,9 @@ describe('useNotificationFilters', () => {
expect(hook.result.current.allNotifications).toHaveLength(1);
});
- it('filters and returns wallet notifications and announcement notifications', () => {
+ it('returns all notifications', () => {
const notifications = [createEthNotif(), createAnnonucementNotif()];
const hook = renderHook(() => useNotificationFilters({ notifications }));
expect(hook.result.current.allNotifications).toHaveLength(2);
- expect(hook.result.current.walletNotifications).toHaveLength(1);
- expect(hook.result.current.announcementNotifications).toHaveLength(1);
});
});
diff --git a/app/components/Views/Notifications/index.tsx b/app/components/Views/Notifications/index.tsx
index ecce0d9dd2d0..758bc58b0671 100644
--- a/app/components/Views/Notifications/index.tsx
+++ b/app/components/Views/Notifications/index.tsx
@@ -1,10 +1,7 @@
import React, { useCallback, useMemo } from 'react';
import { View } from 'react-native';
import { useSelector } from 'react-redux';
-import {
- TRIGGER_TYPES,
- INotification,
-} from '@metamask/notification-services-controller/notification-services';
+import { INotification } from '@metamask/notification-services-controller/notification-services';
import { useMetrics } from '../../../components/hooks/useMetrics';
import { NotificationsViewSelectorsIDs } from '../../../../e2e/selectors/wallet/NotificationsView.selectors';
@@ -82,30 +79,7 @@ export function useNotificationFilters(props: {
return sortedNotifications;
}, [notifications]);
- // Wallet notifications
- const walletNotifications = useMemo(
- () =>
- (allNotifications ?? []).filter(
- (n) =>
- n.type !== TRIGGER_TYPES.FEATURES_ANNOUNCEMENT &&
- n.type !== TRIGGER_TYPES.SNAP,
- ),
- [allNotifications],
- );
-
- const announcementNotifications = useMemo(
- () =>
- (allNotifications ?? []).filter(
- (n) => n.type === TRIGGER_TYPES.FEATURES_ANNOUNCEMENT,
- ),
- [allNotifications],
- );
-
- return {
- allNotifications,
- walletNotifications,
- announcementNotifications,
- };
+ return { allNotifications };
}
const NotificationsView = ({
@@ -123,8 +97,7 @@ const NotificationsView = ({
notifications,
});
- const { allNotifications, walletNotifications, announcementNotifications } =
- useNotificationFilters({ notifications });
+ const { allNotifications } = useNotificationFilters({ notifications });
const unreadCount = useMemo(
() => allNotifications.filter((n) => !n.isRead).length,
@@ -141,8 +114,6 @@ const NotificationsView = ({
{!isLoading && unreadCount > 0 && (
diff --git a/app/util/notifications/notification-states/index.test.tsx b/app/util/notifications/notification-states/index.test.tsx
index 934dc448a845..5a9f25e34092 100644
--- a/app/util/notifications/notification-states/index.test.tsx
+++ b/app/util/notifications/notification-states/index.test.tsx
@@ -1,96 +1,13 @@
-import {
- TRIGGER_TYPES,
- processNotification,
-} from '@metamask/notification-services-controller/notification-services';
-import {
- createMockNotificationEthSent,
- createMockNotificationEthReceived,
- createMockNotificationERC20Sent,
- createMockNotificationERC20Received,
- createMockNotificationERC721Sent,
- createMockNotificationERC721Received,
- createMockNotificationERC1155Sent,
- createMockNotificationERC1155Received,
- createMockNotificationMetaMaskSwapsCompleted,
- createMockNotificationRocketPoolStakeCompleted,
- createMockNotificationRocketPoolUnStakeCompleted,
- createMockNotificationLidoStakeCompleted,
- createMockNotificationLidoWithdrawalRequested,
- createMockNotificationLidoReadyToBeWithdrawn,
- createMockNotificationLidoWithdrawalCompleted,
- createMockPlatformNotification,
- createMockFeatureAnnouncementRaw,
-} from '@metamask/notification-services-controller/notification-services/mocks';
+import { TRIGGER_TYPES } from '@metamask/notification-services-controller/notification-services';
import {
hasNotificationComponents,
hasNotificationModal,
NotificationComponentState,
} from '.';
-
-const mockAllNotifications = [
- { n: processNotification(createMockNotificationEthSent()), hasModal: true },
- {
- n: processNotification(createMockNotificationEthReceived()),
- hasModal: true,
- },
- { n: processNotification(createMockNotificationERC20Sent()), hasModal: true },
- {
- n: processNotification(createMockNotificationERC20Received()),
- hasModal: true,
- },
- {
- n: processNotification(createMockNotificationERC721Sent()),
- hasModal: true,
- },
- {
- n: processNotification(createMockNotificationERC721Received()),
- hasModal: true,
- },
- {
- n: processNotification(createMockNotificationERC1155Sent()),
- hasModal: true,
- },
- {
- n: processNotification(createMockNotificationERC1155Received()),
- hasModal: true,
- },
- {
- n: processNotification(createMockNotificationMetaMaskSwapsCompleted()),
- hasModal: true,
- },
- {
- n: processNotification(createMockNotificationRocketPoolStakeCompleted()),
- hasModal: true,
- },
- {
- n: processNotification(createMockNotificationRocketPoolUnStakeCompleted()),
- hasModal: true,
- },
- {
- n: processNotification(createMockNotificationLidoStakeCompleted()),
- hasModal: true,
- },
- {
- n: processNotification(createMockNotificationLidoWithdrawalRequested()),
- hasModal: true,
- },
- {
- n: processNotification(createMockNotificationLidoReadyToBeWithdrawn()),
- hasModal: true,
- },
- {
- n: processNotification(createMockNotificationLidoWithdrawalCompleted()),
- hasModal: true,
- },
- {
- n: processNotification(createMockFeatureAnnouncementRaw()),
- hasModal: true,
- },
- { n: processNotification(createMockPlatformNotification()), hasModal: false },
-].map((x) => ({ ...x, type: x.n.type }));
+import { mockNotificationsWithMetaData } from '../../../components/UI/Notification/__mocks__/mock_notifications';
describe('hasNotificationComponents()', () => {
- it.each(mockAllNotifications)(
+ it.each(mockNotificationsWithMetaData)(
'returns true for all supported notifications - $type',
({ type }) => {
expect(hasNotificationComponents(type)).toBe(true);
@@ -105,14 +22,14 @@ describe('hasNotificationComponents()', () => {
});
describe('hasNotificationModal()', () => {
- it.each(mockAllNotifications.filter((x) => x.hasModal))(
+ it.each(mockNotificationsWithMetaData.filter((x) => x.hasModal))(
'returns true for all notifications that should render a modal details screen - $type',
({ type }) => {
expect(hasNotificationModal(type)).toBe(true);
},
);
- it.each(mockAllNotifications.filter((x) => !x.hasModal))(
+ it.each(mockNotificationsWithMetaData.filter((x) => !x.hasModal))(
'returns false for all notifications that should not render a modal details screen - $type',
({ type }) => {
expect(hasNotificationModal(type)).toBe(false);
@@ -127,15 +44,15 @@ describe('hasNotificationModal()', () => {
});
describe('NotificationComponentState', () => {
- it.each(mockAllNotifications)(
+ it.each(mockNotificationsWithMetaData)(
'computes notification component state for each notification type - $type',
- ({ n, hasModal }) => {
- if (!hasNotificationComponents(n.type)) {
+ ({ notification, hasModal }) => {
+ if (!hasNotificationComponents(notification.type)) {
throw new Error('UNSUPPORTED NOTIFICATION');
}
- const notificationState = NotificationComponentState[n.type];
- expect(notificationState.createMenuItem(n)).toStrictEqual(
+ const notificationState = NotificationComponentState[notification.type];
+ expect(notificationState.createMenuItem(notification)).toStrictEqual(
expect.objectContaining({
title: expect.any(String),
description: expect.objectContaining({
@@ -145,7 +62,9 @@ describe('NotificationComponentState', () => {
}),
);
- expect(notificationState.createModalDetails?.(n)).toStrictEqual(
+ expect(
+ notificationState.createModalDetails?.(notification),
+ ).toStrictEqual(
!hasModal
? undefined
: expect.objectContaining({
diff --git a/app/util/notifications/notification-states/node-guard.test.ts b/app/util/notifications/notification-states/node-guard.test.ts
index edd720617ec2..b252a573ffa7 100644
--- a/app/util/notifications/notification-states/node-guard.test.ts
+++ b/app/util/notifications/notification-states/node-guard.test.ts
@@ -2,32 +2,41 @@ import { isOfTypeNodeGuard } from './node-guard';
import {
INotification,
TRIGGER_TYPES,
+ processNotification,
} from '@metamask/notification-services-controller/notification-services';
-import MOCK_NOTIFICATIONS from '../../../components/UI/Notification/__mocks__/mock_notifications';
+import {
+ createMockNotificationERC1155Received,
+ createMockNotificationERC721Received,
+ createMockNotificationEthReceived,
+} from '@metamask/notification-services-controller/notification-services/mocks';
describe('isOfTypeNodeGuard', () => {
+ const erc1155Notification = processNotification(
+ createMockNotificationERC1155Received(),
+ );
+ const erc721Notification = processNotification(
+ createMockNotificationERC721Received(),
+ );
+ const otherNotification = processNotification(
+ createMockNotificationEthReceived(),
+ );
+
const sampleTypes = [
TRIGGER_TYPES.ERC1155_RECEIVED,
TRIGGER_TYPES.ERC721_RECEIVED,
];
-
const isERC1155Or721ReceivedNotification = isOfTypeNodeGuard(sampleTypes);
it('returns true for notifications with matching types', () => {
- const erc1155Notification: INotification = MOCK_NOTIFICATIONS[7];
-
expect(isERC1155Or721ReceivedNotification(erc1155Notification)).toBe(true);
});
it('returns false for notifications with non-matching types', () => {
- const otherNotification: INotification = MOCK_NOTIFICATIONS[1];
-
expect(isERC1155Or721ReceivedNotification(otherNotification)).toBe(false);
});
it('returns undefined for notifications with undefined type', () => {
const undefinedTypeNotification: Partial = {};
-
expect(
isERC1155Or721ReceivedNotification(
undefinedTypeNotification as INotification,
@@ -37,9 +46,9 @@ describe('isOfTypeNodeGuard', () => {
it('narrows types correctly when used in a type guard context', () => {
const mixedNotifications: INotification[] = [
- MOCK_NOTIFICATIONS[7],
- MOCK_NOTIFICATIONS[1],
- MOCK_NOTIFICATIONS[5],
+ erc1155Notification,
+ erc721Notification,
+ otherNotification,
{} as INotification,
];
From 97becda9703e0ddb20f0a8a930aefd1edde28498 Mon Sep 17 00:00:00 2001
From: Charly Chevalier
Date: Fri, 14 Nov 2025 17:06:45 +0100
Subject: [PATCH 16/17] fix: prevent concurrency for `createAccount` for Snap
account providers cp-7.59.0 (#22719)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
It seems that having concurrent calls to `keyring_createAccount` cause
some synchronization issues between Snap's accounts and MetaMask
accounts. We're not sure of the real root cause yet for this, but
preventing concurrent calls seems to mitigate (or even completely
prevent) this kind of issues.
## **Changelog**
CHANGELOG entry: null
## **Related issues**
N/A
## **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]
> Enforces single-concurrency createAccount and shared timeouts for
Snap-backed BTC/TRX providers and SOL via providerConfigs.
>
> - **Multichain Account Service**
(`app/core/Engine/controllers/multichain-account-service/multichain-account-service-init.ts`):
> - **New Snap provider config**: `maxConcurrency: 1` plus
discovery/create timeouts.
> - **Providers**:
> - Pass config to `new BtcAccountProvider(...)` and `new
TrxAccountProvider(...)` via `AccountProviderWrapper`.
> - **Service config**:
> - Add `providerConfigs` with `[SOL_ACCOUNT_PROVIDER_NAME]` mapped to
the same Snap config.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
8be27eb77eb194f01d65a5281379ada144c1840a. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
.../multichain-account-service-init.ts | 24 +++++++++++++++++--
1 file changed, 22 insertions(+), 2 deletions(-)
diff --git a/app/core/Engine/controllers/multichain-account-service/multichain-account-service-init.ts b/app/core/Engine/controllers/multichain-account-service/multichain-account-service-init.ts
index c4ba5bc9df2b..a1add41caed0 100644
--- a/app/core/Engine/controllers/multichain-account-service/multichain-account-service-init.ts
+++ b/app/core/Engine/controllers/multichain-account-service/multichain-account-service-init.ts
@@ -4,6 +4,7 @@ import {
BtcAccountProvider,
TrxAccountProvider,
AccountProviderWrapper,
+ SOL_ACCOUNT_PROVIDER_NAME,
} from '@metamask/multichain-account-service';
import { ControllerInitFunction } from '../../types';
import Engine from '../../Engine';
@@ -25,11 +26,27 @@ export const multichainAccountServiceInit: ControllerInitFunction<
MultichainAccountServiceMessenger,
MultichainAccountServiceInitMessenger
> = ({ controllerMessenger, initMessenger }) => {
+ const snapAccountProviderConfig = {
+ // READ THIS CAREFULLY:
+ // We using 1 to prevent any concurrent `keyring_createAccount` requests, that make sure
+ // we prevent any desync between Snap's accounts and Metamask's accounts.
+ maxConcurrency: 1,
+ // Re-use the default config for the rest:
+ discovery: {
+ timeoutMs: 2000,
+ maxAttempts: 3,
+ backOffMs: 1000,
+ },
+ createAccounts: {
+ timeoutMs: 3000,
+ },
+ };
+
/// BEGIN:ONLY_INCLUDE_IF(bitcoin)
// Create Bitcoin provider wrapped for feature flag control
const btcProvider = new AccountProviderWrapper(
controllerMessenger,
- new BtcAccountProvider(controllerMessenger),
+ new BtcAccountProvider(controllerMessenger, snapAccountProviderConfig),
);
/// END:ONLY_INCLUDE_IF
@@ -37,7 +54,7 @@ export const multichainAccountServiceInit: ControllerInitFunction<
// Create Tron provider wrapped for feature flag control
const trxProvider = new AccountProviderWrapper(
controllerMessenger,
- new TrxAccountProvider(controllerMessenger),
+ new TrxAccountProvider(controllerMessenger, snapAccountProviderConfig),
);
/// END:ONLY_INCLUDE_IF
@@ -53,6 +70,9 @@ export const multichainAccountServiceInit: ControllerInitFunction<
const controller = new MultichainAccountService({
messenger: controllerMessenger,
providers,
+ providerConfigs: {
+ [SOL_ACCOUNT_PROVIDER_NAME]: snapAccountProviderConfig,
+ },
});
// Handle provider feature flags
From e700d867c217da42356bde2b42b04053afdb110a Mon Sep 17 00:00:00 2001
From: jvbriones <1674192+jvbriones@users.noreply.github.com>
Date: Fri, 14 Nov 2025 18:03:42 +0100
Subject: [PATCH 17/17] chore: refactor AI files - keeping same logic (#22706)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
## **Changelog**
CHANGELOG entry:
## **Related issues**
Fixes:
## **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]
> Harden grep and related-files handlers, add tool result preview/error
logging in analyzer, and update select-tags prompt guidance.
>
> - **AI Tools**:
> - **`ai-tools/handlers/grep-codebase.ts`**:
> - Replace shell escaping with `sanitizeGrepPattern`; validate/reject
dangerous input and escape shell metacharacters while allowing grep
regex.
> - Switch to `grep -Erni`; preserve and report using raw input in
messages; add explicit invalid-pattern handling.
> - **`ai-tools/handlers/related-files.ts`**:
> - Add `escapeGrepRegex` and build safe `-E` grep pattern for importer
search; improve filename handling and quoting.
> - **Analyzer** (`analysis/analyzer.ts`):
> - Log tool result previews and flag errors; keep finalize flow and
conservative fallback.
> - **Prompt** (`modes/select-tags/prompt.ts`):
> - Import `CLAUDE_CONFIG`; adjust guidance to allow selecting all or no
tags and to respect `maxIterations`.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
c060eba17d13555d741c147478d496a335813e6b. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
.../ai-tools/handlers/grep-codebase.ts | 43 +++++++++++++------
.../ai-tools/handlers/related-files.ts | 28 +++++++++---
.../e2e-ai-analyzer/analysis/analyzer.ts | 13 ++++++
.../modes/select-tags/prompt.ts | 6 ++-
4 files changed, 70 insertions(+), 20 deletions(-)
diff --git a/e2e/tools/e2e-ai-analyzer/ai-tools/handlers/grep-codebase.ts b/e2e/tools/e2e-ai-analyzer/ai-tools/handlers/grep-codebase.ts
index 4f2590d056ac..848c714502ca 100644
--- a/e2e/tools/e2e-ai-analyzer/ai-tools/handlers/grep-codebase.ts
+++ b/e2e/tools/e2e-ai-analyzer/ai-tools/handlers/grep-codebase.ts
@@ -9,26 +9,38 @@ import { ToolInput } from '../../types';
import { TOOL_LIMITS } from '../../config';
/**
- * Escapes shell special characters to prevent command injection
+ * Validates and sanitizes grep pattern
+ * Rejects dangerous patterns, allows safe grep regex
*/
-function escapeShell(str: string): string {
- return str.replace(/[`$\\"\n]/g, '\\$&');
+function sanitizeGrepPattern(str: string): string {
+ // Reject patterns with command substitution or command chaining
+ if (str.includes('`') || str.includes('$(') || str.includes('\n')) {
+ throw new Error('Invalid pattern: contains dangerous characters');
+ }
+
+ // Escape shell metacharacters that could cause issues
+ // Escapes: $, ", \ (shell interpretation)
+ // Preserves: |, *, ., [], {}, +, ? (grep regex)
+ return str.replace(/[$"\\]/g, '\\$&');
}
export function handleGrepCodebase(input: ToolInput, baseDir: string): string {
- const pattern = escapeShell(input.pattern as string);
- const filePattern = escapeShell((input.file_pattern as string) || '*');
+ const rawPattern = input.pattern as string;
+ const rawFilePattern = (input.file_pattern as string) || '*';
const maxResults =
(input.max_results as number) || TOOL_LIMITS.grepMaxResults;
- if (!pattern) {
+ if (!rawPattern) {
return 'Error: pattern is required';
}
try {
- // Use grep with common source code file extensions
- // -r: recursive, -n: line numbers, -i: case insensitive, --include: file pattern
- const command = `grep -rni --include="${filePattern}" "${pattern}" app/ | head -${maxResults}`;
+ const pattern = sanitizeGrepPattern(rawPattern);
+ const filePattern = sanitizeGrepPattern(rawFilePattern);
+
+ // Use grep -E for extended regex (supports |, +, ?, etc.)
+ // -E: extended regex, -r: recursive, -n: line numbers, -i: case insensitive
+ const command = `grep -Erni --include="${filePattern}" "${pattern}" app/ | head -${maxResults}`;
const result = execSync(command, {
encoding: 'utf-8',
@@ -37,14 +49,19 @@ export function handleGrepCodebase(input: ToolInput, baseDir: string): string {
});
if (!result.trim()) {
- return `No matches found for pattern: "${pattern}" in files: ${filePattern}`;
+ return `No matches found for pattern: "${rawPattern}" in files: ${rawFilePattern}`;
}
const lines = result.trim().split('\n');
const resultCount = lines.length;
- return `Found ${resultCount} matches for "${pattern}" (showing up to ${maxResults}):\n\n${result}`;
+ return `Found ${resultCount} matches for "${rawPattern}" (showing up to ${maxResults}):\n\n${result}`;
} catch (error: unknown) {
+ // Check if it's a validation error
+ if (error instanceof Error && error.message.includes('Invalid pattern')) {
+ return `Invalid pattern: ${error.message}`;
+ }
+
// grep returns exit code 1 when no matches found
if (
error &&
@@ -52,10 +69,10 @@ export function handleGrepCodebase(input: ToolInput, baseDir: string): string {
'status' in error &&
error.status === 1
) {
- return `No matches found for pattern: "${pattern}" in files: ${filePattern}`;
+ return `No matches found for pattern: "${rawPattern}" in files: ${rawFilePattern}`;
}
- return `Error searching for pattern "${pattern}": ${
+ return `Error searching for pattern "${rawPattern}": ${
error instanceof Error ? error.message : String(error)
}`;
}
diff --git a/e2e/tools/e2e-ai-analyzer/ai-tools/handlers/related-files.ts b/e2e/tools/e2e-ai-analyzer/ai-tools/handlers/related-files.ts
index 974739262409..4cfc48dc9ea1 100644
--- a/e2e/tools/e2e-ai-analyzer/ai-tools/handlers/related-files.ts
+++ b/e2e/tools/e2e-ai-analyzer/ai-tools/handlers/related-files.ts
@@ -17,6 +17,13 @@ function escapeShell(str: string): string {
return str.replace(/[`$\\"\n]/g, '\\$&');
}
+/**
+ * Escapes grep regex metacharacters to treat as literal
+ */
+function escapeGrepRegex(str: string): string {
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
export function handleRelatedFiles(input: ToolInput, baseDir: string): string {
const filePath = escapeShell(input.file_path as string);
const searchType = input.search_type as string;
@@ -123,20 +130,31 @@ function findImporters(
maxResults: number,
): string {
try {
- const fileName = escapeShell(
+ const rawFileName =
filePath
.replace(/^app\//, '')
.replace(/\.(ts|tsx|js|jsx)$/, '')
.split('/')
- .pop() || '',
- );
+ .pop() || '';
- if (!fileName) {
+ if (!rawFileName) {
return `Cannot extract filename from ${filePath}`;
}
+ // Escape fileName for grep regex (. * + ? etc. become literals)
+ const fileNameEscaped = escapeGrepRegex(rawFileName);
+
+ // Then escape for shell
+ const fileNameSafe = escapeShell(fileNameEscaped);
+
+ // Find files that import this file
+ // Pattern matches: from './fileName' or from "../fileName" (with space after from)
+ // Build pattern with properly escaped quotes for shell
+ // eslint-disable-next-line no-useless-escape
+ const pattern = `from ['\\\"].*${fileNameSafe}`; // from ['\"].*fileName
+
const importers = execSync(
- `grep -r -l --include="*.ts" --include="*.tsx" --include="*.js" --include="*.jsx" "from.*['"].*${fileName}" app/ 2>/dev/null | grep -v "${filePath}" | head -${maxResults} || true`,
+ `grep -r -l --include="*.ts" --include="*.tsx" --include="*.js" --include="*.jsx" -E "${pattern}" app/ 2>/dev/null | grep -v "${filePath}" | head -${maxResults} || true`,
{ encoding: 'utf-8', cwd: baseDir },
)
.trim()
diff --git a/e2e/tools/e2e-ai-analyzer/analysis/analyzer.ts b/e2e/tools/e2e-ai-analyzer/analysis/analyzer.ts
index 9a404583e0f2..d73b4e3e2402 100644
--- a/e2e/tools/e2e-ai-analyzer/analysis/analyzer.ts
+++ b/e2e/tools/e2e-ai-analyzer/analysis/analyzer.ts
@@ -140,6 +140,19 @@ export async function analyzeWithAgent(
},
);
+ // Log tool result summary
+ const resultPreview = toolResult
+ .substring(0, 150)
+ .replace(/\n/g, ' ');
+ console.log(
+ ` → ${resultPreview}${toolResult.length > 150 ? '...' : ''}`,
+ );
+
+ // Check for actual errors (starts with "Error:")
+ if (toolResult.startsWith('Error:')) {
+ console.log(` ⚠️ Tool returned error`);
+ }
+
// Handle finalize tool (mode-specific)
if (toolUse.name === modeConfig.finalizeToolName) {
const analysis = await modeConfig.processAnalysis(
diff --git a/e2e/tools/e2e-ai-analyzer/modes/select-tags/prompt.ts b/e2e/tools/e2e-ai-analyzer/modes/select-tags/prompt.ts
index 9dd1d260d296..19328da32a01 100644
--- a/e2e/tools/e2e-ai-analyzer/modes/select-tags/prompt.ts
+++ b/e2e/tools/e2e-ai-analyzer/modes/select-tags/prompt.ts
@@ -10,6 +10,7 @@ import {
buildConfidenceGuidanceSection,
buildRiskAssessmentSection,
} from '../shared/base-system-prompt';
+import { CLAUDE_CONFIG } from '../../config';
/**
* Builds the system prompt, i.e. the initial system message
@@ -19,10 +20,11 @@ export function buildSystemPrompt(): string {
const goal = `GOAL: Analyze code changes and select appropriate test tags to run.`;
const guidanceSection = `GUIDANCE:
-Use your judgment - selecting 0 tags is acceptable for non-functional changes.
+Use your judgment - selecting all tags is acceptable (recommended as conservative approach), as well as selecting none of them.
Critical files (marked in file list) typically warrant testing. Use tools to investigate when uncertain.
For E2E test infrastructure related changes, consider running the necessary tests or all of them in case the changes are wide-ranging.
-Balance thoroughness with efficiency, and be conservative in the assessment of risk and tags to run.`;
+Balance thoroughness with efficiency, and be conservative in the assessment of risk and tags to run.
+Do not exceed the maximum number of analysis iterations which is ${CLAUDE_CONFIG.maxIterations}.`;
const prompt = [
role,