diff --git a/app/actions/settings/index.js b/app/actions/settings/index.js
index f68a9dd0837..21140d14c63 100644
--- a/app/actions/settings/index.js
+++ b/app/actions/settings/index.js
@@ -107,3 +107,10 @@ export function setPerpsChartPreferredCandlePeriod(preferredCandlePeriod) {
preferredCandlePeriod,
};
}
+
+export function setShowAccountOnLeaderboard(showAccountOnLeaderboard) {
+ return {
+ type: 'SET_SHOW_ACCOUNT_ON_LEADERBOARD',
+ showAccountOnLeaderboard,
+ };
+}
diff --git a/app/components/Views/Settings/SecuritySettings/Sections/TopTradersSection.test.tsx b/app/components/Views/Settings/SecuritySettings/Sections/TopTradersSection.test.tsx
new file mode 100644
index 00000000000..86bafaaa5e4
--- /dev/null
+++ b/app/components/Views/Settings/SecuritySettings/Sections/TopTradersSection.test.tsx
@@ -0,0 +1,192 @@
+import { fireEvent, screen, waitFor } from '@testing-library/react-native';
+import React from 'react';
+
+import { strings } from '../../../../../../locales/i18n';
+import { MetaMetricsEvents } from '../../../../../core/Analytics';
+import renderWithProvider from '../../../../../util/test/renderWithProvider';
+import { SecurityPrivacyViewSelectorsIDs } from '../SecurityPrivacyView.testIds';
+import TopTradersSection from './TopTradersSection';
+
+const mockMessengerCall = jest.fn().mockResolvedValue(undefined);
+const mockLoggerError = jest.fn();
+const mockTrackEvent = jest.fn();
+const mockAddProperties = jest.fn().mockReturnThis();
+const mockBuild = jest.fn().mockReturnValue({});
+const mockCreateEventBuilder = jest.fn().mockReturnValue({
+ addProperties: mockAddProperties,
+ build: mockBuild,
+});
+let mockLeaderboardEnabled = true;
+let mockOptFlowEnabled = true;
+
+jest.mock('../../../../../core/Engine', () => ({
+ controllerMessenger: {
+ call: (...args: unknown[]) => mockMessengerCall(...args),
+ },
+}));
+
+jest.mock('../../../../../util/Logger', () => ({
+ error: (...args: unknown[]) => mockLoggerError(...args),
+}));
+
+jest.mock('../../../../hooks/useAnalytics/useAnalytics', () => ({
+ useAnalytics: () => ({
+ trackEvent: mockTrackEvent,
+ createEventBuilder: mockCreateEventBuilder,
+ }),
+}));
+
+jest.mock(
+ '../../../../../selectors/featureFlagController/socialLeaderboard',
+ () => ({
+ selectSocialLeaderboardEnabled: () => mockLeaderboardEnabled,
+ selectSocialLeaderboardOptFlowEnabled: () => mockOptFlowEnabled,
+ }),
+);
+
+const renderWith = (showAccountOnLeaderboard = true) =>
+ renderWithProvider(, {
+ state: { settings: { showAccountOnLeaderboard } },
+ });
+
+describe('TopTradersSection', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockLeaderboardEnabled = true;
+ mockOptFlowEnabled = true;
+ mockMessengerCall.mockResolvedValue(undefined);
+ });
+
+ it('renders the section and toggle when the leaderboard is enabled', () => {
+ renderWith(true);
+
+ expect(
+ screen.getByTestId(SecurityPrivacyViewSelectorsIDs.TOP_TRADERS_SECTION),
+ ).toBeOnTheScreen();
+ expect(
+ screen.getByText(
+ strings('social_leaderboard.settings.show_account_on_leaderboard'),
+ ),
+ ).toBeOnTheScreen();
+ expect(
+ screen.getByTestId(
+ SecurityPrivacyViewSelectorsIDs.SHOW_ACCOUNT_ON_LEADERBOARD_TOGGLE,
+ ).props.value,
+ ).toBe(true);
+ });
+
+ it('renders nothing when the leaderboard feature is disabled', () => {
+ mockLeaderboardEnabled = false;
+ renderWith(true);
+
+ expect(
+ screen.queryByTestId(SecurityPrivacyViewSelectorsIDs.TOP_TRADERS_SECTION),
+ ).toBeNull();
+ });
+
+ it('renders nothing when the leaderboard opt-flow flag is disabled', () => {
+ mockOptFlowEnabled = false;
+ renderWith(true);
+
+ expect(
+ screen.queryByTestId(SecurityPrivacyViewSelectorsIDs.TOP_TRADERS_SECTION),
+ ).toBeNull();
+ });
+
+ it('opts out and reflects the toggle off after the backend confirms', async () => {
+ renderWith(true);
+ const toggle = screen.getByTestId(
+ SecurityPrivacyViewSelectorsIDs.SHOW_ACCOUNT_ON_LEADERBOARD_TOGGLE,
+ );
+
+ fireEvent(toggle, 'valueChange', false);
+
+ await waitFor(() =>
+ expect(mockMessengerCall).toHaveBeenCalledWith(
+ 'SocialController:optOutOfLeaderboard',
+ ),
+ );
+ // Persisted only after the backend confirms, not optimistically.
+ await waitFor(() => expect(toggle.props.value).toBe(false));
+ });
+
+ it('opts in when turned on', async () => {
+ renderWith(false);
+ const toggle = screen.getByTestId(
+ SecurityPrivacyViewSelectorsIDs.SHOW_ACCOUNT_ON_LEADERBOARD_TOGGLE,
+ );
+
+ fireEvent(toggle, 'valueChange', true);
+
+ await waitFor(() =>
+ expect(mockMessengerCall).toHaveBeenCalledWith(
+ 'SocialController:optInToLeaderboard',
+ ),
+ );
+ });
+
+ it('tracks the visibility-toggled event on a successful opt-out', async () => {
+ renderWith(true);
+ const toggle = screen.getByTestId(
+ SecurityPrivacyViewSelectorsIDs.SHOW_ACCOUNT_ON_LEADERBOARD_TOGGLE,
+ );
+
+ fireEvent(toggle, 'valueChange', false);
+
+ await waitFor(() =>
+ expect(mockCreateEventBuilder).toHaveBeenCalledWith(
+ MetaMetricsEvents.SOCIAL_TRADER_LEADERBOARD_VISIBILITY_TOGGLED,
+ ),
+ );
+ expect(mockAddProperties).toHaveBeenCalledWith({
+ show_account_on_leaderboard: false,
+ });
+ expect(mockTrackEvent).toHaveBeenCalledTimes(1);
+ });
+
+ it('tracks the visibility-toggled event on a successful opt-in', async () => {
+ renderWith(false);
+ const toggle = screen.getByTestId(
+ SecurityPrivacyViewSelectorsIDs.SHOW_ACCOUNT_ON_LEADERBOARD_TOGGLE,
+ );
+
+ fireEvent(toggle, 'valueChange', true);
+
+ await waitFor(() =>
+ expect(mockCreateEventBuilder).toHaveBeenCalledWith(
+ MetaMetricsEvents.SOCIAL_TRADER_LEADERBOARD_VISIBILITY_TOGGLED,
+ ),
+ );
+ expect(mockAddProperties).toHaveBeenCalledWith({
+ show_account_on_leaderboard: true,
+ });
+ expect(mockTrackEvent).toHaveBeenCalledTimes(1);
+ });
+
+ it('logs and leaves the toggle unchanged when the request fails', async () => {
+ mockMessengerCall.mockRejectedValueOnce(new Error('network down'));
+ renderWith(true);
+ const toggle = screen.getByTestId(
+ SecurityPrivacyViewSelectorsIDs.SHOW_ACCOUNT_ON_LEADERBOARD_TOGGLE,
+ );
+
+ fireEvent(toggle, 'valueChange', false);
+
+ await waitFor(() => expect(mockLoggerError).toHaveBeenCalled());
+ // Never persisted optimistically, so nothing to revert — stays as it was.
+ expect(toggle.props.value).toBe(true);
+ });
+
+ it('does not track the event when the request fails', async () => {
+ mockMessengerCall.mockRejectedValueOnce(new Error('network down'));
+ renderWith(true);
+ const toggle = screen.getByTestId(
+ SecurityPrivacyViewSelectorsIDs.SHOW_ACCOUNT_ON_LEADERBOARD_TOGGLE,
+ );
+
+ fireEvent(toggle, 'valueChange', false);
+
+ await waitFor(() => expect(mockLoggerError).toHaveBeenCalled());
+ expect(mockTrackEvent).not.toHaveBeenCalled();
+ });
+});
diff --git a/app/components/Views/Settings/SecuritySettings/Sections/TopTradersSection.tsx b/app/components/Views/Settings/SecuritySettings/Sections/TopTradersSection.tsx
new file mode 100644
index 00000000000..6a61e309b9f
--- /dev/null
+++ b/app/components/Views/Settings/SecuritySettings/Sections/TopTradersSection.tsx
@@ -0,0 +1,108 @@
+import { Text, TextVariant } from '@metamask/design-system-react-native';
+import React, { useCallback, useState } from 'react';
+import { View } from 'react-native';
+import { useDispatch, useSelector } from 'react-redux';
+
+import { strings } from '../../../../../../locales/i18n';
+import { setShowAccountOnLeaderboard } from '../../../../../actions/settings';
+import { MetaMetricsEvents } from '../../../../../core/Analytics';
+import Engine from '../../../../../core/Engine';
+import { RootState } from '../../../../../reducers';
+import {
+ selectSocialLeaderboardEnabled,
+ selectSocialLeaderboardOptFlowEnabled,
+} from '../../../../../selectors/featureFlagController/socialLeaderboard';
+import Logger from '../../../../../util/Logger';
+import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics';
+import { SecurityOptionToggle } from '../../../../UI/SecurityOptionToggle';
+import { useStyles } from '../../../../hooks/useStyles';
+import createStyles from '../SecuritySettings.styles';
+import { SecurityPrivacyViewSelectorsIDs } from '../SecurityPrivacyView.testIds';
+
+/**
+ * "Top Traders" section for the Security & privacy screen. Lets the user toggle
+ * whether their account is shown on the Top Traders leaderboard, calling
+ * `SocialController:optInToLeaderboard` / `optOutOfLeaderboard`. The displayed
+ * value is a local (non-AUS) mirror kept in the `settings` reducer, defaulting
+ * to shown; it is updated optimistically and reverted if the request fails.
+ */
+const TopTradersSection = () => {
+ const { styles } = useStyles(createStyles, {});
+ const dispatch = useDispatch();
+ const { trackEvent, createEventBuilder } = useAnalytics();
+ const isSocialLeaderboardEnabled = useSelector(
+ selectSocialLeaderboardEnabled,
+ );
+ const isLeaderboardOptFlowEnabled = useSelector(
+ selectSocialLeaderboardOptFlowEnabled,
+ );
+ const showAccountOnLeaderboard = useSelector(
+ (state: RootState) => state.settings.showAccountOnLeaderboard ?? true,
+ );
+ const [isUpdating, setIsUpdating] = useState(false);
+
+ const handleToggle = useCallback(
+ async (enabled: boolean) => {
+ if (isUpdating) {
+ return;
+ }
+ setIsUpdating(true);
+ try {
+ await (Engine.controllerMessenger.call as CallableFunction)(
+ enabled
+ ? 'SocialController:optInToLeaderboard'
+ : 'SocialController:optOutOfLeaderboard',
+ );
+ // Update state only after the backend confirms.
+ dispatch(setShowAccountOnLeaderboard(enabled));
+ trackEvent(
+ createEventBuilder(
+ MetaMetricsEvents.SOCIAL_TRADER_LEADERBOARD_VISIBILITY_TOGGLED,
+ )
+ .addProperties({ show_account_on_leaderboard: enabled })
+ .build(),
+ );
+ } catch (error) {
+ Logger.error(
+ error as Error,
+ 'TopTradersSection: failed to update leaderboard visibility',
+ );
+ }
+ // Not a `finally`: React Compiler can't lower try/finally and bails on it.
+ setIsUpdating(false);
+ },
+ [createEventBuilder, dispatch, isUpdating, trackEvent],
+ );
+
+ if (!isSocialLeaderboardEnabled || !isLeaderboardOptFlowEnabled) {
+ return null;
+ }
+
+ return (
+
+
+ {strings('social_leaderboard.settings.section_title')}
+
+ {/* Wrap the toggle in `styles.setting` (marginTop) so the gap below the
+ section heading matches the other sections (e.g. Analytics). */}
+
+
+
+
+ );
+};
+
+export default TopTradersSection;
diff --git a/app/components/Views/Settings/SecuritySettings/SecurityPrivacyView.testIds.ts b/app/components/Views/Settings/SecuritySettings/SecurityPrivacyView.testIds.ts
index 50fd8b639a2..4d7f3f813c4 100644
--- a/app/components/Views/Settings/SecuritySettings/SecurityPrivacyView.testIds.ts
+++ b/app/components/Views/Settings/SecuritySettings/SecurityPrivacyView.testIds.ts
@@ -14,6 +14,8 @@ export const SecurityPrivacyViewSelectorsIDs = {
CLEAR_PRIVACY_DATA_BUTTON: 'clear-privacy-data-button',
PROTECT_YOUR_WALLET: 'protect-your-wallet',
DELETE_WALLET_BUTTON: 'security-settings-delete-wallet-buttons',
+ TOP_TRADERS_SECTION: 'top-traders-section',
+ SHOW_ACCOUNT_ON_LEADERBOARD_TOGGLE: 'show-account-on-leaderboard-toggle',
};
export const SecurityPrivacyViewSelectorsText = {
diff --git a/app/components/Views/Settings/SecuritySettings/SecuritySettings.tsx b/app/components/Views/Settings/SecuritySettings/SecuritySettings.tsx
index e077aca68c7..4e24106761b 100644
--- a/app/components/Views/Settings/SecuritySettings/SecuritySettings.tsx
+++ b/app/components/Views/Settings/SecuritySettings/SecuritySettings.tsx
@@ -51,6 +51,7 @@ import { TextVariant as LibraryTextVariant } from '../../../../component-library
import BasicFunctionalityComponent from '../../../UI/BasicFunctionality/BasicFunctionality';
import Routes from '../../../../constants/navigation/Routes';
import MetaMetricsAndDataCollectionSection from './Sections/MetaMetricsAndDataCollectionSection/MetaMetricsAndDataCollectionSection';
+import TopTradersSection from './Sections/TopTradersSection';
import { selectIsMetamaskNotificationsEnabled } from '../../../../selectors/notifications';
import SwitchLoadingModal from '../../../../components/UI/Notification/SwitchLoadingModal';
import { RootState } from '../../../../reducers';
@@ -411,6 +412,7 @@ const Settings: React.FC = () => {
+
{renderHint()}
diff --git a/app/core/Analytics/MetaMetrics.events.ts b/app/core/Analytics/MetaMetrics.events.ts
index 20c992ad58f..9802a86849b 100644
--- a/app/core/Analytics/MetaMetrics.events.ts
+++ b/app/core/Analytics/MetaMetrics.events.ts
@@ -789,6 +789,7 @@ enum EVENT_NAME {
SOCIAL_QUICK_BUY_INTERACTED = 'Quick Buy Interacted',
SOCIAL_QUICK_TRADE_MODE_TOGGLED = 'Quick Trade Mode Toggled',
SOCIAL_FOLLOW_TRADING_NOTIFICATION_CLICKED = 'Follow Trading Notification Clicked',
+ SOCIAL_TRADER_LEADERBOARD_VISIBILITY_TOGGLED = 'Trader Leaderboard Visibility Toggled',
SOCIAL_FOLLOW_TRADING_ONBOARDING_VIEWED = 'Follow Trading Onboarding Viewed',
SOCIAL_FOLLOW_TRADING_ONBOARDING_COMPLETED = 'Follow Trading Onboarding Completed',
SOCIAL_FOLLOW_TRADING_ONBOARDING_INTERACTION = 'Follow Trading Onboarding Interaction',
@@ -2117,6 +2118,9 @@ const events = {
SOCIAL_FOLLOW_TRADING_NOTIFICATION_CLICKED: generateOpt(
EVENT_NAME.SOCIAL_FOLLOW_TRADING_NOTIFICATION_CLICKED,
),
+ SOCIAL_TRADER_LEADERBOARD_VISIBILITY_TOGGLED: generateOpt(
+ EVENT_NAME.SOCIAL_TRADER_LEADERBOARD_VISIBILITY_TOGGLED,
+ ),
SOCIAL_FOLLOW_TRADING_ONBOARDING_VIEWED: generateOpt(
EVENT_NAME.SOCIAL_FOLLOW_TRADING_ONBOARDING_VIEWED,
),
diff --git a/app/core/Engine/messengers/social-controller-messenger.ts b/app/core/Engine/messengers/social-controller-messenger.ts
index 8e34e4fb842..053fd64015d 100644
--- a/app/core/Engine/messengers/social-controller-messenger.ts
+++ b/app/core/Engine/messengers/social-controller-messenger.ts
@@ -33,6 +33,8 @@ export function getSocialControllerMessenger(
'SocialService:follow',
'SocialService:unfollow',
'SocialService:fetchFollowing',
+ 'SocialService:optOutOfLeaderboard',
+ 'SocialService:optInToLeaderboard',
],
messenger,
});
diff --git a/app/reducers/settings/index.js b/app/reducers/settings/index.js
index 6cb0af34a81..eb863d381c3 100644
--- a/app/reducers/settings/index.js
+++ b/app/reducers/settings/index.js
@@ -10,6 +10,9 @@ const initialState = {
basicFunctionalityEnabled: true,
deepLinkModalDisabled: false,
hapticsEnabled: true,
+ // Whether this account is shown on the Top Traders leaderboard. Local mirror
+ // of the backend opt-in/out state; defaults to shown. Not persisted to AUS.
+ showAccountOnLeaderboard: true,
// Perps chart preferences
perpsChartPreferences: {
preferredCandlePeriod: '15m', // Default to 15 minutes
@@ -73,6 +76,11 @@ const settingsReducer = (state = initialState, action) => {
...state,
hapticsEnabled: action.hapticsEnabled,
};
+ case 'SET_SHOW_ACCOUNT_ON_LEADERBOARD':
+ return {
+ ...state,
+ showAccountOnLeaderboard: action.showAccountOnLeaderboard,
+ };
case 'SET_PERPS_CHART_PREFERRED_CANDLE_PERIOD':
return {
...state,
diff --git a/app/selectors/featureFlagController/socialLeaderboard/index.test.ts b/app/selectors/featureFlagController/socialLeaderboard/index.test.ts
index f603411a424..259d2d45287 100644
--- a/app/selectors/featureFlagController/socialLeaderboard/index.test.ts
+++ b/app/selectors/featureFlagController/socialLeaderboard/index.test.ts
@@ -2,6 +2,7 @@ import {
selectAiSocialLeaderboardOnboardingEnabled,
selectSocialAIQuickBuyStreamQuotesEnabled,
selectSocialLeaderboardEnabled,
+ selectSocialLeaderboardOptFlowEnabled,
selectSocialLeaderboardPerpsEnabled,
} from '.';
// eslint-disable-next-line import-x/no-namespace
@@ -67,6 +68,74 @@ describe('selectSocialLeaderboardEnabled', () => {
});
});
+describe('selectSocialLeaderboardOptFlowEnabled', () => {
+ let mockHasMinimumRequiredVersion: jest.SpyInstance;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockHasMinimumRequiredVersion = jest.spyOn(
+ remoteFeatureFlagModule,
+ 'hasMinimumRequiredVersion',
+ );
+ mockHasMinimumRequiredVersion.mockReturnValue(true);
+ });
+
+ afterEach(() => {
+ mockHasMinimumRequiredVersion?.mockRestore();
+ });
+
+ it('returns true when remote flag is enabled and version requirement is met', () => {
+ const result = selectSocialLeaderboardOptFlowEnabled.resultFunc({
+ aiSocialLeaderboardOptFlowEnabled: {
+ enabled: true,
+ minimumVersion: '7.72.0',
+ },
+ });
+
+ expect(result).toBe(true);
+ });
+
+ it('returns false when remote flag is disabled', () => {
+ const result = selectSocialLeaderboardOptFlowEnabled.resultFunc({
+ aiSocialLeaderboardOptFlowEnabled: {
+ enabled: false,
+ minimumVersion: '7.72.0',
+ },
+ });
+
+ expect(result).toBe(false);
+ });
+
+ it('returns false when version requirement is not met', () => {
+ mockHasMinimumRequiredVersion.mockReturnValue(false);
+ const result = selectSocialLeaderboardOptFlowEnabled.resultFunc({
+ aiSocialLeaderboardOptFlowEnabled: {
+ enabled: true,
+ minimumVersion: '99.0.0',
+ },
+ });
+
+ expect(result).toBe(false);
+ });
+
+ it('returns false when remote flag is absent', () => {
+ const result = selectSocialLeaderboardOptFlowEnabled.resultFunc({});
+
+ expect(result).toBe(false);
+ });
+
+ it('returns false when remote flag has an invalid shape', () => {
+ const result = selectSocialLeaderboardOptFlowEnabled.resultFunc({
+ aiSocialLeaderboardOptFlowEnabled: {
+ enabled: 'invalid',
+ minimumVersion: 123,
+ },
+ });
+
+ expect(result).toBe(false);
+ });
+});
+
describe('selectSocialLeaderboardPerpsEnabled', () => {
let mockHasMinimumRequiredVersion: jest.SpyInstance;
diff --git a/app/selectors/featureFlagController/socialLeaderboard/index.ts b/app/selectors/featureFlagController/socialLeaderboard/index.ts
index 70b77520804..481fac529ad 100644
--- a/app/selectors/featureFlagController/socialLeaderboard/index.ts
+++ b/app/selectors/featureFlagController/socialLeaderboard/index.ts
@@ -15,6 +15,16 @@ export const selectSocialLeaderboardEnabled = createSelector(
},
);
+export const selectSocialLeaderboardOptFlowEnabled = createSelector(
+ selectRemoteFeatureFlags,
+ (remoteFeatureFlags) => {
+ const remoteFlag =
+ remoteFeatureFlags?.aiSocialLeaderboardOptFlowEnabled as unknown as VersionGatedFeatureFlag;
+
+ return validatedVersionGatedFeatureFlag(remoteFlag) ?? false;
+ },
+);
+
export const selectSocialLeaderboardPerpsEnabled = createSelector(
selectRemoteFeatureFlags,
(remoteFeatureFlags) => {
diff --git a/locales/languages/en.json b/locales/languages/en.json
index 8e37d9f34cc..95f89838c0e 100644
--- a/locales/languages/en.json
+++ b/locales/languages/en.json
@@ -1181,6 +1181,11 @@
"traders_you_follow_empty": "You aren't following any traders yet.",
"error_loading_followed": "Unable to load the traders you follow."
},
+ "settings": {
+ "section_title": "Top Traders",
+ "show_account_on_leaderboard": "Show account on leaderboard",
+ "show_account_on_leaderboard_description": "Appear on top traders leaderboard when your weekly earnings rank."
+ },
"follow": "Follow",
"following": "Following",
"mute": {
diff --git a/tests/feature-flags/feature-flag-registry.ts b/tests/feature-flags/feature-flag-registry.ts
index 6641f83982b..e461e081e03 100644
--- a/tests/feature-flags/feature-flag-registry.ts
+++ b/tests/feature-flags/feature-flag-registry.ts
@@ -93,6 +93,17 @@ export const FEATURE_FLAG_REGISTRY: Record = {
status: FeatureFlagStatus.Active,
},
+ aiSocialLeaderboardOptFlowEnabled: {
+ name: 'aiSocialLeaderboardOptFlowEnabled',
+ type: FeatureFlagType.Remote,
+ inProd: true,
+ productionDefault: {
+ enabled: false,
+ minimumVersion: '8.3.0',
+ },
+ status: FeatureFlagStatus.Active,
+ },
+
aiSocialLeaderboardPerpsEnabled: {
name: 'aiSocialLeaderboardPerpsEnabled',
type: FeatureFlagType.Remote,