diff --git a/.js.env.example b/.js.env.example
index b8ab9dd52ffa..a6a3c96bef1e 100644
--- a/.js.env.example
+++ b/.js.env.example
@@ -80,6 +80,9 @@ export DECODING_API_URL: 'https://signature-insights.api.cx.metamask.io/v1'
# URL of security alerts API used to validate dApp requests.
export SECURITY_ALERTS_API_URL="https://security-alerts.api.cx.metamask.io"
+# URL of the price alerts API. Override to http://localhost:3333 for local dev.
+export PRICE_ALERTS_API_URL="https://price-alerts.dev-api.cx.metamask.io"
+
# URL of the compliance API used for wallet compliance checks.
export COMPLIANCE_API_URL="https://compliance.api.cx.metamask.io"
diff --git a/app/components/Nav/Main/MainNavigator.js b/app/components/Nav/Main/MainNavigator.js
index cda3a0e809a7..816e800bae2d 100644
--- a/app/components/Nav/Main/MainNavigator.js
+++ b/app/components/Nav/Main/MainNavigator.js
@@ -171,6 +171,8 @@ import RewardsSelectSheet from '../../UI/Rewards/components/RewardsSelectSheet';
import SitesFullView from '../../Views/SitesFullView/SitesFullView';
import { TokenDetails } from '../../UI/TokenDetails/Views/TokenDetails';
+import CreatePriceAlertView from '../../UI/Assets/PriceAlerts/Views/CreatePriceAlertView/CreatePriceAlertView';
+import ManagePriceAlertsView from '../../UI/Assets/PriceAlerts/Views/ManagePriceAlertsView/ManagePriceAlertsView';
import BenefitFullView from '../../UI/Rewards/Views/BenefitFullView';
import BenefitsFullView from '../../UI/Rewards/Views/BenefitsFullView';
import MoneyTabPressTracker from '../../UI/Money/components/MoneyTabPressTracker';
@@ -211,6 +213,14 @@ const AssetStackFlow = (props) => (
name={Routes.TRANSACTION_DETAILS}
component={TransactionDetails}
/>
+
+
);
diff --git a/app/components/UI/AssetOverview/TokenOverview.testIds.ts b/app/components/UI/AssetOverview/TokenOverview.testIds.ts
index 96a32a92e177..ff257f57a518 100644
--- a/app/components/UI/AssetOverview/TokenOverview.testIds.ts
+++ b/app/components/UI/AssetOverview/TokenOverview.testIds.ts
@@ -3,6 +3,7 @@ import enContent from '../../../../locales/languages/en.json';
export const TokenOverviewSelectorsIDs = {
CONTAINER: 'token-asset-overview',
TOKEN_PRICE: 'token-price',
+ PRICE_ALERT_BUTTON: 'token-price-alert-button',
SEND_BUTTON: 'token-send-button',
RECEIVE_BUTTON: 'token-receive-button',
BUY_BUTTON: 'token-buy-button',
diff --git a/app/components/UI/Assets/PriceAlerts/Views/CreatePriceAlertView/CreatePriceAlertView.test.tsx b/app/components/UI/Assets/PriceAlerts/Views/CreatePriceAlertView/CreatePriceAlertView.test.tsx
new file mode 100644
index 000000000000..c76038c23062
--- /dev/null
+++ b/app/components/UI/Assets/PriceAlerts/Views/CreatePriceAlertView/CreatePriceAlertView.test.tsx
@@ -0,0 +1,384 @@
+import React from 'react';
+import { render, fireEvent, act } from '@testing-library/react-native';
+import CreatePriceAlertView from './CreatePriceAlertView';
+import { CreatePriceAlertTestIds } from '../../constants';
+
+const mockGoBack = jest.fn();
+const mockPop = jest.fn();
+const mockSave = jest.fn();
+const mockShowToast = jest.fn();
+const mockCloseToast = jest.fn();
+const mockUseSavePriceAlert = jest.fn(() => ({
+ save: mockSave,
+ isSubmitting: false,
+}));
+
+jest.mock('@react-navigation/native', () => ({
+ ...jest.requireActual('@react-navigation/native'),
+ useNavigation: () => ({ goBack: mockGoBack, pop: mockPop }),
+ useRoute: () => ({
+ params: {
+ symbol: 'ETH',
+ ticker: 'ETH',
+ currentPrice: 1201.98,
+ currentCurrency: 'USD',
+ assetId: 'eip155:1/slip44:60',
+ },
+ }),
+}));
+
+jest.mock('../../api', () => ({
+ useSavePriceAlert: () => mockUseSavePriceAlert(),
+}));
+
+import { ToastContext } from '../../../../../../component-library/components/Toast';
+
+function WithToast({ children }: { children: React.ReactNode }) {
+ const ref = React.useRef({
+ showToast: mockShowToast,
+ closeToast: mockCloseToast,
+ });
+ return (
+
+ {children}
+
+ );
+}
+
+const renderWithToast = () =>
+ render(
+
+
+ ,
+ );
+
+const setRoute = (params: object) =>
+ jest
+ .spyOn(jest.requireMock('@react-navigation/native'), 'useRoute')
+ .mockReturnValue({ params });
+
+describe('CreatePriceAlertView', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockSave.mockResolvedValue(undefined);
+ mockUseSavePriceAlert.mockImplementation(() => ({
+ save: mockSave,
+ isSubmitting: false,
+ }));
+ });
+
+ it('renders the create alert title and current price subtitle', () => {
+ const { getByText, getAllByText } = renderWithToast();
+ expect(getByText('Create ETH price alert')).toBeOnTheScreen();
+ // price appears in the header subtitle and as the keypad placeholder
+ expect(getAllByText('$1,201.98').length).toBeGreaterThanOrEqual(2);
+ });
+
+ it('renders the price reaches experience by default', () => {
+ const { getByTestId, getByText } = renderWithToast();
+
+ expect(getByText('Enter target price')).toBeOnTheScreen();
+ expect(
+ getByTestId(CreatePriceAlertTestIds.TARGET_PRICE_INPUT),
+ ).toBeOnTheScreen();
+ expect(
+ getByTestId(CreatePriceAlertTestIds.RECURRING_TOGGLE),
+ ).toBeOnTheScreen();
+ expect(
+ getByTestId(`${CreatePriceAlertTestIds.QUICK_PERCENTAGE_PREFIX}-5`),
+ ).toBeOnTheScreen();
+ });
+
+ it('shows percentage pickers and hides Set button before any input', () => {
+ const { getByTestId, queryByTestId } = renderWithToast();
+
+ expect(
+ getByTestId(`${CreatePriceAlertTestIds.QUICK_PERCENTAGE_PREFIX}-5`),
+ ).toBeOnTheScreen();
+ expect(queryByTestId(CreatePriceAlertTestIds.SET_ALERT_BUTTON)).toBeNull();
+ });
+
+ it('hides percentage pickers and shows Set button once user types a digit', () => {
+ const { getByTestId, queryByTestId } = renderWithToast();
+
+ fireEvent.press(getByTestId('keypad-key-1'));
+
+ expect(
+ queryByTestId(`${CreatePriceAlertTestIds.QUICK_PERCENTAGE_PREFIX}-5`),
+ ).toBeNull();
+ expect(
+ getByTestId(CreatePriceAlertTestIds.SET_ALERT_BUTTON),
+ ).toBeOnTheScreen();
+ });
+
+ it('keeps percentage pickers and hides Set button for zero-valued keypad input like "0."', () => {
+ const { getByTestId, getByText, queryByTestId } = renderWithToast();
+
+ fireEvent.press(getByTestId('keypad-key-dot'));
+
+ expect(getByText('$0.')).toBeOnTheScreen();
+ expect(
+ getByTestId(`${CreatePriceAlertTestIds.QUICK_PERCENTAGE_PREFIX}-5`),
+ ).toBeOnTheScreen();
+ expect(queryByTestId(CreatePriceAlertTestIds.SET_ALERT_BUTTON)).toBeNull();
+ });
+
+ it('shows Set button after a quick-percentage pill is pressed', () => {
+ const { getByTestId } = renderWithToast();
+
+ fireEvent.press(
+ getByTestId(`${CreatePriceAlertTestIds.QUICK_PERCENTAGE_PREFIX}-10`),
+ );
+
+ expect(
+ getByTestId(CreatePriceAlertTestIds.SET_ALERT_BUTTON),
+ ).toBeOnTheScreen();
+ });
+
+ it('shows under development message when price change is selected', () => {
+ const { getByTestId, getByText } = renderWithToast();
+
+ fireEvent.press(getByTestId(CreatePriceAlertTestIds.PRICE_CHANGE_TAB));
+
+ expect(
+ getByTestId(CreatePriceAlertTestIds.UNDER_DEVELOPMENT),
+ ).toBeOnTheScreen();
+ expect(
+ getByText('This experience is currently under development'),
+ ).toBeOnTheScreen();
+ });
+
+ it('updates the displayed price when a quick-percentage pill is pressed', () => {
+ const { getByTestId, getByText } = renderWithToast();
+
+ fireEvent.press(
+ getByTestId(`${CreatePriceAlertTestIds.QUICK_PERCENTAGE_PREFIX}-10`),
+ );
+
+ // 1201.98 * 1.10 = 1322.178 → stripped to 6 sig figs → "1322.18"
+ expect(getByText('$1322.18')).toBeOnTheScreen();
+ });
+
+ it('shows raw digits without forced decimals while typing', () => {
+ const { getByTestId, getByText } = renderWithToast();
+
+ fireEvent.press(getByTestId('keypad-key-1'));
+
+ expect(getByText('$1')).toBeOnTheScreen();
+ });
+
+ it('shows ≈ 0% when no target has been entered', () => {
+ const { getByTestId } = renderWithToast();
+ expect(getByTestId(CreatePriceAlertTestIds.PERCENT_DIFF)).toHaveTextContent(
+ '≈ 0%',
+ );
+ });
+
+ it('shows the positive percentage and "above" wording when target exceeds current price', () => {
+ const { getByTestId, getByText } = renderWithToast();
+
+ // 5% above 1201.98
+ fireEvent.press(
+ getByTestId(`${CreatePriceAlertTestIds.QUICK_PERCENTAGE_PREFIX}-5`),
+ );
+
+ expect(getByText('5%')).toBeOnTheScreen();
+ expect(getByText(/above current ETH price/)).toBeOnTheScreen();
+ });
+
+ it('shows the percentage and "below" wording when target is less than current price', () => {
+ const { getByTestId, getByText } = renderWithToast();
+
+ // $1000 is ~17% below 1201.98
+ fireEvent.press(getByTestId('keypad-key-1'));
+ fireEvent.press(getByTestId('keypad-key-0'));
+ fireEvent.press(getByTestId('keypad-key-0'));
+ fireEvent.press(getByTestId('keypad-key-0'));
+
+ expect(getByText(/below current ETH price/)).toBeOnTheScreen();
+ });
+
+ describe('Set price alert button', () => {
+ it('calls save with the correct asset, threshold, and recurring values', async () => {
+ const { getByTestId } = renderWithToast();
+
+ fireEvent.press(getByTestId('keypad-key-1'));
+ fireEvent.press(getByTestId('keypad-key-5'));
+ fireEvent.press(getByTestId('keypad-key-0'));
+ fireEvent.press(getByTestId('keypad-key-0'));
+
+ await act(async () => {
+ fireEvent.press(getByTestId(CreatePriceAlertTestIds.SET_ALERT_BUTTON));
+ });
+
+ expect(mockSave).toHaveBeenCalledWith({
+ asset: 'eip155:1/slip44:60',
+ threshold: 1500,
+ recurring: true,
+ });
+ });
+
+ it('passes recurring=false when the toggle is switched off', async () => {
+ const { getByTestId } = renderWithToast();
+
+ fireEvent(
+ getByTestId(CreatePriceAlertTestIds.RECURRING_TOGGLE),
+ 'valueChange',
+ false,
+ );
+ fireEvent.press(getByTestId('keypad-key-2'));
+
+ await act(async () => {
+ fireEvent.press(getByTestId(CreatePriceAlertTestIds.SET_ALERT_BUTTON));
+ });
+
+ expect(mockSave).toHaveBeenCalledWith(
+ expect.objectContaining({ recurring: false }),
+ );
+ });
+
+ it('navigates back and shows a success toast on save', async () => {
+ const { getByTestId } = renderWithToast();
+
+ fireEvent.press(getByTestId('keypad-key-1'));
+
+ await act(async () => {
+ fireEvent.press(getByTestId(CreatePriceAlertTestIds.SET_ALERT_BUTTON));
+ });
+
+ expect(mockGoBack).toHaveBeenCalledTimes(1);
+ expect(mockShowToast).toHaveBeenCalledWith(
+ expect.objectContaining({
+ hasNoTimeout: false,
+ labelOptions: expect.arrayContaining([
+ expect.objectContaining({ label: expect.stringContaining('ETH') }),
+ ]),
+ }),
+ );
+ });
+
+ it('pops two screens instead of goBack when opened from the manage list', async () => {
+ setRoute({
+ symbol: 'ETH',
+ ticker: 'ETH',
+ currentPrice: 1201.98,
+ currentCurrency: 'USD',
+ assetId: 'eip155:1/slip44:60',
+ fromManage: true,
+ });
+
+ const { getByTestId } = renderWithToast();
+ fireEvent.press(getByTestId('keypad-key-1'));
+
+ await act(async () => {
+ fireEvent.press(getByTestId(CreatePriceAlertTestIds.SET_ALERT_BUTTON));
+ });
+
+ expect(mockPop).toHaveBeenCalledWith(2);
+ expect(mockGoBack).not.toHaveBeenCalled();
+ });
+
+ it('does not navigate or show toast when save throws', async () => {
+ mockSave.mockRejectedValueOnce(new Error('HTTP 500'));
+ const { getByTestId } = renderWithToast();
+
+ fireEvent.press(getByTestId('keypad-key-1'));
+
+ await act(async () => {
+ fireEvent.press(getByTestId(CreatePriceAlertTestIds.SET_ALERT_BUTTON));
+ });
+
+ expect(mockGoBack).not.toHaveBeenCalled();
+ expect(mockShowToast).not.toHaveBeenCalled();
+ });
+
+ it('does not call save a second time when the button is pressed while already submitting', async () => {
+ // The real hook sets isSubmitting=true and the Button becomes disabled+loading,
+ // preventing re-presses. Here we verify the component only calls save once
+ // even if the underlying hook guard is bypassed in tests.
+ let resolveFirst!: () => void;
+ mockSave.mockReturnValueOnce(
+ new Promise((res) => {
+ resolveFirst = res;
+ }),
+ );
+
+ const { getByTestId } = renderWithToast();
+ fireEvent.press(getByTestId('keypad-key-1'));
+ fireEvent.press(getByTestId(CreatePriceAlertTestIds.SET_ALERT_BUTTON));
+
+ await act(async () => {
+ resolveFirst();
+ });
+
+ expect(mockSave).toHaveBeenCalledTimes(1);
+ });
+ });
+});
+
+describe('CreatePriceAlertView — tiny price token', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockSave.mockResolvedValue(undefined);
+ setRoute({
+ symbol: 'SHIB',
+ ticker: 'SHIB',
+ currentPrice: 0.00000000000001,
+ currentCurrency: 'USD',
+ assetId: 'eip155:1/erc20:0x95aD61b0a150d79219dCF64E1E6Cc01f0B64C4cE',
+ });
+ });
+
+ it('quick-percentage pill shows a plain decimal string (never scientific notation)', () => {
+ const { getByTestId, getByText } = render();
+
+ fireEvent.press(
+ getByTestId(`${CreatePriceAlertTestIds.QUICK_PERCENTAGE_PREFIX}-5`),
+ );
+
+ // 0.00000000000001 * 1.05 = 1.05e-14 → must render as plain decimal
+ expect(getByText('$0.0000000000000105')).toBeOnTheScreen();
+ });
+
+ it('quick-percentage pill produces a non-zero value and reveals the Set button', () => {
+ const { getByTestId } = render();
+
+ fireEvent.press(
+ getByTestId(`${CreatePriceAlertTestIds.QUICK_PERCENTAGE_PREFIX}-5`),
+ );
+
+ expect(
+ getByTestId(CreatePriceAlertTestIds.SET_ALERT_BUTTON),
+ ).toBeOnTheScreen();
+ });
+
+ it('allows manual keypad entry beyond two decimal places for sub-cent tokens', () => {
+ setRoute({
+ symbol: 'SHIB',
+ ticker: 'SHIB',
+ currentPrice: 0.00001234,
+ currentCurrency: 'USD',
+ assetId: 'eip155:1/erc20:0x95aD61b0a150d79219dCF64E1E6Cc01f0B64C4cE',
+ });
+
+ const { getByTestId, getByText } = render();
+
+ // Type 0.000012345 — blocked when keypad decimals are fixed at 2
+ fireEvent.press(getByTestId('keypad-key-0'));
+ fireEvent.press(getByTestId('keypad-key-dot'));
+ fireEvent.press(getByTestId('keypad-key-0'));
+ fireEvent.press(getByTestId('keypad-key-0'));
+ fireEvent.press(getByTestId('keypad-key-0'));
+ fireEvent.press(getByTestId('keypad-key-0'));
+ fireEvent.press(getByTestId('keypad-key-1'));
+ fireEvent.press(getByTestId('keypad-key-2'));
+ fireEvent.press(getByTestId('keypad-key-3'));
+ fireEvent.press(getByTestId('keypad-key-4'));
+ fireEvent.press(getByTestId('keypad-key-5'));
+
+ expect(getByText('$0.000012345')).toBeOnTheScreen();
+ expect(
+ getByTestId(CreatePriceAlertTestIds.SET_ALERT_BUTTON),
+ ).toBeOnTheScreen();
+ });
+});
diff --git a/app/components/UI/Assets/PriceAlerts/Views/CreatePriceAlertView/CreatePriceAlertView.tsx b/app/components/UI/Assets/PriceAlerts/Views/CreatePriceAlertView/CreatePriceAlertView.tsx
new file mode 100644
index 000000000000..b33056766ad7
--- /dev/null
+++ b/app/components/UI/Assets/PriceAlerts/Views/CreatePriceAlertView/CreatePriceAlertView.tsx
@@ -0,0 +1,441 @@
+import React, {
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+import {
+ Animated,
+ StyleSheet,
+ Switch,
+ Text as RNText,
+ View,
+} from 'react-native';
+import {
+ useNavigation,
+ useRoute,
+ type RouteProp,
+} from '@react-navigation/native';
+import type { StackNavigationProp } from '@react-navigation/stack';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import {
+ Box,
+ BoxAlignItems,
+ BoxFlexDirection,
+ BoxJustifyContent,
+ Button,
+ ButtonVariant,
+ FontWeight,
+ HeaderStandard,
+ Text,
+ TextColor,
+ TextVariant,
+} from '@metamask/design-system-react-native';
+import { useTailwind } from '@metamask/design-system-twrnc-preset';
+import { strings } from '../../../../../../../locales/i18n';
+import { useTheme } from '../../../../../../util/theme';
+import KeypadComponent, {
+ type KeypadChangeData,
+} from '../../../../../Base/Keypad';
+import { formatPriceWithSubscriptNotation } from '../../../../Predict/utils/format';
+import {
+ ToastContext,
+ ToastVariants,
+} from '../../../../../../component-library/components/Toast';
+import { IconName } from '../../../../../../component-library/components/Icons/Icon';
+import AlertTypeToggle from '../../components/AlertTypeToggle';
+import {
+ CreatePriceAlertRouteParams,
+ CreatePriceAlertTestIds,
+ CURRENCY_SYMBOLS,
+ PRICE_ALERT_QUICK_PERCENTAGES,
+ PriceAlertType,
+} from '../../constants';
+import { useSavePriceAlert } from '../../api';
+import { trimTrailingZeros } from '../../../../Bridge/utils/trimTrailingZeros';
+
+const KEYPAD_EMPTY = '0';
+const MIN_KEYPAD_DECIMALS = 2;
+/** Fractional offset that yields 6 significant figures via toFixed. */
+const SIG_FIGS_FRACTIONAL_OFFSET = 5;
+
+/**
+ * Max fractional digits the keypad should allow for a given USD price.
+ * Mirrors the precision used by {@link toKeypadString} so manual entry and
+ * quick-percentage pills stay consistent (e.g. sub-cent SHIB prices).
+ */
+const getKeypadDecimalPlaces = (price: number): number => {
+ if (!Number.isFinite(price) || price <= 0) {
+ return MIN_KEYPAD_DECIMALS;
+ }
+
+ const exponent = Math.floor(Math.log10(price));
+ return exponent >= 0
+ ? Math.max(MIN_KEYPAD_DECIMALS, SIG_FIGS_FRACTIONAL_OFFSET - exponent)
+ : Math.abs(exponent) + SIG_FIGS_FRACTIONAL_OFFSET;
+};
+
+/**
+ * Converts a computed price into a plain decimal string suitable for the keypad.
+ * Always preserves 6 significant figures and never produces scientific notation.
+ * e.g. 0.3224 * 1.10 → "0.35464", 1.05e-14 → "0.0000000000000105".
+ */
+const toKeypadString = (price: number): string => {
+ if (!Number.isFinite(price) || price <= 0) return KEYPAD_EMPTY;
+
+ const decimalPlaces = getKeypadDecimalPlaces(price);
+ const str = trimTrailingZeros(price.toFixed(decimalPlaces));
+
+ return str || KEYPAD_EMPTY;
+};
+
+const viewStyles = StyleSheet.create({
+ priceText: {
+ fontSize: 48,
+ flexShrink: 1,
+ maxWidth: '95%',
+ },
+ cursor: {
+ flexShrink: 0,
+ },
+});
+
+const CreatePriceAlertView: React.FC = () => {
+ const tw = useTailwind();
+ const { colors } = useTheme();
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const navigation = useNavigation>();
+ const route =
+ useRoute<
+ RouteProp<
+ { CreatePriceAlert: CreatePriceAlertRouteParams },
+ 'CreatePriceAlert'
+ >
+ >();
+ const { symbol, ticker, currentPrice, currentCurrency, assetId, fromManage } =
+ route.params;
+
+ const displayTicker = ticker || symbol;
+ const [alertType, setAlertType] = useState(
+ PriceAlertType.PriceReaches,
+ );
+ const [targetAmount, setTargetAmount] = useState(KEYPAD_EMPTY);
+ const [isRecurring, setIsRecurring] = useState(true);
+ const fadeAnim = useRef(new Animated.Value(1)).current;
+
+ useEffect(() => {
+ const animation = Animated.loop(
+ Animated.sequence([
+ Animated.timing(fadeAnim, {
+ toValue: 0,
+ duration: 500,
+ useNativeDriver: true,
+ }),
+ Animated.timing(fadeAnim, {
+ toValue: 1,
+ duration: 500,
+ useNativeDriver: true,
+ }),
+ ]),
+ );
+ animation.start();
+ return () => animation.stop();
+ }, [fadeAnim]);
+
+ const hasInput = targetAmount !== KEYPAD_EMPTY;
+
+ const targetPrice = useMemo(() => {
+ const parsed = parseFloat(targetAmount);
+ return Number.isFinite(parsed) ? parsed : 0;
+ }, [targetAmount]);
+
+ const hasValidTarget = targetPrice > 0;
+
+ const percentDiff = useMemo(() => {
+ if (!hasInput || currentPrice <= 0 || targetPrice <= 0) {
+ return { rounded: 0, direction: 'none' as const };
+ }
+ const percent = ((targetPrice - currentPrice) / currentPrice) * 100;
+ const rounded = Math.round(percent);
+ if (rounded > 0) return { rounded, direction: 'above' as const };
+ if (rounded < 0)
+ return { rounded: Math.abs(rounded), direction: 'below' as const };
+ return { rounded: 0, direction: 'none' as const };
+ }, [hasInput, currentPrice, targetPrice]);
+
+ // While typing, show the raw digits the user entered (e.g. "1", "12.", "12.5").
+ // Only format with currency notation for the placeholder and after a quick-percentage press.
+ const displayText = useMemo(() => {
+ if (!hasInput) {
+ return formatPriceWithSubscriptNotation(currentPrice, currentCurrency);
+ }
+ // Prepend the currency symbol but keep the raw keypad string intact so
+ // no unexpected decimals appear while the user is mid-entry.
+ const currencySymbol =
+ CURRENCY_SYMBOLS[currentCurrency.toLowerCase()] ?? '';
+ return `${currencySymbol}${targetAmount}`;
+ }, [hasInput, currentPrice, currentCurrency, targetAmount]);
+
+ const formattedCurrentPrice = useMemo(
+ () => formatPriceWithSubscriptNotation(currentPrice, currentCurrency),
+ [currentCurrency, currentPrice],
+ );
+
+ const keypadDecimals = useMemo(
+ () => getKeypadDecimalPlaces(currentPrice),
+ [currentPrice],
+ );
+
+ const { save, isSubmitting } = useSavePriceAlert();
+ const { toastRef } = useContext(ToastContext);
+
+ const handleBack = useCallback(() => {
+ navigation.goBack();
+ }, [navigation]);
+
+ const handleSaveAlert = useCallback(async () => {
+ if (!hasValidTarget) {
+ return;
+ }
+ try {
+ await save({
+ asset: assetId,
+ threshold: targetPrice,
+ recurring: isRecurring,
+ });
+ toastRef?.current?.showToast({
+ variant: ToastVariants.Icon,
+ iconName: IconName.Confirmation,
+ iconColor: colors.success.default,
+ labelOptions: [
+ {
+ label: strings('price_alerts.save_success', {
+ ticker: displayTicker,
+ }),
+ },
+ ],
+ hasNoTimeout: false,
+ });
+ // If opened from the manage list, pop both CreatePriceAlert and ManagePriceAlerts
+ // so the user lands back on TokenDetails instead of the list.
+ if (fromManage) {
+ navigation.pop(2);
+ } else {
+ navigation.goBack();
+ }
+ } catch {
+ // save() surfaces the error via its thrown rejection; nothing to do here
+ }
+ }, [
+ save,
+ assetId,
+ targetPrice,
+ hasValidTarget,
+ isRecurring,
+ fromManage,
+ navigation,
+ toastRef,
+ colors,
+ displayTicker,
+ ]);
+
+ const handleKeypadChange = useCallback(({ value }: KeypadChangeData) => {
+ setTargetAmount(value);
+ }, []);
+
+ const handleQuickPercentagePress = useCallback(
+ (percentage: number) => {
+ if (currentPrice <= 0) {
+ return;
+ }
+ const nextPrice = currentPrice * (1 + percentage / 100);
+ setTargetAmount(toKeypadString(nextPrice));
+ },
+ [currentPrice],
+ );
+
+ return (
+
+
+
+
+
+
+ {alertType === PriceAlertType.PriceReaches ? (
+ <>
+
+
+ {strings('price_alerts.enter_target_price')}
+
+
+
+
+ {displayText}
+
+
+
+
+
+ {percentDiff.direction === 'none' ? (
+ strings('price_alerts.approx_percent', { percent: '0' })
+ ) : (
+ <>
+ {'≈ '}
+
+ {`${percentDiff.rounded}%`}
+
+ {` ${strings(
+ percentDiff.direction === 'above'
+ ? 'price_alerts.approx_percent_above'
+ : 'price_alerts.approx_percent_below',
+ { ticker: displayTicker },
+ )}`}
+ >
+ )}
+
+
+
+
+ {/* Recurring row — always visible above the bottom controls */}
+
+
+ {strings('price_alerts.recurring')}
+
+
+
+
+ {/* Quick-percentage pickers → hidden once a positive target is set */}
+ {hasValidTarget ? (
+
+ ) : (
+
+ {PRICE_ALERT_QUICK_PERCENTAGES.map((percentage) => (
+
+ ))}
+
+ )}
+
+
+
+ >
+ ) : (
+
+
+ {strings('price_alerts.price_change_under_development')}
+
+
+ )}
+
+
+ );
+};
+
+export default CreatePriceAlertView;
diff --git a/app/components/UI/Assets/PriceAlerts/Views/ManagePriceAlertsView/ManagePriceAlertsView.test.tsx b/app/components/UI/Assets/PriceAlerts/Views/ManagePriceAlertsView/ManagePriceAlertsView.test.tsx
new file mode 100644
index 000000000000..6c92a94ff420
--- /dev/null
+++ b/app/components/UI/Assets/PriceAlerts/Views/ManagePriceAlertsView/ManagePriceAlertsView.test.tsx
@@ -0,0 +1,261 @@
+import React from 'react';
+import { render, act, fireEvent, waitFor } from '@testing-library/react-native';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { notifyManager } from '@tanstack/query-core';
+import ManagePriceAlertsView from './ManagePriceAlertsView';
+import { ManagePriceAlertsTestIds, type PriceAlert } from '../../constants';
+import Routes from '../../../../../../constants/navigation/Routes';
+
+// Prevents act() warnings caused by useQuery's internal batched updates
+notifyManager.setBatchNotifyFunction((callback: () => void) => {
+ callback();
+});
+
+const createWrapper = () => {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ const Wrapper = ({ children }: { children: React.ReactNode }) =>
+ React.createElement(QueryClientProvider, { client: queryClient }, children);
+ return { Wrapper, queryClient };
+};
+
+const renderView = () => {
+ const { Wrapper } = createWrapper();
+ return render(, { wrapper: Wrapper });
+};
+
+const mockGoBack = jest.fn();
+const mockReplace = jest.fn();
+const mockNavigate = jest.fn();
+
+jest.mock('@react-navigation/native', () => ({
+ ...jest.requireActual('@react-navigation/native'),
+ useNavigation: () => ({
+ goBack: mockGoBack,
+ replace: mockReplace,
+ navigate: mockNavigate,
+ }),
+ useRoute: () => ({
+ params: {
+ symbol: 'ETH',
+ ticker: 'ETH',
+ currentPrice: 2500,
+ currentCurrency: 'USD',
+ assetId: 'eip155:1/slip44:60',
+ },
+ }),
+}));
+
+const mockFetchAlerts = jest.fn();
+jest.mock('../../api', () => ({
+ fetchAlerts: (...args: unknown[]) => mockFetchAlerts(...args),
+}));
+
+const makeAlert = (overrides: Partial = {}): PriceAlert => ({
+ id: 'alert-1',
+ userId: 'user-1',
+ asset: 'eip155:1/slip44:60',
+ threshold: 3000,
+ recurring: true,
+ active: true,
+ createdAt: '2025-01-01T00:00:00.000Z',
+ ...overrides,
+});
+
+const makeFetchResponse = (alerts: PriceAlert[], ok = true) => ({
+ ok,
+ status: ok ? 200 : 500,
+ json: jest.fn().mockResolvedValue(alerts),
+ text: jest.fn().mockResolvedValue(''),
+});
+
+const waitForLoaded = (screen: ReturnType) =>
+ waitFor(() => {
+ expect(screen.queryByTestId(ManagePriceAlertsTestIds.LOADING)).toBeNull();
+ });
+
+beforeEach(() => {
+ jest.clearAllMocks();
+});
+
+describe('ManagePriceAlertsView', () => {
+ it('shows a loading indicator while the fetch is in-flight', async () => {
+ let resolve!: (value: unknown) => void;
+ mockFetchAlerts.mockReturnValue(
+ new Promise((r) => {
+ resolve = r;
+ }),
+ );
+
+ const { getByTestId } = renderView();
+ expect(getByTestId(ManagePriceAlertsTestIds.LOADING)).toBeOnTheScreen();
+
+ await act(async () => {
+ resolve(makeFetchResponse([makeAlert()]));
+ });
+ });
+
+ it('hides the loading indicator once alerts are loaded', async () => {
+ mockFetchAlerts.mockResolvedValue(makeFetchResponse([makeAlert()]));
+ const screen = renderView();
+
+ await waitForLoaded(screen);
+
+ expect(screen.queryByTestId(ManagePriceAlertsTestIds.LOADING)).toBeNull();
+ });
+
+ it('calls fetchAlerts with the assetId from route params', async () => {
+ mockFetchAlerts.mockResolvedValue(makeFetchResponse([makeAlert()]));
+ const screen = renderView();
+
+ await waitForLoaded(screen);
+
+ expect(mockFetchAlerts).toHaveBeenCalledWith('eip155:1/slip44:60');
+ });
+
+ describe('when alerts are returned', () => {
+ const twoAlerts = [
+ makeAlert({ id: 'alert-1', threshold: 3000, recurring: true }),
+ makeAlert({
+ id: 'alert-2',
+ threshold: 1500,
+ recurring: false,
+ active: false,
+ }),
+ ];
+
+ beforeEach(() => {
+ mockFetchAlerts.mockResolvedValue(makeFetchResponse(twoAlerts));
+ });
+
+ it('renders one row per alert', async () => {
+ const screen = renderView();
+ await waitForLoaded(screen);
+
+ expect(
+ screen.getByTestId(
+ `${ManagePriceAlertsTestIds.ALERT_ITEM_PREFIX}-alert-1`,
+ ),
+ ).toBeOnTheScreen();
+ expect(
+ screen.getByTestId(
+ `${ManagePriceAlertsTestIds.ALERT_ITEM_PREFIX}-alert-2`,
+ ),
+ ).toBeOnTheScreen();
+ });
+
+ it('shows the formatted threshold in each row', async () => {
+ const screen = renderView();
+ await waitForLoaded(screen);
+
+ expect(screen.getByText('Reaches $3,000.00')).toBeOnTheScreen();
+ });
+
+ it('formats tiny thresholds with subscript notation instead of scientific notation', async () => {
+ mockFetchAlerts.mockResolvedValue(
+ makeFetchResponse([
+ makeAlert({ id: 'alert-tiny', threshold: 0.0000000000000105 }),
+ ]),
+ );
+
+ const screen = renderView();
+ await waitForLoaded(screen);
+
+ expect(screen.getByText('Reaches $0.0₁₃105')).toBeOnTheScreen();
+ });
+
+ it('shows "Recurring" for recurring alerts and "Once" for one-shot alerts', async () => {
+ const screen = renderView();
+ await waitForLoaded(screen);
+
+ expect(screen.getAllByText('Recurring').length).toBeGreaterThanOrEqual(1);
+ expect(screen.getByText('Once')).toBeOnTheScreen();
+ });
+
+ it('renders a delete button and active toggle for each row', async () => {
+ const screen = renderView();
+ await waitForLoaded(screen);
+
+ expect(
+ screen.getByTestId(
+ `${ManagePriceAlertsTestIds.ALERT_DELETE_PREFIX}-alert-1`,
+ ),
+ ).toBeOnTheScreen();
+ expect(
+ screen.getByTestId(
+ `${ManagePriceAlertsTestIds.ALERT_TOGGLE_PREFIX}-alert-1`,
+ ),
+ ).toBeOnTheScreen();
+ });
+
+ it('shows the "Add alert" button', async () => {
+ const screen = renderView();
+ await waitForLoaded(screen);
+
+ expect(
+ screen.getByTestId(ManagePriceAlertsTestIds.ADD_ALERT_BUTTON),
+ ).toBeOnTheScreen();
+ });
+
+ it('navigates to CreatePriceAlert with fromManage=true when "Add alert" is pressed', async () => {
+ const screen = renderView();
+ await waitForLoaded(screen);
+
+ fireEvent.press(
+ screen.getByTestId(ManagePriceAlertsTestIds.ADD_ALERT_BUTTON),
+ );
+
+ expect(mockNavigate).toHaveBeenCalledWith(
+ Routes.CREATE_PRICE_ALERT,
+ expect.objectContaining({
+ symbol: 'ETH',
+ ticker: 'ETH',
+ currentPrice: 2500,
+ currentCurrency: 'USD',
+ assetId: 'eip155:1/slip44:60',
+ fromManage: true,
+ }),
+ );
+ });
+ });
+
+ describe('redirect to CreatePriceAlert', () => {
+ it('replaces the screen when the API returns an empty list', async () => {
+ mockFetchAlerts.mockResolvedValue(makeFetchResponse([]));
+ renderView();
+
+ await waitFor(() => expect(mockReplace).toHaveBeenCalled());
+
+ expect(mockReplace).toHaveBeenCalledWith(
+ Routes.CREATE_PRICE_ALERT,
+ expect.objectContaining({ assetId: 'eip155:1/slip44:60' }),
+ );
+ expect(mockReplace.mock.calls[0][1].fromManage).toBeUndefined();
+ });
+
+ it('replaces the screen on a non-ok HTTP response', async () => {
+ mockFetchAlerts.mockResolvedValue(makeFetchResponse([], false));
+ renderView();
+
+ await waitFor(() => expect(mockReplace).toHaveBeenCalled());
+
+ expect(mockReplace).toHaveBeenCalledWith(
+ Routes.CREATE_PRICE_ALERT,
+ expect.objectContaining({ assetId: 'eip155:1/slip44:60' }),
+ );
+ });
+
+ it('replaces the screen when the fetch rejects entirely', async () => {
+ mockFetchAlerts.mockRejectedValue(new Error('Network failure'));
+ renderView();
+
+ await waitFor(() => expect(mockReplace).toHaveBeenCalled());
+
+ expect(mockReplace).toHaveBeenCalledWith(
+ Routes.CREATE_PRICE_ALERT,
+ expect.objectContaining({ assetId: 'eip155:1/slip44:60' }),
+ );
+ });
+ });
+});
diff --git a/app/components/UI/Assets/PriceAlerts/Views/ManagePriceAlertsView/ManagePriceAlertsView.tsx b/app/components/UI/Assets/PriceAlerts/Views/ManagePriceAlertsView/ManagePriceAlertsView.tsx
new file mode 100644
index 000000000000..50bc7023a004
--- /dev/null
+++ b/app/components/UI/Assets/PriceAlerts/Views/ManagePriceAlertsView/ManagePriceAlertsView.tsx
@@ -0,0 +1,208 @@
+import React, { useCallback, useEffect } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { ActivityIndicator, FlatList, Switch, View } from 'react-native';
+import {
+ useNavigation,
+ useRoute,
+ type RouteProp,
+} from '@react-navigation/native';
+import type { StackNavigationProp } from '@react-navigation/stack';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import {
+ Box,
+ BoxAlignItems,
+ BoxFlexDirection,
+ BoxJustifyContent,
+ Button,
+ ButtonVariant,
+ ButtonIcon,
+ ButtonIconSize,
+ HeaderStandard,
+ Text,
+ TextColor,
+ TextVariant,
+} from '@metamask/design-system-react-native';
+import { useTailwind } from '@metamask/design-system-twrnc-preset';
+import { strings } from '../../../../../../../locales/i18n';
+import { useTheme } from '../../../../../../util/theme';
+import { IconName } from '../../../../../../component-library/components/Icons/Icon';
+import Routes from '../../../../../../constants/navigation/Routes';
+import { formatPriceWithSubscriptNotation } from '../../../../Predict/utils/format';
+import {
+ ManagePriceAlertsTestIds,
+ PriceAlert,
+ PriceAlertRouteParams,
+} from '../../constants';
+import { fetchAlerts } from '../../api';
+
+const ManagePriceAlertsView: React.FC = () => {
+ const tw = useTailwind();
+ const { colors } = useTheme();
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const navigation = useNavigation>();
+ const route =
+ useRoute<
+ RouteProp<
+ { ManagePriceAlerts: PriceAlertRouteParams },
+ 'ManagePriceAlerts'
+ >
+ >();
+ const { symbol, ticker, currentPrice, currentCurrency, assetId } =
+ route.params;
+
+ const {
+ data: alerts = [],
+ isLoading,
+ isError,
+ } = useQuery({
+ queryKey: ['priceAlerts', assetId],
+ queryFn: async (): Promise => {
+ const response = await fetchAlerts(assetId);
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ return response.json();
+ },
+ retry: false,
+ staleTime: 0,
+ cacheTime: 0,
+ });
+
+ useEffect(() => {
+ if (!isLoading && (isError || alerts.length === 0)) {
+ navigation.replace(Routes.CREATE_PRICE_ALERT, {
+ symbol,
+ ticker,
+ currentPrice,
+ currentCurrency,
+ assetId,
+ });
+ }
+ }, [
+ isLoading,
+ isError,
+ alerts.length,
+ navigation,
+ symbol,
+ ticker,
+ currentPrice,
+ currentCurrency,
+ assetId,
+ ]);
+
+ const handleBack = useCallback(() => {
+ navigation.goBack();
+ }, [navigation]);
+
+ const handleAddAlert = useCallback(() => {
+ navigation.navigate(Routes.CREATE_PRICE_ALERT, {
+ symbol,
+ ticker,
+ currentPrice,
+ currentCurrency,
+ assetId,
+ fromManage: true,
+ });
+ }, [navigation, symbol, ticker, currentPrice, currentCurrency, assetId]);
+
+ const renderItem = ({ item }: { item: PriceAlert }) => (
+
+
+
+ {strings('price_alerts.reaches_threshold', {
+ threshold: formatPriceWithSubscriptNotation(
+ item.threshold,
+ currentCurrency,
+ ),
+ })}
+
+
+ {item.recurring
+ ? strings('price_alerts.recurring')
+ : strings('price_alerts.once_label')}
+
+
+
+ {
+ // TODO: delete alert
+ }}
+ size={ButtonIconSize.Md}
+ iconName={IconName.Trash}
+ testID={`${ManagePriceAlertsTestIds.ALERT_DELETE_PREFIX}-${item.id}`}
+ accessibilityLabel="Delete alert"
+ style={tw.style('self-center')}
+ />
+
+ {
+ // TODO: toggle alert active state
+ }}
+ trackColor={{
+ true: colors.primary.default,
+ false: colors.border.muted,
+ }}
+ thumbColor={colors.background.default}
+ ios_backgroundColor={colors.border.muted}
+ testID={`${ManagePriceAlertsTestIds.ALERT_TOGGLE_PREFIX}-${item.id}`}
+ style={tw.style('ml-3 self-center')}
+ />
+
+ );
+
+ return (
+
+
+
+
+ {isLoading ? (
+
+
+
+ ) : (
+
+ item.id}
+ renderItem={renderItem}
+ testID={ManagePriceAlertsTestIds.ALERT_LIST}
+ />
+
+ )}
+
+ {!isLoading && alerts.length > 0 && (
+
+
+
+ )}
+
+
+ );
+};
+
+export default ManagePriceAlertsView;
diff --git a/app/components/UI/Assets/PriceAlerts/api.test.ts b/app/components/UI/Assets/PriceAlerts/api.test.ts
new file mode 100644
index 000000000000..09bd60be5c2e
--- /dev/null
+++ b/app/components/UI/Assets/PriceAlerts/api.test.ts
@@ -0,0 +1,266 @@
+import React from 'react';
+import { renderHook, act, waitFor } from '@testing-library/react-native';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { notifyManager } from '@tanstack/query-core';
+import { fetchAlerts, createAlert, useSavePriceAlert } from './api';
+
+// Prevents teardown crashes with unstable_batchedUpdates in Jest
+notifyManager.setBatchNotifyFunction((callback: () => void) => {
+ callback();
+});
+
+const createWrapper = () => {
+ const queryClient = new QueryClient({
+ defaultOptions: { mutations: { retry: false } },
+ });
+ const Wrapper = ({ children }: { children: React.ReactNode }) =>
+ React.createElement(QueryClientProvider, { client: queryClient }, children);
+ return { Wrapper, queryClient };
+};
+
+const mockGetBearerToken = jest.fn().mockResolvedValue('test-bearer-token');
+
+jest.mock('../../../../core/Engine', () => ({
+ context: {
+ AuthenticationController: {
+ getBearerToken: (...args: unknown[]) => mockGetBearerToken(...args),
+ },
+ },
+}));
+
+jest.mock('../../../../core/AppConstants', () => ({
+ PRICE_ALERTS_API: { URL: 'https://price-alerts.api.cx.metamask.io' },
+}));
+
+const ALERTS_URL = 'https://price-alerts.api.cx.metamask.io/alerts';
+
+const mockFetch = jest.fn();
+global.fetch = mockFetch;
+
+const makeOkResponse = (body?: unknown) =>
+ ({
+ ok: true,
+ status: 200,
+ json: jest.fn().mockResolvedValue(body ?? []),
+ text: jest.fn().mockResolvedValue(''),
+ }) as unknown as Response;
+
+const makeErrorResponse = (status: number, bodyText = 'Bad Request') =>
+ ({
+ ok: false,
+ status,
+ json: jest.fn().mockResolvedValue({}),
+ text: jest.fn().mockResolvedValue(bodyText),
+ }) as unknown as Response;
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ mockFetch.mockResolvedValue(makeOkResponse());
+});
+
+// authenticatedFetch is tested indirectly through fetchAlerts / createAlert
+describe('authenticatedFetch', () => {
+ it('fetches a bearer token before every request', async () => {
+ await fetchAlerts('eip155:1/slip44:60');
+ expect(mockGetBearerToken).toHaveBeenCalledTimes(1);
+ });
+
+ it('attaches the token as an Authorization: Bearer header', async () => {
+ await fetchAlerts('eip155:1/slip44:60');
+ const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
+ expect((init.headers as Record).Authorization).toBe(
+ 'Bearer test-bearer-token',
+ );
+ });
+
+ it('always sends Accept: application/json', async () => {
+ await fetchAlerts('eip155:1/slip44:60');
+ const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
+ expect((init.headers as Record).Accept).toBe(
+ 'application/json',
+ );
+ });
+
+ it('sends credentials: omit so cookies are never forwarded', async () => {
+ await fetchAlerts('eip155:1/slip44:60');
+ const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
+ expect(init.credentials).toBe('omit');
+ });
+});
+
+describe('fetchAlerts', () => {
+ it('calls the correct URL with the asset URL-encoded as a query param', async () => {
+ await fetchAlerts('eip155:1/slip44:60');
+ const [url] = mockFetch.mock.calls[0] as [string];
+ expect(url).toBe(
+ `${ALERTS_URL}?asset=${encodeURIComponent('eip155:1/slip44:60')}`,
+ );
+ });
+
+ it('encodes special characters in the asset identifier', async () => {
+ await fetchAlerts('eip155:1/erc20:0xABCDEF');
+ const [url] = mockFetch.mock.calls[0] as [string];
+ expect(url).toContain(encodeURIComponent('eip155:1/erc20:0xABCDEF'));
+ });
+
+ it('returns the raw Response from fetch', async () => {
+ const response = makeOkResponse([{ id: '1' }]);
+ mockFetch.mockResolvedValue(response);
+ expect(await fetchAlerts('eip155:1/slip44:60')).toBe(response);
+ });
+});
+
+describe('createAlert', () => {
+ it('POSTs to the alerts endpoint with a JSON-serialised body', async () => {
+ const params = {
+ asset: 'eip155:1/slip44:60',
+ threshold: 2000,
+ recurring: false,
+ };
+ await createAlert(params);
+
+ const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
+ expect(url).toBe(ALERTS_URL);
+ expect(init.method).toBe('POST');
+ expect(init.body).toBe(JSON.stringify(params));
+ expect((init.headers as Record)['Content-Type']).toBe(
+ 'application/json',
+ );
+ });
+
+ it('returns the raw Response from fetch', async () => {
+ const response = makeOkResponse({ id: 'abc' });
+ mockFetch.mockResolvedValue(response);
+ expect(
+ await createAlert({
+ asset: 'eip155:1/slip44:60',
+ threshold: 1500,
+ recurring: true,
+ }),
+ ).toBe(response);
+ });
+});
+
+describe('useSavePriceAlert', () => {
+ it('starts with isSubmitting = false', () => {
+ const { Wrapper } = createWrapper();
+ const { result } = renderHook(() => useSavePriceAlert(), {
+ wrapper: Wrapper,
+ });
+ expect(result.current.isSubmitting).toBe(false);
+ });
+
+ it('sets isSubmitting = true while the request is in-flight and clears it on success', async () => {
+ let resolveRequest!: () => void;
+ mockFetch.mockReturnValueOnce(
+ new Promise((res) => {
+ resolveRequest = () => res(makeOkResponse());
+ }),
+ );
+
+ const { Wrapper } = createWrapper();
+ const { result } = renderHook(() => useSavePriceAlert(), {
+ wrapper: Wrapper,
+ });
+
+ act(() => {
+ result.current.save({
+ asset: 'eip155:1/slip44:60',
+ threshold: 1000,
+ recurring: true,
+ });
+ });
+
+ await waitFor(() => {
+ expect(result.current.isSubmitting).toBe(true);
+ });
+
+ await act(async () => {
+ resolveRequest();
+ });
+
+ await waitFor(() => {
+ expect(result.current.isSubmitting).toBe(false);
+ });
+ });
+
+ it('resets isSubmitting = false even when the request fails', async () => {
+ mockFetch.mockResolvedValueOnce(makeErrorResponse(500, 'Server Error'));
+ const { Wrapper } = createWrapper();
+ const { result } = renderHook(() => useSavePriceAlert(), {
+ wrapper: Wrapper,
+ });
+
+ await act(async () => {
+ await expect(
+ result.current.save({
+ asset: 'eip155:1/slip44:60',
+ threshold: 1000,
+ recurring: true,
+ }),
+ ).rejects.toThrow();
+ });
+
+ expect(result.current.isSubmitting).toBe(false);
+ });
+
+ it('throws with the HTTP status and body text on a non-ok response', async () => {
+ mockFetch.mockResolvedValueOnce(makeErrorResponse(409, 'Conflict'));
+ const { Wrapper } = createWrapper();
+ const { result } = renderHook(() => useSavePriceAlert(), {
+ wrapper: Wrapper,
+ });
+
+ await act(async () => {
+ await expect(
+ result.current.save({
+ asset: 'eip155:1/slip44:60',
+ threshold: 1000,
+ recurring: true,
+ }),
+ ).rejects.toThrow('HTTP 409: Conflict');
+ });
+ });
+
+ it('falls back to "(no body)" when the error response body is unreadable', async () => {
+ const response = {
+ ok: false,
+ status: 500,
+ text: jest.fn().mockRejectedValue(new Error('stream error')),
+ } as unknown as Response;
+ mockFetch.mockResolvedValueOnce(response);
+ const { Wrapper } = createWrapper();
+ const { result } = renderHook(() => useSavePriceAlert(), {
+ wrapper: Wrapper,
+ });
+
+ await act(async () => {
+ await expect(
+ result.current.save({
+ asset: 'eip155:1/slip44:60',
+ threshold: 1000,
+ recurring: true,
+ }),
+ ).rejects.toThrow('HTTP 500: (no body)');
+ });
+ });
+
+ it('forwards the exact params to createAlert', async () => {
+ const params = {
+ asset: 'eip155:1/erc20:0xABC',
+ threshold: 3500,
+ recurring: false,
+ };
+ const { Wrapper } = createWrapper();
+ const { result } = renderHook(() => useSavePriceAlert(), {
+ wrapper: Wrapper,
+ });
+
+ await act(async () => {
+ await result.current.save(params);
+ });
+
+ const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
+ expect(init.body).toBe(JSON.stringify(params));
+ });
+});
diff --git a/app/components/UI/Assets/PriceAlerts/api.ts b/app/components/UI/Assets/PriceAlerts/api.ts
new file mode 100644
index 000000000000..94f4f6393238
--- /dev/null
+++ b/app/components/UI/Assets/PriceAlerts/api.ts
@@ -0,0 +1,45 @@
+import { useMutation } from '@tanstack/react-query';
+import AppConstants from '../../../../core/AppConstants';
+import Engine from '../../../../core/Engine';
+import type { SaveAlertParams } from './constants';
+
+const ALERTS_URL = `${AppConstants.PRICE_ALERTS_API.URL}/alerts`;
+
+async function authenticatedFetch(
+ url: string,
+ options: RequestInit = {},
+): Promise {
+ const token = await Engine.context.AuthenticationController.getBearerToken();
+ return fetch(url, {
+ ...options,
+ headers: {
+ Authorization: `Bearer ${token}`,
+ Accept: 'application/json',
+ ...options.headers,
+ },
+ credentials: 'omit',
+ });
+}
+
+export const fetchAlerts = (assetId: string): Promise =>
+ authenticatedFetch(`${ALERTS_URL}?asset=${encodeURIComponent(assetId)}`);
+
+export const createAlert = (params: SaveAlertParams): Promise =>
+ authenticatedFetch(ALERTS_URL, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(params),
+ });
+
+export const useSavePriceAlert = () => {
+ const { mutateAsync, isPending } = useMutation({
+ mutationFn: async (params) => {
+ const response = await createAlert(params);
+ if (!response.ok) {
+ const body = await response.text().catch(() => '(no body)');
+ throw new Error(`HTTP ${response.status}: ${body}`);
+ }
+ },
+ });
+ return { save: mutateAsync, isSubmitting: isPending };
+};
diff --git a/app/components/UI/Assets/PriceAlerts/components/AlertTypeToggle.test.tsx b/app/components/UI/Assets/PriceAlerts/components/AlertTypeToggle.test.tsx
new file mode 100644
index 000000000000..04ea859fd1e1
--- /dev/null
+++ b/app/components/UI/Assets/PriceAlerts/components/AlertTypeToggle.test.tsx
@@ -0,0 +1,131 @@
+import React from 'react';
+import { render, fireEvent } from '@testing-library/react-native';
+import AlertTypeToggle from './AlertTypeToggle';
+import { PriceAlertType, CreatePriceAlertTestIds } from '../constants';
+
+jest.mock('../../../../../util/haptics', () => ({
+ playSelection: jest.fn(),
+}));
+
+import { playSelection } from '../../../../../util/haptics';
+const mockPlaySelection = playSelection as jest.MockedFunction<
+ typeof playSelection
+>;
+
+const mockOnChange = jest.fn();
+
+beforeEach(() => {
+ jest.clearAllMocks();
+});
+
+describe('AlertTypeToggle', () => {
+ it('renders both tabs', () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ expect(
+ getByTestId(CreatePriceAlertTestIds.PRICE_REACHES_TAB),
+ ).toBeOnTheScreen();
+ expect(
+ getByTestId(CreatePriceAlertTestIds.PRICE_CHANGE_TAB),
+ ).toBeOnTheScreen();
+ });
+
+ it('marks the active tab as selected via accessibilityState', () => {
+ const { getByTestId, rerender } = render(
+ ,
+ );
+
+ expect(
+ getByTestId(CreatePriceAlertTestIds.PRICE_REACHES_TAB).props
+ .accessibilityState?.selected,
+ ).toBe(true);
+ expect(
+ getByTestId(CreatePriceAlertTestIds.PRICE_CHANGE_TAB).props
+ .accessibilityState?.selected,
+ ).toBe(false);
+
+ rerender(
+ ,
+ );
+
+ expect(
+ getByTestId(CreatePriceAlertTestIds.PRICE_REACHES_TAB).props
+ .accessibilityState?.selected,
+ ).toBe(false);
+ expect(
+ getByTestId(CreatePriceAlertTestIds.PRICE_CHANGE_TAB).props
+ .accessibilityState?.selected,
+ ).toBe(true);
+ });
+
+ it('both tabs have accessibilityRole="button"', () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ expect(
+ getByTestId(CreatePriceAlertTestIds.PRICE_REACHES_TAB).props
+ .accessibilityRole,
+ ).toBe('button');
+ expect(
+ getByTestId(CreatePriceAlertTestIds.PRICE_CHANGE_TAB).props
+ .accessibilityRole,
+ ).toBe('button');
+ });
+
+ it('calls onChange with the new value when a different tab is pressed', () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ fireEvent.press(getByTestId(CreatePriceAlertTestIds.PRICE_CHANGE_TAB));
+
+ expect(mockOnChange).toHaveBeenCalledTimes(1);
+ expect(mockOnChange).toHaveBeenCalledWith(PriceAlertType.PriceChange);
+ });
+
+ it('does not call onChange when the already-selected tab is pressed', () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ fireEvent.press(getByTestId(CreatePriceAlertTestIds.PRICE_REACHES_TAB));
+
+ expect(mockOnChange).not.toHaveBeenCalled();
+ });
+
+ it('plays haptic feedback when switching tabs, but not when re-pressing the current tab', () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ fireEvent.press(getByTestId(CreatePriceAlertTestIds.PRICE_REACHES_TAB));
+ expect(mockPlaySelection).not.toHaveBeenCalled();
+
+ fireEvent.press(getByTestId(CreatePriceAlertTestIds.PRICE_CHANGE_TAB));
+ expect(mockPlaySelection).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/app/components/UI/Assets/PriceAlerts/components/AlertTypeToggle.tsx b/app/components/UI/Assets/PriceAlerts/components/AlertTypeToggle.tsx
new file mode 100644
index 000000000000..8764162bffde
--- /dev/null
+++ b/app/components/UI/Assets/PriceAlerts/components/AlertTypeToggle.tsx
@@ -0,0 +1,146 @@
+import React, { useEffect, useRef, useState } from 'react';
+import {
+ Animated,
+ StyleSheet,
+ TouchableOpacity,
+ type LayoutRectangle,
+} from 'react-native';
+import {
+ Box,
+ BoxFlexDirection,
+ FontWeight,
+ Text,
+ TextColor,
+ TextVariant,
+} from '@metamask/design-system-react-native';
+import { strings } from '../../../../../../locales/i18n';
+import { useTheme } from '../../../../../util/theme';
+import { playSelection } from '../../../../../util/haptics';
+import { CreatePriceAlertTestIds, PriceAlertType } from '../constants';
+
+const styles = StyleSheet.create({
+ container: {
+ position: 'relative',
+ },
+ slider: {
+ position: 'absolute',
+ borderRadius: 10,
+ },
+ pill: {
+ flex: 1,
+ },
+});
+
+interface AlertTypeToggleProps {
+ value: PriceAlertType;
+ onChange: (value: PriceAlertType) => void;
+}
+
+/**
+ * Animated sliding-pill toggle for price alert type selection.
+ * Uses the same spring-animation technique as QuickBuyTradeModeToggle.
+ */
+const AlertTypeToggle: React.FC = ({
+ value,
+ onChange,
+}) => {
+ const { colors } = useTheme();
+ const slideAnim = useRef(new Animated.Value(0)).current;
+ const [firstLayout, setFirstLayout] = useState(null);
+ const [secondWidth, setSecondWidth] = useState(0);
+
+ const handlePress = (next: PriceAlertType) => {
+ if (value !== next) {
+ playSelection();
+ onChange(next);
+ }
+ };
+
+ useEffect(() => {
+ if (!firstLayout) return;
+ Animated.spring(slideAnim, {
+ toValue: value === PriceAlertType.PriceReaches ? 0 : firstLayout.width,
+ useNativeDriver: true,
+ tension: 180,
+ friction: 20,
+ }).start();
+ }, [value, firstLayout, slideAnim]);
+
+ const sliderWidth =
+ value === PriceAlertType.PriceReaches
+ ? (firstLayout?.width ?? 0)
+ : secondWidth;
+
+ return (
+
+ {firstLayout && sliderWidth > 0 && (
+
+ )}
+
+ handlePress(PriceAlertType.PriceReaches)}
+ onLayout={(e) => setFirstLayout(e.nativeEvent.layout)}
+ accessibilityRole="button"
+ accessibilityState={{ selected: value === PriceAlertType.PriceReaches }}
+ testID={CreatePriceAlertTestIds.PRICE_REACHES_TAB}
+ style={styles.pill}
+ >
+
+
+ {strings('price_alerts.price_reaches')}
+
+
+
+
+ handlePress(PriceAlertType.PriceChange)}
+ onLayout={(e) => setSecondWidth(e.nativeEvent.layout.width)}
+ accessibilityRole="button"
+ accessibilityState={{ selected: value === PriceAlertType.PriceChange }}
+ testID={CreatePriceAlertTestIds.PRICE_CHANGE_TAB}
+ style={styles.pill}
+ >
+
+
+ {strings('price_alerts.price_change')}
+
+
+
+
+ );
+};
+
+export default AlertTypeToggle;
diff --git a/app/components/UI/Assets/PriceAlerts/constants.ts b/app/components/UI/Assets/PriceAlerts/constants.ts
new file mode 100644
index 000000000000..770255d98651
--- /dev/null
+++ b/app/components/UI/Assets/PriceAlerts/constants.ts
@@ -0,0 +1,74 @@
+export enum PriceAlertType {
+ PriceReaches = 'price_reaches',
+ PriceChange = 'price_change',
+}
+
+/** Shared navigation params used by both Create and Manage screens. */
+export interface PriceAlertRouteParams {
+ symbol: string;
+ ticker?: string;
+ currentPrice: number;
+ currentCurrency: string;
+ /** CAIP-19 asset identifier, e.g. "eip155:1/slip44:60" or "eip155:1/erc20:0x..." */
+ assetId: string;
+}
+
+/** Route params for the Create Price Alert screen. */
+export interface CreatePriceAlertRouteParams extends PriceAlertRouteParams {
+ /** When true the screen was opened from ManagePriceAlertsView; pop 2 on save to return to TokenDetails. */
+ fromManage?: boolean;
+}
+
+/** API response shape for a single price alert. */
+export interface PriceAlert {
+ id: string;
+ userId: string;
+ asset: string;
+ threshold: number;
+ recurring: boolean;
+ active: boolean;
+ createdAt: string;
+}
+
+export const PRICE_ALERT_QUICK_PERCENTAGES = [5, 10, 20, 30] as const;
+
+export const CURRENCY_SYMBOLS: Record = {
+ usd: '$',
+ eur: '€',
+ gbp: '£',
+ jpy: '¥',
+ cad: 'CA$',
+ aud: 'A$',
+};
+
+/** Request body for creating a price alert. */
+export interface SaveAlertParams {
+ /** CAIP-19 asset identifier, e.g. "eip155:1/slip44:60". */
+ asset: string;
+ threshold: number;
+ recurring: boolean;
+}
+
+export const CreatePriceAlertTestIds = {
+ CONTAINER: 'create-price-alert-container',
+ ALERT_TYPE_TOGGLE: 'create-price-alert-type-toggle',
+ PRICE_REACHES_TAB: 'create-price-alert-price-reaches-tab',
+ PRICE_CHANGE_TAB: 'create-price-alert-price-change-tab',
+ TARGET_PRICE_INPUT: 'create-price-alert-target-price',
+ PERCENT_DIFF: 'create-price-alert-percent-diff',
+ RECURRING_TOGGLE: 'create-price-alert-recurring-toggle',
+ UNDER_DEVELOPMENT: 'create-price-alert-under-development',
+ QUICK_PERCENTAGE_PREFIX: 'create-price-alert-quick-percentage',
+ SET_ALERT_BUTTON: 'create-price-alert-set-button',
+} as const;
+
+export const ManagePriceAlertsTestIds = {
+ CONTAINER: 'manage-price-alerts-container',
+ LOADING: 'manage-price-alerts-loading',
+ ALERT_LIST: 'manage-price-alerts-list',
+ ALERT_ITEM_PREFIX: 'manage-price-alerts-item',
+ ALERT_TOGGLE_PREFIX: 'manage-price-alerts-toggle',
+ ALERT_DELETE_PREFIX: 'manage-price-alerts-delete',
+ ADD_ALERT_BUTTON: 'manage-price-alerts-add-button',
+ EMPTY_STATE: 'manage-price-alerts-empty',
+} as const;
diff --git a/app/components/UI/Assets/components/AssetLogo/AssetLogo.hook.test.tsx b/app/components/UI/Assets/components/AssetLogo/AssetLogo.hook.test.tsx
index 487674a220ca..bb36d03e59a9 100644
--- a/app/components/UI/Assets/components/AssetLogo/AssetLogo.hook.test.tsx
+++ b/app/components/UI/Assets/components/AssetLogo/AssetLogo.hook.test.tsx
@@ -135,6 +135,56 @@ describe('useSmartImageFallback', () => {
expect(result.current.source).toStrictEqual(updatedSources[0]);
});
+ it('resets fallback index when sources change after onError cycles', () => {
+ const initialSources = createSourcesFromUris([
+ 'https://example.com/reset-token-a-primary.png',
+ 'https://example.com/reset-token-a-fallback.png',
+ ]);
+ const newSources = createSourcesFromUris([
+ 'https://example.com/reset-token-b-primary.png',
+ 'https://example.com/reset-token-b-fallback.png',
+ ]);
+
+ const { result, rerender } = renderHook(
+ ({ hookSources }) => useSmartImageFallback(hookSources),
+ { initialProps: { hookSources: initialSources } },
+ );
+
+ act(() => {
+ result.current.onError();
+ });
+ expect(result.current.source).toStrictEqual(initialSources[1]);
+
+ rerender({ hookSources: newSources });
+
+ expect(result.current.source).toStrictEqual(newSources[0]);
+ });
+
+ it('preserves fallback index when sources reference changes but URIs stay the same', () => {
+ const makeSources = () =>
+ createSourcesFromUris([
+ 'https://example.com/stable-ref-primary.png',
+ 'https://example.com/stable-ref-fallback.png',
+ ]);
+
+ const { result, rerender } = renderHook(
+ ({ hookSources }) => useSmartImageFallback(hookSources),
+ { initialProps: { hookSources: makeSources() } },
+ );
+
+ act(() => {
+ result.current.onError();
+ });
+ const sourceAfterError = result.current.source;
+ expect(sourceAfterError).toStrictEqual({
+ uri: 'https://example.com/stable-ref-fallback.png',
+ });
+
+ rerender({ hookSources: makeSources() });
+
+ expect(result.current.source).toStrictEqual(sourceAfterError);
+ });
+
it('persists dead images across hook instances', () => {
const sources = createSourcesFromUris([
'https://example.com/persist-primary.png',
diff --git a/app/components/UI/Assets/components/AssetLogo/AssetLogo.hook.tsx b/app/components/UI/Assets/components/AssetLogo/AssetLogo.hook.tsx
index 82e1339fdbe5..3a5461d8a588 100644
--- a/app/components/UI/Assets/components/AssetLogo/AssetLogo.hook.tsx
+++ b/app/components/UI/Assets/components/AssetLogo/AssetLogo.hook.tsx
@@ -1,4 +1,4 @@
-import { useMemo, useState } from 'react';
+import { useMemo, useRef, useState } from 'react';
import { ImageURISource } from 'react-native';
import { createRotatingSet } from './AssetLogo.utils';
@@ -11,6 +11,12 @@ export function useSmartImageFallback(sources: ImageURISource[]) {
const LAST_ITEM_INDEX = validSources.length - 1;
const [index, setIndex] = useState(0);
+ const uriKey = sources.map((s) => s.uri ?? '').join('\0');
+ const prevUriKeyRef = useRef(uriKey);
+ if (prevUriKeyRef.current !== uriKey) {
+ prevUriKeyRef.current = uriKey;
+ setIndex(0);
+ }
const currentSource: ImageURISource | undefined =
validSources[index] || sources[sources.length - 1];
diff --git a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.styles.ts b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.styles.ts
index 018d17eee20c..9a838ff0567a 100644
--- a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.styles.ts
+++ b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.styles.ts
@@ -135,6 +135,12 @@ const styleSheet = (params: { theme: Theme }) => {
animatedListContainer: {
flex: 1,
},
+ watchlistScrollContainer: {
+ flex: 1,
+ },
+ watchlistScrollContent: {
+ paddingBottom: 120,
+ },
searchBarRow: {
paddingHorizontal: 16,
paddingBottom: 8,
diff --git a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.test.tsx b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.test.tsx
index 60ac166da794..13193748b840 100644
--- a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.test.tsx
+++ b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.test.tsx
@@ -154,6 +154,9 @@ jest.mock('../../hooks', () => ({
favoritesState: {
showFavoritesOnly: false,
setShowFavoritesOnly: jest.fn(),
+ hasWatchlistMarkets: false,
+ watchlistMarketObjects: [],
+ suggestedMarkets: [],
},
marketTypeFilterState: {
marketTypeFilter: 'all',
@@ -188,6 +191,59 @@ jest.mock('../../hooks/usePerpsOrderFees', () => ({
formatFeeRate: jest.fn((rate) => `${((rate || 0) * 100).toFixed(3)}%`),
}));
+jest.mock(
+ '../../components/PerpsWatchlistMarkets/PerpsWatchlistMarkets',
+ () => {
+ const MockReact = jest.requireActual('react');
+ const { View, Text, TouchableOpacity } = jest.requireActual('react-native');
+ return function MockPerpsWatchlistMarkets({
+ markets,
+ suggestedMarkets,
+ enableShowMore,
+ onMarketPress,
+ }: {
+ markets: { symbol: string }[];
+ suggestedMarkets?: { symbol: string }[];
+ enableShowMore?: boolean;
+ onMarketPress?: (market: { symbol: string }) => void;
+ }) {
+ return MockReact.createElement(
+ View,
+ { testID: 'perps-watchlist-markets' },
+ markets.map((m) =>
+ MockReact.createElement(
+ TouchableOpacity,
+ {
+ key: m.symbol,
+ testID: `watchlist-row-${m.symbol}`,
+ onPress: () => onMarketPress?.(m),
+ },
+ MockReact.createElement(Text, null, m.symbol),
+ ),
+ ),
+ (suggestedMarkets ?? []).map((m) =>
+ MockReact.createElement(
+ TouchableOpacity,
+ {
+ key: m.symbol,
+ testID: `suggested-row-${m.symbol}`,
+ onPress: () => onMarketPress?.(m),
+ },
+ MockReact.createElement(Text, null, m.symbol),
+ ),
+ ),
+ enableShowMore === false
+ ? MockReact.createElement(
+ Text,
+ { testID: 'show-more-disabled' },
+ 'no-show-more',
+ )
+ : null,
+ );
+ };
+ },
+);
+
jest.mock('../../components/PerpsMarketBalanceActions', () => {
const MockReact = jest.requireActual('react');
const { View, Text } = jest.requireActual('react-native');
@@ -576,6 +632,9 @@ describe('PerpsMarketListView', () => {
beforeEach(() => {
jest.clearAllMocks();
+ // Reset watchlist flag so each test starts with it off
+ mockWatchlistFlagEnabled = false;
+
// Set mock market data for the hook
mockMarketDataForHook.length = 0;
mockMarketDataForHook.push(...mockMarketData);
@@ -743,6 +802,520 @@ describe('PerpsMarketListView', () => {
expect(ethRows.length).toBeGreaterThan(0);
expect(solRows.length).toBeGreaterThan(0);
});
+
+ it('renders PerpsWatchlistMarkets with suggestions when watchlist filter is active and populated', () => {
+ mockWatchlistFlagEnabled = true;
+
+ const watchlistMarket = mockMarketData[0]; // BTC
+ const suggested = [mockMarketData[1], mockMarketData[2]]; // ETH, SOL
+
+ mockUsePerpsMarketListView.mockReturnValueOnce({
+ markets: [watchlistMarket],
+ searchState: {
+ searchQuery: '',
+ setSearchQuery: mockSetSearchQuery,
+ clearSearch: mockClearSearch,
+ },
+ sortState: {
+ selectedOptionId: 'volume',
+ sortBy: 'volume',
+ direction: 'desc',
+ handleOptionChange: jest.fn(),
+ },
+ favoritesState: {
+ showFavoritesOnly: true,
+ setShowFavoritesOnly: jest.fn(),
+ hasWatchlistMarkets: true,
+ watchlistMarketObjects: [watchlistMarket],
+ suggestedMarkets: suggested,
+ },
+ marketTypeFilterState: {
+ marketTypeFilter: 'all',
+ setMarketTypeFilter: jest.fn(),
+ },
+ marketCounts: {
+ crypto: 3,
+ stocks: 0,
+ 'pre-ipo': 0,
+ indices: 0,
+ etfs: 0,
+ commodities: 0,
+ forex: 0,
+ new: 0,
+ },
+ isLoading: false,
+ error: null,
+ });
+
+ renderWithProvider(, { state: mockState });
+
+ // PerpsWatchlistMarkets renders the watchlist row and suggested rows
+ expect(screen.getByTestId('perps-watchlist-markets')).toBeOnTheScreen();
+ expect(screen.getByTestId('watchlist-row-BTC')).toBeOnTheScreen();
+ expect(screen.getByTestId('suggested-row-ETH')).toBeOnTheScreen();
+ expect(screen.getByTestId('suggested-row-SOL')).toBeOnTheScreen();
+ // enableShowMore={false} indicator is present
+ expect(screen.getByTestId('show-more-disabled')).toBeOnTheScreen();
+ });
+
+ it('renders PerpsWatchlistMarkets with suggestions when watchlist filter is active and empty', () => {
+ mockWatchlistFlagEnabled = true;
+
+ const suggested = [mockMarketData[0], mockMarketData[1]]; // BTC, ETH
+
+ mockUsePerpsMarketListView.mockReturnValueOnce({
+ markets: [],
+ searchState: {
+ searchQuery: '',
+ setSearchQuery: mockSetSearchQuery,
+ clearSearch: mockClearSearch,
+ },
+ sortState: {
+ selectedOptionId: 'volume',
+ sortBy: 'volume',
+ direction: 'desc',
+ handleOptionChange: jest.fn(),
+ },
+ favoritesState: {
+ showFavoritesOnly: true,
+ setShowFavoritesOnly: jest.fn(),
+ hasWatchlistMarkets: false,
+ watchlistMarketObjects: [],
+ suggestedMarkets: suggested,
+ },
+ marketTypeFilterState: {
+ marketTypeFilter: 'all',
+ setMarketTypeFilter: jest.fn(),
+ },
+ marketCounts: {
+ crypto: 3,
+ stocks: 0,
+ 'pre-ipo': 0,
+ indices: 0,
+ etfs: 0,
+ commodities: 0,
+ forex: 0,
+ new: 0,
+ },
+ isLoading: false,
+ error: null,
+ });
+
+ renderWithProvider(, { state: mockState });
+
+ expect(screen.getByTestId('perps-watchlist-markets')).toBeOnTheScreen();
+ expect(screen.getByTestId('suggested-row-BTC')).toBeOnTheScreen();
+ expect(screen.getByTestId('suggested-row-ETH')).toBeOnTheScreen();
+ });
+
+ it('navigates to market details via push when watchlist row is pressed', () => {
+ mockWatchlistFlagEnabled = true;
+
+ const watchlistMarket = mockMarketData[0]; // BTC
+
+ mockUsePerpsMarketListView.mockReturnValueOnce({
+ markets: [watchlistMarket],
+ searchState: {
+ searchQuery: '',
+ setSearchQuery: mockSetSearchQuery,
+ clearSearch: mockClearSearch,
+ },
+ sortState: {
+ selectedOptionId: 'volume',
+ sortBy: 'volume',
+ direction: 'desc',
+ handleOptionChange: jest.fn(),
+ },
+ favoritesState: {
+ showFavoritesOnly: true,
+ setShowFavoritesOnly: jest.fn(),
+ hasWatchlistMarkets: true,
+ watchlistMarketObjects: [watchlistMarket],
+ suggestedMarkets: [],
+ },
+ marketTypeFilterState: {
+ marketTypeFilter: 'all',
+ setMarketTypeFilter: jest.fn(),
+ },
+ marketCounts: {
+ crypto: 3,
+ stocks: 0,
+ 'pre-ipo': 0,
+ indices: 0,
+ etfs: 0,
+ commodities: 0,
+ forex: 0,
+ new: 0,
+ },
+ isLoading: false,
+ error: null,
+ });
+
+ renderWithProvider(, { state: mockState });
+
+ fireEvent.press(screen.getByTestId('watchlist-row-BTC'));
+
+ // handleMarketPress uses StackActions.push via navigation.dispatch so that
+ // MARKET_LIST is always preserved in the stack below MARKET_DETAILS.
+ expect(mockNavigation.dispatch).toHaveBeenCalledTimes(1);
+ expect(mockNavigation.dispatch).toHaveBeenCalledWith(
+ expect.objectContaining({
+ payload: expect.objectContaining({
+ params: expect.objectContaining({
+ market: watchlistMarket,
+ source: 'perp_markets',
+ }),
+ }),
+ }),
+ );
+ });
+
+ it('routes watchlist row press through onMarketSelect when provided and watchlist filter is active', () => {
+ mockWatchlistFlagEnabled = true;
+
+ const mockOnMarketSelect = jest.fn();
+ const watchlistMarket = mockMarketData[0]; // BTC
+
+ mockUsePerpsMarketListView.mockReturnValueOnce({
+ markets: [watchlistMarket],
+ searchState: {
+ searchQuery: '',
+ setSearchQuery: mockSetSearchQuery,
+ clearSearch: mockClearSearch,
+ },
+ sortState: {
+ selectedOptionId: 'volume',
+ sortBy: 'volume',
+ direction: 'desc',
+ handleOptionChange: jest.fn(),
+ },
+ favoritesState: {
+ showFavoritesOnly: true,
+ setShowFavoritesOnly: jest.fn(),
+ hasWatchlistMarkets: true,
+ watchlistMarketObjects: [watchlistMarket],
+ suggestedMarkets: [],
+ },
+ marketTypeFilterState: {
+ marketTypeFilter: 'all',
+ setMarketTypeFilter: jest.fn(),
+ },
+ marketCounts: {
+ crypto: 3,
+ stocks: 0,
+ 'pre-ipo': 0,
+ indices: 0,
+ etfs: 0,
+ commodities: 0,
+ forex: 0,
+ new: 0,
+ },
+ isLoading: false,
+ error: null,
+ });
+
+ renderWithProvider(
+ ,
+ { state: mockState },
+ );
+
+ fireEvent.press(screen.getByTestId('watchlist-row-BTC'));
+
+ expect(mockOnMarketSelect).toHaveBeenCalledWith(watchlistMarket);
+ expect(mockNavigation.dispatch).not.toHaveBeenCalled();
+ });
+
+ it('carries transactionActiveAbTests through dispatch when watchlist row is pressed', () => {
+ mockWatchlistFlagEnabled = true;
+
+ const transactionActiveAbTests = [
+ createActiveABTestAssignment(
+ 'homeTMCU725AbtestHomepagePerpsPillsEmptyState',
+ 'treatment',
+ ),
+ ];
+
+ const watchlistMarket = mockMarketData[0]; // BTC
+
+ mockUseRoute.mockReturnValue({
+ key: 'PerpsMarketListView-123',
+ name: 'PerpsMarketListView',
+ params: { transactionActiveAbTests },
+ });
+
+ mockUsePerpsMarketListView.mockReturnValueOnce({
+ markets: [watchlistMarket],
+ searchState: {
+ searchQuery: '',
+ setSearchQuery: mockSetSearchQuery,
+ clearSearch: mockClearSearch,
+ },
+ sortState: {
+ selectedOptionId: 'volume',
+ sortBy: 'volume',
+ direction: 'desc',
+ handleOptionChange: jest.fn(),
+ },
+ favoritesState: {
+ showFavoritesOnly: true,
+ setShowFavoritesOnly: jest.fn(),
+ hasWatchlistMarkets: true,
+ watchlistMarketObjects: [watchlistMarket],
+ suggestedMarkets: [],
+ },
+ marketTypeFilterState: {
+ marketTypeFilter: 'all',
+ setMarketTypeFilter: jest.fn(),
+ },
+ marketCounts: {
+ crypto: 3,
+ stocks: 0,
+ 'pre-ipo': 0,
+ indices: 0,
+ etfs: 0,
+ commodities: 0,
+ forex: 0,
+ new: 0,
+ },
+ isLoading: false,
+ error: null,
+ });
+
+ renderWithProvider(, { state: mockState });
+
+ fireEvent.press(screen.getByTestId('watchlist-row-BTC'));
+
+ expect(mockNavigation.dispatch).toHaveBeenCalledTimes(1);
+ expect(mockNavigation.dispatch).toHaveBeenCalledWith(
+ expect.objectContaining({
+ payload: expect.objectContaining({
+ params: expect.objectContaining({
+ market: watchlistMarket,
+ source: 'perp_markets',
+ transactionActiveAbTests,
+ }),
+ }),
+ }),
+ );
+ });
+
+ it('filters watchlist rows by query and also filters suggestions, showing matching suggestions', () => {
+ mockWatchlistFlagEnabled = true;
+
+ const btcMarket = mockMarketData[0]; // BTC — watchlisted
+ const ethMarket = mockMarketData[1]; // ETH — watchlisted
+ const suggested = [mockMarketData[2]]; // SOL — suggested (not watchlisted)
+
+ mockUsePerpsMarketListView.mockReturnValueOnce({
+ markets: [btcMarket, ethMarket],
+ searchState: {
+ searchQuery: 'BTC',
+ setSearchQuery: mockSetSearchQuery,
+ clearSearch: mockClearSearch,
+ },
+ sortState: {
+ selectedOptionId: 'volume',
+ sortBy: 'volume',
+ direction: 'desc',
+ handleOptionChange: jest.fn(),
+ },
+ favoritesState: {
+ showFavoritesOnly: true,
+ setShowFavoritesOnly: jest.fn(),
+ hasWatchlistMarkets: true,
+ watchlistMarketObjects: [btcMarket, ethMarket],
+ suggestedMarkets: suggested,
+ },
+ marketTypeFilterState: {
+ marketTypeFilter: 'all',
+ setMarketTypeFilter: jest.fn(),
+ },
+ marketCounts: {
+ crypto: 3,
+ stocks: 0,
+ 'pre-ipo': 0,
+ indices: 0,
+ etfs: 0,
+ commodities: 0,
+ forex: 0,
+ new: 0,
+ },
+ isLoading: false,
+ error: null,
+ });
+
+ renderWithProvider(, { state: mockState });
+
+ // BTC matches the watchlist query; ETH does not
+ expect(screen.getByTestId('watchlist-row-BTC')).toBeOnTheScreen();
+ expect(screen.queryByTestId('watchlist-row-ETH')).not.toBeOnTheScreen();
+ // SOL is in suggestions but doesn't match "BTC", so it is filtered out too
+ expect(screen.queryByTestId('suggested-row-SOL')).not.toBeOnTheScreen();
+ });
+
+ it('shows a matching suggestion when it is in suggestions but not in the watchlist', () => {
+ mockWatchlistFlagEnabled = true;
+
+ const btcMarket = mockMarketData[0]; // BTC — watchlisted
+ const ethMarket = mockMarketData[1]; // ETH — suggested (not watchlisted)
+
+ mockUsePerpsMarketListView.mockReturnValueOnce({
+ markets: [btcMarket],
+ searchState: {
+ searchQuery: 'ETH',
+ setSearchQuery: mockSetSearchQuery,
+ clearSearch: mockClearSearch,
+ },
+ sortState: {
+ selectedOptionId: 'volume',
+ sortBy: 'volume',
+ direction: 'desc',
+ handleOptionChange: jest.fn(),
+ },
+ favoritesState: {
+ showFavoritesOnly: true,
+ setShowFavoritesOnly: jest.fn(),
+ hasWatchlistMarkets: true,
+ watchlistMarketObjects: [btcMarket],
+ suggestedMarkets: [ethMarket],
+ },
+ marketTypeFilterState: {
+ marketTypeFilter: 'all',
+ setMarketTypeFilter: jest.fn(),
+ },
+ marketCounts: {
+ crypto: 3,
+ stocks: 0,
+ 'pre-ipo': 0,
+ indices: 0,
+ etfs: 0,
+ commodities: 0,
+ forex: 0,
+ new: 0,
+ },
+ isLoading: false,
+ error: null,
+ });
+
+ renderWithProvider(, { state: mockState });
+
+ // BTC is watchlisted but doesn't match "ETH"
+ expect(screen.queryByTestId('watchlist-row-BTC')).not.toBeOnTheScreen();
+ // ETH is in suggestions and matches — rendered under suggested section
+ expect(screen.getByTestId('suggested-row-ETH')).toBeOnTheScreen();
+ // No-results state must NOT appear since a suggestion matched
+ expect(
+ screen.queryByTestId(PerpsMarketListViewSelectorsIDs.NO_RESULTS),
+ ).not.toBeOnTheScreen();
+ });
+
+ it('shows the no-results empty state when a search query matches nothing in watchlist or suggestions', () => {
+ mockWatchlistFlagEnabled = true;
+
+ const btcMarket = mockMarketData[0]; // BTC — watchlisted
+ const ethMarket = mockMarketData[1]; // ETH — suggested
+
+ mockUsePerpsMarketListView.mockReturnValueOnce({
+ markets: [btcMarket],
+ searchState: {
+ searchQuery: 'XYZ',
+ setSearchQuery: mockSetSearchQuery,
+ clearSearch: mockClearSearch,
+ },
+ sortState: {
+ selectedOptionId: 'volume',
+ sortBy: 'volume',
+ direction: 'desc',
+ handleOptionChange: jest.fn(),
+ },
+ favoritesState: {
+ showFavoritesOnly: true,
+ setShowFavoritesOnly: jest.fn(),
+ hasWatchlistMarkets: true,
+ watchlistMarketObjects: [btcMarket],
+ suggestedMarkets: [ethMarket],
+ },
+ marketTypeFilterState: {
+ marketTypeFilter: 'all',
+ setMarketTypeFilter: jest.fn(),
+ },
+ marketCounts: {
+ crypto: 3,
+ stocks: 0,
+ 'pre-ipo': 0,
+ indices: 0,
+ etfs: 0,
+ commodities: 0,
+ forex: 0,
+ new: 0,
+ },
+ isLoading: false,
+ error: null,
+ });
+
+ renderWithProvider(, { state: mockState });
+
+ // Neither watchlist (BTC) nor suggestion (ETH) matches "XYZ"
+ expect(
+ screen.getByTestId(PerpsMarketListViewSelectorsIDs.NO_RESULTS),
+ ).toBeOnTheScreen();
+ expect(
+ screen.queryByTestId('perps-watchlist-markets'),
+ ).not.toBeOnTheScreen();
+ });
+
+ it('restores full watchlist and suggestions when search query is cleared', () => {
+ mockWatchlistFlagEnabled = true;
+
+ const btcMarket = mockMarketData[0];
+ const ethMarket = mockMarketData[1];
+ const suggested = [mockMarketData[2]]; // SOL
+
+ mockUsePerpsMarketListView.mockReturnValueOnce({
+ markets: [btcMarket, ethMarket],
+ searchState: {
+ searchQuery: '',
+ setSearchQuery: mockSetSearchQuery,
+ clearSearch: mockClearSearch,
+ },
+ sortState: {
+ selectedOptionId: 'volume',
+ sortBy: 'volume',
+ direction: 'desc',
+ handleOptionChange: jest.fn(),
+ },
+ favoritesState: {
+ showFavoritesOnly: true,
+ setShowFavoritesOnly: jest.fn(),
+ hasWatchlistMarkets: true,
+ watchlistMarketObjects: [btcMarket, ethMarket],
+ suggestedMarkets: suggested,
+ },
+ marketTypeFilterState: {
+ marketTypeFilter: 'all',
+ setMarketTypeFilter: jest.fn(),
+ },
+ marketCounts: {
+ crypto: 3,
+ stocks: 0,
+ 'pre-ipo': 0,
+ indices: 0,
+ etfs: 0,
+ commodities: 0,
+ forex: 0,
+ new: 0,
+ },
+ isLoading: false,
+ error: null,
+ });
+
+ renderWithProvider(, { state: mockState });
+
+ // All watchlist rows visible, suggestion restored
+ expect(screen.getByTestId('watchlist-row-BTC')).toBeOnTheScreen();
+ expect(screen.getByTestId('watchlist-row-ETH')).toBeOnTheScreen();
+ expect(screen.getByTestId('suggested-row-SOL')).toBeOnTheScreen();
+ });
});
describe('Market Selection', () => {
@@ -765,7 +1338,7 @@ describe('PerpsMarketListView', () => {
expect(() => fireEvent.press(btcRows[0])).not.toThrow();
});
- it('navigates to SPCX details with market-list source when SPCX is pressed', () => {
+ it('navigates to SPCX details via push with market-list source when SPCX is pressed', () => {
const spcxMarket: PerpsMarketData = {
symbol: 'xyz:SPCX',
name: 'SPCX',
@@ -783,14 +1356,20 @@ describe('PerpsMarketListView', () => {
fireEvent.press(screen.getByTestId('market-row-xyz:SPCX'));
- expect(mockNavigateToMarketDetails).toHaveBeenCalledWith(
- spcxMarket,
- 'perp_markets',
- undefined,
+ expect(mockNavigation.dispatch).toHaveBeenCalledTimes(1);
+ expect(mockNavigation.dispatch).toHaveBeenCalledWith(
+ expect.objectContaining({
+ payload: expect.objectContaining({
+ params: expect.objectContaining({
+ market: spcxMarket,
+ source: 'perp_markets',
+ }),
+ }),
+ }),
);
});
- it('carries route transactionActiveAbTests when a market opens market details', () => {
+ it('carries route transactionActiveAbTests when a market row is pressed', () => {
const transactionActiveAbTests = [
createActiveABTestAssignment(
'homeTMCU725AbtestHomepagePerpsPillsEmptyState',
@@ -807,10 +1386,17 @@ describe('PerpsMarketListView', () => {
fireEvent.press(screen.getAllByTestId('market-row-BTC')[0]);
- expect(mockNavigateToMarketDetails).toHaveBeenCalledWith(
- mockMarketData[0],
- 'perp_markets',
- transactionActiveAbTests,
+ expect(mockNavigation.dispatch).toHaveBeenCalledTimes(1);
+ expect(mockNavigation.dispatch).toHaveBeenCalledWith(
+ expect.objectContaining({
+ payload: expect.objectContaining({
+ params: expect.objectContaining({
+ market: mockMarketData[0],
+ source: 'perp_markets',
+ transactionActiveAbTests,
+ }),
+ }),
+ }),
);
});
});
@@ -946,6 +1532,9 @@ describe('PerpsMarketListView', () => {
favoritesState: {
showFavoritesOnly: false,
setShowFavoritesOnly: jest.fn(),
+ hasWatchlistMarkets: false,
+ watchlistMarketObjects: [],
+ suggestedMarkets: [],
},
marketTypeFilterState: {
marketTypeFilter: 'all',
diff --git a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx
index dd25a34d8555..2a45a6b7424f 100644
--- a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx
+++ b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx
@@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { HeaderStandard } from '@metamask/design-system-react-native';
-import { View, Animated } from 'react-native';
+import { View, Animated, ScrollView } from 'react-native';
import { useStyles } from '../../../../../component-library/hooks';
import Icon, {
IconName,
@@ -34,7 +34,13 @@ import {
type MarketTypeFilter,
} from '@metamask/perps-controller';
import { PerpsMarketListViewSelectorsIDs } from '../../Perps.testIds';
-import { useRoute, RouteProp } from '@react-navigation/native';
+import {
+ useRoute,
+ RouteProp,
+ useNavigation,
+ StackActions,
+} from '@react-navigation/native';
+import Routes from '../../../../../constants/navigation/Routes';
import { useSelector } from 'react-redux';
import { SafeAreaView } from 'react-native-safe-area-context';
import { TraceName } from '../../../../../util/trace';
@@ -56,6 +62,7 @@ const PerpsMarketListView = ({
useRoute>();
const perpsNavigation = usePerpsNavigation();
+ const navigation = useNavigation();
const variant = route.params?.variant ?? propVariant ?? 'full';
const title = route.params?.title ?? propTitle;
@@ -114,14 +121,23 @@ const PerpsMarketListView = ({
if (onMarketSelect) {
onMarketSelect(market);
} else {
- perpsNavigation.navigateToMarketDetails(
- market,
- PERPS_EVENT_VALUE.SOURCE.PERP_MARKETS,
- transactionActiveAbTests,
+ // Use push instead of navigate so that MARKET_LIST is always beneath
+ // MARKET_DETAILS in the stack. navigate() can jump to an existing
+ // MARKET_DETAILS entry (e.g. one opened from PerpsHome via the watchlist
+ // component's ROOT-based navigation), which would skip MARKET_LIST on
+ // back and land the user on PERPS_HOME instead.
+ navigation.dispatch(
+ StackActions.push(Routes.PERPS.MARKET_DETAILS, {
+ market,
+ source: PERPS_EVENT_VALUE.SOURCE.PERP_MARKETS,
+ ...(transactionActiveAbTests?.length
+ ? { transactionActiveAbTests }
+ : {}),
+ }),
);
}
},
- [onMarketSelect, perpsNavigation, transactionActiveAbTests],
+ [onMarketSelect, navigation, transactionActiveAbTests],
);
const { track } = usePerpsEventTracking();
@@ -245,15 +261,76 @@ const PerpsMarketListView = ({
);
}
- // Empty watchlist — show suggested markets with the same default state as PerpsHome
- // Only reachable when the watchlist flag is enabled (pill is hidden otherwise)
- if (isWatchlistEnabled && showFavoritesOnly && !hasWatchlistMarkets) {
+ // Watchlist filter active — show watchlisted markets plus suggestions.
+ // Mirrors PerpsHome behavior, without the collapsible "Show more" toggle.
+ // Only reachable when the watchlist flag is enabled (pill is hidden otherwise).
+ // When a search query is active both watchlist rows and suggested markets are
+ // filtered inline so the user can find any relevant market by name or symbol.
+ // "No tokens found" is only shown when nothing matches in either section.
+ if (isWatchlistEnabled && showFavoritesOnly) {
+ const trimmedQuery = searchQuery.trim().toLowerCase();
+ const visibleWatchlistMarkets = trimmedQuery
+ ? watchlistMarketObjects.filter(
+ (m) =>
+ m.symbol.toLowerCase().includes(trimmedQuery) ||
+ m.name.toLowerCase().includes(trimmedQuery),
+ )
+ : watchlistMarketObjects;
+ const visibleSuggestedMarkets = trimmedQuery
+ ? suggestedMarkets?.filter(
+ (m) =>
+ m.symbol.toLowerCase().includes(trimmedQuery) ||
+ m.name.toLowerCase().includes(trimmedQuery),
+ )
+ : suggestedMarkets;
+
+ if (
+ trimmedQuery &&
+ visibleWatchlistMarkets.length === 0 &&
+ !visibleSuggestedMarkets?.length
+ ) {
+ return (
+
+
+
+ {strings('perps.no_tokens_found')}
+
+
+ {strings('perps.no_tokens_found_description', { searchQuery })}
+
+
+ );
+ }
+
return (
-
+
+
+
);
}
diff --git a/app/components/UI/Perps/components/PerpsWatchlistMarkets/PerpsWatchlistMarkets.test.tsx b/app/components/UI/Perps/components/PerpsWatchlistMarkets/PerpsWatchlistMarkets.test.tsx
index fae4501d08da..ccd879d4c788 100644
--- a/app/components/UI/Perps/components/PerpsWatchlistMarkets/PerpsWatchlistMarkets.test.tsx
+++ b/app/components/UI/Perps/components/PerpsWatchlistMarkets/PerpsWatchlistMarkets.test.tsx
@@ -496,6 +496,49 @@ describe('PerpsWatchlistMarkets', () => {
});
});
+ // -------------------------------------------------------------------------
+ // onMarketPress override
+ // -------------------------------------------------------------------------
+
+ describe('onMarketPress prop (navigation override)', () => {
+ it('calls onMarketPress instead of navigation.navigate when a watchlist row is pressed', () => {
+ const onMarketPress = jest.fn();
+ render(
+ ,
+ );
+ fireEvent.press(screen.getByTestId('perps-market-row-BTC'));
+ expect(onMarketPress).toHaveBeenCalledTimes(1);
+ expect(onMarketPress).toHaveBeenCalledWith(mockMarkets[0]);
+ expect(mockNavigate).not.toHaveBeenCalled();
+ });
+
+ it('calls onMarketPress instead of navigation.navigate when a suggested row is pressed', () => {
+ const onMarketPress = jest.fn();
+ render(
+ ,
+ );
+ fireEvent.press(screen.getByTestId('perps-market-row-SOL'));
+ expect(onMarketPress).toHaveBeenCalledTimes(1);
+ expect(onMarketPress).toHaveBeenCalledWith(mockSuggestedMarkets[0]);
+ expect(mockNavigate).not.toHaveBeenCalled();
+ });
+
+ it('falls back to navigation.navigate when onMarketPress is not provided', () => {
+ render(
+ ,
+ );
+ fireEvent.press(screen.getByTestId('perps-market-row-BTC'));
+ expect(mockNavigate).toHaveBeenCalledTimes(1);
+ });
+ });
+
// -------------------------------------------------------------------------
// onSeeAllPress header behaviour
// -------------------------------------------------------------------------
@@ -629,6 +672,58 @@ describe('PerpsWatchlistMarkets', () => {
});
});
+ // -------------------------------------------------------------------------
+ // enableShowMore prop
+ // -------------------------------------------------------------------------
+
+ describe('enableShowMore prop', () => {
+ const fiveMarkets = [
+ makeMarket('BTC', 'Bitcoin'),
+ makeMarket('ETH', 'Ethereum'),
+ makeMarket('SOL', 'Solana'),
+ makeMarket('AVAX', 'Avalanche'),
+ makeMarket('DOT', 'Polkadot'),
+ ];
+
+ it('shows all markets without a "Show more" button when enableShowMore is false', () => {
+ render(
+ ,
+ );
+ // All five markets visible
+ expect(screen.getByText('BTC')).toBeOnTheScreen();
+ expect(screen.getByText('ETH')).toBeOnTheScreen();
+ expect(screen.getByText('SOL')).toBeOnTheScreen();
+ expect(screen.getByText('AVAX')).toBeOnTheScreen();
+ expect(screen.getByText('DOT')).toBeOnTheScreen();
+ // No show-more toggle
+ expect(screen.queryByText(/Show \d+ more/)).not.toBeOnTheScreen();
+ expect(screen.queryByText('Show less')).not.toBeOnTheScreen();
+ });
+
+ it('still renders suggestions below watchlist rows when enableShowMore is false', () => {
+ render(
+ ,
+ );
+ // All watchlist rows visible (incl. markets beyond INITIAL_DISPLAY_COUNT)
+ expect(screen.getByText('DOT')).toBeOnTheScreen();
+ // Suggested rows present with add buttons — BNB only exists in suggestions
+ expect(
+ screen.getByTestId('perps-market-row-BNB-add-button'),
+ ).toBeOnTheScreen();
+ // No toggle
+ expect(screen.queryByText(/Show \d+ more/)).not.toBeOnTheScreen();
+ });
+
+ it('defaults to showing show-more toggle when enableShowMore is not specified', () => {
+ render();
+ expect(screen.getByText('Show 2 more')).toBeOnTheScreen();
+ });
+ });
+
// -------------------------------------------------------------------------
// Component lifecycle
// -------------------------------------------------------------------------
diff --git a/app/components/UI/Perps/components/PerpsWatchlistMarkets/PerpsWatchlistMarkets.tsx b/app/components/UI/Perps/components/PerpsWatchlistMarkets/PerpsWatchlistMarkets.tsx
index 9f6450883ee0..00e0cd9ac2cf 100644
--- a/app/components/UI/Perps/components/PerpsWatchlistMarkets/PerpsWatchlistMarkets.tsx
+++ b/app/components/UI/Perps/components/PerpsWatchlistMarkets/PerpsWatchlistMarkets.tsx
@@ -61,10 +61,19 @@ interface PerpsWatchlistMarketsProps {
headerStyle?: StyleProp;
/** Override content container styles (e.g., to remove horizontal margin) */
contentContainerStyle?: StyleProp;
+ /**
+ * Override the default internal navigation when a market row is pressed.
+ * Use this when the parent screen owns navigation (e.g. PerpsMarketListView),
+ * so that onMarketSelect and transactionActiveAbTests are handled correctly.
+ * When omitted the component falls back to its own navigation.navigate call.
+ */
+ onMarketPress?: (market: PerpsMarketData) => void;
/** Called when the "Watchlist >" header is pressed */
onSeeAllPress?: () => void;
/** Whether to render the "Watchlist" section header. Defaults to true. */
showHeader?: boolean;
+ /** Whether to render the collapsible "Show more"/"Show less" toggle. Defaults to true. */
+ enableShowMore?: boolean;
}
// ─── Legacy (flag OFF) ──────────────────────────────────────────────────────
@@ -82,11 +91,17 @@ const PerpsWatchlistMarketsV1: React.FC = ({
transactionActiveAbTests,
sectionStyle,
contentContainerStyle,
+ onMarketPress,
}) => {
const navigation = useNavigation();
const handleMarketPress = useCallback(
(market: PerpsMarketData) => {
+ if (onMarketPress) {
+ onMarketPress(market);
+ return;
+ }
+
const hasPosition = positions.some((p) => p.symbol === market.symbol);
const hasOrder = orders.some((o) => o.symbol === market.symbol);
@@ -109,7 +124,14 @@ const PerpsWatchlistMarketsV1: React.FC = ({
},
});
},
- [navigation, positions, orders, source, transactionActiveAbTests],
+ [
+ onMarketPress,
+ navigation,
+ positions,
+ orders,
+ source,
+ transactionActiveAbTests,
+ ],
);
const renderMarket = useCallback(
@@ -161,8 +183,10 @@ const PerpsWatchlistMarketsV2: React.FC = ({
sectionStyle,
headerStyle,
contentContainerStyle,
+ onMarketPress,
onSeeAllPress,
showHeader = true,
+ enableShowMore = true,
}) => {
const { styles } = useStyles(styleSheet, {});
const navigation = useNavigation();
@@ -175,6 +199,11 @@ const PerpsWatchlistMarketsV2: React.FC = ({
const handleMarketPress = useCallback(
(market: PerpsMarketData) => {
+ if (onMarketPress) {
+ onMarketPress(market);
+ return;
+ }
+
const hasPosition = positions.some((p) => p.symbol === market.symbol);
const hasOrder = orders.some((o) => o.symbol === market.symbol);
@@ -197,7 +226,14 @@ const PerpsWatchlistMarketsV2: React.FC = ({
},
});
},
- [navigation, positions, orders, source, transactionActiveAbTests],
+ [
+ onMarketPress,
+ navigation,
+ positions,
+ orders,
+ source,
+ transactionActiveAbTests,
+ ],
);
const watchlistHeader = showHeader ? (
@@ -235,7 +271,8 @@ const PerpsWatchlistMarketsV2: React.FC = ({
}
// Expand/collapse applies only to the watchlist rows
- const hasMore = hasWatchlist && markets.length > INITIAL_DISPLAY_COUNT;
+ const hasMore =
+ enableShowMore && hasWatchlist && markets.length > INITIAL_DISPLAY_COUNT;
const displayedMarkets =
hasMore && !expanded ? markets.slice(0, INITIAL_DISPLAY_COUNT) : markets;
const hiddenCount = markets.length - INITIAL_DISPLAY_COUNT;
diff --git a/app/components/UI/TokenDetails/Views/TokenDetails.test.tsx b/app/components/UI/TokenDetails/Views/TokenDetails.test.tsx
index 4463d451896c..6a08e7da509c 100644
--- a/app/components/UI/TokenDetails/Views/TokenDetails.test.tsx
+++ b/app/components/UI/TokenDetails/Views/TokenDetails.test.tsx
@@ -11,6 +11,8 @@ import {
selectDepositActiveFlag,
selectDepositMinimumVersionFlag,
} from '../../../../selectors/featureFlagController/deposit';
+import { selectPriceAlertsEnabled } from '../../../../selectors/featureFlagController/priceAlerts';
+import Routes from '../../../../constants/navigation/Routes';
import {
AMBIENT_NEGATIVE_COLOR,
AMBIENT_PRICE_COLOR_AB_KEY,
@@ -160,10 +162,10 @@ jest.mock('../components/AssetOverviewContent', () => {
token?: { address?: string; chainId?: string; symbol?: string };
useAmbientColor?: boolean;
}) => {
- mockLastUseAmbientColorProp = useAmbientColor;
- mockLatestPriceDirectionChange = onPriceDirectionChange;
const insightsTokenKey = `${token?.address ?? ''}:${token?.chainId ?? ''}:${token?.symbol ?? ''}`;
ReactLib.useEffect(() => {
+ mockLastUseAmbientColorProp = useAmbientColor;
+ mockLatestPriceDirectionChange = onPriceDirectionChange;
mockLatestMarketInsightsResolver = onMarketInsightsDisplayResolved;
if (!mockAutoResolveMarketInsights) {
return;
@@ -172,7 +174,12 @@ jest.mock('../components/AssetOverviewContent', () => {
isDisplayed: true,
severity: undefined,
});
- }, [onMarketInsightsDisplayResolved, insightsTokenKey]);
+ }, [
+ onMarketInsightsDisplayResolved,
+ onPriceDirectionChange,
+ useAmbientColor,
+ insightsTokenKey,
+ ]);
return null;
};
@@ -233,6 +240,10 @@ jest.mock('../../../../selectors/featureFlagController/deposit', () => ({
selectDepositMinimumVersionFlag: jest.fn(() => null),
}));
+jest.mock('../../../../selectors/featureFlagController/priceAlerts', () => ({
+ selectPriceAlertsEnabled: jest.fn(() => false),
+}));
+
jest.mock('../../Ramp/Aggregator/utils', () => ({
isNetworkRampNativeTokenSupported: jest.fn(() => true),
isNetworkRampSupported: jest.fn(() => true),
@@ -350,6 +361,7 @@ describe('TokenDetails', () => {
if (selector === getRampNetworks) return [];
if (selector === selectDepositActiveFlag) return false;
if (selector === selectDepositMinimumVersionFlag) return null;
+ if (selector === selectPriceAlertsEnabled) return false;
return undefined;
});
});
@@ -704,6 +716,114 @@ describe('TokenDetails', () => {
});
});
+ describe('price alert button gating', () => {
+ const enablePriceAlerts = () => {
+ mockUseSelector.mockImplementation((selector) => {
+ if (selector === selectNetworkConfigurationByChainId)
+ return { name: 'Ethereum' };
+ if (selector === selectPerpsEnabledFlag) return false;
+ if (selector === selectMerklCampaignClaimingEnabledFlag) return false;
+ if (selector === getRampNetworks) return [];
+ if (selector === selectDepositActiveFlag) return false;
+ if (selector === selectDepositMinimumVersionFlag) return null;
+ if (selector === selectPriceAlertsEnabled) return true;
+ return undefined;
+ });
+ };
+
+ it('passes onPriceAlertPress to the header when the flag is enabled and currentPrice > 0', () => {
+ enablePriceAlerts();
+ mockUseTokenPrice.mockReturnValue({
+ ...defaultUseTokenPriceReturn,
+ currentPrice: 100,
+ });
+
+ render();
+
+ expect(mockTokenDetailsInlineHeader).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ onPriceAlertPress: expect.any(Function),
+ }),
+ );
+ });
+
+ it('passes undefined onPriceAlertPress when the flag is disabled', () => {
+ // Flag disabled — default mockUseSelector returns false for selectPriceAlertsEnabled
+ render();
+
+ expect(mockTokenDetailsInlineHeader).toHaveBeenLastCalledWith(
+ expect.objectContaining({ onPriceAlertPress: undefined }),
+ );
+ });
+
+ it('passes undefined onPriceAlertPress when currentPrice is 0, even if flag is enabled', () => {
+ enablePriceAlerts();
+ mockUseTokenPrice.mockReturnValue({
+ ...defaultUseTokenPriceReturn,
+ currentPrice: 0,
+ });
+
+ render();
+
+ expect(mockTokenDetailsInlineHeader).toHaveBeenLastCalledWith(
+ expect.objectContaining({ onPriceAlertPress: undefined }),
+ );
+ });
+
+ it('passes undefined onPriceAlertPress when CAIP-19 asset id cannot be resolved', () => {
+ enablePriceAlerts();
+ mockUseTokenPrice.mockReturnValue({
+ ...defaultUseTokenPriceReturn,
+ currentPrice: 100,
+ });
+ mockRouteParams.mockReturnValue({
+ ...defaultRouteParams,
+ chainId: undefined,
+ });
+
+ render();
+
+ expect(mockTokenDetailsInlineHeader).toHaveBeenLastCalledWith(
+ expect.objectContaining({ onPriceAlertPress: undefined }),
+ );
+ });
+
+ it('navigates to MANAGE_PRICE_ALERTS with the correct params when the price alert button is pressed', () => {
+ enablePriceAlerts();
+ mockUseTokenPrice.mockReturnValue({
+ ...defaultUseTokenPriceReturn,
+ currentPrice: 2500,
+ currentCurrency: 'USD',
+ });
+ mockRouteParams.mockReturnValue({
+ ...defaultRouteParams,
+ address: '0x6b175474e89094c44da98b954eedeac495271d0f',
+ chainId: '0x1',
+ symbol: 'DAI',
+ });
+
+ render();
+
+ // Retrieve the handler passed to the mocked header component and invoke it
+ const lastCall = mockTokenDetailsInlineHeader.mock.calls.at(-1)?.[0] as {
+ onPriceAlertPress?: () => void;
+ };
+ act(() => {
+ lastCall.onPriceAlertPress?.();
+ });
+
+ expect(mockNavigate).toHaveBeenCalledWith(
+ Routes.MANAGE_PRICE_ALERTS,
+ expect.objectContaining({
+ symbol: 'DAI',
+ currentPrice: 2500,
+ currentCurrency: 'USD',
+ assetId: expect.stringMatching(/^eip155:1\//),
+ }),
+ );
+ });
+ });
+
describe('TOKEN_DETAILS_CLOSED app state tracking', () => {
let handleAppStateChange: (nextState: AppStateStatus) => void;
diff --git a/app/components/UI/TokenDetails/Views/TokenDetails.tsx b/app/components/UI/TokenDetails/Views/TokenDetails.tsx
index f25accb33f0e..2f743dfefa21 100644
--- a/app/components/UI/TokenDetails/Views/TokenDetails.tsx
+++ b/app/components/UI/TokenDetails/Views/TokenDetails.tsx
@@ -52,6 +52,8 @@ import { useTokenBalance } from '../hooks/useTokenBalance';
import { useTokenPrice } from '../hooks/useTokenPrice';
import { useTokenSecurityData } from '../hooks/useTokenSecurityData';
import { useTokenTransactions } from '../hooks/useTokenTransactions';
+import Routes from '../../../../constants/navigation/Routes';
+import { selectPriceAlertsEnabled } from '../../../../selectors/featureFlagController/priceAlerts';
const styleSheet = (params: { theme: Theme }) => {
const { theme } = params;
@@ -182,6 +184,8 @@ const TokenDetails: React.FC<{
}
}, [token.address, token.chainId]);
+ const isPriceAlertsFeatureEnabled = useSelector(selectPriceAlertsEnabled);
+
const {
securityData,
isLoading: isSecurityDataLoading,
@@ -263,6 +267,26 @@ const TokenDetails: React.FC<{
await onSend();
}, [onSend, onCtaClicked]);
+ const handlePriceAlertPress = useCallback(() => {
+ if (!caip19AssetId) {
+ return;
+ }
+ navigation.navigate(Routes.MANAGE_PRICE_ALERTS, {
+ symbol: token.symbol,
+ ticker: token.ticker,
+ currentPrice,
+ currentCurrency,
+ assetId: caip19AssetId,
+ });
+ }, [
+ navigation,
+ token.symbol,
+ token.ticker,
+ currentPrice,
+ currentCurrency,
+ caip19AssetId,
+ ]);
+
const {
transactions,
submittedTxs,
@@ -337,6 +361,11 @@ const TokenDetails: React.FC<{
navigation.goBack()}
+ onPriceAlertPress={
+ isPriceAlertsFeatureEnabled && currentPrice > 0 && caip19AssetId
+ ? handlePriceAlertPress
+ : undefined
+ }
iconColor={ambientIconColor}
useAmbientColor={useAmbientColor}
/>
diff --git a/app/components/UI/TokenDetails/components/TokenDetailsInlineHeader.test.tsx b/app/components/UI/TokenDetails/components/TokenDetailsInlineHeader.test.tsx
index 1e380d0f4782..4a95f1640afc 100644
--- a/app/components/UI/TokenDetails/components/TokenDetailsInlineHeader.test.tsx
+++ b/app/components/UI/TokenDetails/components/TokenDetailsInlineHeader.test.tsx
@@ -46,6 +46,31 @@ describe('TokenDetailsInlineHeader', () => {
expect(mockOnBackPress).toHaveBeenCalledTimes(1);
});
+
+ it('renders price alert button when onPriceAlertPress is provided', () => {
+ const mockOnPriceAlertPress = jest.fn();
+ const { getByTestId } = render(
+ ,
+ );
+
+ fireEvent.press(getByTestId('token-price-alert-button'));
+ expect(mockOnPriceAlertPress).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not render the price alert button when onPriceAlertPress is undefined', () => {
+ const { queryByTestId } = render(
+ ,
+ );
+
+ expect(queryByTestId('token-price-alert-button')).toBeNull();
+ });
});
describe('treatment group (useAmbientColor=true)', () => {
@@ -85,5 +110,35 @@ describe('TokenDetailsInlineHeader', () => {
expect(mockOnBackPress).toHaveBeenCalledTimes(1);
});
+
+ it('renders price alert button when iconColor is provided and onPriceAlertPress is set', () => {
+ const mockOnPriceAlertPress = jest.fn();
+ const { getByTestId } = render(
+ ,
+ );
+
+ fireEvent.press(getByTestId('token-price-alert-button'));
+ expect(mockOnPriceAlertPress).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not render the price alert button when iconColor is undefined', () => {
+ const mockOnPriceAlertPress = jest.fn();
+ const { queryByTestId } = render(
+ ,
+ );
+
+ // shouldShowButton is false when useAmbientColor=true and iconColor is undefined,
+ // so the price alert button must not be rendered even if the handler is provided
+ expect(queryByTestId('token-price-alert-button')).toBeNull();
+ });
});
});
diff --git a/app/components/UI/TokenDetails/components/TokenDetailsInlineHeader.tsx b/app/components/UI/TokenDetails/components/TokenDetailsInlineHeader.tsx
index a3e548b31416..66fa35cbdee5 100644
--- a/app/components/UI/TokenDetails/components/TokenDetailsInlineHeader.tsx
+++ b/app/components/UI/TokenDetails/components/TokenDetailsInlineHeader.tsx
@@ -8,6 +8,7 @@ import {
IconName,
} from '@metamask/design-system-react-native';
import { EdgeInsets, useSafeAreaInsets } from 'react-native-safe-area-context';
+import { TokenOverviewSelectorsIDs } from '../../AssetOverview/TokenOverview.testIds';
const inlineHeaderStyles = (params: {
theme: Theme;
@@ -38,18 +39,25 @@ const inlineHeaderStyles = (params: {
gap: 10,
flexShrink: 0,
},
- rightPlaceholder: {
- width: 24,
+ endButtonHitArea: {
+ width: 40,
+ height: 40,
+ flexDirection: 'row',
+ justifyContent: 'center',
+ alignItems: 'center',
+ flexShrink: 0,
},
});
};
export const TokenDetailsInlineHeader = ({
onBackPress,
+ onPriceAlertPress,
iconColor,
useAmbientColor = false,
}: {
onBackPress: () => void;
+ onPriceAlertPress?: () => void;
/** Hex color string for the back button icon (A/B test). */
iconColor?: string;
useAmbientColor?: boolean;
@@ -76,7 +84,17 @@ export const TokenDetailsInlineHeader = ({
/>
)}
-
+
+ {shouldShowButton && onPriceAlertPress ? (
+
+ ) : null}
+
);
};
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyActionFooter.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyActionFooter.tsx
index 6321ef269d1e..ae31d2e796b8 100644
--- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyActionFooter.tsx
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyActionFooter.tsx
@@ -7,15 +7,11 @@ import {
Text,
TextVariant,
TextColor,
- AvatarToken,
AvatarTokenSize,
Icon,
IconColor,
IconName,
IconSize,
- BadgeWrapper,
- BadgeWrapperPosition,
- BadgeNetwork,
} from '@metamask/design-system-react-native';
import { TouchableOpacity } from 'react-native';
import { strings } from '../../../../../../../../locales/i18n';
@@ -23,8 +19,7 @@ import QuickBuyConfirmButton from '../QuickBuyConfirmButton';
import QuickBuyBanners from '../QuickBuyBanners';
import { useQuickBuyContext } from '../useQuickBuyContext';
import { QuickBuyPercentageSlider } from './QuickBuyPercentageSlider';
-import { getNetworkImageSource } from '../../../../../../../util/networks';
-import { getBridgeTokenImageSource } from '../getBridgeTokenImageSource';
+import QuickBuyTokenIcon from './QuickBuyTokenIcon';
const QuickBuyActionFooter: React.FC = () => {
const {
@@ -41,7 +36,6 @@ const QuickBuyActionFooter: React.FC = () => {
isHardwareSolanaBlocked,
tradeMode,
sourceToken,
- sourceChainId,
sourceBalanceFiat,
destBalanceFiat,
destToken,
@@ -51,23 +45,9 @@ const QuickBuyActionFooter: React.FC = () => {
} = useQuickBuyContext();
const pickerToken = tradeMode === 'sell' ? selectedDestStable : sourceToken;
- const pickerChainId =
- tradeMode === 'sell'
- ? (selectedDestStable?.chainId as
- | import('@metamask/utils').Hex
- | undefined)
- : sourceChainId;
- // Both balances are driven by live, selector-backed state (TSA-632):
- // `sourceBalanceFiat` from `useLatestBalance` re-keyed off the live cached
- // balance, and `destBalanceFiat` resynced from the reactive receive-token
- // list. Either updates the pill the moment the underlying balance changes.
const pickerBalanceFiat =
tradeMode === 'sell' ? destBalanceFiat : sourceBalanceFiat;
- const networkImage = pickerChainId
- ? getNetworkImageSource({ chainId: pickerChainId })
- : undefined;
-
return (
{/* Slider — reduced top padding to tighten gap with the amount section */}
@@ -106,24 +86,10 @@ const QuickBuyActionFooter: React.FC = () => {
gap={2}
>
{pickerToken ? (
- networkImage ? (
- }
- >
-
-
- ) : (
-
- )
+
) : null}
{pickerToken
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyPayWithRow.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyPayWithRow.tsx
index e41189781b9a..3e7597ebc07e 100644
--- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyPayWithRow.tsx
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyPayWithRow.tsx
@@ -1,9 +1,5 @@
import {
- AvatarToken,
AvatarTokenSize,
- BadgeNetwork,
- BadgeWrapper,
- BadgeWrapperPosition,
Box,
BoxAlignItems,
BoxFlexDirection,
@@ -17,10 +13,9 @@ import {
} from '@metamask/design-system-react-native';
import React, { useMemo } from 'react';
import { TouchableOpacity } from 'react-native';
-import { getNetworkImageSource } from '../../../../../../../util/networks';
import type { BridgeToken } from '../../../../../../UI/Bridge/types';
-import { getBridgeTokenImageSource } from '../getBridgeTokenImageSource';
import { getTokenKey } from '../tokenKey';
+import QuickBuyTokenIcon from './QuickBuyTokenIcon';
interface QuickBuyPayWithRowProps {
token: BridgeToken;
@@ -51,7 +46,6 @@ const QuickBuyPayWithRow: React.FC = ({
isSelected,
onPress,
}) => {
- const networkImage = getNetworkImageSource({ chainId: token.chainId });
const tokenKey = getTokenKey(token);
const cryptoBalanceLabel = useMemo(
() => formatTokenBalance(token.balance, token.symbol),
@@ -71,24 +65,7 @@ const QuickBuyPayWithRow: React.FC = ({
gap={4}
twClassName={`px-4 py-2 ${isSelected ? 'bg-muted' : ''}`}
>
- {networkImage ? (
- }
- >
-
-
- ) : (
-
- )}
+
= {}): BridgeToken => ({
+ symbol: 'CAKE',
+ name: 'PancakeSwap',
+ address: CAKE_ADDRESS,
+ decimals: 18,
+ chainId: BSC_CHAIN_ID,
+ ...overrides,
+});
+
+describe('QuickBuyTokenIcon', () => {
+ it('renders a round AvatarToken from the token image with the requested size', () => {
+ const token = createToken({ image: 'https://example.com/cake-own.png' });
+
+ render();
+
+ const avatar = screen.UNSAFE_getByType(AvatarToken);
+ expect(avatar.props).toStrictEqual(
+ expect.objectContaining({
+ name: 'CAKE',
+ src: { uri: 'https://example.com/cake-own.png' },
+ size: AvatarTokenSize.Sm,
+ }),
+ );
+ });
+
+ it('renders the component-library network badge used by the homepage token list', () => {
+ const token = createToken({ image: 'https://example.com/cake-badge.png' });
+
+ render();
+
+ expect(screen.getByTestId(BADGENETWORK_TEST_ID)).toBeOnTheScreen();
+ });
+
+ it('falls back to the shared static-CDN token icon when the token has no image', () => {
+ const token = createToken({ image: undefined });
+ const expectedFallbackUrl = getFallbackAssetImageUrls(
+ BSC_CHAIN_ID,
+ CAKE_ADDRESS,
+ )?.[0];
+
+ render();
+
+ const avatar = screen.UNSAFE_getByType(AvatarToken);
+ expect(avatar.props.src).toStrictEqual({ uri: expectedFallbackUrl });
+ });
+
+ it('resolves the same fallback image list as the homepage token list', () => {
+ // The hook receives the token image first, then the static-CDN fallbacks
+ // (lowercased + checksummed address variants) used by AssetLogo.
+ const token = createToken({ image: 'https://example.com/cake-list.png' });
+ const fallbackUrls = getFallbackAssetImageUrls(BSC_CHAIN_ID, CAKE_ADDRESS);
+
+ render();
+
+ fireEvent(
+ screen.getByTestId(QUICK_BUY_TOKEN_ICON_AVATAR_TEST_ID),
+ 'error',
+ { nativeEvent: {} },
+ );
+
+ const avatar = screen.UNSAFE_getByType(AvatarToken);
+ expect(avatar.props.src).toStrictEqual({ uri: fallbackUrls?.[0] });
+ });
+
+ it('cycles through to the checksummed CDN variant when earlier images fail', () => {
+ const token = createToken({ image: 'https://example.com/cake-cycle.png' });
+ const fallbackUrls = getFallbackAssetImageUrls(BSC_CHAIN_ID, CAKE_ADDRESS);
+
+ render();
+
+ // own image fails -> lowercase CDN fails -> checksummed CDN remains.
+ fireEvent(
+ screen.getByTestId(QUICK_BUY_TOKEN_ICON_AVATAR_TEST_ID),
+ 'error',
+ { nativeEvent: {} },
+ );
+ fireEvent(
+ screen.getByTestId(QUICK_BUY_TOKEN_ICON_AVATAR_TEST_ID),
+ 'error',
+ { nativeEvent: {} },
+ );
+
+ const avatar = screen.UNSAFE_getByType(AvatarToken);
+ expect(avatar.props.src).toStrictEqual({ uri: fallbackUrls?.[1] });
+ });
+});
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyTokenIcon.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyTokenIcon.tsx
new file mode 100644
index 000000000000..2500856c34d7
--- /dev/null
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyTokenIcon.tsx
@@ -0,0 +1,102 @@
+import {
+ AvatarToken,
+ AvatarTokenSize,
+} from '@metamask/design-system-react-native';
+import React, { useMemo } from 'react';
+import { StyleSheet } from 'react-native';
+import Badge, {
+ BadgeVariant,
+} from '../../../../../../../component-library/components/Badges/Badge';
+import BadgeWrapper, {
+ BadgePosition,
+} from '../../../../../../../component-library/components/Badges/BadgeWrapper';
+import { getNetworkImageSource } from '../../../../../../../util/networks';
+import { useSmartImageFallback } from '../../../../../../UI/Assets/components/AssetLogo/AssetLogo.hook';
+import { getFallbackAssetImageUrls } from '../../../../../../UI/Assets/components/AssetLogo/AssetLogo.utils';
+import type { BridgeToken } from '../../../../../../UI/Bridge/types';
+import { getBridgeTokenImageSource } from '../getBridgeTokenImageSource';
+
+export const QUICK_BUY_TOKEN_ICON_AVATAR_TEST_ID =
+ 'quick-buy-token-icon-avatar';
+
+const styles = StyleSheet.create({
+ badgeWrapper: {
+ // Override BadgeWrapper's alignSelf: 'flex-start' so the parent row keeps
+ // controlling the vertical alignment (same as Bridge's TokenSelectorItem).
+ alignSelf: undefined,
+ },
+});
+
+interface QuickBuyTokenIconProps {
+ token: BridgeToken;
+ size?: AvatarTokenSize;
+}
+
+/**
+ * Token avatar with a network badge for the Quick Buy sheet.
+ *
+ * Mirrors the homepage Token list rendering (`TokenListItem` + `AssetLogo`):
+ * a fully round `AvatarToken` resolved from the token image with the shared
+ * static-CDN fallbacks (`getFallbackAssetImageUrls` + `useSmartImageFallback`),
+ * and a circular network badge scaled to half the avatar via the
+ * component-library `BadgeWrapper`/`Badge` pair, sourced from
+ * `getNetworkImageSource`.
+ */
+const QuickBuyTokenIcon: React.FC = ({
+ token,
+ size = AvatarTokenSize.Md,
+}) => {
+ const networkImageSource = getNetworkImageSource({
+ chainId: token.chainId,
+ });
+
+ // Same data sources as the homepage Token list (`AssetLogo`): the token's
+ // own image first, then the static-CDN fallbacks (lowercased + checksummed
+ // address variants). `getBridgeTokenImageSource` keeps native assets working
+ // by resolving their canonical SLIP-44 asset-id icon.
+ const bridgeImageUri = getBridgeTokenImageSource(token)?.uri;
+ const images = useMemo(() => {
+ const urls = [
+ token.image,
+ bridgeImageUri,
+ ...(getFallbackAssetImageUrls(token.chainId, token.address) ?? []),
+ ].filter((image): image is string => Boolean(image));
+ return [...new Set(urls)].map((uri) => ({ uri }));
+ }, [token.image, token.chainId, token.address, bridgeImageUri]);
+
+ const { source, onError, uniqueSourceImageKey } =
+ useSmartImageFallback(images);
+
+ const avatar = (
+
+ );
+
+ if (!networkImageSource) {
+ return avatar;
+ }
+
+ return (
+
+ }
+ >
+ {avatar}
+
+ );
+};
+
+export default QuickBuyTokenIcon;
diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts
index c1d2f8d8c255..f51316cf410a 100644
--- a/app/constants/navigation/Routes.ts
+++ b/app/constants/navigation/Routes.ts
@@ -566,6 +566,8 @@ const Routes = {
CONFIRM: 'AgenticCliDashboardConfirmation',
},
FEATURE_FLAG_OVERRIDE: 'FeatureFlagOverride',
+ CREATE_PRICE_ALERT: 'CreatePriceAlert',
+ MANAGE_PRICE_ALERTS: 'ManagePriceAlerts',
SECURITY_TRUST: 'SecurityTrust',
AGENTIC_CLI_APPROVAL: {
ID: 'AgenticCliApproval',
diff --git a/app/core/AppConstants.ts b/app/core/AppConstants.ts
index 13378f636664..46d5d4a09d0f 100644
--- a/app/core/AppConstants.ts
+++ b/app/core/AppConstants.ts
@@ -33,6 +33,10 @@ const SECURITY_ALERTS_API_URL =
process.env.SECURITY_ALERTS_API_URL ??
'https://security-alerts.api.cx.metamask.io';
+const PRICE_ALERTS_API_URL =
+ process.env.PRICE_ALERTS_API_URL ??
+ 'https://price-alerts.dev-api.cx.metamask.io';
+
export default {
IS_DEV: process.env?.NODE_ENV === DEVELOPMENT,
METAMASK_BUILD_TYPE: process.env.METAMASK_BUILD_TYPE,
@@ -48,6 +52,9 @@ export default {
SECURITY_ALERTS_API: {
URL: SECURITY_ALERTS_API_URL,
},
+ PRICE_ALERTS_API: {
+ URL: PRICE_ALERTS_API_URL,
+ },
PORTFOLIO: {
URL: PORTFOLIO_URL,
},
diff --git a/app/selectors/featureFlagController/priceAlerts/index.test.ts b/app/selectors/featureFlagController/priceAlerts/index.test.ts
new file mode 100644
index 000000000000..f311f7369350
--- /dev/null
+++ b/app/selectors/featureFlagController/priceAlerts/index.test.ts
@@ -0,0 +1,151 @@
+import { selectPriceAlertsEnabled } from '.';
+import mockedEngine from '../../../core/__mocks__/MockedEngine';
+import { mockedEmptyFlagsState, mockedUndefinedFlagsState } from '../mocks';
+import { getVersion } from 'react-native-device-info';
+// eslint-disable-next-line import-x/no-namespace
+import * as remoteFeatureFlagModule from '../../../util/remoteFeatureFlag';
+
+jest.mock('../../../core/Engine', () => ({
+ init: () => mockedEngine.init(),
+}));
+
+jest.mock('react-native-device-info', () => ({
+ getVersion: jest.fn().mockReturnValue('1.0.0'),
+}));
+
+jest.mock(
+ '../../../core/Engine/controllers/remote-feature-flag-controller',
+ () => ({
+ isRemoteFeatureFlagOverrideActivated: false,
+ }),
+);
+
+describe('Price Alerts Feature Flag Selector (version-gated)', () => {
+ let mockHasMinimumRequiredVersion: jest.SpyInstance;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ mockHasMinimumRequiredVersion = jest.spyOn(
+ remoteFeatureFlagModule,
+ 'hasMinimumRequiredVersion',
+ );
+ mockHasMinimumRequiredVersion.mockReturnValue(true);
+ (getVersion as jest.MockedFunction).mockReturnValue(
+ '1.0.0',
+ );
+ });
+
+ afterEach(() => {
+ mockHasMinimumRequiredVersion?.mockRestore();
+ });
+
+ it('returns true when priceAlertsEnabled is enabled and minimum version requirement passes', () => {
+ const state = {
+ engine: {
+ backgroundState: {
+ RemoteFeatureFlagController: {
+ remoteFeatureFlags: {
+ priceAlertsEnabled: { enabled: true, minimumVersion: '1.0.0' },
+ },
+ cacheTimestamp: 0,
+ },
+ },
+ },
+ };
+
+ expect(selectPriceAlertsEnabled(state)).toBe(true);
+ });
+
+ it('returns false when priceAlertsEnabled is enabled but minimum version requirement fails', () => {
+ mockHasMinimumRequiredVersion.mockReturnValue(false);
+
+ const state = {
+ engine: {
+ backgroundState: {
+ RemoteFeatureFlagController: {
+ remoteFeatureFlags: {
+ priceAlertsEnabled: { enabled: true, minimumVersion: '99.0.0' },
+ },
+ cacheTimestamp: 0,
+ },
+ },
+ },
+ };
+
+ expect(selectPriceAlertsEnabled(state)).toBe(false);
+ });
+
+ it('returns false when priceAlertsEnabled is disabled regardless of version', () => {
+ const state = {
+ engine: {
+ backgroundState: {
+ RemoteFeatureFlagController: {
+ remoteFeatureFlags: {
+ priceAlertsEnabled: { enabled: false, minimumVersion: '0.0.0' },
+ },
+ cacheTimestamp: 0,
+ },
+ },
+ },
+ };
+
+ expect(selectPriceAlertsEnabled(state)).toBe(false);
+ expect(mockHasMinimumRequiredVersion).not.toHaveBeenCalled();
+ });
+
+ it('returns false when priceAlertsEnabled flag is missing', () => {
+ const state = {
+ engine: {
+ backgroundState: {
+ RemoteFeatureFlagController: {
+ remoteFeatureFlags: {},
+ cacheTimestamp: 0,
+ },
+ },
+ },
+ };
+
+ expect(selectPriceAlertsEnabled(state)).toBe(false);
+ });
+
+ it('returns false when feature flag state is empty', () => {
+ expect(selectPriceAlertsEnabled(mockedEmptyFlagsState)).toBe(false);
+ });
+
+ it('returns false when RemoteFeatureFlagController state is undefined', () => {
+ expect(selectPriceAlertsEnabled(mockedUndefinedFlagsState)).toBe(false);
+ });
+
+ it('returns false when priceAlertsEnabled has wrong property types', () => {
+ const state = {
+ engine: {
+ backgroundState: {
+ RemoteFeatureFlagController: {
+ remoteFeatureFlags: {
+ priceAlertsEnabled: { enabled: 'true', minimumVersion: 100 },
+ },
+ cacheTimestamp: 0,
+ },
+ },
+ },
+ };
+
+ expect(selectPriceAlertsEnabled(state)).toBe(false);
+ });
+
+ it('returns false when priceAlertsEnabled is null', () => {
+ const state = {
+ engine: {
+ backgroundState: {
+ RemoteFeatureFlagController: {
+ remoteFeatureFlags: { priceAlertsEnabled: null },
+ cacheTimestamp: 0,
+ },
+ },
+ },
+ };
+
+ expect(selectPriceAlertsEnabled(state)).toBe(false);
+ });
+});
diff --git a/app/selectors/featureFlagController/priceAlerts/index.ts b/app/selectors/featureFlagController/priceAlerts/index.ts
new file mode 100644
index 000000000000..5c11b2c94615
--- /dev/null
+++ b/app/selectors/featureFlagController/priceAlerts/index.ts
@@ -0,0 +1,19 @@
+import { createSelector } from 'reselect';
+import { selectRemoteFeatureFlags } from '..';
+import {
+ VersionGatedFeatureFlag,
+ validatedVersionGatedFeatureFlag,
+} from '../../../util/remoteFeatureFlag';
+
+export const PRICE_ALERTS_FLAG_KEY = 'priceAlertsEnabled';
+
+export const selectPriceAlertsEnabled = createSelector(
+ selectRemoteFeatureFlags,
+ (remoteFeatureFlags) => {
+ const remoteFlag = remoteFeatureFlags?.[
+ PRICE_ALERTS_FLAG_KEY
+ ] as unknown as VersionGatedFeatureFlag;
+
+ return validatedVersionGatedFeatureFlag(remoteFlag) ?? false;
+ },
+);
diff --git a/builds.yml b/builds.yml
index 02449b5f82fe..209ce23a432a 100644
--- a/builds.yml
+++ b/builds.yml
@@ -27,6 +27,7 @@ _public_envs: &public_envs # Servers (production)
MM_PORTFOLIO_URL: 'https://portfolio.api.cx.metamask.io'
SECURITY_ALERTS_API_URL: 'https://security-alerts.api.cx.metamask.io'
DECODING_API_URL: 'https://signature-insights.api.cx.metamask.io/v1'
+ PRICE_ALERTS_API_URL: 'https://price-alerts.api.cx.metamask.io'
COMPLIANCE_API_URL: 'https://compliance.api.cx.metamask.io'
AUTH_SERVICE_URL: 'https://auth-service.api.cx.metamask.io'
DIGEST_API_URL: 'https://digest.api.cx.metamask.io/api/v1'
@@ -313,6 +314,7 @@ builds:
BAANX_API_URL: 'https://dev.api.baanx.com'
DIGEST_API_URL: 'https://digest.dev-api.cx.metamask.io/api/v1'
SOCIAL_API_URL: 'https://social.dev-api.cx.metamask.io'
+ PRICE_ALERTS_API_URL: 'https://price-alerts.dev-api.cx.metamask.io'
BRIDGE_USE_DEV_APIS: 'true'
RAMPS_ENVIRONMENT: 'staging'
RAMP_DEV_BUILD: 'true'
@@ -337,6 +339,7 @@ builds:
BAANX_API_URL: 'https://dev.api.baanx.com'
DIGEST_API_URL: 'https://digest.dev-api.cx.metamask.io/api/v1'
SOCIAL_API_URL: 'https://social.dev-api.cx.metamask.io'
+ PRICE_ALERTS_API_URL: 'https://price-alerts.dev-api.cx.metamask.io'
BRIDGE_USE_DEV_APIS: 'true'
RAMPS_ENVIRONMENT: 'staging'
RAMP_INTERNAL_BUILD: 'true'
@@ -361,6 +364,7 @@ builds:
BAANX_API_URL: 'https://dev.api.baanx.com'
DIGEST_API_URL: 'https://digest.dev-api.cx.metamask.io/api/v1'
SOCIAL_API_URL: 'https://social.dev-api.cx.metamask.io'
+ PRICE_ALERTS_API_URL: 'https://price-alerts.dev-api.cx.metamask.io'
BRIDGE_USE_DEV_APIS: 'true'
RAMPS_ENVIRONMENT: 'staging'
RAMP_INTERNAL_BUILD: 'true'
@@ -420,6 +424,7 @@ builds:
BAANX_API_URL: 'https://dev.api.baanx.com'
DIGEST_API_URL: 'https://digest.dev-api.cx.metamask.io/api/v1'
SOCIAL_API_URL: 'https://social.dev-api.cx.metamask.io'
+ PRICE_ALERTS_API_URL: 'https://price-alerts.dev-api.cx.metamask.io'
BRIDGE_USE_DEV_APIS: 'true'
RAMPS_ENVIRONMENT: 'staging'
RAMP_INTERNAL_BUILD: 'true'
@@ -447,6 +452,7 @@ builds:
BAANX_API_URL: 'https://dev.api.baanx.com'
DIGEST_API_URL: 'https://digest.dev-api.cx.metamask.io/api/v1'
SOCIAL_API_URL: 'https://social.dev-api.cx.metamask.io'
+ PRICE_ALERTS_API_URL: 'https://price-alerts.dev-api.cx.metamask.io'
BRIDGE_USE_DEV_APIS: 'true'
RAMPS_ENVIRONMENT: 'staging'
RAMP_INTERNAL_BUILD: 'true'
@@ -491,6 +497,7 @@ builds:
BAANX_API_URL: 'https://dev.api.baanx.com'
DIGEST_API_URL: 'https://digest.dev-api.cx.metamask.io/api/v1'
SOCIAL_API_URL: 'https://social.dev-api.cx.metamask.io'
+ PRICE_ALERTS_API_URL: 'https://price-alerts.dev-api.cx.metamask.io'
BRIDGE_USE_DEV_APIS: 'true'
RAMPS_ENVIRONMENT: 'staging'
RAMP_INTERNAL_BUILD: 'true'
@@ -515,6 +522,7 @@ builds:
BAANX_API_URL: 'https://dev.api.baanx.com'
DIGEST_API_URL: 'https://digest.dev-api.cx.metamask.io/api/v1'
SOCIAL_API_URL: 'https://social.dev-api.cx.metamask.io'
+ PRICE_ALERTS_API_URL: 'https://price-alerts.dev-api.cx.metamask.io'
BRIDGE_USE_DEV_APIS: 'true'
RAMPS_ENVIRONMENT: 'staging'
RAMP_INTERNAL_BUILD: 'true'
@@ -538,6 +546,7 @@ builds:
BAANX_API_URL: 'https://dev.api.baanx.com'
DIGEST_API_URL: 'https://digest.dev-api.cx.metamask.io/api/v1'
SOCIAL_API_URL: 'https://social.dev-api.cx.metamask.io'
+ PRICE_ALERTS_API_URL: 'https://price-alerts.dev-api.cx.metamask.io'
BRIDGE_USE_DEV_APIS: 'true'
RAMPS_ENVIRONMENT: 'staging'
RAMP_DEV_BUILD: 'true'
diff --git a/locales/languages/en.json b/locales/languages/en.json
index e479778f6b85..5241edd5cd0f 100644
--- a/locales/languages/en.json
+++ b/locales/languages/en.json
@@ -4047,6 +4047,25 @@
"sell": "Sell",
"sell_description": "Sell crypto for cash"
},
+ "price_alerts": {
+ "create_title": "Create {{ticker}} price alert",
+ "price_reaches": "Price reaches",
+ "price_change": "Price change",
+ "enter_target_price": "Enter target price",
+ "approx_percent": "≈ {{percent}}%",
+ "approx_percent_above": "above current {{ticker}} price",
+ "approx_percent_below": "below current {{ticker}} price",
+ "recurring": "Recurring",
+ "quick_percentage": "{{percentage}}%",
+ "price_change_under_development": "This experience is currently under development",
+ "set_price_alert": "Set price alert",
+ "save_success": "Price alert for {{ticker}} saved successfully.",
+ "manage_title": "Price alerts",
+ "add_alert": "Add alert",
+ "reaches_threshold": "Reaches {{threshold}}",
+ "once_label": "Once",
+ "no_alerts": "No price alerts set for this asset."
+ },
"asset_overview": {
"market_closed": "Market closed",
"send_button": "Send",
diff --git a/scripts/apply-build-config.js b/scripts/apply-build-config.js
index 6f571fa94aef..2815dfdb9ec8 100755
--- a/scripts/apply-build-config.js
+++ b/scripts/apply-build-config.js
@@ -125,6 +125,7 @@ function writeBuildEnvJson(buildName) {
'PORTFOLIO_API_URL',
'SECURITY_ALERTS_API_URL',
'DECODING_API_URL',
+ 'PRICE_ALERTS_API_URL',
'COMPLIANCE_API_URL',
'AUTH_SERVICE_URL',
'DIGEST_API_URL',
diff --git a/tests/feature-flags/feature-flag-registry.ts b/tests/feature-flags/feature-flag-registry.ts
index f97317105e6b..05d5aa50cdb7 100644
--- a/tests/feature-flags/feature-flag-registry.ts
+++ b/tests/feature-flags/feature-flag-registry.ts
@@ -5042,6 +5042,17 @@ export const FEATURE_FLAG_REGISTRY: Record = {
},
status: FeatureFlagStatus.Active,
},
+
+ priceAlertsEnabled: {
+ name: 'priceAlertsEnabled',
+ type: FeatureFlagType.Remote,
+ inProd: false,
+ productionDefault: {
+ enabled: false,
+ minimumVersion: '0.0.0',
+ },
+ status: FeatureFlagStatus.Active,
+ },
};
// ============================================================================