diff --git a/app/components/UI/Bridge/hooks/useSwapBridgeNavigation/index.ts b/app/components/UI/Bridge/hooks/useSwapBridgeNavigation/index.ts
index 0c8800b6a63..677641c8748 100644
--- a/app/components/UI/Bridge/hooks/useSwapBridgeNavigation/index.ts
+++ b/app/components/UI/Bridge/hooks/useSwapBridgeNavigation/index.ts
@@ -135,6 +135,7 @@ export const useSwapBridgeNavigation = ({
transactionActiveAbTests,
skipLocationUpdate = false,
swapButtonEventLocationOverride,
+ skipActionButtonClickTracking = false,
}: {
location: SwapBridgeNavigationLocation;
sourcePage: string;
@@ -161,6 +162,11 @@ export const useSwapBridgeNavigation = ({
swapButtonEventLocationOverride?:
| ActionLocation
| SwapBridgeNavigationLocation;
+ /**
+ * When true, skip consolidated ACTION_BUTTON_CLICKED from this hook so the
+ * caller can emit it with the correct location / position (e.g. homepage grid).
+ */
+ skipActionButtonClickTracking?: boolean;
}) => {
const navigation = useNavigation();
const dispatch = useDispatch();
@@ -370,20 +376,26 @@ export const useSwapBridgeNavigation = ({
}
// Track Swap button click with new consolidated event
- const isFromNavbar = location === SwapBridgeNavigationLocation.MainView;
- const actionButtonProps = {
- action_name: ActionButtonType.SWAP,
- // Omit action_position for navbar to avoid confusion with main action buttons
- ...(isFromNavbar
- ? {}
- : { action_position: ActionPosition.SECOND_POSITION }),
- button_label: buttonLabel ?? strings('asset_overview.swap'),
- location: isFromNavbar
- ? ActionLocation.NAVBAR
- : ActionLocation.ASSET_DETAILS,
- };
-
- trackActionButtonClick(trackEvent, createEventBuilder, actionButtonProps);
+ if (!skipActionButtonClickTracking) {
+ const isFromNavbar = location === SwapBridgeNavigationLocation.MainView;
+ const actionButtonProps = {
+ action_name: ActionButtonType.SWAP,
+ // Omit action_position for navbar to avoid confusion with main action buttons
+ ...(isFromNavbar
+ ? {}
+ : { action_position: ActionPosition.SECOND_POSITION }),
+ button_label: buttonLabel ?? strings('asset_overview.swap'),
+ location: isFromNavbar
+ ? ActionLocation.NAVBAR
+ : ActionLocation.ASSET_DETAILS,
+ };
+
+ trackActionButtonClick(
+ trackEvent,
+ createEventBuilder,
+ actionButtonProps,
+ );
+ }
const swapEventProperties = {
location:
@@ -421,6 +433,7 @@ export const useSwapBridgeNavigation = ({
getIsBridgeEnabledSource,
skipLocationUpdate,
swapButtonEventLocationOverride,
+ skipActionButtonClickTracking,
transactionActiveAbTests,
isBasicFunctionalityEnabled,
prefetchPopularTokens,
diff --git a/app/components/Views/Homepage/abTestConfig.ts b/app/components/Views/Homepage/abTestConfig.ts
index 4596092bbef..dd433db0e68 100644
--- a/app/components/Views/Homepage/abTestConfig.ts
+++ b/app/components/Views/Homepage/abTestConfig.ts
@@ -282,3 +282,60 @@ export function getHomepageDiscoveryPillsTransactionActiveAbTests(
createActiveABTestAssignment(HOMEPAGE_DISCOVERY_PILLS_AB_KEY, variantName),
];
}
+
+// ─── Homepage action buttons 2×4 grid (TMCU-1103) ────────────────────────────
+
+/**
+ * LaunchDarkly / remote flag key. Pattern: `{team}{TICKET}Abtest{Name}` — keep in
+ * sync with the flag in LD (team `home`, ticket TMCU-1103).
+ */
+export const HOMEPAGE_ACTION_BUTTONS_GRID_AB_KEY =
+ 'homeTMCU1103AbtestActionButtonsGrid';
+
+export enum HomepageActionButtonsGridVariant {
+ Control = 'control',
+ Row1Top = 'row1Top',
+ Row2Top = 'row2Top',
+}
+
+export type HomepageActionButtonsGridRowOrder = 'row1Top' | 'row2Top';
+
+type HomepageActionButtonsGridVariantConfig =
+ | { layout: 'fourSquare' }
+ | {
+ layout: 'eightCircular';
+ rowOrder: HomepageActionButtonsGridRowOrder;
+ };
+
+export const HOMEPAGE_ACTION_BUTTONS_GRID_VARIANTS: Record<
+ HomepageActionButtonsGridVariant,
+ HomepageActionButtonsGridVariantConfig
+> = {
+ [HomepageActionButtonsGridVariant.Control]: {
+ layout: 'fourSquare',
+ },
+ [HomepageActionButtonsGridVariant.Row1Top]: {
+ layout: 'eightCircular',
+ rowOrder: 'row1Top',
+ },
+ [HomepageActionButtonsGridVariant.Row2Top]: {
+ layout: 'eightCircular',
+ rowOrder: 'row2Top',
+ },
+};
+
+export const HOMEPAGE_ACTION_BUTTONS_GRID_AB_TEST_EXPOSURE_OPTIONS = {
+ experimentName: 'Homepage action buttons 2x4 grid',
+ variationNames: {
+ control: '4 square action buttons',
+ row1Top: '8 circular buttons, Row 1 top',
+ row2Top: '8 circular buttons, Row 2 top',
+ },
+} as const;
+
+export const HOMEPAGE_ACTION_BUTTONS_GRID_AB_TEST_ANALYTICS_MAPPING: ABTestAnalyticsMapping =
+ {
+ flagKey: HOMEPAGE_ACTION_BUTTONS_GRID_AB_KEY,
+ validVariants: Object.values(HomepageActionButtonsGridVariant),
+ eventNames: [EVENT_NAME.HOME_VIEWED, EVENT_NAME.ACTION_BUTTON_CLICKED],
+ };
diff --git a/app/components/Views/Homepage/components/HomepageActionButtonsGrid/HomepageActionButton.tsx b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/HomepageActionButton.tsx
new file mode 100644
index 00000000000..c52a0e2a732
--- /dev/null
+++ b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/HomepageActionButton.tsx
@@ -0,0 +1,102 @@
+import React from 'react';
+import { Animated, Pressable } from 'react-native';
+import {
+ Box,
+ BoxAlignItems,
+ BoxJustifyContent,
+ FontWeight,
+ Icon,
+ IconColor,
+ IconName,
+ IconSize,
+ Text,
+ TextColor,
+ TextVariant,
+} from '@metamask/design-system-react-native';
+import { useTailwind } from '@metamask/design-system-twrnc-preset';
+import { useAnimatedPressable } from '../../../../../component-library/hooks';
+
+export interface HomepageActionButtonProps {
+ iconName: IconName;
+ label: string;
+ onPress: () => void;
+ isDisabled?: boolean;
+ /** When true, label wraps to 2 lines before tail ellipsis. */
+ allowTwoLineLabel?: boolean;
+ testID?: string;
+}
+
+/**
+ * Presentation-only circular action button (icon pill + label).
+ * Navigation and analytics live in each `buttons/*Button.tsx` caller.
+ * Press feedback: slight scale-down + muted → muted-pressed pill background.
+ */
+const HomepageActionButton = ({
+ iconName,
+ label,
+ onPress,
+ isDisabled = false,
+ allowTwoLineLabel = false,
+ testID,
+}: HomepageActionButtonProps) => {
+ const tw = useTailwind();
+ const labelNumberOfLines = allowTwoLineLabel ? 2 : 1;
+ const { scaleAnim, handlePressIn, handlePressOut } = useAnimatedPressable();
+
+ return (
+
+
+ {({ pressed }) => (
+ <>
+
+
+
+
+ {label}
+
+ >
+ )}
+
+
+ );
+};
+
+export default HomepageActionButton;
diff --git a/app/components/Views/Homepage/components/HomepageActionButtonsGrid/HomepageActionButtonsGrid.test.tsx b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/HomepageActionButtonsGrid.test.tsx
new file mode 100644
index 00000000000..bd2e495e2ee
--- /dev/null
+++ b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/HomepageActionButtonsGrid.test.tsx
@@ -0,0 +1,347 @@
+import React from 'react';
+import { fireEvent, render } from '@testing-library/react-native';
+import { selectCanSignTransactions } from '../../../../../selectors/accountsController';
+import { selectIsSwapsEnabled } from '../../../../../core/redux/slices/bridge';
+import { selectBatchSellEnabled } from '../../../../../selectors/featureFlagController/batchSell';
+import { selectSocialLeaderboardEnabled } from '../../../../../selectors/featureFlagController/socialLeaderboard';
+import { selectPerpsEnabledFlag } from '../../../../UI/Perps';
+import { selectIsFirstTimePerpsUser } from '../../../../UI/Perps/selectors/perpsController';
+import { selectPredictEnabledFlag } from '../../../../UI/Predict/selectors/featureFlags';
+import {
+ ActionButtonType,
+ ActionLocation,
+ ActionPosition,
+} from '../../../../../util/analytics/actionButtonTracking';
+import { HomepageActionButtonsGridTestIds } from './HomepageActionButtonsGrid.testIds';
+
+const mockTrackActionButtonClick = jest.fn();
+const mockGoToBuy = jest.fn();
+const mockGoToSell = jest.fn();
+const mockGoToSwaps = jest.fn();
+const mockNavigate = jest.fn();
+const mockOnSend = jest.fn();
+const mockNavigateToSocialLeaderboard = jest.fn();
+
+jest.mock('../../../../../util/analytics/actionButtonTracking', () => {
+ const actual = jest.requireActual(
+ '../../../../../util/analytics/actionButtonTracking',
+ );
+ return {
+ ...actual,
+ trackActionButtonClick: (...args: unknown[]) =>
+ mockTrackActionButtonClick(...args),
+ };
+});
+
+jest.mock('../../../../hooks/useAnalytics/useAnalytics', () => ({
+ useAnalytics: () => ({
+ trackEvent: jest.fn(),
+ createEventBuilder: jest.fn(() => ({
+ addProperties: jest.fn().mockReturnThis(),
+ build: jest.fn(),
+ })),
+ }),
+}));
+
+jest.mock('@react-navigation/native', () => ({
+ ...jest.requireActual('@react-navigation/native'),
+ useNavigation: () => ({
+ navigate: mockNavigate,
+ }),
+}));
+
+jest.mock('../../../../UI/Ramp/hooks/useRampNavigation', () => ({
+ useRampNavigation: () => ({
+ goToBuy: mockGoToBuy,
+ goToSell: mockGoToSell,
+ }),
+}));
+
+jest.mock('../../../../UI/Bridge/hooks/useSwapBridgeNavigation', () => ({
+ SwapBridgeNavigationLocation: { MainView: 'MainView' },
+ useSwapBridgeNavigation: () => ({
+ goToSwaps: mockGoToSwaps,
+ }),
+}));
+
+jest.mock(
+ '../../../SocialLeaderboard/Onboarding/socialLeaderboardOnboardingNavigation',
+ () => ({
+ navigateToSocialLeaderboard: (...args: unknown[]) =>
+ mockNavigateToSocialLeaderboard(...args),
+ }),
+);
+
+const mockUseSelector = jest.fn();
+jest.mock('react-redux', () => ({
+ ...jest.requireActual('react-redux'),
+ useSelector: (selector: unknown) => mockUseSelector(selector),
+}));
+
+import HomepageActionButtonsGrid from './HomepageActionButtonsGrid';
+
+interface SelectorOverrides {
+ canSignTransactions?: boolean;
+ isSwapsEnabled?: boolean;
+ isBatchSellEnabled?: boolean;
+ isSocialLeaderboardEnabled?: boolean;
+ isPerpsEnabled?: boolean;
+ isPredictEnabled?: boolean;
+ isFirstTimePerpsUser?: boolean;
+}
+
+const mockSelectorState = (overrides: SelectorOverrides = {}) => {
+ const {
+ canSignTransactions = true,
+ isSwapsEnabled = true,
+ isBatchSellEnabled = true,
+ isSocialLeaderboardEnabled = true,
+ isPerpsEnabled = true,
+ isPredictEnabled = true,
+ isFirstTimePerpsUser = false,
+ } = overrides;
+
+ mockUseSelector.mockImplementation((selector: unknown) => {
+ if (selector === selectCanSignTransactions) {
+ return canSignTransactions;
+ }
+ if (selector === selectIsSwapsEnabled) {
+ return isSwapsEnabled;
+ }
+ if (selector === selectBatchSellEnabled) {
+ return isBatchSellEnabled;
+ }
+ if (selector === selectSocialLeaderboardEnabled) {
+ return isSocialLeaderboardEnabled;
+ }
+ if (selector === selectPerpsEnabledFlag) {
+ return isPerpsEnabled;
+ }
+ if (selector === selectIsFirstTimePerpsUser) {
+ return isFirstTimePerpsUser;
+ }
+ if (selector === selectPredictEnabledFlag) {
+ return isPredictEnabled;
+ }
+ // Swap / Batch Swap wrap selectIsSwapsEnabled in an arrow selector.
+ if (typeof selector === 'function') {
+ return isSwapsEnabled;
+ }
+ return true;
+ });
+};
+
+const ROW1_TOP_BUTTON_CASES = [
+ {
+ testId: HomepageActionButtonsGridTestIds.BUY_BUTTON,
+ actionName: ActionButtonType.BUY,
+ actionPosition: ActionPosition.FIRST_POSITION,
+ },
+ {
+ testId: HomepageActionButtonsGridTestIds.SELL_BUTTON,
+ actionName: ActionButtonType.SELL,
+ actionPosition: ActionPosition.SECOND_POSITION,
+ },
+ {
+ testId: HomepageActionButtonsGridTestIds.SWAP_BUTTON,
+ actionName: ActionButtonType.SWAP,
+ actionPosition: ActionPosition.THIRD_POSITION,
+ },
+ {
+ testId: HomepageActionButtonsGridTestIds.SEND_BUTTON,
+ actionName: ActionButtonType.SEND,
+ actionPosition: ActionPosition.FOURTH_POSITION,
+ },
+ {
+ testId: HomepageActionButtonsGridTestIds.PERPS_BUTTON,
+ actionName: ActionButtonType.PERPS,
+ actionPosition: ActionPosition.FIFTH_POSITION,
+ },
+ {
+ testId: HomepageActionButtonsGridTestIds.PREDICT_BUTTON,
+ actionName: ActionButtonType.PREDICT,
+ actionPosition: ActionPosition.SIXTH_POSITION,
+ },
+ {
+ testId: HomepageActionButtonsGridTestIds.BATCH_SWAP_BUTTON,
+ actionName: ActionButtonType.BATCH_SWAP,
+ actionPosition: ActionPosition.SEVENTH_POSITION,
+ },
+ {
+ testId: HomepageActionButtonsGridTestIds.TRADERS_BUTTON,
+ actionName: ActionButtonType.TRADERS,
+ actionPosition: ActionPosition.EIGHTH_POSITION,
+ },
+] as const;
+
+describe('HomepageActionButtonsGrid', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockSelectorState();
+ });
+
+ it('renders all eight buttons for row1Top order', () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ expect(
+ getByTestId(HomepageActionButtonsGridTestIds.CONTAINER),
+ ).toBeOnTheScreen();
+ ROW1_TOP_BUTTON_CASES.forEach(({ testId }) => {
+ expect(getByTestId(testId)).toBeOnTheScreen();
+ });
+ });
+
+ it.each(ROW1_TOP_BUTTON_CASES)(
+ 'tracks $actionName tap with position $actionPosition for row1Top',
+ ({ testId, actionName, actionPosition }) => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ fireEvent.press(getByTestId(testId));
+
+ expect(mockTrackActionButtonClick).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.anything(),
+ expect.objectContaining({
+ action_name: actionName,
+ action_position: actionPosition,
+ location: ActionLocation.HOME,
+ }),
+ );
+ },
+ );
+
+ it('tracks sell tap with sixth position for row2Top', () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ fireEvent.press(getByTestId(HomepageActionButtonsGridTestIds.SELL_BUTTON));
+
+ expect(mockTrackActionButtonClick).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.anything(),
+ expect.objectContaining({
+ action_name: ActionButtonType.SELL,
+ action_position: ActionPosition.SIXTH_POSITION,
+ location: ActionLocation.HOME,
+ }),
+ );
+ });
+
+ it('calls onSend when send is pressed', () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ fireEvent.press(getByTestId(HomepageActionButtonsGridTestIds.SEND_BUTTON));
+
+ expect(mockOnSend).toHaveBeenCalledTimes(1);
+ });
+
+ describe('grey-out when product flags are off', () => {
+ it('disables buy and sell when account cannot sign', () => {
+ mockSelectorState({ canSignTransactions: false });
+
+ const { getByTestId } = render(
+ ,
+ );
+
+ expect(
+ getByTestId(HomepageActionButtonsGridTestIds.BUY_BUTTON).props
+ .accessibilityState,
+ ).toMatchObject({ disabled: true });
+ expect(
+ getByTestId(HomepageActionButtonsGridTestIds.SELL_BUTTON).props
+ .accessibilityState,
+ ).toMatchObject({ disabled: true });
+ expect(
+ getByTestId(HomepageActionButtonsGridTestIds.SEND_BUTTON).props
+ .accessibilityState,
+ ).toMatchObject({ disabled: true });
+ });
+
+ it('disables swap when swaps are disabled', () => {
+ mockSelectorState({ isSwapsEnabled: false });
+
+ const { getByTestId } = render(
+ ,
+ );
+
+ expect(
+ getByTestId(HomepageActionButtonsGridTestIds.SWAP_BUTTON).props
+ .accessibilityState,
+ ).toMatchObject({ disabled: true });
+ });
+
+ it('disables perps when perps flag is off', () => {
+ mockSelectorState({ isPerpsEnabled: false });
+
+ const { getByTestId } = render(
+ ,
+ );
+
+ expect(
+ getByTestId(HomepageActionButtonsGridTestIds.PERPS_BUTTON).props
+ .accessibilityState,
+ ).toMatchObject({ disabled: true });
+ });
+
+ it('disables predict when predict flag is off', () => {
+ mockSelectorState({ isPredictEnabled: false });
+
+ const { getByTestId } = render(
+ ,
+ );
+
+ expect(
+ getByTestId(HomepageActionButtonsGridTestIds.PREDICT_BUTTON).props
+ .accessibilityState,
+ ).toMatchObject({ disabled: true });
+ });
+
+ it('disables batch swap when batch sell flag is off', () => {
+ mockSelectorState({ isBatchSellEnabled: false });
+
+ const { getByTestId } = render(
+ ,
+ );
+
+ expect(
+ getByTestId(HomepageActionButtonsGridTestIds.BATCH_SWAP_BUTTON).props
+ .accessibilityState,
+ ).toMatchObject({ disabled: true });
+ });
+
+ it('disables traders when social leaderboard flag is off', () => {
+ mockSelectorState({ isSocialLeaderboardEnabled: false });
+
+ const { getByTestId } = render(
+ ,
+ );
+
+ expect(
+ getByTestId(HomepageActionButtonsGridTestIds.TRADERS_BUTTON).props
+ .accessibilityState,
+ ).toMatchObject({ disabled: true });
+ });
+
+ it('does not track or navigate when a disabled button is pressed', () => {
+ mockSelectorState({ isPerpsEnabled: false });
+
+ const { getByTestId } = render(
+ ,
+ );
+
+ fireEvent.press(
+ getByTestId(HomepageActionButtonsGridTestIds.PERPS_BUTTON),
+ );
+
+ expect(mockTrackActionButtonClick).not.toHaveBeenCalled();
+ expect(mockNavigate).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/app/components/Views/Homepage/components/HomepageActionButtonsGrid/HomepageActionButtonsGrid.testIds.ts b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/HomepageActionButtonsGrid.testIds.ts
new file mode 100644
index 00000000000..90461f9c387
--- /dev/null
+++ b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/HomepageActionButtonsGrid.testIds.ts
@@ -0,0 +1,11 @@
+export const HomepageActionButtonsGridTestIds = {
+ CONTAINER: 'homepage-action-buttons-grid',
+ BUY_BUTTON: 'homepage-action-buttons-grid-buy',
+ SELL_BUTTON: 'homepage-action-buttons-grid-sell',
+ SWAP_BUTTON: 'homepage-action-buttons-grid-swap',
+ SEND_BUTTON: 'homepage-action-buttons-grid-send',
+ PERPS_BUTTON: 'homepage-action-buttons-grid-perps',
+ PREDICT_BUTTON: 'homepage-action-buttons-grid-predict',
+ BATCH_SWAP_BUTTON: 'homepage-action-buttons-grid-batch-swap',
+ TRADERS_BUTTON: 'homepage-action-buttons-grid-traders',
+} as const;
diff --git a/app/components/Views/Homepage/components/HomepageActionButtonsGrid/HomepageActionButtonsGrid.tsx b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/HomepageActionButtonsGrid.tsx
new file mode 100644
index 00000000000..0047e50e6c9
--- /dev/null
+++ b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/HomepageActionButtonsGrid.tsx
@@ -0,0 +1,92 @@
+import React from 'react';
+import {
+ Box,
+ BoxFlexDirection,
+ BoxJustifyContent,
+} from '@metamask/design-system-react-native';
+import BuyButton from './buttons/BuyButton';
+import SellButton from './buttons/SellButton';
+import SwapButton from './buttons/SwapButton';
+import SendButton from './buttons/SendButton';
+import PerpsButton from './buttons/PerpsButton';
+import PredictButton from './buttons/PredictButton';
+import BatchSwapButton from './buttons/BatchSwapButton';
+import TradersButton from './buttons/TradersButton';
+import {
+ ACTION_POSITION_BY_INDEX,
+ getOrderedButtonRows,
+ type HomepageActionButtonId,
+} from './constants';
+import { HomepageActionButtonsGridTestIds } from './HomepageActionButtonsGrid.testIds';
+import type { HomepageActionButtonsGridProps } from './types';
+
+const renderButton = (
+ id: HomepageActionButtonId,
+ actionPosition: (typeof ACTION_POSITION_BY_INDEX)[number],
+ onSend: () => void,
+ allowTwoLineLabel: boolean,
+) => {
+ const slotProps = { actionPosition, allowTwoLineLabel };
+ switch (id) {
+ case 'buy':
+ return ;
+ case 'sell':
+ return ;
+ case 'swap':
+ return ;
+ case 'send':
+ return ;
+ case 'perps':
+ return ;
+ case 'predict':
+ return ;
+ case 'batch_swap':
+ return ;
+ case 'traders':
+ return ;
+ default: {
+ const exhaustive: never = id;
+ return exhaustive;
+ }
+ }
+};
+
+/**
+ * Homepage 8-button 2×4 action grid for TMCU-1103 treatments.
+ * Row order is controlled by the AB variant (`row1Top` | `row2Top`).
+ */
+const HomepageActionButtonsGrid = ({
+ rowOrder,
+ onSend,
+ allowTwoLineLabel = false,
+}: HomepageActionButtonsGridProps) => {
+ const rows = getOrderedButtonRows(rowOrder);
+
+ return (
+
+ {rows.map((rowIds, rowIndex) => (
+
+ {rowIds.map((id, colIndex) => {
+ const actionPosition =
+ ACTION_POSITION_BY_INDEX[rowIndex * 4 + colIndex];
+ return (
+
+ {renderButton(id, actionPosition, onSend, allowTwoLineLabel)}
+
+ );
+ })}
+
+ ))}
+
+ );
+};
+
+export default HomepageActionButtonsGrid;
diff --git a/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/BatchSwapButton.tsx b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/BatchSwapButton.tsx
new file mode 100644
index 00000000000..e31e43c7b2c
--- /dev/null
+++ b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/BatchSwapButton.tsx
@@ -0,0 +1,68 @@
+import React, { useCallback } from 'react';
+import { useNavigation } from '@react-navigation/native';
+import { useSelector } from 'react-redux';
+import { BatchSellMetricsLocation } from '@metamask/bridge-controller';
+import { IconName } from '@metamask/design-system-react-native';
+import { strings } from '../../../../../../../locales/i18n';
+import AppConstants from '../../../../../../core/AppConstants';
+import Routes from '../../../../../../constants/navigation/Routes';
+import { selectIsSwapsEnabled } from '../../../../../../core/redux/slices/bridge';
+import type { RootState } from '../../../../../../reducers';
+import { selectBatchSellEnabled } from '../../../../../../selectors/featureFlagController/batchSell';
+import { useAnalytics } from '../../../../../hooks/useAnalytics/useAnalytics';
+import {
+ ActionButtonType,
+ ActionLocation,
+ trackActionButtonClick,
+} from '../../../../../../util/analytics/actionButtonTracking';
+import HomepageActionButton from '../HomepageActionButton';
+import { HomepageActionButtonsGridTestIds } from '../HomepageActionButtonsGrid.testIds';
+import type { HomepageActionButtonSlotProps } from '../types';
+
+/**
+ * Opens Batch Sell (trade-menu flow). Label matches Figma "Batch Swap".
+ * `batchSellLocation` left as Unknown until analytics defines a home entrypoint.
+ */
+const BatchSwapButton = ({
+ actionPosition,
+ allowTwoLineLabel,
+}: HomepageActionButtonSlotProps) => {
+ const navigation = useNavigation();
+ const { trackEvent, createEventBuilder } = useAnalytics();
+ const isBatchSellEnabled = useSelector(selectBatchSellEnabled);
+ const isSwapsEnabled = useSelector((state: RootState) =>
+ selectIsSwapsEnabled(state),
+ );
+ const label = strings('homepage.action_buttons.batch_swap');
+ const isDisabled =
+ !isBatchSellEnabled || !AppConstants.SWAPS.ACTIVE || !isSwapsEnabled;
+
+ const handlePress = useCallback(() => {
+ trackActionButtonClick(trackEvent, createEventBuilder, {
+ action_name: ActionButtonType.BATCH_SWAP,
+ action_position: actionPosition,
+ button_label: label,
+ location: ActionLocation.HOME,
+ });
+
+ navigation.navigate(Routes.BRIDGE.ROOT, {
+ screen: Routes.BRIDGE.BATCH_SELL_TOKEN_SELECT,
+ params: {
+ batchSellLocation: BatchSellMetricsLocation.Unknown,
+ },
+ });
+ }, [actionPosition, createEventBuilder, label, navigation, trackEvent]);
+
+ return (
+
+ );
+};
+
+export default BatchSwapButton;
diff --git a/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/BuyButton.tsx b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/BuyButton.tsx
new file mode 100644
index 00000000000..9674ddb182d
--- /dev/null
+++ b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/BuyButton.tsx
@@ -0,0 +1,48 @@
+import React, { useCallback } from 'react';
+import { useSelector } from 'react-redux';
+import { IconName } from '@metamask/design-system-react-native';
+import { strings } from '../../../../../../../locales/i18n';
+import { selectCanSignTransactions } from '../../../../../../selectors/accountsController';
+import { useAnalytics } from '../../../../../hooks/useAnalytics/useAnalytics';
+import { useRampNavigation } from '../../../../../UI/Ramp/hooks/useRampNavigation';
+import {
+ ActionButtonType,
+ ActionLocation,
+ trackActionButtonClick,
+} from '../../../../../../util/analytics/actionButtonTracking';
+import HomepageActionButton from '../HomepageActionButton';
+import { HomepageActionButtonsGridTestIds } from '../HomepageActionButtonsGrid.testIds';
+import type { HomepageActionButtonSlotProps } from '../types';
+
+const BuyButton = ({
+ actionPosition,
+ allowTwoLineLabel,
+}: HomepageActionButtonSlotProps) => {
+ const { trackEvent, createEventBuilder } = useAnalytics();
+ const { goToBuy } = useRampNavigation();
+ const canSignTransactions = useSelector(selectCanSignTransactions);
+ const label = strings('homepage.action_buttons.buy');
+
+ const handlePress = useCallback(() => {
+ trackActionButtonClick(trackEvent, createEventBuilder, {
+ action_name: ActionButtonType.BUY,
+ action_position: actionPosition,
+ button_label: label,
+ location: ActionLocation.HOME,
+ });
+ goToBuy();
+ }, [actionPosition, createEventBuilder, goToBuy, label, trackEvent]);
+
+ return (
+
+ );
+};
+
+export default BuyButton;
diff --git a/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/PerpsButton.tsx b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/PerpsButton.tsx
new file mode 100644
index 00000000000..b366be1734c
--- /dev/null
+++ b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/PerpsButton.tsx
@@ -0,0 +1,69 @@
+import React, { useCallback } from 'react';
+import { useNavigation } from '@react-navigation/native';
+import { useSelector } from 'react-redux';
+import { IconName } from '@metamask/design-system-react-native';
+import { strings } from '../../../../../../../locales/i18n';
+import Routes from '../../../../../../constants/navigation/Routes';
+import { selectCanSignTransactions } from '../../../../../../selectors/accountsController';
+import { selectPerpsEnabledFlag } from '../../../../../UI/Perps';
+import { selectIsFirstTimePerpsUser } from '../../../../../UI/Perps/selectors/perpsController';
+import { useAnalytics } from '../../../../../hooks/useAnalytics/useAnalytics';
+import {
+ ActionButtonType,
+ ActionLocation,
+ trackActionButtonClick,
+} from '../../../../../../util/analytics/actionButtonTracking';
+import HomepageActionButton from '../HomepageActionButton';
+import { HomepageActionButtonsGridTestIds } from '../HomepageActionButtonsGrid.testIds';
+import type { HomepageActionButtonSlotProps } from '../types';
+
+const PerpsButton = ({
+ actionPosition,
+ allowTwoLineLabel,
+}: HomepageActionButtonSlotProps) => {
+ const navigation = useNavigation();
+ const { trackEvent, createEventBuilder } = useAnalytics();
+ const isPerpsEnabled = useSelector(selectPerpsEnabledFlag);
+ const isFirstTimePerpsUser = useSelector(selectIsFirstTimePerpsUser);
+ const canSignTransactions = useSelector(selectCanSignTransactions);
+ const label = strings('homepage.action_buttons.perps');
+ const isDisabled = !isPerpsEnabled || !canSignTransactions;
+
+ const handlePress = useCallback(() => {
+ trackActionButtonClick(trackEvent, createEventBuilder, {
+ action_name: ActionButtonType.PERPS,
+ action_position: actionPosition,
+ button_label: label,
+ location: ActionLocation.HOME,
+ });
+
+ if (isFirstTimePerpsUser) {
+ navigation.navigate(Routes.PERPS.TUTORIAL);
+ return;
+ }
+
+ navigation.navigate(Routes.PERPS.ROOT, {
+ screen: Routes.PERPS.PERPS_HOME,
+ });
+ }, [
+ actionPosition,
+ createEventBuilder,
+ isFirstTimePerpsUser,
+ label,
+ navigation,
+ trackEvent,
+ ]);
+
+ return (
+
+ );
+};
+
+export default PerpsButton;
diff --git a/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/PredictButton.tsx b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/PredictButton.tsx
new file mode 100644
index 00000000000..c3c37d0972d
--- /dev/null
+++ b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/PredictButton.tsx
@@ -0,0 +1,59 @@
+import React, { useCallback } from 'react';
+import { useNavigation } from '@react-navigation/native';
+import { useSelector } from 'react-redux';
+import { IconName } from '@metamask/design-system-react-native';
+import { strings } from '../../../../../../../locales/i18n';
+import Routes from '../../../../../../constants/navigation/Routes';
+import { selectCanSignTransactions } from '../../../../../../selectors/accountsController';
+import { selectPredictEnabledFlag } from '../../../../../UI/Predict/selectors/featureFlags';
+import { PredictEventValues } from '../../../../../UI/Predict/constants/eventNames';
+import { useAnalytics } from '../../../../../hooks/useAnalytics/useAnalytics';
+import {
+ ActionButtonType,
+ ActionLocation,
+ trackActionButtonClick,
+} from '../../../../../../util/analytics/actionButtonTracking';
+import HomepageActionButton from '../HomepageActionButton';
+import { HomepageActionButtonsGridTestIds } from '../HomepageActionButtonsGrid.testIds';
+import type { HomepageActionButtonSlotProps } from '../types';
+
+const PredictButton = ({
+ actionPosition,
+ allowTwoLineLabel,
+}: HomepageActionButtonSlotProps) => {
+ const navigation = useNavigation();
+ const { trackEvent, createEventBuilder } = useAnalytics();
+ const isPredictEnabled = useSelector(selectPredictEnabledFlag);
+ const canSignTransactions = useSelector(selectCanSignTransactions);
+ const label = strings('homepage.action_buttons.predict');
+ const isDisabled = !isPredictEnabled || !canSignTransactions;
+
+ const handlePress = useCallback(() => {
+ trackActionButtonClick(trackEvent, createEventBuilder, {
+ action_name: ActionButtonType.PREDICT,
+ action_position: actionPosition,
+ button_label: label,
+ location: ActionLocation.HOME,
+ });
+
+ navigation.navigate(Routes.PREDICT.ROOT, {
+ screen: Routes.PREDICT.MARKET_LIST,
+ params: {
+ entryPoint: PredictEventValues.ENTRY_POINT.HOMESCREEN_PILL,
+ },
+ });
+ }, [actionPosition, createEventBuilder, label, navigation, trackEvent]);
+
+ return (
+
+ );
+};
+
+export default PredictButton;
diff --git a/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/SellButton.tsx b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/SellButton.tsx
new file mode 100644
index 00000000000..10aa1815454
--- /dev/null
+++ b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/SellButton.tsx
@@ -0,0 +1,48 @@
+import React, { useCallback } from 'react';
+import { useSelector } from 'react-redux';
+import { IconName } from '@metamask/design-system-react-native';
+import { strings } from '../../../../../../../locales/i18n';
+import { selectCanSignTransactions } from '../../../../../../selectors/accountsController';
+import { useAnalytics } from '../../../../../hooks/useAnalytics/useAnalytics';
+import { useRampNavigation } from '../../../../../UI/Ramp/hooks/useRampNavigation';
+import {
+ ActionButtonType,
+ ActionLocation,
+ trackActionButtonClick,
+} from '../../../../../../util/analytics/actionButtonTracking';
+import HomepageActionButton from '../HomepageActionButton';
+import { HomepageActionButtonsGridTestIds } from '../HomepageActionButtonsGrid.testIds';
+import type { HomepageActionButtonSlotProps } from '../types';
+
+const SellButton = ({
+ actionPosition,
+ allowTwoLineLabel,
+}: HomepageActionButtonSlotProps) => {
+ const { trackEvent, createEventBuilder } = useAnalytics();
+ const { goToSell } = useRampNavigation();
+ const canSignTransactions = useSelector(selectCanSignTransactions);
+ const label = strings('homepage.action_buttons.sell');
+
+ const handlePress = useCallback(() => {
+ trackActionButtonClick(trackEvent, createEventBuilder, {
+ action_name: ActionButtonType.SELL,
+ action_position: actionPosition,
+ button_label: label,
+ location: ActionLocation.HOME,
+ });
+ goToSell();
+ }, [actionPosition, createEventBuilder, goToSell, label, trackEvent]);
+
+ return (
+
+ );
+};
+
+export default SellButton;
diff --git a/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/SendButton.tsx b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/SendButton.tsx
new file mode 100644
index 00000000000..9eebe889732
--- /dev/null
+++ b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/SendButton.tsx
@@ -0,0 +1,51 @@
+import React, { useCallback } from 'react';
+import { useSelector } from 'react-redux';
+import { IconName } from '@metamask/design-system-react-native';
+import { strings } from '../../../../../../../locales/i18n';
+import { selectCanSignTransactions } from '../../../../../../selectors/accountsController';
+import { useAnalytics } from '../../../../../hooks/useAnalytics/useAnalytics';
+import {
+ ActionButtonType,
+ ActionLocation,
+ trackActionButtonClick,
+} from '../../../../../../util/analytics/actionButtonTracking';
+import HomepageActionButton from '../HomepageActionButton';
+import { HomepageActionButtonsGridTestIds } from '../HomepageActionButtonsGrid.testIds';
+import type { HomepageActionButtonSlotProps } from '../types';
+
+interface SendButtonProps extends HomepageActionButtonSlotProps {
+ onSend: () => void;
+}
+
+const SendButton = ({
+ actionPosition,
+ allowTwoLineLabel,
+ onSend,
+}: SendButtonProps) => {
+ const { trackEvent, createEventBuilder } = useAnalytics();
+ const canSignTransactions = useSelector(selectCanSignTransactions);
+ const label = strings('homepage.action_buttons.send');
+
+ const handlePress = useCallback(() => {
+ trackActionButtonClick(trackEvent, createEventBuilder, {
+ action_name: ActionButtonType.SEND,
+ action_position: actionPosition,
+ button_label: label,
+ location: ActionLocation.HOME,
+ });
+ onSend();
+ }, [actionPosition, createEventBuilder, label, onSend, trackEvent]);
+
+ return (
+
+ );
+};
+
+export default SendButton;
diff --git a/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/SwapButton.tsx b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/SwapButton.tsx
new file mode 100644
index 00000000000..c61874add60
--- /dev/null
+++ b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/SwapButton.tsx
@@ -0,0 +1,60 @@
+import React, { useCallback } from 'react';
+import { useSelector } from 'react-redux';
+import { IconName } from '@metamask/design-system-react-native';
+import { strings } from '../../../../../../../locales/i18n';
+import AppConstants from '../../../../../../core/AppConstants';
+import { selectIsSwapsEnabled } from '../../../../../../core/redux/slices/bridge';
+import type { RootState } from '../../../../../../reducers';
+import { useAnalytics } from '../../../../../hooks/useAnalytics/useAnalytics';
+import {
+ SwapBridgeNavigationLocation,
+ useSwapBridgeNavigation,
+} from '../../../../../UI/Bridge/hooks/useSwapBridgeNavigation';
+import {
+ ActionButtonType,
+ ActionLocation,
+ trackActionButtonClick,
+} from '../../../../../../util/analytics/actionButtonTracking';
+import HomepageActionButton from '../HomepageActionButton';
+import { HomepageActionButtonsGridTestIds } from '../HomepageActionButtonsGrid.testIds';
+import type { HomepageActionButtonSlotProps } from '../types';
+
+const SwapButton = ({
+ actionPosition,
+ allowTwoLineLabel,
+}: HomepageActionButtonSlotProps) => {
+ const { trackEvent, createEventBuilder } = useAnalytics();
+ const isSwapsEnabled = useSelector((state: RootState) =>
+ selectIsSwapsEnabled(state),
+ );
+ const { goToSwaps } = useSwapBridgeNavigation({
+ location: SwapBridgeNavigationLocation.MainView,
+ sourcePage: 'MainView',
+ skipActionButtonClickTracking: true,
+ });
+ const label = strings('homepage.action_buttons.swap');
+ const isDisabled = !AppConstants.SWAPS.ACTIVE || !isSwapsEnabled;
+
+ const handlePress = useCallback(() => {
+ trackActionButtonClick(trackEvent, createEventBuilder, {
+ action_name: ActionButtonType.SWAP,
+ action_position: actionPosition,
+ button_label: label,
+ location: ActionLocation.HOME,
+ });
+ goToSwaps();
+ }, [actionPosition, createEventBuilder, goToSwaps, label, trackEvent]);
+
+ return (
+
+ );
+};
+
+export default SwapButton;
diff --git a/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/TradersButton.tsx b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/TradersButton.tsx
new file mode 100644
index 00000000000..a0d58829004
--- /dev/null
+++ b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/TradersButton.tsx
@@ -0,0 +1,56 @@
+import React, { useCallback } from 'react';
+import { useNavigation } from '@react-navigation/native';
+import { useSelector } from 'react-redux';
+import { IconName } from '@metamask/design-system-react-native';
+import { strings } from '../../../../../../../locales/i18n';
+import { selectSocialLeaderboardEnabled } from '../../../../../../selectors/featureFlagController/socialLeaderboard';
+import type { AppNavigationProp } from '../../../../../../core/NavigationService/types';
+import { useAnalytics } from '../../../../../hooks/useAnalytics/useAnalytics';
+// eslint-disable-next-line import-x/no-restricted-paths -- TODO(ADR-0020): route-isolation backlog
+import { navigateToSocialLeaderboard } from '../../../../SocialLeaderboard/Onboarding/socialLeaderboardOnboardingNavigation';
+import {
+ ActionButtonType,
+ ActionLocation,
+ trackActionButtonClick,
+} from '../../../../../../util/analytics/actionButtonTracking';
+import HomepageActionButton from '../HomepageActionButton';
+import { HomepageActionButtonsGridTestIds } from '../HomepageActionButtonsGrid.testIds';
+import type { HomepageActionButtonSlotProps } from '../types';
+
+const TradersButton = ({
+ actionPosition,
+ allowTwoLineLabel,
+}: HomepageActionButtonSlotProps) => {
+ const navigation = useNavigation();
+ const { trackEvent, createEventBuilder } = useAnalytics();
+ const isSocialLeaderboardEnabled = useSelector(
+ selectSocialLeaderboardEnabled,
+ );
+ const label = strings('homepage.action_buttons.traders');
+
+ const handlePress = useCallback(() => {
+ trackActionButtonClick(trackEvent, createEventBuilder, {
+ action_name: ActionButtonType.TRADERS,
+ action_position: actionPosition,
+ button_label: label,
+ location: ActionLocation.HOME,
+ });
+
+ navigateToSocialLeaderboard(navigation.navigate, {
+ source: 'home_carousel',
+ });
+ }, [actionPosition, createEventBuilder, label, navigation, trackEvent]);
+
+ return (
+
+ );
+};
+
+export default TradersButton;
diff --git a/app/components/Views/Homepage/components/HomepageActionButtonsGrid/constants.test.ts b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/constants.test.ts
new file mode 100644
index 00000000000..94f43c40c61
--- /dev/null
+++ b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/constants.test.ts
@@ -0,0 +1,36 @@
+import {
+ ACTION_POSITION_BY_INDEX,
+ getOrderedButtonRows,
+ ROW1_BUTTON_IDS,
+ ROW2_BUTTON_IDS,
+} from './constants';
+import { ActionPosition } from '../../../../../util/analytics/actionButtonTracking';
+
+describe('HomepageActionButtonsGrid constants', () => {
+ it('returns Row 1 then Row 2 for row1Top', () => {
+ expect(getOrderedButtonRows('row1Top')).toEqual([
+ ROW1_BUTTON_IDS,
+ ROW2_BUTTON_IDS,
+ ]);
+ });
+
+ it('returns Row 2 then Row 1 for row2Top', () => {
+ expect(getOrderedButtonRows('row2Top')).toEqual([
+ ROW2_BUTTON_IDS,
+ ROW1_BUTTON_IDS,
+ ]);
+ });
+
+ it('maps eight on-screen slots to ActionPosition 0–7', () => {
+ expect(ACTION_POSITION_BY_INDEX).toEqual([
+ ActionPosition.FIRST_POSITION,
+ ActionPosition.SECOND_POSITION,
+ ActionPosition.THIRD_POSITION,
+ ActionPosition.FOURTH_POSITION,
+ ActionPosition.FIFTH_POSITION,
+ ActionPosition.SIXTH_POSITION,
+ ActionPosition.SEVENTH_POSITION,
+ ActionPosition.EIGHTH_POSITION,
+ ]);
+ });
+});
diff --git a/app/components/Views/Homepage/components/HomepageActionButtonsGrid/constants.ts b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/constants.ts
new file mode 100644
index 00000000000..ce5e9092dac
--- /dev/null
+++ b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/constants.ts
@@ -0,0 +1,52 @@
+import { ActionPosition } from '../../../../../util/analytics/actionButtonTracking';
+import type { HomepageActionButtonsGridRowOrder } from '../../abTestConfig';
+
+/**
+ * Row 1 (Figma Treatment 4): Buy, Sell, Swap, Send
+ * Row 2: Perps, Predict, Batch Swap, Traders
+ */
+export const HOMEPAGE_ACTION_BUTTON_IDS = [
+ 'buy',
+ 'sell',
+ 'swap',
+ 'send',
+ 'perps',
+ 'predict',
+ 'batch_swap',
+ 'traders',
+] as const;
+
+export type HomepageActionButtonId =
+ (typeof HOMEPAGE_ACTION_BUTTON_IDS)[number];
+
+export const ROW1_BUTTON_IDS: HomepageActionButtonId[] = [
+ 'buy',
+ 'sell',
+ 'swap',
+ 'send',
+];
+
+export const ROW2_BUTTON_IDS: HomepageActionButtonId[] = [
+ 'perps',
+ 'predict',
+ 'batch_swap',
+ 'traders',
+];
+
+export const ACTION_POSITION_BY_INDEX: ActionPosition[] = [
+ ActionPosition.FIRST_POSITION,
+ ActionPosition.SECOND_POSITION,
+ ActionPosition.THIRD_POSITION,
+ ActionPosition.FOURTH_POSITION,
+ ActionPosition.FIFTH_POSITION,
+ ActionPosition.SIXTH_POSITION,
+ ActionPosition.SEVENTH_POSITION,
+ ActionPosition.EIGHTH_POSITION,
+];
+
+export const getOrderedButtonRows = (
+ rowOrder: HomepageActionButtonsGridRowOrder,
+): HomepageActionButtonId[][] =>
+ rowOrder === 'row1Top'
+ ? [ROW1_BUTTON_IDS, ROW2_BUTTON_IDS]
+ : [ROW2_BUTTON_IDS, ROW1_BUTTON_IDS];
diff --git a/app/components/Views/Homepage/components/HomepageActionButtonsGrid/index.ts b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/index.ts
new file mode 100644
index 00000000000..a1774f5c0f5
--- /dev/null
+++ b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/index.ts
@@ -0,0 +1,4 @@
+export { default as HomepageActionButtonsGrid } from './HomepageActionButtonsGrid';
+export { default as HomepageActionButton } from './HomepageActionButton';
+export { HomepageActionButtonsGridTestIds } from './HomepageActionButtonsGrid.testIds';
+export type { HomepageActionButtonsGridProps } from './types';
diff --git a/app/components/Views/Homepage/components/HomepageActionButtonsGrid/types.ts b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/types.ts
new file mode 100644
index 00000000000..74a655adbf3
--- /dev/null
+++ b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/types.ts
@@ -0,0 +1,24 @@
+import type { ActionPosition } from '../../../../../util/analytics/actionButtonTracking';
+import type { HomepageActionButtonsGridRowOrder } from '../../abTestConfig';
+
+export interface HomepageActionButtonSlotProps {
+ /** On-screen position (0–7) for ACTION_BUTTON_CLICKED analytics. */
+ actionPosition: ActionPosition;
+ /** When true, label wraps to 2 lines before tail ellipsis. */
+ allowTwoLineLabel?: boolean;
+}
+
+export interface HomepageActionButtonsGridProps {
+ rowOrder: HomepageActionButtonsGridRowOrder;
+ /**
+ * Send handler owned by Wallet (includes non-EVM / keyring-snaps paths).
+ */
+ onSend: () => void;
+ /**
+ * When true, action labels wrap to 2 lines before ellipsizing (e.g. "Batch Swap").
+ * @default false
+ */
+ allowTwoLineLabel?: boolean;
+}
+
+export type { HomepageActionButtonsGridRowOrder };
diff --git a/app/components/Views/Wallet/index.test.tsx b/app/components/Views/Wallet/index.test.tsx
index 23076014bf7..cdfcf67e296 100644
--- a/app/components/Views/Wallet/index.test.tsx
+++ b/app/components/Views/Wallet/index.test.tsx
@@ -109,6 +109,7 @@ jest.mock('../../UI/NetworkConnectionBanner', () => () => null);
// Control discovery tabs AB test variant per test (default control so existing tests are unaffected)
let mockDiscoveryTabsVariantName = 'control';
let mockDiscoveryPillsVariantName = 'control';
+let mockActionButtonsGridVariantName = 'control';
jest.mock('../../../hooks', () => ({
...jest.requireActual('../../../hooks'),
useABTest: jest.fn((flagKey: string) => {
@@ -127,6 +128,28 @@ jest.mock('../../../hooks', () => ({
};
}
+ if (flagKey === 'homeTMCU1103AbtestActionButtonsGrid') {
+ if (mockActionButtonsGridVariantName === 'row1Top') {
+ return {
+ variantName: 'row1Top',
+ variant: { layout: 'eightCircular', rowOrder: 'row1Top' },
+ isActive: true,
+ };
+ }
+ if (mockActionButtonsGridVariantName === 'row2Top') {
+ return {
+ variantName: 'row2Top',
+ variant: { layout: 'eightCircular', rowOrder: 'row2Top' },
+ isActive: true,
+ };
+ }
+ return {
+ variantName: 'control',
+ variant: { layout: 'fourSquare' },
+ isActive: true,
+ };
+ }
+
return {
variantName: mockDiscoveryTabsVariantName,
variant: {
@@ -138,6 +161,7 @@ jest.mock('../../../hooks', () => ({
}));
const mockHomepageDiscoveryPills = jest.fn();
+const mockHomepageActionButtonsGrid = jest.fn();
jest.mock('../Homepage/components/HomepageDiscoveryPills', () => {
const React = jest.requireActual('react');
const { View } = jest.requireActual('react-native');
@@ -150,6 +174,21 @@ jest.mock('../Homepage/components/HomepageDiscoveryPills', () => {
},
};
});
+jest.mock('../Homepage/components/HomepageActionButtonsGrid', () => {
+ const React = jest.requireActual('react');
+ const { View } = jest.requireActual('react-native');
+ return {
+ HomepageActionButtonsGrid: (props: {
+ rowOrder: string;
+ onSend: () => void;
+ }) => {
+ mockHomepageActionButtonsGrid(props);
+ return React.createElement(View, {
+ testID: 'homepage-action-buttons-grid-mock',
+ });
+ },
+ };
+});
// Track HomepageDiscoveryTabs renders
const mockHomepageDiscoveryTabs = jest.fn();
@@ -1788,6 +1827,7 @@ describe('HomepageDiscoveryPills AB test', () => {
afterEach(() => {
mockDiscoveryTabsVariantName = 'control';
mockDiscoveryPillsVariantName = 'control';
+ mockActionButtonsGridVariantName = 'control';
jest.clearAllMocks();
});
@@ -1837,6 +1877,91 @@ describe('HomepageDiscoveryPills AB test', () => {
});
});
+describe('HomepageActionButtonsGrid AB test', () => {
+ let mockNavigation: NavigationProp;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockDiscoveryTabsVariantName = 'control';
+ mockDiscoveryPillsVariantName = 'control';
+ mockActionButtonsGridVariantName = 'control';
+ mockHomepageActionButtonsGrid.mockClear();
+
+ mockNavigation = {
+ navigate: mockNavigate,
+ setOptions: mockSetOptions,
+ addListener: jest.fn(() => jest.fn()),
+ isFocused: jest.fn(() => false),
+ dangerouslyGetParent: jest.fn(() => ({
+ dangerouslyGetState: jest.fn(() => ({ type: 'stack' })),
+ addListener: jest.fn(() => jest.fn()),
+ dangerouslyGetParent: jest.fn(() => ({
+ dangerouslyGetState: jest.fn(() => ({ type: 'tab' })),
+ addListener: jest.fn(() => jest.fn()),
+ dangerouslyGetParent: jest.fn(() => undefined),
+ })),
+ })),
+ } as unknown as NavigationProp;
+
+ jest
+ .mocked(useSelector)
+ .mockImplementation((callback: (state: unknown) => unknown) =>
+ callback(mockInitialState),
+ );
+ });
+
+ afterEach(() => {
+ mockActionButtonsGridVariantName = 'control';
+ jest.clearAllMocks();
+ });
+
+ it('renders action buttons grid when row1Top variant is active', () => {
+ mockActionButtonsGridVariantName = 'row1Top';
+
+ renderWithProvider(
+ ,
+ { state: mockInitialState },
+ );
+
+ expect(mockHomepageActionButtonsGrid).toHaveBeenCalledWith(
+ expect.objectContaining({ rowOrder: 'row1Top' }),
+ );
+ });
+
+ it('renders action buttons grid when row2Top variant is active', () => {
+ mockActionButtonsGridVariantName = 'row2Top';
+
+ renderWithProvider(
+ ,
+ { state: mockInitialState },
+ );
+
+ expect(mockHomepageActionButtonsGrid).toHaveBeenCalledWith(
+ expect.objectContaining({ rowOrder: 'row2Top' }),
+ );
+ });
+
+ it('does not render action buttons grid when control variant is active', () => {
+ mockActionButtonsGridVariantName = 'control';
+
+ renderWithProvider(
+ ,
+ { state: mockInitialState },
+ );
+
+ expect(mockHomepageActionButtonsGrid).not.toHaveBeenCalled();
+ });
+});
+
describe('useHomeDeepLinkEffects', () => {
beforeEach(() => {
jest.clearAllMocks();
diff --git a/app/components/Views/Wallet/index.tsx b/app/components/Views/Wallet/index.tsx
index cb66f96c035..bdcd7eac0f6 100644
--- a/app/components/Views/Wallet/index.tsx
+++ b/app/components/Views/Wallet/index.tsx
@@ -132,6 +132,9 @@ import Homepage from '../Homepage';
// eslint-disable-next-line import-x/no-restricted-paths -- TODO(ADR-0020): route-isolation backlog
import HomepageDiscoveryTabs from '../Homepage/components/HomepageDiscoveryTabs';
import {
+ HOMEPAGE_ACTION_BUTTONS_GRID_AB_KEY,
+ HOMEPAGE_ACTION_BUTTONS_GRID_AB_TEST_EXPOSURE_OPTIONS,
+ HOMEPAGE_ACTION_BUTTONS_GRID_VARIANTS,
HOMEPAGE_DISCOVERY_PILLS_AB_KEY,
HOMEPAGE_DISCOVERY_PILLS_AB_TEST_EXPOSURE_OPTIONS,
HOMEPAGE_DISCOVERY_PILLS_VARIANTS,
@@ -142,6 +145,8 @@ import {
} from '../Homepage/abTestConfig';
// eslint-disable-next-line import-x/no-restricted-paths -- TODO(ADR-0020): route-isolation backlog
import { HomepageDiscoveryPills } from '../Homepage/components/HomepageDiscoveryPills';
+// eslint-disable-next-line import-x/no-restricted-paths -- TODO(ADR-0020): route-isolation backlog
+import { HomepageActionButtonsGrid } from '../Homepage/components/HomepageActionButtonsGrid';
import { useABTest } from '../../../hooks';
// eslint-disable-next-line import-x/no-restricted-paths -- TODO(ADR-0020): route-isolation backlog
import { HomepageScrollContext } from '../Homepage/context/HomepageScrollContext';
@@ -537,6 +542,30 @@ const Wallet = ({
///: END:ONLY_INCLUDE_IF
]);
+ /** Navigation-only send for AB treatment buttons (they own ACTION_BUTTON_CLICKED). */
+ const onSendWithoutTracking = useCallback(async () => {
+ try {
+ ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps)
+ const wasHandledAsNonEvm = await sendNonEvmAsset(
+ InitSendLocation.HomePage,
+ );
+ if (wasHandledAsNonEvm) {
+ return;
+ }
+ ///: END:ONLY_INCLUDE_IF
+
+ navigateToSendPage({ location: InitSendLocation.HomePage });
+ } catch (error) {
+ console.error('Error initiating send flow:', error);
+ navigateToSendPage({ location: InitSendLocation.HomePage });
+ }
+ }, [
+ navigateToSendPage,
+ ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps)
+ sendNonEvmAsset,
+ ///: END:ONLY_INCLUDE_IF
+ ]);
+
const isDataCollectionForMarketingEnabled = useSelector(
(state: RootState) => state.security.dataCollectionForMarketing,
);
@@ -757,6 +786,12 @@ const Wallet = ({
HOMEPAGE_DISCOVERY_PILLS_AB_TEST_EXPOSURE_OPTIONS,
);
+ const { variant: actionButtonsGridVariant } = useABTest(
+ HOMEPAGE_ACTION_BUTTONS_GRID_AB_KEY,
+ HOMEPAGE_ACTION_BUTTONS_GRID_VARIANTS,
+ HOMEPAGE_ACTION_BUTTONS_GRID_AB_TEST_EXPOSURE_OPTIONS,
+ );
+
const isDiscoveryTabsTreatment =
discoveryTabsVariantName === HubPageDiscoveryTabsVariant.Treatment;
@@ -1021,18 +1056,25 @@ const Wallet = ({
};
const walletHomeMainAssetDetailsActions = showWalletHomeMainActions ? (
-
+ actionButtonsGridVariant.layout === 'eightCircular' ? (
+
+ ) : (
+
+ )
) : null;
const homepageDiscoveryPills = showDiscoveryPills ? (
diff --git a/app/util/analytics/abTestAnalyticsRegistry.ts b/app/util/analytics/abTestAnalyticsRegistry.ts
index 7edb99c1734..385fbda2b1a 100644
--- a/app/util/analytics/abTestAnalyticsRegistry.ts
+++ b/app/util/analytics/abTestAnalyticsRegistry.ts
@@ -6,6 +6,7 @@ import { POST_TRADE_MODAL_AB_TEST_ANALYTICS_MAPPING } from '../../components/UI/
import { BRIDGE_TOKEN_SELECTOR_VERIFIED_BADGE_AB_TEST_ANALYTICS_MAPPING } from '../../components/UI/Bridge/components/TokenButton.abTestConfig';
import { TOKEN_SELECTOR_BALANCE_LAYOUT_AB_TEST_ANALYTICS_MAPPING } from '../../components/UI/Bridge/components/TokenSelectorItem.abTestConfig';
import {
+ HOMEPAGE_ACTION_BUTTONS_GRID_AB_TEST_ANALYTICS_MAPPING,
HOMEPAGE_DISCOVERY_PILLS_AB_TEST_ANALYTICS_MAPPING,
HOMEPAGE_PERPS_PILLS_EMPTY_AB_TEST_HOME_VIEWED_MAPPING,
HOMEPAGE_TRENDING_SECTIONS_AB_TEST_ANALYTICS_MAPPING,
@@ -31,6 +32,7 @@ export const AB_TEST_ANALYTICS_MAPPINGS: readonly ABTestAnalyticsMapping[] = [
TOKEN_SELECTOR_BALANCE_LAYOUT_AB_TEST_ANALYTICS_MAPPING,
// Homepage
+ HOMEPAGE_ACTION_BUTTONS_GRID_AB_TEST_ANALYTICS_MAPPING,
HOMEPAGE_DISCOVERY_PILLS_AB_TEST_ANALYTICS_MAPPING,
HOMEPAGE_PERPS_PILLS_EMPTY_AB_TEST_HOME_VIEWED_MAPPING,
HOMEPAGE_TRENDING_SECTIONS_AB_TEST_ANALYTICS_MAPPING,
diff --git a/app/util/analytics/actionButtonTracking.test.ts b/app/util/analytics/actionButtonTracking.test.ts
index 4c7ebbe7f15..81be68b0a16 100644
--- a/app/util/analytics/actionButtonTracking.test.ts
+++ b/app/util/analytics/actionButtonTracking.test.ts
@@ -57,18 +57,33 @@ describe('actionButtonTracking', () => {
expect(ActionButtonType.SWAP).toBe('swap');
expect(ActionButtonType.SEND).toBe('send');
expect(ActionButtonType.RECEIVE).toBe('receive');
+ expect(ActionButtonType.SELL).toBe('sell');
+ expect(ActionButtonType.PERPS).toBe('perps');
+ expect(ActionButtonType.PREDICT).toBe('predict');
+ expect(ActionButtonType.BATCH_SWAP).toBe('batch_swap');
+ expect(ActionButtonType.TRADERS).toBe('traders');
});
it('covers all action button types', () => {
// Given: all expected action types
- const expectedTypes = ['buy', 'swap', 'send', 'receive'];
+ const expectedTypes = [
+ 'buy',
+ 'swap',
+ 'send',
+ 'receive',
+ 'sell',
+ 'perps',
+ 'predict',
+ 'batch_swap',
+ 'traders',
+ ];
// When: checking enum values
const actualTypes = Object.values(ActionButtonType);
// Then: should match expected types
expect(actualTypes).toEqual(expect.arrayContaining(expectedTypes));
- expect(actualTypes).toHaveLength(4);
+ expect(actualTypes).toHaveLength(9);
});
});
@@ -108,19 +123,20 @@ describe('actionButtonTracking', () => {
expect(ActionPosition.SECOND_POSITION).toBe(1);
expect(ActionPosition.THIRD_POSITION).toBe(2);
expect(ActionPosition.FOURTH_POSITION).toBe(3);
+ expect(ActionPosition.FIFTH_POSITION).toBe(4);
+ expect(ActionPosition.SIXTH_POSITION).toBe(5);
+ expect(ActionPosition.SEVENTH_POSITION).toBe(6);
+ expect(ActionPosition.EIGHTH_POSITION).toBe(7);
});
- it('has corresponding ActionButtonType values', () => {
- // Given/When/Then: position values should correspond to button type values
- expect(ActionPosition.FIRST_POSITION).toBe(0);
- expect(ActionPosition.SECOND_POSITION).toBe(1);
- expect(ActionPosition.THIRD_POSITION).toBe(2);
- expect(ActionPosition.FOURTH_POSITION).toBe(3);
- // Verify ActionButtonType has corresponding string values
- expect(ActionButtonType.BUY).toBe('buy');
- expect(ActionButtonType.SWAP).toBe('swap');
- expect(ActionButtonType.SEND).toBe('send');
- expect(ActionButtonType.RECEIVE).toBe('receive');
+ it('covers eight positions for homepage grid AB tests', () => {
+ // Given/When: checking enum values
+ const actualPositions = Object.values(ActionPosition).filter(
+ (value) => typeof value === 'number',
+ );
+
+ // Then: should include positions 0–7
+ expect(actualPositions).toEqual([0, 1, 2, 3, 4, 5, 6, 7]);
});
});
diff --git a/app/util/analytics/actionButtonTracking.ts b/app/util/analytics/actionButtonTracking.ts
index 3b7241694f8..db1a480a358 100644
--- a/app/util/analytics/actionButtonTracking.ts
+++ b/app/util/analytics/actionButtonTracking.ts
@@ -14,17 +14,26 @@ export enum ActionButtonType {
SWAP = 'swap',
SEND = 'send',
RECEIVE = 'receive',
+ SELL = 'sell',
+ PERPS = 'perps',
+ PREDICT = 'predict',
+ BATCH_SWAP = 'batch_swap',
+ TRADERS = 'traders',
}
/**
- * The position of the button in the action button for remote config use and a/b testing
- * Not in use but will be in the future
+ * The position of the button in the action button for remote config use and a/b testing.
+ * Supports up to 8 slots for the homepage 2×4 action-button grid AB test (TMCU-1103).
*/
export enum ActionPosition {
FIRST_POSITION = 0,
SECOND_POSITION = 1,
THIRD_POSITION = 2,
FOURTH_POSITION = 3,
+ FIFTH_POSITION = 4,
+ SIXTH_POSITION = 5,
+ SEVENTH_POSITION = 6,
+ EIGHTH_POSITION = 7,
}
/**
diff --git a/locales/languages/en.json b/locales/languages/en.json
index 8487b9cbb2f..4970c27542c 100644
--- a/locales/languages/en.json
+++ b/locales/languages/en.json
@@ -10339,6 +10339,16 @@
}
},
"homepage": {
+ "action_buttons": {
+ "buy": "Buy",
+ "sell": "Sell",
+ "swap": "Swap",
+ "send": "Send",
+ "perps": "Perps",
+ "predict": "Predict",
+ "batch_swap": "Batch Swap",
+ "traders": "Traders"
+ },
"sections": {
"money": "Money",
"money_empty_description": "You don't have any mUSD yet. Convert stablecoins to mUSD from the Money section on the homepage.",
diff --git a/tests/feature-flags/feature-flag-registry.ts b/tests/feature-flags/feature-flag-registry.ts
index e461e081e03..f7fd67414c9 100644
--- a/tests/feature-flags/feature-flag-registry.ts
+++ b/tests/feature-flags/feature-flag-registry.ts
@@ -5135,6 +5135,36 @@ export const FEATURE_FLAG_REGISTRY: Record = {
status: FeatureFlagStatus.Active,
},
+ homeTMCU1103AbtestActionButtonsGrid: {
+ name: 'homeTMCU1103AbtestActionButtonsGrid',
+ type: FeatureFlagType.Remote,
+ inProd: true,
+ productionDefault: [
+ {
+ name: 'control',
+ scope: {
+ type: 'percentage_rollout',
+ value: 1,
+ },
+ },
+ {
+ name: 'row1Top',
+ scope: {
+ type: 'percentage_rollout',
+ value: 1,
+ },
+ },
+ {
+ name: 'row2Top',
+ scope: {
+ type: 'percentage_rollout',
+ value: 1,
+ },
+ },
+ ],
+ status: FeatureFlagStatus.Active,
+ },
+
homepageRedesignV1: {
name: 'homepageRedesignV1',
type: FeatureFlagType.Remote,