From 813c4156170ebd630d9ec66d8569d3f3a118c979 Mon Sep 17 00:00:00 2001 From: tommasini <46944231+tommasini@users.noreply.github.com> Date: Tue, 5 May 2026 09:52:19 +0100 Subject: [PATCH 1/9] chore: bump axios to 1.15.1 (#29711) ## **Description** Bump axios to 1.15.1 ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > Low risk dependency bump limited to `axios` via Yarn resolutions/lockfile, with potential for minor runtime behavior changes in HTTP requests. > > **Overview** > Updates the Yarn `resolutions` override to `axios@^1.15.1` and refreshes `yarn.lock` to pull in `axios@1.15.2`. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit c6c607305657356bf1810de8459041e74e82f884. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 8b22e7d6c60..6aed2b947a8 100644 --- a/package.json +++ b/package.json @@ -175,7 +175,7 @@ "@unrs/resolver-binding-wasm32-wasi": "npm:npm-empty-package@1.0.0", "d3-color": "3.1.0", "napi-postinstall": "npm:npm-empty-package@1.0.0", - "axios": "^1.15.0", + "axios": "^1.15.1", "lodash": "4.18.1", "redux-persist-filesystem-storage/react-native-blob-util": "^0.19.9", "@ethersproject/providers/ws": "^7.5.10", diff --git a/yarn.lock b/yarn.lock index 2b86a672343..b046a7ba45e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -22725,14 +22725,14 @@ __metadata: languageName: node linkType: hard -"axios@npm:^1.15.0": - version: 1.15.0 - resolution: "axios@npm:1.15.0" +"axios@npm:^1.15.1": + version: 1.15.2 + resolution: "axios@npm:1.15.2" dependencies: follow-redirects: "npm:^1.15.11" form-data: "npm:^4.0.5" proxy-from-env: "npm:^2.1.0" - checksum: 10/d39a2c0ebc7ff4739401b282e726cc2673377949d6c46d60eb619458f8d7a2f7eadbcada7097f4dbc7d5c59abb4d3bf6fac33d474412bc3415d3f5aa7ed45530 + checksum: 10/eebbd8cb777316d4252cd994a06ec9fb956ef519214a62dab6c5443ae8b753b5116e9a770502316789e6cdef1101e6aae53b6936d6a3791b2d66d75f4d7d2462 languageName: node linkType: hard From 03cf010379c2724c8d62773b13d8fbc5959f955d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ant=C3=B3nio=20Regadas?= Date: Tue, 5 May 2026 10:00:38 +0100 Subject: [PATCH 2/9] fix: top traders section disappears on fetch error (#29501) ## **Description** The Top Traders homepage section would silently disappear whenever the leaderboard API call failed. This happened because the section guard condition treated a failed fetch, where isLoading is false and traders is empty. Identically to a genuinely empty result set, causing return null. We now show the default error state when that happens and while the user Retries the request the skeleton is also activated while the request is in-flight. Simulator Screenshot - iPhone 17 Pro - 2026-04-29
at 16 17 58 ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Moderate UI/telemetry change: alters Top Traders section render gating and adds new SocialService error telemetry (Logger extras + Sentry breadcrumbs) across multiple hooks, which could affect user-visible states and error reporting but not security-sensitive logic. > > **Overview** > Prevents the homepage **Top Traders** section from disappearing on leaderboard fetch failures by distinguishing *empty* vs *error* vs *background refetch* states, rendering an `ErrorState` with Retry, and showing skeletons while a retry is in-flight (while keeping cached traders visible on refetch failures). > > Adds shared SocialService telemetry helpers (`socialServiceTelemetry`) and updates Social Leaderboard hooks to emit enriched `Logger.error` extras and Sentry breadcrumbs (including endpoint, coarse error category, and optional HTTP status/query params), with tests updated/expanded accordingly. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit fcbc39b4c5e39d3a768e2695d48ebe4b70af7bb4. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../TopTraders/TopTradersSection.test.tsx | 86 +++++- .../Sections/TopTraders/TopTradersSection.tsx | 59 +++- .../TopTraders/hooks/useTopTraders.test.ts | 57 +++- .../TopTraders/hooks/useTopTraders.ts | 47 ++- .../hooks/useFollowedTraders.test.ts | 57 +++- .../hooks/useFollowedTraders.ts | 26 +- .../TopTradersView/TopTradersView.test.tsx | 1 + .../hooks/useTraderPosition.test.ts | 51 +++- .../hooks/useTraderPosition.ts | 20 +- .../hooks/useTraderPositions.test.ts | 109 ++++++- .../hooks/useTraderPositions.ts | 43 ++- .../social/socialServiceTelemetry.test.ts | 283 ++++++++++++++++++ app/util/social/socialServiceTelemetry.ts | 199 ++++++++++++ 13 files changed, 1000 insertions(+), 38 deletions(-) create mode 100644 app/util/social/socialServiceTelemetry.test.ts create mode 100644 app/util/social/socialServiceTelemetry.ts diff --git a/app/components/Views/Homepage/Sections/TopTraders/TopTradersSection.test.tsx b/app/components/Views/Homepage/Sections/TopTraders/TopTradersSection.test.tsx index ff4289db332..17168682b60 100644 --- a/app/components/Views/Homepage/Sections/TopTraders/TopTradersSection.test.tsx +++ b/app/components/Views/Homepage/Sections/TopTraders/TopTradersSection.test.tsx @@ -23,7 +23,8 @@ const mockTraders = [ const mockUseTopTraders = jest.fn((_options?: unknown) => ({ traders: mockTraders, isLoading: false, - error: null, + isFetching: false, + error: null as string | null, refresh: mockRefetch, toggleFollow: jest.fn(), })); @@ -75,6 +76,7 @@ describe('TopTradersSection', () => { mockUseTopTraders.mockReturnValue({ traders: mockTraders, isLoading: false, + isFetching: false, error: null, refresh: mockRefetch, toggleFollow: jest.fn(), @@ -85,6 +87,7 @@ describe('TopTradersSection', () => { mockUseTopTraders.mockReturnValue({ traders: [], isLoading: false, + isFetching: false, error: null, refresh: mockRefetch, toggleFollow: jest.fn(), @@ -97,6 +100,7 @@ describe('TopTradersSection', () => { mockUseTopTraders.mockReturnValue({ traders: [], isLoading: true, + isFetching: true, error: null, refresh: mockRefetch, toggleFollow: jest.fn(), @@ -139,6 +143,86 @@ describe('TopTradersSection', () => { ); }); + it('renders the error state instead of the carousel when the fetch fails', () => { + mockUseTopTraders.mockReturnValue({ + traders: [], + isLoading: false, + isFetching: false, + error: 'Network error', + refresh: mockRefetch, + toggleFollow: jest.fn(), + }); + renderWithProvider(); + + expect(screen.queryByTestId('homepage-top-traders-carousel')).toBeNull(); + expect( + screen.getByTestId('homepage-top-traders-section-root'), + ).toBeOnTheScreen(); + }); + + it('calls refresh when the retry button in the error state is pressed', async () => { + mockUseTopTraders.mockReturnValue({ + traders: [], + isLoading: false, + isFetching: false, + error: 'Network error', + refresh: mockRefetch, + toggleFollow: jest.fn(), + }); + renderWithProvider(); + + fireEvent.press(screen.getByText('Retry')); + + expect(mockRefetch).toHaveBeenCalledTimes(1); + }); + + it('renders skeletons instead of error state while a retry is in flight', () => { + mockUseTopTraders.mockReturnValue({ + traders: [], + isLoading: false, + isFetching: true, + error: 'Network error', + refresh: mockRefetch, + toggleFollow: jest.fn(), + }); + renderWithProvider(); + + expect( + screen.getByTestId('homepage-top-traders-carousel'), + ).toBeOnTheScreen(); + expect(screen.queryByText('Retry')).toBeNull(); + }); + + it('keeps cached traders visible when a background refetch fails', () => { + mockUseTopTraders.mockReturnValue({ + traders: mockTraders, + isLoading: false, + isFetching: false, + error: 'Network error', + refresh: mockRefetch, + toggleFollow: jest.fn(), + }); + renderWithProvider(); + + expect(screen.getByTestId('top-trader-card-trader-1')).toBeOnTheScreen(); + expect(screen.queryByText('Retry')).toBeNull(); + }); + + it('keeps cached traders and ViewMoreCard visible during a background refetch', () => { + mockUseTopTraders.mockReturnValue({ + traders: mockTraders, + isLoading: false, + isFetching: true, + error: null, + refresh: mockRefetch, + toggleFollow: jest.fn(), + }); + renderWithProvider(); + + expect(screen.getByTestId('top-trader-card-trader-1')).toBeOnTheScreen(); + expect(screen.getByTestId('top-traders-view-more-card')).toBeOnTheScreen(); + }); + it('exposes refresh via ref and resolves when called', async () => { const ref = createRef(); renderWithProvider(); diff --git a/app/components/Views/Homepage/Sections/TopTraders/TopTradersSection.tsx b/app/components/Views/Homepage/Sections/TopTraders/TopTradersSection.tsx index e4d4e987664..e1005c6c2ac 100644 --- a/app/components/Views/Homepage/Sections/TopTraders/TopTradersSection.tsx +++ b/app/components/Views/Homepage/Sections/TopTraders/TopTradersSection.tsx @@ -16,6 +16,7 @@ import SectionHeader from '../../../../../component-library/components-temp/Sect import Routes from '../../../../../constants/navigation/Routes'; import type { RootStackParamList } from '../../../../../core/NavigationService/types'; import { selectSocialLeaderboardEnabled } from '../../../../../selectors/featureFlagController/socialLeaderboard'; +import ErrorState from '../../components/ErrorState'; import ViewMoreCard from '../../components/ViewMoreCard'; import useHomeViewedEvent, { HomeSectionNames, @@ -55,10 +56,11 @@ const TopTradersSection = forwardRef< const isEnabled = useSelector(selectSocialLeaderboardEnabled); const title = strings('homepage.sections.top_traders'); - const { traders, isLoading, refresh, toggleFollow } = useTopTraders({ - limit: HOME_TRADER_LIMIT, - enabled: isEnabled, - }); + const { traders, isLoading, isFetching, error, refresh, toggleFollow } = + useTopTraders({ + limit: HOME_TRADER_LIMIT, + enabled: isEnabled, + }); useImperativeHandle( ref, @@ -68,7 +70,11 @@ const TopTradersSection = forwardRef< [refresh], ); - const willRender = isEnabled && (isLoading || traders.length > 0); + const isInFlight = isLoading || isFetching; + const hasTraders = traders.length > 0; + const hasError = Boolean(error); + const showError = hasError && !isFetching && !hasTraders; + const willRender = isEnabled && (isInFlight || hasError || hasTraders); const { onLayout } = useHomeViewedEvent({ sectionRef: willRender ? sectionViewRef : null, @@ -82,12 +88,23 @@ const TopTradersSection = forwardRef< useSectionPerformance({ sectionId: HomeSectionNames.TOP_TRADERS, - contentReady: !isLoading && traders.length > 0, - isEmpty: !isLoading && traders.length === 0, + contentReady: !isLoading && hasTraders, + // Exclude error renders from the empty bucket so Sentry doesn't conflate + // visible error states (which render the retry UI) with truly empty + // sections. Without this, a fetch error with no cached traders would be + // reported as `content_state: 'empty'`. + isEmpty: !isLoading && !hasError && !hasTraders, isLoading, - enabled: isEnabled, + // Disable telemetry once we render the error UI so the in-flight TTC and + // data-fetch spans get closed via the hook's cleanup instead of remaining + // open until the user navigates away. + enabled: isEnabled && !showError, }); + const showSkeletons = isInFlight && !hasTraders; + const showViewMore = hasTraders; + const isEmpty = !isInFlight && !hasError && !hasTraders; + const handleViewAll = useCallback(() => { navigation.navigate(Routes.SOCIAL_LEADERBOARD.VIEW); }, [navigation]); @@ -103,10 +120,30 @@ const TopTradersSection = forwardRef< [navigation], ); - if (!isEnabled || (!isLoading && traders.length === 0)) { + if (!isEnabled || isEmpty) { return null; } + if (showError) { + return ( + + + + + + + ); + } + return ( - {isLoading + {showSkeletons ? SKELETON_KEYS.map((key) => ) : traders.map((trader) => ( ))} - {!isLoading && traders.length > 0 && ( + {showViewMore && ( ({ + addBreadcrumb: jest.fn(), +})); + +const mockAddBreadcrumb = addBreadcrumb as jest.Mock; + const mockRefetch = jest.fn(); const mockUseQuery = useQuery as jest.MockedFunction; const mockUseSelector = useSelector as jest.MockedFunction; @@ -74,6 +81,7 @@ const makeQueryResult = ( ({ data: undefined, isLoading: false, + isFetching: false, error: null, refetch: mockRefetch, ...overrides, @@ -83,6 +91,7 @@ describe('useTopTraders', () => { beforeEach(() => { jest.clearAllMocks(); mockUseQuery.mockReturnValue(makeQueryResult()); + mockAddBreadcrumb.mockClear(); mockUseSelector.mockImplementation((selector) => { if (selector === selectIsUnlocked) return true; return []; // default for other selectors (e.g. selectFollowingProfileIds) @@ -163,13 +172,17 @@ describe('useTopTraders', () => { expect(result.current.error).toBe('Network error'); }); - it('logs the full error object via Logger.error', () => { + it('logs the full error object via Logger.error with enriched extras', () => { const err = new Error('Network error'); mockUseQuery.mockReturnValue(makeQueryResult({ error: err })); renderHook(() => useTopTraders()); expect(Logger.error).toHaveBeenCalledWith( err, - 'useTopTraders: leaderboard fetch failed', + expect.objectContaining({ + message: 'useTopTraders: leaderboard fetch failed', + endpoint: 'leaderboard', + errorCategory: expect.any(String), + }), ); }); @@ -330,9 +343,47 @@ describe('useTopTraders', () => { expect(Logger.error).toHaveBeenCalledWith( err, - 'useTopTraders: refresh failed', + expect.objectContaining({ + message: 'useTopTraders: refresh failed', + endpoint: 'leaderboard', + errorCategory: expect.any(String), + }), + ); + }); + }); + + describe('breadcrumbs', () => { + it('emits a failure breadcrumb when an error is set', () => { + const err = new Error('fetch failed'); + mockUseQuery.mockReturnValue(makeQueryResult({ error: err })); + renderHook(() => useTopTraders()); + expect(mockAddBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + category: 'social_service', + level: 'error', + message: expect.stringContaining( + 'social_service.leaderboard.failure', + ), + }), + ); + }); + + it('includes httpStatus in the failure breadcrumb for HttpError', () => { + const err = Object.assign(new Error('Unauthorized'), { httpStatus: 401 }); + mockUseQuery.mockReturnValue(makeQueryResult({ error: err })); + renderHook(() => useTopTraders()); + expect(mockAddBreadcrumb.mock.calls[0][0].message).toContain( + 'status=401', ); }); + + it('does not emit a breadcrumb when there is no error', () => { + mockUseQuery.mockReturnValue( + makeQueryResult({ data: mockLeaderboardResponse as never }), + ); + renderHook(() => useTopTraders()); + expect(mockAddBreadcrumb).not.toHaveBeenCalled(); + }); }); describe('options', () => { diff --git a/app/components/Views/Homepage/Sections/TopTraders/hooks/useTopTraders.ts b/app/components/Views/Homepage/Sections/TopTraders/hooks/useTopTraders.ts index a4aa3386664..51effe0f10a 100644 --- a/app/components/Views/Homepage/Sections/TopTraders/hooks/useTopTraders.ts +++ b/app/components/Views/Homepage/Sections/TopTraders/hooks/useTopTraders.ts @@ -9,10 +9,17 @@ import Logger from '../../../../../../util/Logger'; import { useFollowToggleMany } from '../../../../../hooks/useFollowToggle'; import { selectIsUnlocked } from '../../../../../../selectors/keyringController'; import type { TopTrader } from '../types'; +import { + addSocialBreadcrumb, + buildSocialErrorExtras, + categoriseSocialError, + extractHttpStatus, +} from '../../../../../../util/social/socialServiceTelemetry'; export interface UseTopTradersResult { traders: TopTrader[]; isLoading: boolean; + isFetching: boolean; error: string | null; refresh: () => Promise; toggleFollow: (addressOrId: string) => void; @@ -37,10 +44,11 @@ export const useTopTraders = ( fetchOptions, ]; - const { data, isLoading, error, refetch } = useQuery({ - queryKey, - enabled: (options?.enabled ?? true) && isUnlocked, - }); + const { data, isLoading, isFetching, error, refetch } = + useQuery({ + queryKey, + enabled: (options?.enabled ?? true) && isUnlocked, + }); const { isFollowing, toggleFollow } = useFollowToggleMany(); @@ -66,20 +74,43 @@ export const useTopTraders = ( try { await refetch(); } catch (err) { - Logger.error(err as Error, 'useTopTraders: refresh failed'); + Logger.error( + err as Error, + buildSocialErrorExtras({ + legacyMessage: 'useTopTraders: refresh failed', + endpoint: 'leaderboard', + error: err, + queryParams: { limit: options?.limit ?? 0 }, + }), + ); throw err; } - }, [refetch]); + }, [refetch, options?.limit]); useEffect(() => { if (error) { - Logger.error(error as Error, 'useTopTraders: leaderboard fetch failed'); + Logger.error( + error as Error, + buildSocialErrorExtras({ + legacyMessage: 'useTopTraders: leaderboard fetch failed', + endpoint: 'leaderboard', + error, + queryParams: { limit: options?.limit ?? 0 }, + }), + ); + addSocialBreadcrumb({ + endpoint: 'leaderboard', + errorCategory: categoriseSocialError(error), + httpStatus: extractHttpStatus(error), + queryParams: { limit: options?.limit ?? 0 }, + }); } - }, [error]); + }, [error, options?.limit]); return { traders, isLoading, + isFetching, error: error instanceof Error ? error.message : error ? String(error) : null, refresh, diff --git a/app/components/Views/SocialLeaderboard/NotificationPreferencesView/hooks/useFollowedTraders.test.ts b/app/components/Views/SocialLeaderboard/NotificationPreferencesView/hooks/useFollowedTraders.test.ts index 10ec450c28d..904fc75d8c1 100644 --- a/app/components/Views/SocialLeaderboard/NotificationPreferencesView/hooks/useFollowedTraders.test.ts +++ b/app/components/Views/SocialLeaderboard/NotificationPreferencesView/hooks/useFollowedTraders.test.ts @@ -1,5 +1,6 @@ import { renderHook, act } from '@testing-library/react-native'; import { useQuery } from '@metamask/react-data-query'; +import { addBreadcrumb } from '@sentry/react-native'; import Logger from '../../../../../util/Logger'; import { useFollowedTraders } from './useFollowedTraders'; @@ -9,6 +10,12 @@ jest.mock('../../../../../util/Logger', () => ({ jest.mock('@metamask/react-data-query'); +jest.mock('@sentry/react-native', () => ({ + addBreadcrumb: jest.fn(), +})); + +const mockAddBreadcrumb = addBreadcrumb as jest.Mock; + const mockRefetch = jest.fn(); const mockUseQuery = useQuery as jest.MockedFunction; @@ -18,6 +25,7 @@ const makeQueryResult = ( ({ data: undefined, isLoading: false, + isFetching: false, error: null, refetch: mockRefetch, ...overrides, @@ -45,6 +53,7 @@ describe('useFollowedTraders', () => { beforeEach(() => { jest.clearAllMocks(); mockUseQuery.mockReturnValue(makeQueryResult()); + mockAddBreadcrumb.mockClear(); }); describe('query configuration', () => { @@ -135,13 +144,17 @@ describe('useFollowedTraders', () => { expect(result.current.error).toBe('raw error'); }); - it('logs query errors', () => { + it('logs query errors with enriched extras', () => { const error = new Error('fetch failed'); mockUseQuery.mockReturnValue(makeQueryResult({ error })); renderHook(() => useFollowedTraders()); expect(Logger.error).toHaveBeenCalledWith( error, - 'useFollowedTraders: following fetch failed', + expect.objectContaining({ + message: 'useFollowedTraders: following fetch failed', + endpoint: 'following', + errorCategory: expect.any(String), + }), ); }); @@ -177,8 +190,46 @@ describe('useFollowedTraders', () => { expect(Logger.error).toHaveBeenCalledWith( error, - 'useFollowedTraders: refresh failed', + expect.objectContaining({ + message: 'useFollowedTraders: refresh failed', + endpoint: 'following', + errorCategory: expect.any(String), + }), + ); + }); + }); + + describe('breadcrumbs', () => { + it('emits a failure breadcrumb when an error is set', () => { + const error = new Error('fetch failed'); + mockUseQuery.mockReturnValue(makeQueryResult({ error })); + renderHook(() => useFollowedTraders()); + expect(mockAddBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + category: 'social_service', + level: 'error', + message: expect.stringContaining('social_service.following.failure'), + }), + ); + }); + + it('includes httpStatus in the failure breadcrumb for HttpError', () => { + const error = Object.assign(new Error('Unauthorized'), { + httpStatus: 401, + }); + mockUseQuery.mockReturnValue(makeQueryResult({ error })); + renderHook(() => useFollowedTraders()); + expect(mockAddBreadcrumb.mock.calls[0][0].message).toContain( + 'status=401', ); }); + + it('does not emit a breadcrumb when there is no error', () => { + mockUseQuery.mockReturnValue( + makeQueryResult({ data: fixtureFollowing as never }), + ); + renderHook(() => useFollowedTraders()); + expect(mockAddBreadcrumb).not.toHaveBeenCalled(); + }); }); }); diff --git a/app/components/Views/SocialLeaderboard/NotificationPreferencesView/hooks/useFollowedTraders.ts b/app/components/Views/SocialLeaderboard/NotificationPreferencesView/hooks/useFollowedTraders.ts index e19a1c41730..dad7d878154 100644 --- a/app/components/Views/SocialLeaderboard/NotificationPreferencesView/hooks/useFollowedTraders.ts +++ b/app/components/Views/SocialLeaderboard/NotificationPreferencesView/hooks/useFollowedTraders.ts @@ -2,6 +2,12 @@ import { useCallback, useEffect, useMemo } from 'react'; import { useQuery } from '@metamask/react-data-query'; import type { FollowingResponse } from '@metamask/social-controllers'; import Logger from '../../../../../util/Logger'; +import { + addSocialBreadcrumb, + buildSocialErrorExtras, + categoriseSocialError, + extractHttpStatus, +} from '../../../../../util/social/socialServiceTelemetry'; export interface FollowedTrader { /** Clicker profile ID. */ @@ -67,7 +73,14 @@ export const useFollowedTraders = ( try { await refetch(); } catch (err) { - Logger.error(err as Error, 'useFollowedTraders: refresh failed'); + Logger.error( + err as Error, + buildSocialErrorExtras({ + legacyMessage: 'useFollowedTraders: refresh failed', + endpoint: 'following', + error: err, + }), + ); throw err; } }, [refetch]); @@ -76,8 +89,17 @@ export const useFollowedTraders = ( if (error) { Logger.error( error as Error, - 'useFollowedTraders: following fetch failed', + buildSocialErrorExtras({ + legacyMessage: 'useFollowedTraders: following fetch failed', + endpoint: 'following', + error, + }), ); + addSocialBreadcrumb({ + endpoint: 'following', + errorCategory: categoriseSocialError(error), + httpStatus: extractHttpStatus(error), + }); } }, [error]); diff --git a/app/components/Views/SocialLeaderboard/TopTradersView/TopTradersView.test.tsx b/app/components/Views/SocialLeaderboard/TopTradersView/TopTradersView.test.tsx index 64136921239..f2603e7c76c 100644 --- a/app/components/Views/SocialLeaderboard/TopTradersView/TopTradersView.test.tsx +++ b/app/components/Views/SocialLeaderboard/TopTradersView/TopTradersView.test.tsx @@ -63,6 +63,7 @@ const fixtureTraders: TopTrader[] = [ const defaultUseTopTradersResult: UseTopTradersResult = { traders: fixtureTraders, isLoading: false, + isFetching: false, error: null, refresh: mockRefresh as () => Promise, toggleFollow: mockToggleFollow, diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/hooks/useTraderPosition.test.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/hooks/useTraderPosition.test.ts index d9e00b39e0a..8a954918d20 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/hooks/useTraderPosition.test.ts +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/hooks/useTraderPosition.test.ts @@ -1,6 +1,7 @@ import { renderHook } from '@testing-library/react-native'; import { useSelector } from 'react-redux'; import { useQuery } from '@metamask/react-data-query'; +import { addBreadcrumb } from '@sentry/react-native'; import type { Position } from '@metamask/social-controllers'; import Logger from '../../../../../util/Logger'; import { selectIsUnlocked } from '../../../../../selectors/keyringController'; @@ -20,6 +21,12 @@ jest.mock('../../../../../util/Logger', () => ({ jest.mock('@metamask/react-data-query'); +jest.mock('@sentry/react-native', () => ({ + addBreadcrumb: jest.fn(), +})); + +const mockAddBreadcrumb = addBreadcrumb as jest.Mock; + const mockUseQuery = useQuery as jest.MockedFunction; const mockUseSelector = useSelector as jest.MockedFunction; @@ -119,7 +126,7 @@ describe('useTraderPosition', () => { expect(result.current.position).toBeUndefined(); }); - it('returns the error message and logs on failure', () => { + it('returns the error message and logs on failure with enriched extras', () => { const fetchError = new Error('boom'); mockUseQuery.mockReturnValue(makeQueryResult({ error: fetchError })); @@ -128,7 +135,47 @@ describe('useTraderPosition', () => { expect(result.current.error).toBe('boom'); expect(Logger.error).toHaveBeenCalledWith( fetchError, - 'useTraderPosition: fetch failed', + expect.objectContaining({ + message: 'useTraderPosition: fetch failed', + endpoint: 'position_by_id', + errorCategory: expect.any(String), + }), ); }); + + it('emits a failure breadcrumb when an error is set', () => { + const fetchError = new Error('boom'); + mockUseQuery.mockReturnValue(makeQueryResult({ error: fetchError })); + + renderHook(() => useTraderPosition('position-uuid-1')); + + expect(mockAddBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + category: 'social_service', + level: 'error', + message: expect.stringContaining( + 'social_service.position_by_id.failure', + ), + }), + ); + }); + + it('includes httpStatus in the failure breadcrumb for HttpError', () => { + const fetchError = Object.assign(new Error('Unauthorized'), { + httpStatus: 401, + }); + mockUseQuery.mockReturnValue(makeQueryResult({ error: fetchError })); + + renderHook(() => useTraderPosition('position-uuid-1')); + + expect(mockAddBreadcrumb.mock.calls[0][0].message).toContain('status=401'); + }); + + it('does not emit a breadcrumb when there is no error', () => { + mockUseQuery.mockReturnValue(makeQueryResult({ data: mockPosition })); + + renderHook(() => useTraderPosition('position-uuid-1')); + + expect(mockAddBreadcrumb).not.toHaveBeenCalled(); + }); }); diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/hooks/useTraderPosition.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/hooks/useTraderPosition.ts index 9eb66a40ead..06c6aff0f83 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/hooks/useTraderPosition.ts +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/hooks/useTraderPosition.ts @@ -3,6 +3,12 @@ import { useSelector } from 'react-redux'; import { useQuery } from '@metamask/react-data-query'; import type { Position } from '@metamask/social-controllers'; import Logger from '../../../../../util/Logger'; +import { + addSocialBreadcrumb, + buildSocialErrorExtras, + categoriseSocialError, + extractHttpStatus, +} from '../../../../../util/social/socialServiceTelemetry'; import { selectIsUnlocked } from '../../../../../selectors/keyringController'; export interface UseTraderPositionResult { @@ -34,7 +40,19 @@ export const useTraderPosition = ( useEffect(() => { if (error) { - Logger.error(error as Error, 'useTraderPosition: fetch failed'); + Logger.error( + error as Error, + buildSocialErrorExtras({ + legacyMessage: 'useTraderPosition: fetch failed', + endpoint: 'position_by_id', + error, + }), + ); + addSocialBreadcrumb({ + endpoint: 'position_by_id', + errorCategory: categoriseSocialError(error), + httpStatus: extractHttpStatus(error), + }); } }, [error]); diff --git a/app/components/Views/SocialLeaderboard/TraderProfileView/hooks/useTraderPositions.test.ts b/app/components/Views/SocialLeaderboard/TraderProfileView/hooks/useTraderPositions.test.ts index 72426532143..3d515e912c0 100644 --- a/app/components/Views/SocialLeaderboard/TraderProfileView/hooks/useTraderPositions.test.ts +++ b/app/components/Views/SocialLeaderboard/TraderProfileView/hooks/useTraderPositions.test.ts @@ -1,6 +1,7 @@ import { renderHook } from '@testing-library/react-native'; import { useSelector } from 'react-redux'; import { useQuery } from '@metamask/react-data-query'; +import { addBreadcrumb } from '@sentry/react-native'; import Logger from '../../../../../util/Logger'; import { selectIsUnlocked } from '../../../../../selectors/keyringController'; import { useTraderPositions } from './useTraderPositions'; @@ -19,6 +20,12 @@ jest.mock('../../../../../util/Logger', () => ({ jest.mock('@metamask/react-data-query'); +jest.mock('@sentry/react-native', () => ({ + addBreadcrumb: jest.fn(), +})); + +const mockAddBreadcrumb = addBreadcrumb as jest.Mock; + const mockUseQuery = useQuery as jest.MockedFunction; const mockUseSelector = useSelector as jest.MockedFunction; @@ -28,6 +35,7 @@ const makeQueryResult = ( ({ data: undefined, isLoading: false, + isFetching: false, error: null, refetch: jest.fn(), ...overrides, @@ -75,6 +83,7 @@ describe('useTraderPositions', () => { beforeEach(() => { jest.clearAllMocks(); mockUseQuery.mockReturnValue(makeQueryResult()); + mockAddBreadcrumb.mockClear(); mockUseSelector.mockImplementation((selector) => { if (selector === selectIsUnlocked) return true; return undefined; @@ -278,7 +287,7 @@ describe('useTraderPositions', () => { expect(result.current.error).toBe('raw error'); }); - it('logs the combined error', () => { + it('logs the open error with enriched extras including the endpoint', () => { const error = new Error('fetch failed'); mockUseQuery @@ -289,7 +298,30 @@ describe('useTraderPositions', () => { expect(Logger.error).toHaveBeenCalledWith( error, - 'useTraderPositions: positions fetch failed', + expect.objectContaining({ + message: 'useTraderPositions: positions fetch failed', + endpoint: 'open_positions', + errorCategory: expect.any(String), + }), + ); + }); + + it('logs the closed error with enriched extras including the endpoint', () => { + const error = new Error('closed fetch failed'); + + mockUseQuery + .mockReturnValueOnce(makeQueryResult()) + .mockReturnValueOnce(makeQueryResult({ error })); + + renderHook(() => useTraderPositions('trader-1')); + + expect(Logger.error).toHaveBeenCalledWith( + error, + expect.objectContaining({ + message: 'useTraderPositions: positions fetch failed', + endpoint: 'closed_positions', + errorCategory: expect.any(String), + }), ); }); @@ -297,5 +329,78 @@ describe('useTraderPositions', () => { renderHook(() => useTraderPositions('trader-1')); expect(Logger.error).not.toHaveBeenCalled(); }); + + it('does NOT include addressOrId in the Logger.error extras', () => { + const error = new Error('fetch failed'); + + mockUseQuery + .mockReturnValueOnce(makeQueryResult({ error })) + .mockReturnValueOnce(makeQueryResult()); + + renderHook(() => useTraderPositions('0xSensitiveAddress')); + + const call = (Logger.error as jest.Mock).mock.calls[0]; + const extras = call[1]; + const serialised = JSON.stringify(extras); + expect(serialised).not.toContain('0xSensitiveAddress'); + expect(Object.keys(extras)).not.toContain('addressOrId'); + }); + }); + + describe('breadcrumbs', () => { + it('emits a failure breadcrumb for open_positions on error', () => { + const error = new Error('open failed'); + mockUseQuery + .mockReturnValueOnce(makeQueryResult({ error })) + .mockReturnValueOnce(makeQueryResult()); + + renderHook(() => useTraderPositions('trader-1')); + + expect(mockAddBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + level: 'error', + message: expect.stringContaining( + 'social_service.open_positions.failure', + ), + }), + ); + }); + + it('emits a failure breadcrumb for closed_positions on error', () => { + const error = new Error('closed failed'); + mockUseQuery + .mockReturnValueOnce(makeQueryResult()) + .mockReturnValueOnce(makeQueryResult({ error })); + + renderHook(() => useTraderPositions('trader-1')); + + expect(mockAddBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + level: 'error', + message: expect.stringContaining( + 'social_service.closed_positions.failure', + ), + }), + ); + }); + + it('does not emit a breadcrumb when there are no errors', () => { + renderHook(() => useTraderPositions('trader-1')); + expect(mockAddBreadcrumb).not.toHaveBeenCalled(); + }); + + it('never includes addressOrId in breadcrumb data', () => { + const error = new Error('open failed'); + mockUseQuery + .mockReturnValueOnce(makeQueryResult({ error })) + .mockReturnValueOnce(makeQueryResult()); + + renderHook(() => useTraderPositions('0xSensitiveAddress')); + + mockAddBreadcrumb.mock.calls.forEach(([breadcrumb]) => { + const serialised = JSON.stringify(breadcrumb); + expect(serialised).not.toContain('0xSensitiveAddress'); + }); + }); }); }); diff --git a/app/components/Views/SocialLeaderboard/TraderProfileView/hooks/useTraderPositions.ts b/app/components/Views/SocialLeaderboard/TraderProfileView/hooks/useTraderPositions.ts index 5f04513c1ec..d083ee59c2f 100644 --- a/app/components/Views/SocialLeaderboard/TraderProfileView/hooks/useTraderPositions.ts +++ b/app/components/Views/SocialLeaderboard/TraderProfileView/hooks/useTraderPositions.ts @@ -7,6 +7,12 @@ import type { Position, } from '@metamask/social-controllers'; import Logger from '../../../../../util/Logger'; +import { + addSocialBreadcrumb, + buildSocialErrorExtras, + categoriseSocialError, + extractHttpStatus, +} from '../../../../../util/social/socialServiceTelemetry'; import { selectIsUnlocked } from '../../../../../selectors/keyringController'; const EMPTY_POSITIONS: Position[] = []; @@ -52,16 +58,43 @@ export const useTraderPositions = ( const openPositions = openData?.positions ?? EMPTY_POSITIONS; const closedPositions = closedData?.positions ?? EMPTY_POSITIONS; - const combinedError = openError ?? closedError; + useEffect(() => { + if (openError) { + Logger.error( + openError as Error, + buildSocialErrorExtras({ + legacyMessage: 'useTraderPositions: positions fetch failed', + endpoint: 'open_positions', + error: openError, + }), + ); + addSocialBreadcrumb({ + endpoint: 'open_positions', + errorCategory: categoriseSocialError(openError), + httpStatus: extractHttpStatus(openError), + }); + } + }, [openError]); useEffect(() => { - if (combinedError) { + if (closedError) { Logger.error( - combinedError as Error, - 'useTraderPositions: positions fetch failed', + closedError as Error, + buildSocialErrorExtras({ + legacyMessage: 'useTraderPositions: positions fetch failed', + endpoint: 'closed_positions', + error: closedError, + }), ); + addSocialBreadcrumb({ + endpoint: 'closed_positions', + errorCategory: categoriseSocialError(closedError), + httpStatus: extractHttpStatus(closedError), + }); } - }, [combinedError]); + }, [closedError]); + + const combinedError = openError ?? closedError; return { openPositions, diff --git a/app/util/social/socialServiceTelemetry.test.ts b/app/util/social/socialServiceTelemetry.test.ts new file mode 100644 index 00000000000..6baffe893c8 --- /dev/null +++ b/app/util/social/socialServiceTelemetry.test.ts @@ -0,0 +1,283 @@ +import { addBreadcrumb } from '@sentry/react-native'; +import { + extractHttpStatus, + categoriseSocialError, + buildSocialErrorExtras, + addSocialBreadcrumb, + type SocialEndpoint, +} from './socialServiceTelemetry'; + +jest.mock('@sentry/react-native', () => ({ + addBreadcrumb: jest.fn(), +})); + +const mockAddBreadcrumb = addBreadcrumb as jest.Mock; + +// --------------------------------------------------------------------------- +// Helpers to create errors matching the shapes SocialService throws +// --------------------------------------------------------------------------- + +function makeHttpError( + status: number, + message: string, +): Error & { httpStatus: number } { + const err = new Error(message) as Error & { httpStatus: number }; + err.httpStatus = status; + return err; +} + +// --------------------------------------------------------------------------- +// extractHttpStatus +// --------------------------------------------------------------------------- + +describe('extractHttpStatus', () => { + it('returns the status from an HttpError instance', () => { + expect(extractHttpStatus(makeHttpError(401, 'Unauthorized'))).toBe(401); + }); + + it('returns the status from a plain object with httpStatus', () => { + expect(extractHttpStatus({ httpStatus: 503 })).toBe(503); + }); + + it('returns undefined for a plain Error without httpStatus', () => { + expect(extractHttpStatus(new Error('plain error'))).toBeUndefined(); + }); + + it('returns undefined for null', () => { + expect(extractHttpStatus(null)).toBeUndefined(); + }); + + it('returns undefined for a string', () => { + expect(extractHttpStatus('some error string')).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// categoriseSocialError +// --------------------------------------------------------------------------- + +describe('categoriseSocialError', () => { + it('returns http_error when the error has httpStatus', () => { + expect(categoriseSocialError(makeHttpError(401, 'Unauthorized'))).toBe( + 'http_error', + ); + }); + + it('returns schema_error for "invalid response" messages', () => { + expect( + categoriseSocialError( + new Error('SocialService: Leaderboard returned invalid response'), + ), + ).toBe('schema_error'); + }); + + it('returns auth_failure for auth/JWT/bearer/unauthorized messages', () => { + expect( + categoriseSocialError(new Error('getBearerToken: auth token expired')), + ).toBe('auth_failure'); + expect(categoriseSocialError(new Error('JWT verification failed'))).toBe( + 'auth_failure', + ); + expect(categoriseSocialError(new Error('unauthorized access'))).toBe( + 'auth_failure', + ); + expect(categoriseSocialError(new Error('Bearer token missing'))).toBe( + 'auth_failure', + ); + }); + + it('does not classify crypto-token errors as auth_failure', () => { + // Crypto wallet errors frequently reference tokens but are not auth issues. + expect( + categoriseSocialError(new Error('unknown token contract 0xabc')), + ).toBe('unknown'); + expect(categoriseSocialError(new Error('tokenAddress invalid'))).toBe( + 'unknown', + ); + expect( + categoriseSocialError(new Error('tokenSymbol could not be resolved')), + ).toBe('unknown'); + }); + + it('returns network_error for network/timeout/aborted messages', () => { + expect(categoriseSocialError(new Error('Network request failed'))).toBe( + 'network_error', + ); + expect(categoriseSocialError(new Error('Request timed out'))).toBe( + 'network_error', + ); + expect(categoriseSocialError(new Error('The operation was aborted'))).toBe( + 'network_error', + ); + expect( + categoriseSocialError(new Error('connect ETIMEDOUT 1.2.3.4:443')), + ).toBe('network_error'); + }); + + it('returns unknown for unrecognised errors', () => { + expect(categoriseSocialError(new Error('Something went wrong'))).toBe( + 'unknown', + ); + }); + + it('returns unknown for null / undefined', () => { + expect(categoriseSocialError(null)).toBe('unknown'); + expect(categoriseSocialError(undefined)).toBe('unknown'); + }); + + it('http_error takes precedence over message matching', () => { + // An HttpError message also contains "unauthorized" but httpStatus wins + const err = makeHttpError(401, 'unauthorized'); + expect(categoriseSocialError(err)).toBe('http_error'); + }); +}); + +// --------------------------------------------------------------------------- +// buildSocialErrorExtras +// --------------------------------------------------------------------------- + +describe('buildSocialErrorExtras', () => { + it('preserves legacyMessage verbatim under the message field', () => { + const legacy = 'useTopTraders: leaderboard fetch failed'; + const result = buildSocialErrorExtras({ + legacyMessage: legacy, + endpoint: 'leaderboard', + error: new Error('something'), + }); + expect(result.message).toBe(legacy); + }); + + it('includes endpoint, errorCategory, errorMessage', () => { + const error = new Error( + 'SocialService: Leaderboard returned invalid response', + ); + const result = buildSocialErrorExtras({ + legacyMessage: 'msg', + endpoint: 'leaderboard', + error, + }); + expect(result.endpoint).toBe('leaderboard'); + expect(result.errorCategory).toBe('schema_error'); + expect(result.errorMessage).toBe(error.message); + }); + + it('includes httpStatus when the error is an HttpError', () => { + const result = buildSocialErrorExtras({ + legacyMessage: 'msg', + endpoint: 'following', + error: makeHttpError(403, 'Forbidden'), + }); + expect(result.httpStatus).toBe(403); + expect(result.errorCategory).toBe('http_error'); + }); + + it('omits httpStatus when not an HttpError', () => { + const result = buildSocialErrorExtras({ + legacyMessage: 'msg', + endpoint: 'leaderboard', + error: new Error('plain'), + }); + expect(result.httpStatus).toBeUndefined(); + }); + + it('includes durationMs and queryParams when provided', () => { + const result = buildSocialErrorExtras({ + legacyMessage: 'msg', + endpoint: 'open_positions', + error: new Error('plain'), + durationMs: 250, + queryParams: { limit: 10 }, + }); + expect(result.durationMs).toBe(250); + expect(result.queryParams).toEqual({ limit: 10 }); + }); + + it('does NOT leak an addressOrId field even if accidentally passed', () => { + const result = buildSocialErrorExtras({ + legacyMessage: 'msg', + endpoint: 'open_positions', + error: new Error('plain'), + // Simulate a caller accidentally passing address-like data via queryParams + queryParams: { limit: 5 }, + }); + const serialised = JSON.stringify(result); + expect(serialised).not.toMatch(/0x[0-9a-fA-F]{40}/); + expect(Object.keys(result)).not.toContain('addressOrId'); + expect(Object.keys(result)).not.toContain('address'); + expect(Object.keys(result)).not.toContain('profileId'); + }); +}); + +// --------------------------------------------------------------------------- +// addSocialBreadcrumb +// --------------------------------------------------------------------------- + +describe('addSocialBreadcrumb', () => { + beforeEach(() => { + mockAddBreadcrumb.mockClear(); + }); + + it('emits a breadcrumb with category social_service and level error', () => { + addSocialBreadcrumb({ endpoint: 'leaderboard' }); + expect(mockAddBreadcrumb).toHaveBeenCalledTimes(1); + const call = mockAddBreadcrumb.mock.calls[0][0]; + expect(call.category).toBe('social_service'); + expect(call.level).toBe('error'); + }); + + it('formats the failure message with just the endpoint when no extras are given', () => { + addSocialBreadcrumb({ endpoint: 'leaderboard' }); + const { message } = mockAddBreadcrumb.mock.calls[0][0]; + expect(message).toBe('social_service.leaderboard.failure'); + }); + + it('appends status and category when provided', () => { + addSocialBreadcrumb({ + endpoint: 'following', + httpStatus: 401, + errorCategory: 'http_error', + }); + const { message } = mockAddBreadcrumb.mock.calls[0][0]; + expect(message).toBe( + 'social_service.following.failure status=401 category=http_error', + ); + }); + + it('includes structured data payload alongside message string', () => { + addSocialBreadcrumb({ + endpoint: 'open_positions', + httpStatus: 503, + errorCategory: 'http_error', + queryParams: { limit: 5 }, + }); + const { data } = mockAddBreadcrumb.mock.calls[0][0]; + expect(data.endpoint).toBe('open_positions'); + expect(data.httpStatus).toBe(503); + expect(data.errorCategory).toBe('http_error'); + expect(data.queryParams).toEqual({ limit: 5 }); + }); + + it('omits optional fields from the data payload when not provided', () => { + addSocialBreadcrumb({ endpoint: 'leaderboard' }); + const { data } = mockAddBreadcrumb.mock.calls[0][0]; + expect(data.httpStatus).toBeUndefined(); + expect(data.errorCategory).toBeUndefined(); + expect(data.queryParams).toBeUndefined(); + }); + + it('uses a consistent endpoint key across all supported endpoints', () => { + const endpoints: SocialEndpoint[] = [ + 'leaderboard', + 'following', + 'open_positions', + 'closed_positions', + 'position_by_id', + ]; + endpoints.forEach((endpoint) => { + mockAddBreadcrumb.mockClear(); + addSocialBreadcrumb({ endpoint }); + const { message } = mockAddBreadcrumb.mock.calls[0][0]; + expect(message).toBe(`social_service.${endpoint}.failure`); + }); + }); +}); diff --git a/app/util/social/socialServiceTelemetry.ts b/app/util/social/socialServiceTelemetry.ts new file mode 100644 index 00000000000..90dc13315c1 --- /dev/null +++ b/app/util/social/socialServiceTelemetry.ts @@ -0,0 +1,199 @@ +import { addBreadcrumb } from '@sentry/react-native'; + +/** + * The set of SocialService API endpoints we instrument. + * Matches the `queryKey` prefixes used in the three hooks. + */ +export type SocialEndpoint = + | 'leaderboard' + | 'following' + | 'open_positions' + | 'closed_positions' + | 'position_by_id'; + +/** + * Coarse-grained error category derived from the thrown error shape. + * Lets Sentry Discover filter errors without reading full messages. + * + * Categories map to distinct throw sites in SocialService: + * - http_error → SocialService.#throwIfNotOk (non-2xx HTTP response) + * - schema_error → superstruct `is()` check on response body + * - auth_failure → AuthenticationController:getBearerToken rejection + * - network_error → fetch() itself rejected (no response received) + * - unknown → anything else + */ +export type SocialErrorCategory = + | 'http_error' + | 'schema_error' + | 'auth_failure' + | 'network_error' + | 'unknown'; + +/** + * The shape returned by buildSocialErrorExtras, intended to replace + * the string second argument to Logger.error while preserving it. + */ +export interface SocialErrorExtras { + /** Original log message — preserved verbatim for backward-compatible Sentry searches. */ + message: string; + endpoint: SocialEndpoint; + errorCategory: SocialErrorCategory; + httpStatus?: number; + durationMs?: number; + queryParams?: Record; + errorMessage?: string; +} + +/** + * Extract the HTTP status from an error, if present. + * HttpError from @metamask/controller-utils exposes `.httpStatus`. + */ +export function extractHttpStatus(error: unknown): number | undefined { + if ( + error !== null && + typeof error === 'object' && + 'httpStatus' in error && + typeof (error as Record).httpStatus === 'number' + ) { + return (error as Record).httpStatus as number; + } + return undefined; +} + +/** + * Categorise a thrown error into a coarse bucket so it can be + * filtered in Sentry Discover without full-text searches. + */ +export function categoriseSocialError(error: unknown): SocialErrorCategory { + if (extractHttpStatus(error) !== undefined) { + return 'http_error'; + } + + const message = error instanceof Error ? error.message : String(error ?? ''); + + if (/invalid response/i.test(message)) { + return 'schema_error'; + } + + // Match auth-specific terms only. The bare word "token" is intentionally + // excluded because crypto wallet errors frequently mention tokens + // (e.g. "unknown token contract", "tokenAddress invalid") and would + // otherwise be misclassified as auth failures, polluting Sentry filters. + if (/auth|jwt|unauthor|bearer/i.test(message)) { + return 'auth_failure'; + } + + if (/network|timed?\s*out|timeout|aborted|connect/i.test(message)) { + return 'network_error'; + } + + return 'unknown'; +} + +/** + * Build the enriched extras object for an existing Logger.error call. + * + * The `legacyMessage` string is preserved verbatim under `message` so + * any existing Sentry searches on that string keep working. Structured + * fields are added alongside without replacing the existing event. + * + * Usage — replace the string second arg while keeping the call intact: + * ```ts + * // Before + * Logger.error(err, 'useTopTraders: leaderboard fetch failed'); + * + * // After + * Logger.error( + * err, + * buildSocialErrorExtras({ + * legacyMessage: 'useTopTraders: leaderboard fetch failed', + * endpoint: 'leaderboard', + * error: err, + * }), + * ); + * ``` + */ +export function buildSocialErrorExtras({ + legacyMessage, + endpoint, + error, + queryParams, + durationMs, +}: { + legacyMessage: string; + endpoint: SocialEndpoint; + error: unknown; + queryParams?: Record; + durationMs?: number; +}): SocialErrorExtras { + const errorCategory = categoriseSocialError(error); + const httpStatus = extractHttpStatus(error); + const errorMessage = + error instanceof Error ? error.message : String(error ?? ''); + + const extras: SocialErrorExtras = { + message: legacyMessage, + endpoint, + errorCategory, + errorMessage, + }; + + if (httpStatus !== undefined) { + extras.httpStatus = httpStatus; + } + if (durationMs !== undefined) { + extras.durationMs = durationMs; + } + if (queryParams !== undefined) { + extras.queryParams = queryParams; + } + + return extras; +} + +/** + * Add a Sentry breadcrumb when a SocialService fetch fails. + * + * The `message` string encodes discriminating fields inline so they are + * searchable in Sentry Discover via `breadcrumbs.message:"..."`: + * + * social_service.leaderboard.failure status=401 category=http_error + * + * Sentry Discover query examples: + * breadcrumbs.category:social_service + * breadcrumbs.message:"social_service.leaderboard" + * breadcrumbs.message:"status=401" + * breadcrumbs.message:"category=auth_failure" + */ +export function addSocialBreadcrumb({ + endpoint, + errorCategory, + httpStatus, + queryParams, +}: { + endpoint: SocialEndpoint; + errorCategory?: SocialErrorCategory; + httpStatus?: number; + queryParams?: Record; +}): void { + const parts: string[] = [`social_service.${endpoint}.failure`]; + + if (httpStatus !== undefined) { + parts.push(`status=${httpStatus}`); + } + if (errorCategory !== undefined) { + parts.push(`category=${errorCategory}`); + } + + addBreadcrumb({ + category: 'social_service', + level: 'error', + message: parts.join(' '), + data: { + endpoint, + ...(httpStatus !== undefined && { httpStatus }), + ...(errorCategory !== undefined && { errorCategory }), + ...(queryParams !== undefined && { queryParams }), + }, + }); +} From 18a44c7880aa7b2778f4601e8a5bafacb7b91381 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Santos?= Date: Tue, 5 May 2026 11:01:05 +0200 Subject: [PATCH 3/9] chore: TSA-488-1 bump ai-controller (#29678) ## **Description** Bumps AI-controller version which fixes the "What's Happening" (feature flag gated) section display error. ## **Changelog** CHANGELOG entry: no-changelog ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > Low risk dependency bump limited to `@metamask/ai-controllers`, but it can still change runtime behavior in AI/feature-flagged UI flows. > > **Overview** > Updates the `@metamask/ai-controllers` dependency from `^0.6.0` to a pinned `0.6.3` version. > > Refreshes `yarn.lock` accordingly, including updated transitive requirements (notably `@metamask/messenger` and `@metamask/base-controller`) for the new `ai-controllers` release. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 812cdb40d36cbdebfad619459ee7acd1c5d35a2a. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- package.json | 2 +- yarn.lock | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index 6aed2b947a8..5b02f70ebb9 100644 --- a/package.json +++ b/package.json @@ -231,7 +231,7 @@ "@metamask/account-tree-controller": "^7.1.0", "@metamask/accounts-controller": "^37.2.0", "@metamask/address-book-controller": "^7.1.0", - "@metamask/ai-controllers": "^0.6.0", + "@metamask/ai-controllers": "0.6.3", "@metamask/analytics-controller": "^1.0.0", "@metamask/app-metadata-controller": "^2.0.0", "@metamask/approval-controller": "^9.0.0", diff --git a/yarn.lock b/yarn.lock index b046a7ba45e..c0431f508c1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7819,15 +7819,15 @@ __metadata: languageName: node linkType: hard -"@metamask/ai-controllers@npm:^0.6.0": - version: 0.6.0 - resolution: "@metamask/ai-controllers@npm:0.6.0" +"@metamask/ai-controllers@npm:0.6.3": + version: 0.6.3 + resolution: "@metamask/ai-controllers@npm:0.6.3" dependencies: - "@metamask/base-controller": "npm:^9.0.0" - "@metamask/messenger": "npm:^0.3.0" + "@metamask/base-controller": "npm:^9.0.1" + "@metamask/messenger": "npm:^1.0.0" "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^11.9.0" - checksum: 10/80d5cc15d28afeea893bf0b58eea42af85a10574e7d8622e0aa0f28db4e7f9a21524b7bf5d7c2aabc482e19952f3f24e492b096b605e1f3aecf25118bbed4687 + checksum: 10/678f024889a2fe691633df7b3cce529c8a87c0533096df9b238308736ce232f4a71f1a2f9a74cff16ef6cd635b551141517e8c53dedfc0a38a45d2771850b6b6 languageName: node linkType: hard @@ -35696,7 +35696,7 @@ __metadata: "@metamask/account-tree-controller": "npm:^7.1.0" "@metamask/accounts-controller": "npm:^37.2.0" "@metamask/address-book-controller": "npm:^7.1.0" - "@metamask/ai-controllers": "npm:^0.6.0" + "@metamask/ai-controllers": "npm:0.6.3" "@metamask/analytics-controller": "npm:^1.0.0" "@metamask/app-metadata-controller": "npm:^2.0.0" "@metamask/approval-controller": "npm:^9.0.0" From fa695f6d5ff7eb8e245f31f75bc88db9c1e9300a Mon Sep 17 00:00:00 2001 From: Ale Som <560018+alucardzom@users.noreply.github.com> Date: Tue, 5 May 2026 11:10:55 +0200 Subject: [PATCH 4/9] fix(ci): add restore-keys and continue-on-error to yarn cache (#29277) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** **Problem:** iOS yarn cache download stalls account for **28% (~18 runs)** of the 64 Setup Environment CI failures on `main` over 30 days (Mar 16 – Apr 16, 2026). The 610MB yarn cache downloads at 0.3-2.8 MB/s on Cirrus macOS self-hosted runners (`ghcr.io/cirruslabs/macos-runner:tahoe`), with tar extraction sometimes hanging for 9+ minutes until the action times out. See [INFRA-3580](https://consensyssoftware.atlassian.net/browse/INFRA-3580) for the full root cause analysis. **Investigation data:** Examined 6 iOS E2E setup failures from Apr 8-10: | Run | What happened | |-----|---------------| | 24268253971 | Cache hit, 582MB downloaded at ~2.6 MB/s (3.8min), **tar extraction hung 9.4min → timeout** | | 24236471310 | Action timeout during `bundle show cocoapods` | | 24227213798 | Foundry download failure (covered by PR #29255) | | 24226340316 | Generic action timeout | | 24220368199 | **Corepack download stalled** — `repo.yarnpkg.com` hung 4.8min | | 24217223040 | **Corepack download stalled** — same, hung 2.6min | **iOS E2E full setup timing (Cirrus macOS runners):** | Period | Samples | Min | Max | Avg | Median | P95 | |--------|---------|-----|-----|-----|--------|-----| | Apr 8-10 (failure period) | 15 | 180s | 202s | 189s | 187s | 202s | | Apr 22-23 (recent) | 15 | 188s | 267s | 208s | 204s | 267s | iOS setup takes ~2x longer than Android (189-208s vs 103-136s) due to Ruby, Bundler, CocoaPods, and Detox on top of shared steps. **Solution — two changes:** 1. **`restore-keys` fallback** — Currently the yarn cache is exact-match only. On cache miss (e.g., `yarn.lock` changed), it triggers a full 610MB re-download. Adding `restore-keys` lets yarn reuse a stale cache and only update the diff. Pattern already used by Bundler cache in the same file (line 272). 2. **`continue-on-error: true`** — If the cache download or tar extraction stalls, the job currently fails entirely. With `continue-on-error`, a stalled cache is skipped and `yarn install --immutable` runs without cache (slower but succeeds). Pattern already used by CocoaPods specs cache in the same file (line 333). ### Why `restore-keys` is safe The concern: could `restore-keys` restore a stale `node_modules` from `main` with different package versions? **No.** `yarn install --immutable` guarantees that `node_modules` will exactly match `yarn.lock` when it finishes, regardless of cache state. The cache is just a starting point — yarn adds, removes, or changes whatever is needed to reconcile. `--immutable` only prevents `yarn.lock` modifications, not `node_modules` updates. Example: main merges a PR updating package X from v1.0→v1.1. Your PR branch (not rebased) still has X@v1.0 in `yarn.lock`. `restore-keys` restores main's cache with X@v1.1. `yarn install --immutable` runs → installs X@v1.0 per your lockfile. Same outcome as a cold install — the lockfile is always the source of truth. Cache only affects **speed**, not **correctness**. ### `continue-on-error` — needs team input This makes the cache step non-blocking. If the 610MB download stalls or tar extraction hangs (actual observed failure: 9.4min hang), the step fails gracefully and `yarn install --immutable` runs without cache (slower ~60-90s but succeeds). **Pros:** stalled cache no longer kills the job; `yarn install --immutable` still produces correct `node_modules`; pattern already used by CocoaPods specs cache. **Cons:** cache failures become silent (job passes but slower); reduces visibility into cache infra problems. > **This change can be removed from the PR if the team prefers cache-or-fail behavior.** The `restore-keys` change alone still adds value. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: [INFRA-3580](https://consensyssoftware.atlassian.net/browse/INFRA-3580) (partial — addresses iOS yarn cache download stall sub-cause) ## **Manual testing steps** ```gherkin Feature: CI resilience for yarn cache Scenario: Yarn cache miss uses partial match fallback Given yarn.lock has changed since the last cached run When the "Restore Yarn cache" step runs Then it falls back to a partial key match via restore-keys And yarn install only updates the diff instead of full re-download Scenario: Stalled cache download doesn't block the job Given the yarn cache download stalls on a Cirrus macOS runner When the "Restore Yarn cache" step times out Then the step is marked as failed but the job continues (continue-on-error) And yarn install --immutable runs without cache ``` ## **Screenshots/Recordings** N/A — CI workflow changes only, no UI impact. ### **Before** N/A ### **After** N/A ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). _(N/A — CI workflow YAML only, no application code changes)_ - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable _(N/A — CI workflow configuration, validated by CI run on this PR)_ - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable _(N/A — CI workflow YAML, no code)_ - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. [INFRA-3580]: https://consensyssoftware.atlassian.net/browse/INFRA-3580?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ [INFRA-3580]: https://consensyssoftware.atlassian.net/browse/INFRA-3580?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --- > [!NOTE] > **Low Risk** > CI-only workflow change; it may reduce visibility into cache failures but doesn’t affect runtime code or dependency correctness. > > **Overview** > Improves resilience of the `setup-e2e-env` composite action’s Yarn `node_modules` cache restore. > > The Yarn cache step now uses `restore-keys` to allow partial cache matches when `yarn.lock` changes, and is marked `continue-on-error: true` so cache download/extraction failures don’t fail the job and `yarn install --immutable` can proceed without cache. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 271815097719abe25456d2755c4cbc559cd037b9. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .github/actions/setup-e2e-env/action.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/actions/setup-e2e-env/action.yml b/.github/actions/setup-e2e-env/action.yml index 0e54460e06e..a524846ef11 100644 --- a/.github/actions/setup-e2e-env/action.yml +++ b/.github/actions/setup-e2e-env/action.yml @@ -283,6 +283,9 @@ runs: node_modules .yarn/install-state.gz key: ${{ inputs.cache-prefix }}-yarn-${{ inputs.platform }}-${{ runner.os }}-${{ hashFiles('yarn.lock') }} + restore-keys: | + ${{ inputs.cache-prefix }}-yarn-${{ inputs.platform }}-${{ runner.os }}- + continue-on-error: true - name: Install JavaScript dependencies with retry id: yarn-install From 9348a741141828f85f59007b152b4b79a917c53a Mon Sep 17 00:00:00 2001 From: Ale Som <560018+alucardzom@users.noreply.github.com> Date: Tue, 5 May 2026 11:10:59 +0200 Subject: [PATCH 5/9] fix(ci): improve CocoaPods install resilience against CDN rate limiting (#29334) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** ### Problem CocoaPods CDN rate limiting accounts for **7% (~4 runs)** of Setup Environment CI failures on `main` over 30 days (Mar 16 – Apr 16, 2026), per INFRA-3580 analysis. The error signatures are: - `CDN: trunk URL couldn't be downloaded ... Response: 429 Too Many Requests` - HTTP/2 framing layer errors during `pod install` ### Root Cause The current flow in `setup-e2e-env/action.yml` **defeats its own CocoaPods specs cache**: 1. `actions/cache@v4` restores `~/.cocoapods/repos` (including trunk specs) — **100% cache hit rate** observed 2. `pod repo remove trunk || true` immediately **deletes** the restored trunk specs 3. `pod install --repo-update` must re-download the **entire** trunk from `cdn.cocoapods.org` — thousands of HTTP requests This maximises CDN pressure on every single iOS CI run, increasing the surface area for 429 rate-limit errors. **When trunk exists locally**, `--repo-update` performs an incremental delta update — minimal CDN requests, fast. **Without trunk** (after removal), `--repo-update` downloads everything from scratch — heavy CDN load, slow, triggers 429s. ### Why `pod repo remove trunk` was added The step was added in PR #28433 (commit `bc06cd5123`, Apr 8, 2026) as part of the macOS Sequoia → Tahoe migration for Xcode 26.x support. It was added proactively with the comment "prevent stale specs" — no review comments discussed the rationale, and no specific CDN failure motivated it. Since Cirrus runners are **ephemeral** (VMs destroyed after each job — [confirmed by Cirrus Labs](https://cirrus-runners.app/): *"Every job is executed in a reproducible isolated environment which is completely destroyed after the job is finished"*), the only way trunk specs exist at the start of `pod install` is via the `actions/cache` restore. There is no leftover state from previous jobs. The `--repo-update` flag already handles staleness by fetching deltas when trunk exists locally. ### Data **Pod install timing (successful runs, with trunk removal + full CDN re-download):** | Run ID | Duration | Notes | |--------|----------|-------| | 24887670082 | 116s (1m 56s) | Full CDN download after trunk removal | | 24887265337 | ~100s | Cache hit, trunk removed, re-downloaded | | 24888574759 | ~120s | Cache hit, trunk removed, re-downloaded | **CocoaPods specs cache hit rate:** 100% (exact or restore-key match in all sampled runs) **Cache flow (current, counterproductive):** - Cache restore: ~1 MB restored from GitHub Actions cache → trunk specs present - `pod repo remove trunk`: deletes restored specs → trunk gone - `pod install --repo-update`: full CDN download → thousands of requests → 429 risk ### Solution 1. **Remove standalone `pod repo remove trunk` step** — let cached specs be used on first attempt for incremental `--repo-update` (low CDN load) 2. **Move trunk removal to `on_retry_command`** — only clean on failure (handles corrupt/stale cache edge case) 3. **Increase `max_attempts` from 2 to 3** — matches other retry steps in the same file; CDN rate limits may need a third attempt 4. **Increase `retry_wait_seconds` from 30 to 60** — CDN 429 backoff windows need longer wait than apt mirror desyncs 5. **Add `::warning::` annotation on retry** — makes CDN failures visible in GitHub Actions UI 6. **Add `COCOAPODS_DISABLE_STATS=true`** — eliminates unnecessary analytics network calls during CI **First attempt (happy path):** cached trunk specs + incremental `--repo-update` → few HTTP requests, low CDN load **On failure:** trunk removed for clean slate → full CDN download on retry, warned in Actions UI **Second/third attempt:** fresh download from CDN with 60s backoff between attempts ## **Changelog** CHANGELOG entry: null ## **Related issues** Refs: INFRA-3580 ## **Manual testing steps** N/A — CI infrastructure change. Validated by any iOS E2E workflow run (retry logic is transparent in the happy path). The `pod install` step behavior is identical when the first attempt succeeds. ## **Screenshots/Recordings** ### **Before** N/A ### **After** N/A ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. Made with [Cursor](https://cursor.com) --- > [!NOTE] > **Medium Risk** > Changes the iOS CI dependency install flow and retry behavior, which could affect build stability if cache state differs from expectations, but it is limited to CI setup and guarded by retries. > > **Overview** > Improves iOS E2E CI CocoaPods installation reliability by **stopping the unconditional `pod repo remove trunk` cleanup** so the restored CocoaPods specs cache can be used on the first `pod install --repo-update` attempt. > > The CocoaPods install retry policy is strengthened (attempts 2→3, wait 30s→60s), and trunk cleanup is moved into `on_retry_command` with a GitHub Actions `::warning::` annotation; `COCOAPODS_DISABLE_STATS=true` is added to reduce extra network calls during CI. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 13ce7a71e78c74831cc04716135165bdd1d2c5d2. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .github/actions/setup-e2e-env/action.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/actions/setup-e2e-env/action.yml b/.github/actions/setup-e2e-env/action.yml index a524846ef11..07669962f93 100644 --- a/.github/actions/setup-e2e-env/action.yml +++ b/.github/actions/setup-e2e-env/action.yml @@ -390,19 +390,19 @@ runs: ${{ runner.os }}-cocoapods-specs- continue-on-error: true - - name: Clear CocoaPods trunk to prevent stale specs - if: ${{ inputs.platform == 'ios' }} - run: pod repo remove trunk || true - shell: bash - - name: Install CocoaPods via bundler if: ${{ inputs.platform == 'ios'}} uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 with: timeout_minutes: 15 - max_attempts: 2 - retry_wait_seconds: 30 + max_attempts: 3 + retry_wait_seconds: 60 + on_retry_command: | + echo "::warning::CocoaPods install failed, retrying after trunk cleanup..." + pod repo remove trunk || true command: cd ios && bundle exec pod install --repo-update + env: + COCOAPODS_DISABLE_STATS: 'true' - name: Install applesimutils if: ${{ inputs.platform == 'ios' }} From 4b756de7f35000fda7b84cda5ae835ffdf6c51eb Mon Sep 17 00:00:00 2001 From: Jorge Carrasco Date: Tue, 5 May 2026 11:21:49 +0200 Subject: [PATCH 6/9] ci: extract mobile CI status gate (#29619) ## **Description** This PR extracts the final Mobile CI status gate into a composite action while preserving the existing `Check all jobs pass` status check name. Why: - The final gate decides whether the workflow should pass after standard CI, E2E build/test jobs, fork-only skips, and merge queue skips complete. - Keeping that logic in a dedicated action makes the workflow easier to maintain and keeps the pass/fail decision consistent. What changed: - Added `.github/actions/ci-status-gate`. - Updated `.github/workflows/ci.yml` so `Check all jobs pass` calls the composite action. - Removed the intermediate `All jobs pass` job. - Preserved current handling for failed jobs, cancelled jobs, skipped E2E jobs, fork PR skips, merge queue skips, and E2E readiness blocking. - Added a step summary table that explains each job result evaluated by the gate. ## **Changelog** CHANGELOG entry: null ## **Related issues** No public issue: CI maintenance refactor. ## **Manual testing steps** Validated the workflow files locally: ```bash actionlint -color -config-file .github/actionlint.yaml .github/workflows/ci.yml ruby -e 'require "yaml"; YAML.load_file(".github/workflows/ci.yml"); YAML.load_file(".github/actions/ci-status-gate/action.yml")' git diff --check ``` Validated extracted gate behavior in the public test fork: https://github.com/consensys-test/metamask-mobile-test - Non-ignorable app-code PR with `pr-not-ready-for-e2e`: `Check all jobs pass` failed as expected because `block_merge_for_e2e_readiness=true`. - PR: https://github.com/consensys-test/metamask-mobile-test/pull/3 - Gate: https://github.com/consensys-test/metamask-mobile-test/actions/runs/25321128954/job/74237306086 - Locale-only PR without readiness blocking: E2E build/test jobs skipped, and the final gate accepted those skips. - PR: https://github.com/consensys-test/metamask-mobile-test/pull/2 - Gate: https://github.com/consensys-test/metamask-mobile-test/actions/runs/25318425027/job/74223723784 - Locale-only PR with `pr-not-ready-for-e2e`: E2E jobs stayed skipped and `Check all jobs pass` passed. - PR: https://github.com/consensys-test/metamask-mobile-test/pull/2 - Gate: https://github.com/consensys-test/metamask-mobile-test/actions/runs/25321510460/job/74234336146 - Standard CI failure: `Check all jobs pass` failed when a required standard CI job failed. - PR: https://github.com/consensys-test/metamask-mobile-test/pull/4 - Gate: https://github.com/consensys-test/metamask-mobile-test/actions/runs/25321403908/job/74233562352 - E2E job failure: `Check all jobs pass` failed when an E2E smoke job failed. - PR: https://github.com/consensys-test/metamask-mobile-test/pull/6 - E2E job: https://github.com/consensys-test/metamask-mobile-test/actions/runs/25328925239/job/74257209475 - Gate: https://github.com/consensys-test/metamask-mobile-test/actions/runs/25328925239/job/74259419260 - Build job failure: `Check all jobs pass` failed when an E2E build job failed. - PR: https://github.com/consensys-test/metamask-mobile-test/pull/5 - Build job: https://github.com/consensys-test/metamask-mobile-test/actions/runs/25331281786/job/74265395383 - Gate: https://github.com/consensys-test/metamask-mobile-test/actions/runs/25331281786/job/74267776538 ## **Screenshots/Recordings** ### **Before** N/A ### **After** N/A ## **Pre-merge author checklist** - [x] I've followed MetaMask Contributor Docs and MetaMask Mobile Coding Standards. - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using JSDoc format if applicable - [x] I've applied the right labels on the PR if applicable. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR where applicable. - [ ] I confirm that this PR addresses the described change and includes the necessary testing evidence. --- .github/actions/ci-status-gate/action.yml | 158 ++++++++++++++++++++++ .github/workflows/ci.yml | 120 ++++------------ 2 files changed, 182 insertions(+), 96 deletions(-) create mode 100644 .github/actions/ci-status-gate/action.yml diff --git a/.github/actions/ci-status-gate/action.yml b/.github/actions/ci-status-gate/action.yml new file mode 100644 index 00000000000..5c5f1434875 --- /dev/null +++ b/.github/actions/ci-status-gate/action.yml @@ -0,0 +1,158 @@ +name: CI Status Gate +description: Evaluate required CI job results and fail on unexpected skips or failed jobs. + +inputs: + needs-json: + description: JSON representation of the calling job's needs context. + required: true + requirement-context-json: + description: JSON representation of get-requirements outputs. + required: true + e2e-job-regex: + description: Regex matching E2E build/test jobs whose skipped result is allowed. Failed or cancelled E2E jobs still fail. + required: false + default: '^e2e-' + event-name: + description: GitHub event name for the current workflow run. + required: true + is-fork: + description: Whether the current pull request originates from a fork. When true, skipped jobs are treated as allowed skips. + required: false + default: 'false' + +runs: + using: composite + steps: + - name: Evaluate CI status + shell: bash + env: + NEEDS_JSON: ${{ inputs.needs-json }} + REQUIREMENT_CONTEXT_JSON: ${{ inputs.requirement-context-json }} + E2E_JOB_REGEX: ${{ inputs.e2e-job-regex }} + EVENT_NAME: ${{ inputs.event-name }} + IS_FORK: ${{ inputs.is-fork }} + run: | + set -euo pipefail + + get_requirement() { + local key="$1" + jq -nr --arg key "$key" 'env.REQUIREMENT_CONTEXT_JSON | fromjson | .[$key] // "false"' + } + + sanitize_markdown_cell() { + local value="$1" + value="${value//$'\n'/ }" + value="${value//|/\\|}" + printf '%s' "$value" + } + + add_summary_row() { + local job_name result decision reason + job_name="$(sanitize_markdown_cell "$1")" + result="$(sanitize_markdown_cell "$2")" + decision="$(sanitize_markdown_cell "$3")" + reason="$(sanitize_markdown_cell "$4")" + + printf '| `%s` | `%s` | %s | %s |\n' \ + "$job_name" "$result" "$decision" "$reason" >> "$summary_file" + } + + mark_failure() { + local message="$1" + failed="true" + echo "::error::$message" + } + + validate_json_type() { + local variable_name="$1" + local expected_type="$2" + + if ! jq -en --arg variable_name "$variable_name" --arg expected_type "$expected_type" \ + '(env[$variable_name] | fromjson | type) == $expected_type' >/dev/null 2>&1; then + echo "::error::$variable_name is not a valid JSON $expected_type" + exit 1 + fi + } + + require_requirement_key() { + local key="$1" + + if ! jq -en --arg key "$key" \ + 'env.REQUIREMENT_CONTEXT_JSON | fromjson | .[$key] != null' >/dev/null 2>&1; then + echo "::error::REQUIREMENT_CONTEXT_JSON is missing or null for required key: $key" + exit 1 + fi + } + + validate_json_type NEEDS_JSON object + validate_json_type REQUIREMENT_CONTEXT_JSON object + + for required_key in skip_everything block_merge_for_e2e_readiness; do + require_requirement_key "$required_key" + done + + skip_everything="$(get_requirement skip_everything)" + block_merge_for_e2e_readiness="$(get_requirement block_merge_for_e2e_readiness)" + + if [[ "$block_merge_for_e2e_readiness" == "true" ]]; then + echo "::error::The 'pr-not-ready-for-e2e' label is still applied. Remove it to trigger E2E tests before merging." + exit 1 + fi + + if [[ "$skip_everything" == "true" ]]; then + echo "skip_everything=true; treating all jobs as passed" + exit 0 + fi + + failed="false" + summary_file="$(mktemp)" + trap 'if [[ -n "${GITHUB_STEP_SUMMARY:-}" && -f "$summary_file" ]]; then cat "$summary_file" >> "$GITHUB_STEP_SUMMARY"; fi; rm -f "$summary_file"' EXIT + job_count=0 + + { + echo "### CI Status Gate" + echo + echo "| Job | Result | Decision | Reason |" + echo "| --- | --- | --- | --- |" + } >> "$summary_file" + + while IFS=$'\t' read -r job_name result; do + job_count=$((job_count + 1)) + + case "$result" in + success) + add_summary_row "$job_name" "$result" "pass" "job succeeded" + ;; + failure|cancelled) + mark_failure "$job_name finished with result: $result" + add_summary_row "$job_name" "$result" "fail" "job did not complete successfully" + ;; + skipped) + if [[ "$job_name" =~ $E2E_JOB_REGEX ]]; then + add_summary_row "$job_name" "$result" "pass" "skipped E2E jobs are allowed" + elif [[ "$EVENT_NAME" == "merge_group" ]]; then + add_summary_row "$job_name" "$result" "pass" "merge queue skip is allowed" + elif [[ "$IS_FORK" == "true" ]]; then + add_summary_row "$job_name" "$result" "pass" "fork-only skip is allowed" + else + mark_failure "$job_name was skipped unexpectedly" + add_summary_row "$job_name" "$result" "fail" "skip was not expected" + fi + ;; + *) + mark_failure "$job_name has unknown result: $result" + add_summary_row "$job_name" "$result" "fail" "job result is unknown" + ;; + esac + done < <(jq -nr 'env.NEEDS_JSON | fromjson | to_entries[] | [.key, (.value.result // "")] | @tsv') + + if [[ "$job_count" -eq 0 ]]; then + echo "::error::NEEDS_JSON does not contain any jobs" + exit 1 + fi + + if [[ "$failed" == "true" ]]; then + exit 1 + fi + + echo "All required jobs passed" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 96719067513..139886e7570 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -937,113 +937,41 @@ jobs: fi fi - all-jobs-pass: - name: All jobs pass - runs-on: ubuntu-latest - if: ${{ !cancelled() }} - needs: - [ - check-diff, - dedupe, - scripts, - unit-tests, - component-view-tests, - check-workflows, - js-bundle-size-check, - sonar-cloud-quality-gate-status, - ] - outputs: - ALL_JOBS_PASSED: ${{ steps.jobs-passed-status.outputs.ALL_JOBS_PASSED }} - steps: - - name: Set jobs passed status - id: jobs-passed-status - env: - NEEDS_CONTEXT: ${{ toJSON(needs) }} - EVENT_NAME: ${{ github.event_name }} - IS_FORK: ${{ github.event.pull_request.head.repo.fork }} - run: | - # Check results of all required jobs dynamically - # On merge_group events, "skipped" is acceptable (some jobs intentionally skip) - # On fork PRs, "skipped" is acceptable (secret-dependent jobs are intentionally skipped) - # On other events (push to main), all jobs must succeed - - FAILED="false" - - while read -r job_name result; do - if [[ "$result" == "failure" ]] || [[ "$result" == "cancelled" ]]; then - echo "::error::Job '$job_name' failed with result: $result" - FAILED="true" - elif [[ "$result" == "skipped" ]]; then - if [[ "$EVENT_NAME" == "merge_group" ]] || [[ "$IS_FORK" == "true" ]]; then - echo "Job '$job_name' was skipped (OK for merge_group events and fork PRs)" - else - echo "::error::Job '$job_name' was unexpectedly skipped on $EVENT_NAME event" - FAILED="true" - fi - else - echo "Job '$job_name' passed" - fi - done < <(echo "$NEEDS_CONTEXT" | jq -r 'to_entries[] | "\(.key) \(.value.result)"') - - if [[ "$FAILED" == "true" ]]; then - echo "Some required jobs failed" - exit 1 - fi - - echo "ALL_JOBS_PASSED=true" >> "$GITHUB_OUTPUT" - check-all-jobs-pass: name: Check all jobs pass - if: ${{ !cancelled() }} + # Run the aggregate gate even when optional dependencies are skipped. + # The composite action decides which skipped jobs are acceptable. + if: ${{ always() && !cancelled() }} runs-on: ubuntu-latest needs: - get_requirements - - all-jobs-pass + - check-diff + - dedupe + - scripts + - unit-tests + - component-view-tests + - check-workflows + - js-bundle-size-check + - sonar-cloud-quality-gate-status - build-android-apks - build-ios-apps - e2e-smoke-tests-android - e2e-smoke-tests-ios - env: - SKIPPED: ${{ needs.get_requirements.outputs.skip_everything == 'true' }} steps: - - name: Block merge while pr-not-ready-for-e2e label is applied - if: ${{ needs.get_requirements.outputs.block_merge_for_e2e_readiness == 'true' }} - run: | - echo "::error::The 'pr-not-ready-for-e2e' label is still applied. Remove it to trigger E2E tests before merging." - exit 1 - - run: | - # If the merge queue was skipped, consider all jobs as passed - if [[ "$SKIPPED" == "true" ]]; then - echo "Merge queue skipped, considering all jobs as passed" - exit 0 - fi - - # Check if all non-E2E jobs passed - if [[ "${{ needs.all-jobs-pass.outputs.ALL_JOBS_PASSED }}" != "true" ]]; then - echo "Non-E2E jobs failed" - exit 1 - fi - - # Check E2E build + smoke results only if E2E should have run. - # 'skipped' is acceptable — covers merge_group, fork PRs, ignorable-only changes, - # platform-only PRs, and AI selection returning zero tags. - # 'failure'/'cancelled' on any of build or smoke must block merge. - if [[ "${{ needs.get_requirements.outputs.skip_e2e }}" != "true" ]]; then - for entry in \ - "build-android-apks:${{ needs.build-android-apks.result }}" \ - "e2e-smoke-tests-android:${{ needs.e2e-smoke-tests-android.result }}" \ - "build-ios-apps:${{ needs.build-ios-apps.result }}" \ - "e2e-smoke-tests-ios:${{ needs.e2e-smoke-tests-ios.result }}"; do - name="${entry%%:*}" - result="${entry#*:}" - if [[ "$result" == "failure" ]] || [[ "$result" == "cancelled" ]]; then - echo "::error::Required E2E job '$name' did not succeed (result: $result)" - exit 1 - fi - done - fi + - uses: actions/checkout@v6 + with: + fetch-depth: 1 + sparse-checkout: | + .github/actions/ci-status-gate - echo "All required jobs passed" + - name: Evaluate CI status + uses: ./.github/actions/ci-status-gate + with: + needs-json: ${{ toJSON(needs) }} + requirement-context-json: ${{ toJSON(needs.get_requirements.outputs) }} + e2e-job-regex: '^(build-android-apks|build-ios-apps|e2e-smoke-tests-android|e2e-smoke-tests-ios)$' + event-name: ${{ github.event_name }} + is-fork: ${{ github.event.pull_request.head.repo.fork == true }} log-merge-group-failure: name: Log merge group failure From c03ef595c9000a64374615a0558ee16ee4e30404 Mon Sep 17 00:00:00 2001 From: VGR Date: Tue, 5 May 2026 11:37:10 +0200 Subject: [PATCH 7/9] feat(rewards): add Perps Trading Campaign (#29323) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description https://consensyssoftware.atlassian.net/browse/RWDS-1239 https://consensyssoftware.atlassian.net/browse/RWDS-1190 https://consensyssoftware.atlassian.net/browse/RWDS-1215 Adds support for the Perps Trading Competition rewards campaign. Users can view campaign details, join via a guided tour, track their stats (rank, PnL, notional volume and margin thresholds), browse the leaderboard, and monitor the prize pool — which scales from \$10k to \$50k based on total notional volume. Geo-restricted users see a locked CTA. Once opted in, the CTA switches to "Open Position" navigating directly to the Perps tab. New screens: campaign details, stats, and full leaderboard. All data-driven from the existing campaign/leaderboard backend infrastructure. ## Changelog CHANGELOG entry: **Added**: Rewards Perps Trading Campaign — details page, stats page, leaderboard page, prize pool, tour, and opt-in flow. ## Screenshots Simulator Screenshot - E2E Test -
2026-04-28 at 15 31 25 ### ----------------------------------------Qualified---------------------------------------- Simulator Screenshot - E2E Test -
2026-04-29 at 15 24 21 Simulator Screenshot - E2E Test -
2026-04-29 at 15 24 28 Simulator Screenshot - E2E Test -
2026-04-28 at 17 28 50 Simulator Screenshot - E2E Test -
2026-04-28 at 17 29 33 ### ----------------------------------------Pending---------------------------------------- Simulator Screenshot - E2E Test -
2026-04-29 at 15 30 03 Simulator Screenshot - E2E Test -
2026-04-29 at 15 28 25 Simulator Screenshot - E2E Test -
2026-04-29 at 15 30 03 Simulator Screenshot - E2E Test -
2026-04-29 at 15 32 31 ### ----------------------------------------Split View---------------------------------------- Simulator Screenshot - E2E Test -
2026-04-28 at 17 36 22 Simulator Screenshot - E2E Test -
2026-04-28 at 17 36 38 Simulator Screenshot - E2E Test -
2026-04-28 at 17 37 38 Simulator Screenshot - E2E Test -
2026-04-28 at 17 50 45 Simulator Screenshot - E2E Test -
2026-04-28 at 18 00 11 ### ---------------------------------Powered by HyperTracker------------------------------ Simulator Screenshot - E2E Test -
2026-05-01 at 11 44 51 Simulator Screenshot - E2E Test -
2026-05-01 at 11 44 56 --- > [!NOTE] > **Medium Risk** > Adds new Rewards navigation routes and multiple Perps campaign views/CTAs, plus refactors shared leaderboard/prize-pool logic that could affect existing Ondo rendering and testIDs. Risk is moderate due to new conditional UI flows and reused components across campaigns. > > **Overview** > Adds a new **Perps Trading rewards campaign** user flow with dedicated `details`, `leaderboard`, and `stats` screens, wiring them into `RewardsNavigator`, `CampaignTile` navigation (including tour routing), and deeplink handling (`campaign=perps-comp`). > > Refactors campaign UI to improve reuse: extracts a shared `CampaignLeaderboard` (rows/skeleton/separator + shared test IDs) and updates `OndoLeaderboard` to use it; splits `CampaignStatsSummary` into `OndoCampaignStatsSummary` (renamed test IDs) and reuses its `StatCell`/tags in new Perps stats components. > > Unifies prize pool tier computation by introducing `computePrizePoolProgress` and migrating `OndoPrizePool`/`OndoLeaderboardView` off bespoke `getCurrentPrize`/progress logic. Adds extensive test coverage for the new Perps views/components and updated Ondo stats/leaderboard behavior. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit fcf01e2093b9baf57c73078d67e063b7307ed143. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --------- Co-authored-by: sophieqgu --- .../UI/Rewards/RewardsNavigator.tsx | 20 + .../UI/Rewards/Views/CampaignTourStepView.tsx | 11 +- .../Views/OndoCampaignDetailsView.test.tsx | 53 +- .../Rewards/Views/OndoCampaignDetailsView.tsx | 6 +- .../Rewards/Views/OndoCampaignStatsView.tsx | 9 +- .../Views/OndoLeaderboardView.test.tsx | 4 + .../UI/Rewards/Views/OndoLeaderboardView.tsx | 12 +- .../PerpsTradingCampaignDetailsView.test.tsx | 597 ++++++++++++++++++ .../Views/PerpsTradingCampaignDetailsView.tsx | 365 +++++++++++ ...rpsTradingCampaignLeaderboardView.test.tsx | 305 +++++++++ .../PerpsTradingCampaignLeaderboardView.tsx | 142 +++++ .../PerpsTradingCampaignStatsView.test.tsx | 392 ++++++++++++ .../Views/PerpsTradingCampaignStatsView.tsx | 278 ++++++++ .../Campaigns/CampaignEndedStats.tsx | 2 +- .../Campaigns/CampaignLeaderboard.test.tsx | 153 +++++ .../Campaigns/CampaignLeaderboard.tsx | 177 ++++++ .../components/Campaigns/CampaignTile.tsx | 18 +- .../Campaigns/LeaderboardPositionHeader.tsx | 43 +- ....tsx => OndoCampaignStatsSummary.test.tsx} | 212 ++++--- ...mmary.tsx => OndoCampaignStatsSummary.tsx} | 31 +- .../components/Campaigns/OndoLeaderboard.tsx | 164 +---- .../Campaigns/OndoLeaderboard.utils.test.ts | 41 +- .../Campaigns/OndoLeaderboard.utils.ts | 5 +- .../Campaigns/OndoPortfolio.test.tsx | 4 - .../Campaigns/OndoPrizePool.test.tsx | 42 +- .../components/Campaigns/OndoPrizePool.tsx | 51 +- .../PerpsCampaignStatsSummary.test.tsx | 206 ++++++ .../Campaigns/PerpsCampaignStatsSummary.tsx | 187 ++++++ .../PerpsTradingCampaignCTA.test.tsx | 210 ++++++ .../Campaigns/PerpsTradingCampaignCTA.tsx | 131 ++++ .../PerpsTradingCampaignLeaderboard.test.tsx | 198 ++++++ .../PerpsTradingCampaignLeaderboard.tsx | 240 +++++++ .../PerpsTradingCampaignPrizePool.test.tsx | 271 ++++++++ .../PerpsTradingCampaignPrizePool.tsx | 196 ++++++ .../PerpsTradingCampaignStatsHeader.test.tsx | 162 +++++ .../PerpsTradingCampaignStatsHeader.tsx | 149 +++++ ...GetPerpsTradingCampaignLeaderboard.test.ts | 244 +++++++ .../useGetPerpsTradingCampaignLeaderboard.ts | 76 +++ ...TradingCampaignLeaderboardPosition.test.ts | 260 ++++++++ ...PerpsTradingCampaignLeaderboardPosition.ts | 83 +++ .../useGetPerpsTradingCampaignVolume.test.ts | 196 ++++++ .../hooks/useGetPerpsTradingCampaignVolume.ts | 59 ++ .../UI/Rewards/utils/formatUtils.test.ts | 81 +-- .../UI/Rewards/utils/formatUtils.ts | 24 +- .../Rewards/utils/perpsCampaignConstants.ts | 9 + .../UI/Rewards/utils/prizePoolUtils.test.ts | 79 +++ .../UI/Rewards/utils/prizePoolUtils.ts | 49 ++ app/constants/navigation/Routes.ts | 5 + .../RewardsController-method-action-types.ts | 46 +- .../RewardsController.test.ts | 9 + .../rewards-controller/RewardsController.ts | 219 +++++++ .../services/rewards-data-service.test.ts | 137 ++++ .../services/rewards-data-service.ts | 109 +++- .../controllers/rewards-controller/types.ts | 114 ++++ .../rewards-controller-messenger/index.ts | 13 +- app/reducers/rewards/index.test.ts | 204 ++++++ app/reducers/rewards/index.ts | 104 ++- app/reducers/rewards/selectors.ts | 31 + app/util/test/initial-background-state.json | 3 + locales/languages/en.json | 40 ++ 60 files changed, 6790 insertions(+), 491 deletions(-) create mode 100644 app/components/UI/Rewards/Views/PerpsTradingCampaignDetailsView.test.tsx create mode 100644 app/components/UI/Rewards/Views/PerpsTradingCampaignDetailsView.tsx create mode 100644 app/components/UI/Rewards/Views/PerpsTradingCampaignLeaderboardView.test.tsx create mode 100644 app/components/UI/Rewards/Views/PerpsTradingCampaignLeaderboardView.tsx create mode 100644 app/components/UI/Rewards/Views/PerpsTradingCampaignStatsView.test.tsx create mode 100644 app/components/UI/Rewards/Views/PerpsTradingCampaignStatsView.tsx create mode 100644 app/components/UI/Rewards/components/Campaigns/CampaignLeaderboard.test.tsx create mode 100644 app/components/UI/Rewards/components/Campaigns/CampaignLeaderboard.tsx rename app/components/UI/Rewards/components/Campaigns/{CampaignStatsSummary.test.tsx => OndoCampaignStatsSummary.test.tsx} (77%) rename app/components/UI/Rewards/components/Campaigns/{CampaignStatsSummary.tsx => OndoCampaignStatsSummary.tsx} (91%) create mode 100644 app/components/UI/Rewards/components/Campaigns/PerpsCampaignStatsSummary.test.tsx create mode 100644 app/components/UI/Rewards/components/Campaigns/PerpsCampaignStatsSummary.tsx create mode 100644 app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignCTA.test.tsx create mode 100644 app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignCTA.tsx create mode 100644 app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignLeaderboard.test.tsx create mode 100644 app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignLeaderboard.tsx create mode 100644 app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignPrizePool.test.tsx create mode 100644 app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignPrizePool.tsx create mode 100644 app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignStatsHeader.test.tsx create mode 100644 app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignStatsHeader.tsx create mode 100644 app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboard.test.ts create mode 100644 app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboard.ts create mode 100644 app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboardPosition.test.ts create mode 100644 app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboardPosition.ts create mode 100644 app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignVolume.test.ts create mode 100644 app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignVolume.ts create mode 100644 app/components/UI/Rewards/utils/perpsCampaignConstants.ts create mode 100644 app/components/UI/Rewards/utils/prizePoolUtils.test.ts create mode 100644 app/components/UI/Rewards/utils/prizePoolUtils.ts diff --git a/app/components/UI/Rewards/RewardsNavigator.tsx b/app/components/UI/Rewards/RewardsNavigator.tsx index 4ed5495738c..7371d347cfb 100644 --- a/app/components/UI/Rewards/RewardsNavigator.tsx +++ b/app/components/UI/Rewards/RewardsNavigator.tsx @@ -16,6 +16,9 @@ import OndoCampaignRwaSelectorView from './Views/OndoCampaignRwaSelectorView'; import OndoCampaignPortfolioView from './Views/OndoCampaignPortfolioView'; import OndoCampaignStatsView from './Views/OndoCampaignStatsView'; import CampaignTourStepView from './Views/CampaignTourStepView'; +import PerpsTradingCampaignDetailsView from './Views/PerpsTradingCampaignDetailsView'; +import PerpsTradingCampaignLeaderboardView from './Views/PerpsTradingCampaignLeaderboardView'; +import PerpsTradingCampaignStatsView from './Views/PerpsTradingCampaignStatsView'; import { useDispatch, useSelector } from 'react-redux'; import { selectRewardsSubscriptionId } from '../../../selectors/rewards'; import { @@ -92,6 +95,8 @@ const RewardsNavigator: React.FC = () => { navigation.navigate(Routes.REWARDS_ONDO_CAMPAIGN_DETAILS_VIEW); } else if (pendingDeeplink?.campaign === 'season1') { navigation.navigate(Routes.REWARDS_SEASON_ONE_CAMPAIGN_DETAILS_VIEW); + } else if (pendingDeeplink?.campaign === 'perps-comp') { + navigation.navigate(Routes.REWARDS_PERPS_TRADING_CAMPAIGN_DETAILS_VIEW); } else if (pendingDeeplink?.page === 'musd') { navigation.navigate(Routes.REWARDS_MUSD_CALCULATOR_VIEW); } else if (pendingDeeplink?.page === 'benefits') { @@ -194,6 +199,21 @@ const RewardsNavigator: React.FC = () => { component={OndoCampaignStatsView} options={{ headerShown: false }} /> + + + ) : null} diff --git a/app/components/UI/Rewards/Views/CampaignTourStepView.tsx b/app/components/UI/Rewards/Views/CampaignTourStepView.tsx index 7dc70bfa7b5..81e81f80205 100644 --- a/app/components/UI/Rewards/Views/CampaignTourStepView.tsx +++ b/app/components/UI/Rewards/Views/CampaignTourStepView.tsx @@ -28,6 +28,7 @@ import { import ScrollableTabView from '@tommasini/react-native-scrollable-tab-view'; import { selectCampaignById } from '../../../../reducers/rewards/selectors'; import Routes from '../../../../constants/navigation/Routes'; +import { CampaignType } from '../../../../core/Engine/controllers/rewards-controller/types'; import ProgressIndicator from '../components/Onboarding/ProgressIndicator'; import CampaignTourStep, { CAMPAIGN_TOUR_STEP_TEST_IDS, @@ -63,13 +64,19 @@ const CampaignTourStepView: React.FC = () => { typeof ScrollableTabView & { goToPage: (page: number) => void } >(null); + const campaignType = campaign?.type; + const navigateToDetails = useCallback(() => { + const detailsRoute = + campaignType === CampaignType.PERPS_TRADING + ? Routes.REWARDS_PERPS_TRADING_CAMPAIGN_DETAILS_VIEW + : Routes.REWARDS_ONDO_CAMPAIGN_DETAILS_VIEW; navigation.dispatch( - StackActions.replace(Routes.REWARDS_ONDO_CAMPAIGN_DETAILS_VIEW, { + StackActions.replace(detailsRoute, { campaignId, }), ); - }, [navigation, campaignId]); + }, [navigation, campaignId, campaignType]); const currentStep = tour?.[currentTab]; const isLastStep = tour ? currentTab === tour.length - 1 : false; diff --git a/app/components/UI/Rewards/Views/OndoCampaignDetailsView.test.tsx b/app/components/UI/Rewards/Views/OndoCampaignDetailsView.test.tsx index f51095950e9..0093688e435 100644 --- a/app/components/UI/Rewards/Views/OndoCampaignDetailsView.test.tsx +++ b/app/components/UI/Rewards/Views/OndoCampaignDetailsView.test.tsx @@ -3,7 +3,7 @@ import { render, fireEvent } from '@testing-library/react-native'; import OndoCampaignDetailsView, { CAMPAIGN_DETAILS_TEST_IDS, } from './OndoCampaignDetailsView'; -import { CAMPAIGN_STATS_SUMMARY_TEST_IDS } from '../components/Campaigns/CampaignStatsSummary'; +import { ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS } from '../components/Campaigns/OndoCampaignStatsSummary'; import { ONDO_PRIZE_POOL_TEST_IDS } from '../components/Campaigns/OndoPrizePool'; import { CAMPAIGN_CTA_TEST_IDS } from '../components/Campaigns/CampaignOptInCta'; import { CAMPAIGN_ENDED_STATS_TEST_IDS } from '../components/Campaigns/CampaignEndedStats'; @@ -153,18 +153,17 @@ jest.mock('../components/Campaigns/CampaignEndedStats', () => { }; }); -const mockCampaignStatsSummary = jest.fn(); -jest.mock('../components/Campaigns/CampaignStatsSummary', () => { +const mockOndoCampaignStatsSummary = jest.fn(); +jest.mock('../components/Campaigns/OndoCampaignStatsSummary', () => { const ReactActual = jest.requireActual('react'); const { View } = jest.requireActual('react-native'); - const { CAMPAIGN_STATS_SUMMARY_TEST_IDS: actualTestIds } = jest.requireActual( - '../components/Campaigns/CampaignStatsSummary', - ); + const { ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS: actualTestIds } = + jest.requireActual('../components/Campaigns/OndoCampaignStatsSummary'); return { __esModule: true, - CAMPAIGN_STATS_SUMMARY_TEST_IDS: actualTestIds, + ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS: actualTestIds, default: (props: Record) => { - mockCampaignStatsSummary(props); + mockOndoCampaignStatsSummary(props); return ReactActual.createElement(View, { testID: actualTestIds.CONTAINER, }); @@ -556,7 +555,7 @@ describe('OndoCampaignDetailsView', () => { beforeEach(() => { jest.clearAllMocks(); mockIsTokenTradingOpen.mockReturnValue(true); - mockCampaignStatsSummary.mockReset(); + mockOndoCampaignStatsSummary.mockReset(); mockUseRewardCampaigns.mockReturnValue(hookDefaults); mockUseGetCampaignParticipantStatus.mockReturnValue({ status: null, @@ -763,7 +762,7 @@ describe('OndoCampaignDetailsView', () => { expect(queryByTestId('campaign-how-it-works')).toBeNull(); }); - it('renders CampaignStatsSummary when user has portfolio positions', () => { + it('renders OndoCampaignStatsSummary when user has portfolio positions', () => { mockUseRewardCampaigns.mockReturnValue({ ...hookDefaults, campaigns: [createTestCampaign()], @@ -783,11 +782,11 @@ describe('OndoCampaignDetailsView', () => { }); const { getByTestId } = render(); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.CONTAINER), + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.CONTAINER), ).toBeDefined(); }); - it('does not render CampaignStatsSummary when participant has no positions', () => { + it('does not render OndoCampaignStatsSummary when participant has no positions', () => { mockUseRewardCampaigns.mockReturnValue({ ...hookDefaults, campaigns: [createTestCampaign()], @@ -800,7 +799,7 @@ describe('OndoCampaignDetailsView', () => { }); const { queryByTestId } = render(); expect( - queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.CONTAINER), + queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.CONTAINER), ).toBeNull(); }); }); @@ -940,7 +939,7 @@ describe('OndoCampaignDetailsView', () => { }); describe('stats summary and leaderboard', () => { - it('shows CampaignStatsSummary when participant is opted in with positions', () => { + it('shows OndoCampaignStatsSummary when participant is opted in with positions', () => { mockUseRewardCampaigns.mockReturnValue({ ...hookDefaults, campaigns: [createTestCampaign()], @@ -960,18 +959,18 @@ describe('OndoCampaignDetailsView', () => { }); const { getByTestId } = render(); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.CONTAINER), + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.CONTAINER), ).toBeDefined(); }); - it('does not show CampaignStatsSummary when not opted in and campaign is active', () => { + it('does not show OndoCampaignStatsSummary when not opted in and campaign is active', () => { mockUseRewardCampaigns.mockReturnValue({ ...hookDefaults, campaigns: [createTestCampaign()], }); const { queryByTestId } = render(); expect( - queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.CONTAINER), + queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.CONTAINER), ).toBeNull(); }); @@ -995,7 +994,7 @@ describe('OndoCampaignDetailsView', () => { ); expect(getByTestId('ondo-leaderboard')).toBeDefined(); expect( - queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.CONTAINER), + queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.CONTAINER), ).toBeNull(); }); @@ -1166,7 +1165,7 @@ describe('OndoCampaignDetailsView', () => { }); }); - describe('ineligible state — isIneligible prop passed to CampaignStatsSummary', () => { + describe('ineligible state — isIneligible prop passed to OndoCampaignStatsSummary', () => { const setupWithPositions = () => { mockUseGetCampaignParticipantStatus.mockReturnValue({ status: { optedIn: true, participantCount: 1 }, @@ -1201,7 +1200,7 @@ describe('OndoCampaignDetailsView', () => { }); setupWithPositions(); render(); - expect(mockCampaignStatsSummary).toHaveBeenCalledWith( + expect(mockOndoCampaignStatsSummary).toHaveBeenCalledWith( expect.objectContaining({ isIneligible: true }), ); }); @@ -1224,7 +1223,7 @@ describe('OndoCampaignDetailsView', () => { }); setupWithPositions(); render(); - expect(mockCampaignStatsSummary).toHaveBeenCalledWith( + expect(mockOndoCampaignStatsSummary).toHaveBeenCalledWith( expect.objectContaining({ isIneligible: false }), ); }); @@ -1266,7 +1265,7 @@ describe('OndoCampaignDetailsView', () => { refetch: jest.fn(), }); render(); - expect(mockCampaignStatsSummary).toHaveBeenCalledWith( + expect(mockOndoCampaignStatsSummary).toHaveBeenCalledWith( expect.objectContaining({ isIneligible: false }), ); }); @@ -1439,10 +1438,10 @@ describe('OndoCampaignDetailsView', () => { ); }); - it('passes winner outcome props to CampaignStatsSummary when campaign is complete', () => { + it('passes winner outcome props to OndoCampaignStatsSummary when campaign is complete', () => { setupWinner(); render(); - expect(mockCampaignStatsSummary).toHaveBeenCalledWith( + expect(mockOndoCampaignStatsSummary).toHaveBeenCalledWith( expect.objectContaining({ isCampaignComplete: true, outcomeStatus: 'pending', @@ -1451,7 +1450,7 @@ describe('OndoCampaignDetailsView', () => { ); }); - it('passes no outcome status to CampaignStatsSummary when user has no outcome', () => { + it('passes no outcome status to OndoCampaignStatsSummary when user has no outcome', () => { mockUseRewardCampaigns.mockReturnValue({ ...hookDefaults, campaigns: [ @@ -1473,7 +1472,7 @@ describe('OndoCampaignDetailsView', () => { }); render(); expect( - mockCampaignStatsSummary.mock.calls.at(-1)?.[0]?.outcomeStatus, + mockOndoCampaignStatsSummary.mock.calls.at(-1)?.[0]?.outcomeStatus, ).toBeUndefined(); }); @@ -1482,7 +1481,7 @@ describe('OndoCampaignDetailsView', () => { mockNavigate.mockClear(); render(); const onWinnerPress = - mockCampaignStatsSummary.mock.calls.at(-1)?.[0]?.onWinnerPress; + mockOndoCampaignStatsSummary.mock.calls.at(-1)?.[0]?.onWinnerPress; expect(typeof onWinnerPress).toBe('function'); onWinnerPress(); expect(mockNavigate).toHaveBeenCalledWith( diff --git a/app/components/UI/Rewards/Views/OndoCampaignDetailsView.tsx b/app/components/UI/Rewards/Views/OndoCampaignDetailsView.tsx index 3f5cdc13c01..9fd0cd8f975 100644 --- a/app/components/UI/Rewards/Views/OndoCampaignDetailsView.tsx +++ b/app/components/UI/Rewards/Views/OndoCampaignDetailsView.tsx @@ -42,8 +42,8 @@ import OndoPortfolio from '../components/Campaigns/OndoPortfolio'; import OndoAccountPickerSheet from '../components/Campaigns/OndoAccountPickerSheet'; import OndoCampaignCTA from '../components/Campaigns/OndoCampaignCTA'; import OndoNotEligibleSheet from '../components/Campaigns/OndoNotEligibleSheet'; -import CampaignStatsSummary from '../components/Campaigns/CampaignStatsSummary'; import CampaignEndedStats from '../components/Campaigns/CampaignEndedStats'; +import OndoCampaignStatsSummary from '../components/Campaigns/OndoCampaignStatsSummary'; import OndoPrizePool from '../components/Campaigns/OndoPrizePool'; import { getCampaignStatus } from '../components/Campaigns/CampaignTile.utils'; import RewardsErrorBanner from '../components/RewardsErrorBanner'; @@ -408,7 +408,7 @@ const OndoCampaignDetailsView: React.FC = () => { /> - { {strings('rewards.ondo_campaign_leaderboard.title')} diff --git a/app/components/UI/Rewards/Views/OndoCampaignStatsView.tsx b/app/components/UI/Rewards/Views/OndoCampaignStatsView.tsx index 7dda7d1b74c..b17b28efa50 100644 --- a/app/components/UI/Rewards/Views/OndoCampaignStatsView.tsx +++ b/app/components/UI/Rewards/Views/OndoCampaignStatsView.tsx @@ -21,8 +21,8 @@ import ErrorBoundary from '../../../Views/ErrorBoundary'; import CampaignViewHeader from '../components/Campaigns/CampaignViewHeader'; import { StatCell, - CAMPAIGN_STATS_SUMMARY_TEST_IDS, -} from '../components/Campaigns/CampaignStatsSummary'; + ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS, +} from '../components/Campaigns/OndoCampaignStatsSummary'; import LeaderboardPositionHeader from '../components/Campaigns/LeaderboardPositionHeader'; import RewardsErrorBanner from '../components/RewardsErrorBanner'; import { getTierMinNetDeposit } from '../components/Campaigns/OndoLeaderboard.utils'; @@ -47,6 +47,7 @@ type OndoCampaignStatsRouteParams = { export const ONDO_CAMPAIGN_STATS_VIEW_TEST_IDS = { CONTAINER: 'ondo-campaign-stats-view-container', + LAST_COMPUTED: 'ondo-campaign-stats-view-last-computed', } as const; const CheckIcon: React.FC = () => ( @@ -346,7 +347,9 @@ const OndoCampaignStatsView: React.FC = () => { {!isCampaignComplete && isIneligible && ( { const ReactActual = jest.requireActual('react'); const { View } = jest.requireActual('react-native'); + const { CAMPAIGN_LEADERBOARD_TEST_IDS } = jest.requireActual< + typeof import('../components/Campaigns/OndoLeaderboard') + >('../components/Campaigns/OndoLeaderboard'); return { __esModule: true, default: (props: Record) => { @@ -95,6 +98,7 @@ jest.mock('../components/Campaigns/OndoLeaderboard', () => { testID: 'campaign-leaderboard', }); }, + CAMPAIGN_LEADERBOARD_TEST_IDS, }; }); diff --git a/app/components/UI/Rewards/Views/OndoLeaderboardView.tsx b/app/components/UI/Rewards/Views/OndoLeaderboardView.tsx index bb957308a99..fd965e88005 100644 --- a/app/components/UI/Rewards/Views/OndoLeaderboardView.tsx +++ b/app/components/UI/Rewards/Views/OndoLeaderboardView.tsx @@ -36,7 +36,6 @@ import { useGetOndoPortfolioPosition } from '../hooks/useGetOndoPortfolioPositio import { useGetOndoCampaignDeposits } from '../hooks/useGetOndoCampaignDeposits'; import { useGetCampaignParticipantStatus } from '../hooks/useGetCampaignParticipantStatus'; import { useOndoLeaderboardPositionDisplay } from '../hooks/useOndoLeaderboardPositionDisplay'; -import { getCurrentPrize } from '../components/Campaigns/OndoPrizePool'; import { strings } from '../../../../../locales/i18n'; import Routes from '../../../../constants/navigation/Routes'; import { @@ -44,6 +43,8 @@ import { selectCampaignById, } from '../../../../reducers/rewards/selectors'; import useTrackRewardsPageView from '../hooks/useTrackRewardsPageView'; +import { computePrizePoolProgress } from '../utils/prizePoolUtils'; +import { BREAKPOINTS } from '../components/Campaigns/OndoPrizePool'; // ParamListBase requires an index signature, which interfaces don't support // eslint-disable-next-line @typescript-eslint/consistent-type-definitions @@ -104,7 +105,13 @@ const OndoLeaderboardView: React.FC = () => { }); const prizePoolValue = deposits?.totalUsdDeposited - ? formatUsd(getCurrentPrize(parseFloat(deposits.totalUsdDeposited))) + ? formatUsd( + computePrizePoolProgress( + BREAKPOINTS, + parseFloat(deposits.totalUsdDeposited), + (m) => m.deposit, + ).currentPrize, + ) : undefined; const { @@ -116,6 +123,7 @@ const OndoLeaderboardView: React.FC = () => { isLoading: isLeaderboardLoading, hasError: hasLeaderboardError, isLeaderboardNotYetComputed, + computedAt: leaderboardComputedAt, refetch: refetchLeaderboard, } = useGetOndoLeaderboard(campaignId, { defaultTier: position?.projectedTier, diff --git a/app/components/UI/Rewards/Views/PerpsTradingCampaignDetailsView.test.tsx b/app/components/UI/Rewards/Views/PerpsTradingCampaignDetailsView.test.tsx new file mode 100644 index 00000000000..54fe6b66bee --- /dev/null +++ b/app/components/UI/Rewards/Views/PerpsTradingCampaignDetailsView.test.tsx @@ -0,0 +1,597 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import PerpsTradingCampaignDetailsView, { + PERPS_CAMPAIGN_DETAILS_TEST_IDS, +} from './PerpsTradingCampaignDetailsView'; +import { + type CampaignDto, + CampaignType, + type PerpsTradingCampaignLeaderboardEntry, + type PerpsTradingCampaignLeaderboardPositionDto, +} from '../../../../core/Engine/controllers/rewards-controller/types'; +import { useRewardCampaigns } from '../hooks/useRewardCampaigns'; +import { useGetCampaignParticipantStatus } from '../hooks/useGetCampaignParticipantStatus'; +import { useGetPerpsTradingCampaignLeaderboard } from '../hooks/useGetPerpsTradingCampaignLeaderboard'; +import { useGetPerpsTradingCampaignLeaderboardPosition } from '../hooks/useGetPerpsTradingCampaignLeaderboardPosition'; +import { useGetPerpsTradingCampaignVolume } from '../hooks/useGetPerpsTradingCampaignVolume'; +import Routes from '../../../../constants/navigation/Routes'; + +const mockGoBack = jest.fn(); +const mockNavigate = jest.fn(); + +const mockRouteState: { params: { campaignId?: string } } = { + params: { campaignId: 'perps-campaign-1' }, +}; + +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ + goBack: mockGoBack, + navigate: mockNavigate, + addListener: jest.fn(() => jest.fn()), + isFocused: () => true, + }), + useRoute: () => mockRouteState, +})); + +jest.mock('@metamask/design-system-react-native', () => { + const actual = jest.requireActual('@metamask/design-system-react-native'); + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + const Skeleton = (props: Record) => + ReactActual.createElement(View, { testID: 'skeleton', ...props }); + return { ...actual, Skeleton }; +}); + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => { + const tw = () => ({}); + tw.style = (..._args: unknown[]) => ({}); + return tw; + }, +})); + +jest.mock( + '../../../../component-library/components-temp/HeaderCompactStandard', + () => { + const ReactActual = jest.requireActual('react'); + const { View, Text, Pressable } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ + title, + onBack, + endButtonIconProps, + }: { + title: string; + onBack: () => void; + endButtonIconProps?: { testID?: string; onPress?: () => void }[]; + }) => + ReactActual.createElement( + View, + { testID: 'header' }, + ReactActual.createElement(Text, null, title), + ReactActual.createElement(Pressable, { + onPress: onBack, + testID: 'perps-details-back-button', + }), + ...(endButtonIconProps ?? []).map((btn, i) => + ReactActual.createElement(Pressable, { + key: i, + onPress: btn.onPress, + testID: btn.testID ?? `end-button-${i}`, + }), + ), + ), + }; + }, +); + +jest.mock('../../../Views/ErrorBoundary', () => { + const ReactActual = jest.requireActual('react'); + return { + __esModule: true, + default: ({ children }: { children: React.ReactNode }) => + ReactActual.createElement(ReactActual.Fragment, null, children), + }; +}); + +jest.mock('react-native-safe-area-context', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + SafeAreaView: ({ + children, + ...props + }: { + children: React.ReactNode; + testID?: string; + edges?: unknown; + style?: unknown; + }) => ReactActual.createElement(View, props, children), + }; +}); + +jest.mock('../components/Campaigns/CampaignStatus', () => { + const ReactActual = jest.requireActual('react'); + const { View, Text } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ campaign }: { campaign: { name: string } }) => + ReactActual.createElement( + View, + { testID: 'campaign-status' }, + ReactActual.createElement(Text, null, campaign.name), + ), + }; +}); + +jest.mock('../components/Campaigns/CampaignHowItWorks', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: () => + ReactActual.createElement(View, { testID: 'campaign-how-it-works' }), + }; +}); + +jest.mock('../components/Campaigns/PerpsCampaignStatsSummary', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: () => + ReactActual.createElement(View, { + testID: 'perps-campaign-stats-summary-container', + }), + }; +}); + +jest.mock('../components/Campaigns/PerpsTradingCampaignPrizePool', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: () => + ReactActual.createElement(View, { testID: 'perps-prize-pool' }), + }; +}); + +jest.mock('../components/Campaigns/PerpsTradingCampaignLeaderboard', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + PERPS_CAMPAIGN_LEADERBOARD_TEST_IDS: { + TOTAL_PARTICIPANTS: 'perps-campaign-leaderboard-total-participants', + }, + default: () => + ReactActual.createElement(View, { testID: 'perps-leaderboard' }), + }; +}); + +jest.mock('../components/Campaigns/PerpsTradingCampaignCTA', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + const { getCampaignStatus } = jest.requireActual( + '../components/Campaigns/CampaignTile.utils', + ) as typeof import('../components/Campaigns/CampaignTile.utils'); + return { + __esModule: true, + default: ({ campaign }: { campaign: CampaignDto }) => + getCampaignStatus(campaign) === 'complete' + ? null + : ReactActual.createElement(View, { testID: 'perps-trading-cta' }), + }; +}); + +jest.mock('../components/RewardsErrorBanner', () => { + const ReactActual = jest.requireActual('react'); + const { View, Text, Pressable } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ + title, + onConfirm, + confirmButtonLabel, + }: { + title: string; + description: string; + onConfirm?: () => void; + confirmButtonLabel?: string; + }) => + ReactActual.createElement( + View, + { testID: 'campaigns-load-error-banner' }, + ReactActual.createElement(Text, null, title), + confirmButtonLabel && + ReactActual.createElement( + Pressable, + { onPress: onConfirm, testID: 'campaigns-error-retry' }, + ReactActual.createElement(Text, null, confirmButtonLabel), + ), + ), + }; +}); + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +jest.mock('../hooks/useRewardCampaigns'); +const mockUseRewardCampaigns = useRewardCampaigns as jest.MockedFunction< + typeof useRewardCampaigns +>; + +jest.mock('../hooks/useGetCampaignParticipantStatus'); +const mockUseGetCampaignParticipantStatus = + useGetCampaignParticipantStatus as jest.MockedFunction< + typeof useGetCampaignParticipantStatus + >; + +jest.mock('../hooks/useGetPerpsTradingCampaignLeaderboard'); +const mockUseGetPerpsTradingCampaignLeaderboard = + useGetPerpsTradingCampaignLeaderboard as jest.MockedFunction< + typeof useGetPerpsTradingCampaignLeaderboard + >; + +jest.mock('../hooks/useGetPerpsTradingCampaignLeaderboardPosition'); +const mockUseGetPerpsTradingCampaignLeaderboardPosition = + useGetPerpsTradingCampaignLeaderboardPosition as jest.MockedFunction< + typeof useGetPerpsTradingCampaignLeaderboardPosition + >; + +jest.mock('../hooks/useGetPerpsTradingCampaignVolume'); +const mockUseGetPerpsTradingCampaignVolume = + useGetPerpsTradingCampaignVolume as jest.MockedFunction< + typeof useGetPerpsTradingCampaignVolume + >; + +import { useSelector } from 'react-redux'; +import { selectReferralCode } from '../../../../reducers/rewards/selectors'; + +const mockUseSelector = useSelector as jest.MockedFunction; + +const mockFetchCampaigns = jest.fn(); + +function buildPerpsCampaign(overrides: Partial = {}): CampaignDto { + return { + id: 'perps-campaign-1', + type: CampaignType.PERPS_TRADING, + name: 'Perps Trading', + startDate: '2025-06-01T00:00:00.000Z', + endDate: '2026-12-31T23:59:59.999Z', + termsAndConditions: null, + excludedRegions: [], + details: null, + featured: true, + showUpcomingDate: false, + ...overrides, + }; +} + +function toMockLeaderboardPosition( + position: { rank: number; neighbors: unknown[] } | null, +): PerpsTradingCampaignLeaderboardPositionDto | null { + if (!position) { + return null; + } + return { + rank: position.rank, + pnl: 0, + notionalVolume: 0, + marginDeployed: 0, + qualified: true, + neighbors: position.neighbors as PerpsTradingCampaignLeaderboardEntry[], + computedAt: '2025-08-15T12:00:00.000Z', + }; +} + +const defaultLeaderboardHook = { + leaderboard: { + campaignId: 'perps-campaign-1', + entries: [], + totalParticipants: 0, + computedAt: '2025-08-15T12:00:00.000Z', + }, + isLoading: false, + hasError: false, + isLeaderboardNotYetComputed: false, + refetch: jest.fn(), +}; + +const defaultVolumeHook = { + volume: { + totalUsdVolume: '1000000', + }, + isLoading: false, + hasError: false, + refetch: jest.fn(), +}; + +function setupHooks( + overrides: { + campaigns?: CampaignDto[]; + isCampaignsLoading?: boolean; + hasCampaignsError?: boolean; + participant?: { optedIn: boolean }; + position?: { rank: number; neighbors: unknown[] } | null; + totalParticipants?: number; + } = {}, +) { + const { + campaigns = [buildPerpsCampaign()], + isCampaignsLoading = false, + hasCampaignsError = false, + participant = { optedIn: false }, + position = null, + totalParticipants: totalParticipantsOverride, + } = overrides; + + mockUseRewardCampaigns.mockReturnValue({ + campaigns, + isLoading: isCampaignsLoading, + hasError: hasCampaignsError, + fetchCampaigns: mockFetchCampaigns, + categorizedCampaigns: { active: [], upcoming: [], previous: [] }, + hasLoaded: true, + } as ReturnType); + + mockUseGetCampaignParticipantStatus.mockReturnValue({ + status: { + optedIn: participant.optedIn, + participantCount: 0, + }, + isLoading: false, + hasError: false, + refetch: jest.fn(), + } as ReturnType); + + const leaderboard = { + ...defaultLeaderboardHook.leaderboard, + ...(totalParticipantsOverride !== undefined + ? { totalParticipants: totalParticipantsOverride } + : {}), + }; + + mockUseGetPerpsTradingCampaignLeaderboard.mockReturnValue({ + ...defaultLeaderboardHook, + leaderboard, + } as ReturnType); + + mockUseGetPerpsTradingCampaignLeaderboardPosition.mockReturnValue({ + position: toMockLeaderboardPosition(position), + isLoading: false, + hasError: false, + hasFetched: true, + refetch: jest.fn(), + } as ReturnType); + + mockUseGetPerpsTradingCampaignVolume.mockReturnValue({ + ...defaultVolumeHook, + } as ReturnType); +} + +jest.mock('../../../../../locales/i18n', () => ({ + strings: (key: string, params?: { count?: string }) => { + if ( + key === 'rewards.perps_trading_campaign.leaderboard_total_participants' && + params?.count !== undefined + ) { + return `${params.count} participants`; + } + const map: Record = { + 'rewards.perps_trading_campaign.title': 'Perps Trading', + 'rewards.perps_trading_campaign.stats_title': 'Stats', + 'rewards.perps_trading_campaign.prize_pool_title': 'Prize pool', + 'rewards.perps_trading_campaign.leaderboard_title': 'Leaderboard', + 'rewards.campaigns_view.error_title': 'Error', + 'rewards.campaigns_view.error_description': 'Try again', + 'rewards.campaigns_view.retry_button': 'Retry', + }; + return map[key] ?? key; + }, +})); + +describe('PerpsTradingCampaignDetailsView', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2025-08-15T12:00:00.000Z')); + jest.clearAllMocks(); + mockRouteState.params = { campaignId: 'perps-campaign-1' }; + mockUseSelector.mockImplementation((selector) => { + if (selector === selectReferralCode) { + return 'ref-code'; + } + return undefined; + }); + setupHooks(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('renders skeletons while campaigns load and no campaign resolved', () => { + setupHooks({ campaigns: [], isCampaignsLoading: true }); + const { getAllByTestId, queryByTestId } = render( + , + ); + + expect(getAllByTestId('skeleton').length).toBeGreaterThanOrEqual(2); + expect(queryByTestId('campaign-status')).toBeNull(); + }); + + it('renders campaigns error banner and retries fetchCampaigns', () => { + setupHooks({ + campaigns: [], + isCampaignsLoading: false, + hasCampaignsError: true, + }); + const { getByTestId } = render(); + + fireEvent.press(getByTestId('campaigns-error-retry')); + expect(mockFetchCampaigns).toHaveBeenCalledTimes(1); + }); + + it('renders header, campaign status, prize pool, leaderboard, and CTA for active campaign', () => { + const { getByTestId, getByText } = render( + , + ); + + expect( + getByTestId(PERPS_CAMPAIGN_DETAILS_TEST_IDS.CONTAINER), + ).toBeDefined(); + expect(getByTestId('header')).toBeDefined(); + expect(getByTestId('campaign-status')).toBeDefined(); + expect(getByTestId('perps-prize-pool')).toBeDefined(); + expect(getByTestId('perps-leaderboard')).toBeDefined(); + expect(getByTestId('perps-trading-cta')).toBeDefined(); + }); + + it('hides How it works when the user has a leaderboard position', () => { + setupHooks({ + campaigns: [ + buildPerpsCampaign({ + details: { + howItWorks: { + title: 'How it works', + description: 'Test description', + steps: [], + }, + }, + }), + ], + participant: { optedIn: true }, + position: { rank: 5, neighbors: [] }, + }); + + const { queryByTestId } = render(); + expect(queryByTestId('campaign-how-it-works')).toBeNull(); + }); + + it('shows How it works when active, user has no leaderboard position, and details include howItWorks', () => { + setupHooks({ + campaigns: [ + buildPerpsCampaign({ + details: { + howItWorks: { + title: 'How it works', + description: 'Test description', + steps: [], + }, + }, + }), + ], + }); + + const { getByTestId } = render(); + expect(getByTestId('campaign-how-it-works')).toBeDefined(); + }); + + it('shows stats header when user has a leaderboard position', () => { + setupHooks({ + participant: { optedIn: true }, + position: { rank: 3, neighbors: [] }, + }); + + const { getByTestId } = render(); + expect(getByTestId('perps-campaign-stats-summary-container')).toBeDefined(); + }); + + it('navigates to stats when stats header row is pressed and user has a position', () => { + setupHooks({ + participant: { optedIn: true }, + position: { rank: 2, neighbors: [] }, + }); + + const { getByText } = render(); + + fireEvent.press(getByText('Stats')); + expect(mockNavigate).toHaveBeenCalledWith( + Routes.REWARDS_PERPS_TRADING_CAMPAIGN_STATS, + { campaignId: 'perps-campaign-1' }, + ); + }); + + it('navigates to full leaderboard and mechanics help', () => { + const { getByText, getByTestId } = render( + , + ); + + fireEvent.press(getByText('Leaderboard')); + expect(mockNavigate).toHaveBeenCalledWith( + Routes.REWARDS_PERPS_TRADING_CAMPAIGN_LEADERBOARD, + { campaignId: 'perps-campaign-1' }, + ); + + fireEvent.press(getByTestId('perps-details-mechanics-button')); + expect(mockNavigate).toHaveBeenCalledWith( + Routes.REWARDS_CAMPAIGN_MECHANICS, + { campaignId: 'perps-campaign-1' }, + ); + }); + + it('complete campaign shows leaderboard without stats row and hides CTA', () => { + setupHooks({ + campaigns: [ + buildPerpsCampaign({ + startDate: '2024-01-01T00:00:00.000Z', + endDate: '2025-01-01T00:00:00.000Z', + }), + ], + }); + + const { getByTestId, queryByTestId } = render( + , + ); + + expect(getByTestId('perps-leaderboard')).toBeDefined(); + expect(queryByTestId('perps-campaign-stats-summary-container')).toBeNull(); + expect(queryByTestId('perps-prize-pool')).toBeNull(); + expect(queryByTestId('perps-trading-cta')).toBeNull(); + }); + + it('displays total participant count when the leaderboard reports participants', () => { + setupHooks({ totalParticipants: 1500 }); + + const { getByText, getByTestId } = render( + , + ); + + expect( + getByTestId('perps-campaign-leaderboard-total-participants'), + ).toBeDefined(); + expect(getByText('1,500 participants')).toBeDefined(); + }); + + it('hides the prize pool section for upcoming campaigns', () => { + setupHooks({ + campaigns: [ + buildPerpsCampaign({ + startDate: '2026-01-01T00:00:00.000Z', + endDate: '2027-12-31T23:59:59.999Z', + }), + ], + }); + + const { queryByTestId } = render(); + expect(queryByTestId('perps-prize-pool')).toBeNull(); + }); + + it('resolves campaign by PERPS_TRADING type when route has no campaignId', () => { + mockRouteState.params = {}; + setupHooks({ + campaigns: [buildPerpsCampaign({ id: 'resolved-by-type' })], + }); + + const { getByTestId } = render(); + expect(getByTestId('campaign-status')).toBeDefined(); + + fireEvent.press(getByTestId('perps-details-mechanics-button')); + expect(mockNavigate).toHaveBeenCalledWith( + Routes.REWARDS_CAMPAIGN_MECHANICS, + { campaignId: 'resolved-by-type' }, + ); + }); +}); diff --git a/app/components/UI/Rewards/Views/PerpsTradingCampaignDetailsView.tsx b/app/components/UI/Rewards/Views/PerpsTradingCampaignDetailsView.tsx new file mode 100644 index 00000000000..0310b69d8ea --- /dev/null +++ b/app/components/UI/Rewards/Views/PerpsTradingCampaignDetailsView.tsx @@ -0,0 +1,365 @@ +import React, { useCallback, useMemo } from 'react'; +import { Pressable, ScrollView } from 'react-native'; +import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; +import { useSelector } from 'react-redux'; +import { + Box, + BoxAlignItems, + BoxFlexDirection, + FontWeight, + Icon, + IconColor, + IconName, + IconSize, + Skeleton, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import HeaderCompactStandard from '../../../../component-library/components-temp/HeaderCompactStandard'; +import ErrorBoundary from '../../../Views/ErrorBoundary'; +import CampaignStatus from '../components/Campaigns/CampaignStatus'; +import CampaignHowItWorks from '../components/Campaigns/CampaignHowItWorks'; +import PerpsTradingCampaignLeaderboard, { + PERPS_CAMPAIGN_LEADERBOARD_TEST_IDS, +} from '../components/Campaigns/PerpsTradingCampaignLeaderboard'; +import PerpsTradingCampaignPrizePool from '../components/Campaigns/PerpsTradingCampaignPrizePool'; +import PerpsTradingCampaignCTA from '../components/Campaigns/PerpsTradingCampaignCTA'; +import PerpsCampaignStatsSummary from '../components/Campaigns/PerpsCampaignStatsSummary'; +import { getCampaignStatus } from '../components/Campaigns/CampaignTile.utils'; +import { useGetCampaignParticipantStatus } from '../hooks/useGetCampaignParticipantStatus'; +import { useGetPerpsTradingCampaignLeaderboard } from '../hooks/useGetPerpsTradingCampaignLeaderboard'; +import { useGetPerpsTradingCampaignLeaderboardPosition } from '../hooks/useGetPerpsTradingCampaignLeaderboardPosition'; +import { useGetPerpsTradingCampaignVolume } from '../hooks/useGetPerpsTradingCampaignVolume'; +import { useRewardCampaigns } from '../hooks/useRewardCampaigns'; +import { strings } from '../../../../../locales/i18n'; +import Routes from '../../../../constants/navigation/Routes'; +import { + CampaignType, + OndoCampaignHowItWorks, +} from '../../../../core/Engine/controllers/rewards-controller/types'; +import { selectReferralCode } from '../../../../reducers/rewards/selectors'; +import { getCampaignMechanicsButtonProps } from '../utils/campaignHeaderUtils'; +import RewardsErrorBanner from '../components/RewardsErrorBanner'; + +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +type PerpsTradingCampaignDetailsRouteParams = { + RewardsPerpsTradingCampaignDetails: { campaignId?: string }; +}; + +export const PERPS_CAMPAIGN_DETAILS_TEST_IDS = { + CONTAINER: 'perps-campaign-details-container', +} as const; + +const PerpsTradingCampaignDetailsView: React.FC = () => { + const tw = useTailwind(); + const navigation = useNavigation(); + const route = + useRoute< + RouteProp< + PerpsTradingCampaignDetailsRouteParams, + 'RewardsPerpsTradingCampaignDetails' + > + >(); + const routeCampaignId = route.params?.campaignId; + const referralCode = useSelector(selectReferralCode); + + const { + campaigns, + isLoading: isCampaignsLoading, + hasError: hasCampaignsError, + fetchCampaigns, + } = useRewardCampaigns(); + + const campaign = useMemo( + () => + campaigns.find((c) => + routeCampaignId + ? c.id === routeCampaignId + : c.type === CampaignType.PERPS_TRADING, + ) ?? null, + [campaigns, routeCampaignId], + ); + + const effectiveCampaignId = routeCampaignId ?? campaign?.id ?? ''; + + const { + status: participantStatusData, + isLoading: isParticipantStatusLoading, + } = useGetCampaignParticipantStatus(effectiveCampaignId || undefined); + + const isOptedIn = participantStatusData?.optedIn === true; + const campaignStatus = campaign ? getCampaignStatus(campaign) : null; + const isActive = campaignStatus === 'active'; + const isComplete = campaignStatus === 'complete'; + + const { + leaderboard, + isLoading: isLeaderboardLoading, + hasError: hasLeaderboardError, + isLeaderboardNotYetComputed, + refetch: refetchLeaderboard, + } = useGetPerpsTradingCampaignLeaderboard(effectiveCampaignId || undefined); + + const { position } = useGetPerpsTradingCampaignLeaderboardPosition( + isOptedIn ? effectiveCampaignId || undefined : undefined, + ); + + const { + volume, + isLoading: isVolumeLoading, + hasError: hasVolumeError, + refetch: refetchVolume, + } = useGetPerpsTradingCampaignVolume(effectiveCampaignId || undefined); + + const leaderboardUserPosition = useMemo( + () => + position + ? { rank: position.rank, neighbors: position.neighbors ?? [] } + : null, + [position], + ); + + const hasPosition = Boolean(leaderboardUserPosition); + const totalParticipants = leaderboard?.totalParticipants ?? 0; + + const { + showHowItWorksSection, + showStatsSummarySection, + showPrizePoolSection, + showLeaderboardSection, + } = useMemo(() => { + if (!campaign) { + return { + showHowItWorksSection: false, + showStatsSummarySection: false, + showPrizePoolSection: false, + showLeaderboardSection: false, + }; + } + + return { + showHowItWorksSection: + Boolean(campaign.details?.howItWorks) && isActive && !hasPosition, + showStatsSummarySection: hasPosition, + showPrizePoolSection: isActive, + showLeaderboardSection: true, + }; + }, [campaign, isActive, hasPosition]); + + const navigateToLeaderboard = useCallback(() => { + if (!effectiveCampaignId) return; + navigation.navigate(Routes.REWARDS_PERPS_TRADING_CAMPAIGN_LEADERBOARD, { + campaignId: effectiveCampaignId, + }); + }, [navigation, effectiveCampaignId]); + + const navigateToStats = useCallback(() => { + if (!effectiveCampaignId) return; + navigation.navigate(Routes.REWARDS_PERPS_TRADING_CAMPAIGN_STATS, { + campaignId: effectiveCampaignId, + }); + }, [navigation, effectiveCampaignId]); + + const navigateToMechanics = useCallback(() => { + if (!effectiveCampaignId) return; + navigation.navigate(Routes.REWARDS_CAMPAIGN_MECHANICS, { + campaignId: effectiveCampaignId, + }); + }, [navigation, effectiveCampaignId]); + + return ( + + + navigation.goBack()} + backButtonProps={{ testID: 'perps-details-back-button' }} + endButtonIconProps={getCampaignMechanicsButtonProps( + campaign != null, + navigateToMechanics, + 'perps-details-mechanics-button', + )} + includesTopInset + /> + + + {isCampaignsLoading && !campaign && ( + + + + + )} + + {!isCampaignsLoading && hasCampaignsError && !campaign && ( + + + + )} + + {campaign && ( + <> + + + {showHowItWorksSection && ( + + + + )} + + {showStatsSummarySection && ( + + + + + {strings('rewards.perps_trading_campaign.stats_title')} + + + + + + + )} + + {showPrizePoolSection && ( + <> + + + + {strings( + 'rewards.perps_trading_campaign.prize_pool_title', + )} + + + + + )} + + {showLeaderboardSection && ( + <> + + + + + + {strings( + 'rewards.perps_trading_campaign.leaderboard_title', + )} + + + + + + {totalParticipants > 0 && ( + + {strings( + 'rewards.perps_trading_campaign.leaderboard_total_participants', + { count: totalParticipants.toLocaleString() }, + )} + + )} + + + + + )} + + )} + + + {/* Bottom CTA */} + {campaign && ( + + )} + + + ); +}; + +export default PerpsTradingCampaignDetailsView; diff --git a/app/components/UI/Rewards/Views/PerpsTradingCampaignLeaderboardView.test.tsx b/app/components/UI/Rewards/Views/PerpsTradingCampaignLeaderboardView.test.tsx new file mode 100644 index 00000000000..b4a0ce8bccd --- /dev/null +++ b/app/components/UI/Rewards/Views/PerpsTradingCampaignLeaderboardView.test.tsx @@ -0,0 +1,305 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import { useSelector } from 'react-redux'; +import PerpsTradingCampaignLeaderboardView, { + PERPS_CAMPAIGN_LEADERBOARD_VIEW_TEST_IDS, +} from './PerpsTradingCampaignLeaderboardView'; +import { useGetPerpsTradingCampaignLeaderboard } from '../hooks/useGetPerpsTradingCampaignLeaderboard'; +import { useGetPerpsTradingCampaignLeaderboardPosition } from '../hooks/useGetPerpsTradingCampaignLeaderboardPosition'; +import { useGetCampaignParticipantStatus } from '../hooks/useGetCampaignParticipantStatus'; +import { + CampaignType, + type PerpsTradingCampaignLeaderboardPositionDto, +} from '../../../../core/Engine/controllers/rewards-controller/types'; + +const mockGoBack = jest.fn(); +const mockPerpsLeaderboard = jest.fn(); +const mockPerpsStatsHeader = jest.fn(); + +const CAMPAIGN_ID = 'perps-lb-campaign-1'; + +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ goBack: mockGoBack, navigate: jest.fn() }), + useRoute: () => ({ + params: { campaignId: CAMPAIGN_ID }, + }), +})); + +jest.mock('@metamask/design-system-react-native', () => { + const actual = jest.requireActual('@metamask/design-system-react-native'); + return { ...actual }; +}); + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => { + const tw = () => ({}); + tw.style = (..._args: unknown[]) => ({}); + return tw; + }, +})); + +jest.mock( + '../../../../component-library/components-temp/HeaderCompactStandard', + () => { + const ReactActual = jest.requireActual('react'); + const { View, Text, Pressable } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ + title, + onBack, + backButtonProps, + }: { + title: string; + onBack: () => void; + backButtonProps?: { testID?: string }; + }) => + ReactActual.createElement( + View, + { testID: 'perps-lb-header' }, + ReactActual.createElement(Text, null, title), + ReactActual.createElement(Pressable, { + onPress: onBack, + testID: backButtonProps?.testID ?? 'perps-lb-back', + }), + ), + }; + }, +); + +jest.mock('../../../Views/ErrorBoundary', () => { + const ReactActual = jest.requireActual('react'); + return { + __esModule: true, + default: ({ children }: { children: React.ReactNode }) => + ReactActual.createElement(ReactActual.Fragment, null, children), + }; +}); + +jest.mock('react-native-safe-area-context', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + SafeAreaView: ({ + children, + ...props + }: { + children: React.ReactNode; + testID?: string; + }) => ReactActual.createElement(View, props, children), + }; +}); + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), + useDispatch: jest.fn(() => jest.fn()), +})); + +jest.mock('../components/Campaigns/PerpsTradingCampaignLeaderboard', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: (props: Record) => { + mockPerpsLeaderboard(props); + return ReactActual.createElement(View, { + testID: 'perps-leaderboard-mock', + }); + }, + }; +}); + +jest.mock('../components/Campaigns/PerpsTradingCampaignStatsHeader', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: (props: Record) => { + mockPerpsStatsHeader(props); + return ReactActual.createElement(View, { + testID: 'perps-lb-stats-header-mock', + }); + }, + }; +}); + +jest.mock('../hooks/useGetPerpsTradingCampaignLeaderboard'); +jest.mock('../hooks/useGetPerpsTradingCampaignLeaderboardPosition'); +jest.mock('../hooks/useGetCampaignParticipantStatus'); + +jest.mock('../../../../../locales/i18n', () => ({ + strings: (key: string) => key, +})); + +const mockUseSelector = useSelector as jest.MockedFunction; +const mockUseGetLeaderboard = + useGetPerpsTradingCampaignLeaderboard as jest.MockedFunction< + typeof useGetPerpsTradingCampaignLeaderboard + >; +const mockUseGetPosition = + useGetPerpsTradingCampaignLeaderboardPosition as jest.MockedFunction< + typeof useGetPerpsTradingCampaignLeaderboardPosition + >; +const mockUseGetParticipant = + useGetCampaignParticipantStatus as jest.MockedFunction< + typeof useGetCampaignParticipantStatus + >; + +const basePosition: PerpsTradingCampaignLeaderboardPositionDto = { + rank: 4, + pnl: 1000, + notionalVolume: 10_000, + marginDeployed: 2000, + qualified: true, + neighbors: [], + computedAt: '2025-01-01T00:00:00.000Z', +}; + +const leaderboardHookDefaults = { + leaderboard: { + campaignId: CAMPAIGN_ID, + computedAt: '2025-01-01T00:00:00.000Z', + entries: [{ rank: 1, referralCode: 'A', pnl: 1, qualified: true }], + totalParticipants: 50, + }, + isLoading: false, + hasError: false, + isLeaderboardNotYetComputed: false, + refetch: jest.fn(), +}; + +const mockCampaign = { + id: CAMPAIGN_ID, + type: CampaignType.PERPS_TRADING, + name: 'Perps Test', + startDate: '2024-01-01T00:00:00Z', + endDate: '2099-12-31T23:59:59Z', + termsAndConditions: null, + excludedRegions: [], + featured: false, + details: { howItWorks: { title: '', description: '', steps: [] } }, +}; + +const mockState = { + rewards: { + referralCode: 'REFCODE99', + campaigns: [mockCampaign], + }, +}; + +describe('PerpsTradingCampaignLeaderboardView', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseSelector.mockImplementation((selector: (s: unknown) => unknown) => + selector(mockState), + ); + mockUseGetParticipant.mockReturnValue({ + status: { optedIn: false, participantCount: 0 }, + isLoading: false, + hasError: false, + refetch: jest.fn(), + }); + mockUseGetLeaderboard.mockReturnValue(leaderboardHookDefaults); + mockUseGetPosition.mockReturnValue({ + position: null, + isLoading: false, + hasError: false, + hasFetched: true, + refetch: jest.fn(), + }); + }); + + it('renders with the correct container testID', () => { + const { getByTestId } = render(); + expect( + getByTestId(PERPS_CAMPAIGN_LEADERBOARD_VIEW_TEST_IDS.CONTAINER), + ).toBeDefined(); + }); + + it('navigates back when the back button is pressed', () => { + const { getByTestId } = render(); + fireEvent.press(getByTestId('perps-leaderboard-back-button')); + expect(mockGoBack).toHaveBeenCalled(); + }); + + it('fetches leaderboard with route campaignId', () => { + render(); + expect(mockUseGetLeaderboard).toHaveBeenCalledWith(CAMPAIGN_ID); + }); + + it('does not render the stats header when the user is not opted in', () => { + const { queryByTestId } = render(); + expect(queryByTestId('perps-lb-stats-header-mock')).toBeNull(); + }); + + it('passes undefined to position hook when not opted in', () => { + render(); + expect(mockUseGetPosition).toHaveBeenCalledWith(undefined); + }); + + it('renders the stats header and passes campaignId to position hook when opted in', () => { + mockUseGetParticipant.mockReturnValue({ + status: { optedIn: true, participantCount: 10 }, + isLoading: false, + hasError: false, + refetch: jest.fn(), + }); + mockUseGetPosition.mockReturnValue({ + position: basePosition, + isLoading: false, + hasError: false, + hasFetched: true, + refetch: jest.fn(), + }); + + const { getByTestId } = render(); + expect(getByTestId('perps-lb-stats-header-mock')).toBeDefined(); + expect(mockUseGetPosition).toHaveBeenCalledWith(CAMPAIGN_ID); + expect(mockPerpsStatsHeader).toHaveBeenCalledWith( + expect.objectContaining({ + position: basePosition, + isLoading: false, + }), + ); + }); + + it('passes leaderboard data and user position to PerpsTradingCampaignLeaderboard', () => { + mockUseGetParticipant.mockReturnValue({ + status: { optedIn: true, participantCount: 10 }, + isLoading: false, + hasError: false, + refetch: jest.fn(), + }); + mockUseGetPosition.mockReturnValue({ + position: basePosition, + isLoading: false, + hasError: false, + hasFetched: true, + refetch: jest.fn(), + }); + + render(); + expect(mockPerpsLeaderboard).toHaveBeenCalledWith( + expect.objectContaining({ + entries: leaderboardHookDefaults.leaderboard?.entries, + isLoading: leaderboardHookDefaults.isLoading, + hasError: leaderboardHookDefaults.hasError, + isLeaderboardNotYetComputed: + leaderboardHookDefaults.isLeaderboardNotYetComputed, + currentUserReferralCode: 'REFCODE99', + userPosition: { + rank: basePosition.rank, + neighbors: basePosition.neighbors, + }, + campaignId: CAMPAIGN_ID, + onRetry: leaderboardHookDefaults.refetch, + isCampaignComplete: false, + }), + ); + }); + + it('renders the leaderboard section', () => { + const { getByTestId } = render(); + expect(getByTestId('perps-leaderboard-mock')).toBeDefined(); + }); +}); diff --git a/app/components/UI/Rewards/Views/PerpsTradingCampaignLeaderboardView.tsx b/app/components/UI/Rewards/Views/PerpsTradingCampaignLeaderboardView.tsx new file mode 100644 index 00000000000..a62780a64e2 --- /dev/null +++ b/app/components/UI/Rewards/Views/PerpsTradingCampaignLeaderboardView.tsx @@ -0,0 +1,142 @@ +import React, { useMemo } from 'react'; +import { ScrollView } from 'react-native'; +import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; +import { Box, TextVariant } from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useSelector } from 'react-redux'; +import HeaderCompactStandard from '../../../../component-library/components-temp/HeaderCompactStandard'; +import ErrorBoundary from '../../../Views/ErrorBoundary'; +import PerpsTradingCampaignLeaderboard from '../components/Campaigns/PerpsTradingCampaignLeaderboard'; +import PerpsTradingCampaignStatsHeader from '../components/Campaigns/PerpsTradingCampaignStatsHeader'; +import { useGetPerpsTradingCampaignLeaderboard } from '../hooks/useGetPerpsTradingCampaignLeaderboard'; +import { useGetPerpsTradingCampaignLeaderboardPosition } from '../hooks/useGetPerpsTradingCampaignLeaderboardPosition'; +import { useGetCampaignParticipantStatus } from '../hooks/useGetCampaignParticipantStatus'; +import { strings } from '../../../../../locales/i18n'; +import Routes from '../../../../constants/navigation/Routes'; +import { + selectReferralCode, + selectCampaignById, +} from '../../../../reducers/rewards/selectors'; +import { getCampaignMechanicsButtonProps } from '../utils/campaignHeaderUtils'; +import { getCampaignStatus } from '../components/Campaigns/CampaignTile.utils'; + +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +type PerpsTradingCampaignLeaderboardRouteParams = { + RewardsPerpsTradingCampaignLeaderboard: { campaignId: string }; +}; + +export const PERPS_CAMPAIGN_LEADERBOARD_VIEW_TEST_IDS = { + CONTAINER: 'perps-campaign-leaderboard-view-container', +} as const; + +const PerpsTradingCampaignLeaderboardView: React.FC = () => { + const tw = useTailwind(); + const navigation = useNavigation(); + const route = + useRoute< + RouteProp< + PerpsTradingCampaignLeaderboardRouteParams, + 'RewardsPerpsTradingCampaignLeaderboard' + > + >(); + const { campaignId } = route.params; + const referralCode = useSelector(selectReferralCode); + + const selectCampaign = useMemo( + () => selectCampaignById(campaignId), + [campaignId], + ); + const campaign = useSelector(selectCampaign); + + const { status: participantStatus } = + useGetCampaignParticipantStatus(campaignId); + const isOptedIn = participantStatus?.optedIn === true; + + const { position, isLoading: isPositionLoading } = + useGetPerpsTradingCampaignLeaderboardPosition( + isOptedIn ? campaignId : undefined, + ); + + const { + leaderboard, + isLoading: isLeaderboardLoading, + hasError: hasLeaderboardError, + isLeaderboardNotYetComputed, + refetch: refetchLeaderboard, + } = useGetPerpsTradingCampaignLeaderboard(campaignId); + + const leaderboardUserPosition = useMemo( + () => + position + ? { rank: position.rank, neighbors: position.neighbors ?? [] } + : null, + [position], + ); + + const isCampaignComplete = + campaign != null && getCampaignStatus(campaign) === 'complete'; + + return ( + + + navigation.goBack()} + backButtonProps={{ testID: 'perps-leaderboard-back-button' }} + endButtonIconProps={getCampaignMechanicsButtonProps( + campaign != null, + () => + navigation.navigate(Routes.REWARDS_CAMPAIGN_MECHANICS, { + campaignId, + }), + 'perps-leaderboard-mechanics-button', + )} + includesTopInset + /> + + {/* User position header */} + {isOptedIn && ( + <> + + + + + + )} + + {/* Full leaderboard */} + + + + + + + ); +}; + +export default PerpsTradingCampaignLeaderboardView; diff --git a/app/components/UI/Rewards/Views/PerpsTradingCampaignStatsView.test.tsx b/app/components/UI/Rewards/Views/PerpsTradingCampaignStatsView.test.tsx new file mode 100644 index 00000000000..179886d186f --- /dev/null +++ b/app/components/UI/Rewards/Views/PerpsTradingCampaignStatsView.test.tsx @@ -0,0 +1,392 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import { useSelector } from 'react-redux'; +import PerpsTradingCampaignStatsView, { + PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS, +} from './PerpsTradingCampaignStatsView'; +import { useGetPerpsTradingCampaignLeaderboardPosition } from '../hooks/useGetPerpsTradingCampaignLeaderboardPosition'; +import { useGetCampaignParticipantStatus } from '../hooks/useGetCampaignParticipantStatus'; +import { + CampaignType, + type PerpsTradingCampaignLeaderboardPositionDto, +} from '../../../../core/Engine/controllers/rewards-controller/types'; +import Routes from '../../../../constants/navigation/Routes'; + +const mockGoBack = jest.fn(); +const mockNavigate = jest.fn(); +const mockPerpsStatsHeader = jest.fn(); + +const CAMPAIGN_ID = 'perps-stats-campaign-1'; + +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ goBack: mockGoBack, navigate: mockNavigate }), + useRoute: () => ({ + params: { campaignId: CAMPAIGN_ID }, + }), +})); + +jest.mock('@metamask/design-system-react-native', () => { + const actual = jest.requireActual('@metamask/design-system-react-native'); + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + const Skeleton = (props: Record) => + ReactActual.createElement(View, { testID: 'skeleton', ...props }); + return { ...actual, Skeleton }; +}); + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => { + const tw = () => ({}); + tw.style = (..._args: unknown[]) => ({}); + return tw; + }, +})); + +jest.mock( + '../../../../component-library/components-temp/HeaderCompactStandard', + () => { + const ReactActual = jest.requireActual('react'); + const { View, Text, Pressable } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ + title, + onBack, + backButtonProps, + endButtonIconProps, + }: { + title: string; + onBack: () => void; + backButtonProps?: { testID?: string }; + endButtonIconProps?: { testID?: string; onPress?: () => void }[]; + }) => + ReactActual.createElement( + View, + { testID: 'perps-stats-header' }, + ReactActual.createElement(Text, null, title), + ReactActual.createElement(Pressable, { + onPress: onBack, + testID: backButtonProps?.testID ?? 'perps-stats-back', + }), + ...(endButtonIconProps ?? []).map((btn, i) => + ReactActual.createElement(Pressable, { + key: i, + onPress: btn.onPress, + testID: btn.testID, + }), + ), + ), + }; + }, +); + +jest.mock('../../../Views/ErrorBoundary', () => { + const ReactActual = jest.requireActual('react'); + return { + __esModule: true, + default: ({ children }: { children: React.ReactNode }) => + ReactActual.createElement(ReactActual.Fragment, null, children), + }; +}); + +jest.mock('react-native-safe-area-context', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + SafeAreaView: ({ + children, + ...props + }: { + children: React.ReactNode; + testID?: string; + }) => ReactActual.createElement(View, props, children), + }; +}); + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), + useDispatch: jest.fn(() => jest.fn()), +})); + +jest.mock('../components/Campaigns/PerpsTradingCampaignStatsHeader', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: (props: Record) => { + mockPerpsStatsHeader(props); + return ReactActual.createElement(View, { + testID: 'perps-stats-header-mock', + }); + }, + }; +}); + +jest.mock('../utils/formatUtils', () => ({ + formatSignedUsd: (value: number) => `SIGNED_USD_${String(value)}`, + formatUsd: (value: number) => `USD_${String(value)}`, + formatRewardsTimeOnly: () => 'TIME_STUB', +})); + +jest.mock('../components/RewardsErrorBanner', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ testID }: { testID?: string }) => + ReactActual.createElement(View, { + testID: testID ?? 'rewards-error-banner', + }), + }; +}); + +jest.mock('../hooks/useGetPerpsTradingCampaignLeaderboardPosition'); +jest.mock('../hooks/useGetCampaignParticipantStatus'); + +jest.mock('../../../../../locales/i18n', () => ({ + strings: (key: string) => key, +})); + +const mockUseSelector = useSelector as jest.MockedFunction; +const mockUseGetPosition = + useGetPerpsTradingCampaignLeaderboardPosition as jest.MockedFunction< + typeof useGetPerpsTradingCampaignLeaderboardPosition + >; +const mockUseGetParticipant = + useGetCampaignParticipantStatus as jest.MockedFunction< + typeof useGetCampaignParticipantStatus + >; + +const basePosition: PerpsTradingCampaignLeaderboardPositionDto = { + rank: 4, + pnl: 1500.25, + notionalVolume: 30_000, + marginDeployed: 2000, + qualified: true, + neighbors: [], + computedAt: '2025-01-01T00:00:00.000Z', +}; + +const mockCampaign = { + id: CAMPAIGN_ID, + type: CampaignType.PERPS_TRADING, + name: 'Perps Stats Test', + startDate: '2024-01-01T00:00:00Z', + endDate: '2099-12-31T23:59:59Z', + termsAndConditions: null, + excludedRegions: [], + featured: false, + details: { howItWorks: { title: '', description: '', steps: [] } }, +}; + +const mockState = { + rewards: { + campaigns: [mockCampaign], + }, +}; + +describe('PerpsTradingCampaignStatsView', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseSelector.mockImplementation((selector: (s: unknown) => unknown) => + selector(mockState), + ); + mockUseGetParticipant.mockReturnValue({ + status: { optedIn: true, participantCount: 5 }, + isLoading: false, + hasError: false, + refetch: jest.fn(), + }); + mockUseGetPosition.mockReturnValue({ + position: basePosition, + isLoading: false, + hasError: false, + hasFetched: true, + refetch: jest.fn(), + }); + }); + + it('renders with the correct container testID', () => { + const { getByTestId } = render(); + expect( + getByTestId(PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.CONTAINER), + ).toBeDefined(); + }); + + it('navigates back when the back button is pressed', () => { + const { getByTestId } = render(); + fireEvent.press(getByTestId('perps-stats-back-button')); + expect(mockGoBack).toHaveBeenCalled(); + }); + + it('navigates to campaign mechanics when the header mechanics button is pressed', () => { + const { getByTestId } = render(); + fireEvent.press(getByTestId('perps-stats-mechanics-button')); + expect(mockNavigate).toHaveBeenCalledWith( + Routes.REWARDS_CAMPAIGN_MECHANICS, + { campaignId: CAMPAIGN_ID }, + ); + }); + + it('passes position to stats header with PnL and computed-at hidden', () => { + render(); + expect(mockPerpsStatsHeader).toHaveBeenCalledWith( + expect.objectContaining({ + position: basePosition, + isLoading: false, + showPnl: false, + showComputedAt: false, + }), + ); + }); + + it('passes undefined to position hook when not opted in', () => { + mockUseGetParticipant.mockReturnValue({ + status: { optedIn: false, participantCount: 0 }, + isLoading: false, + hasError: false, + refetch: jest.fn(), + }); + mockUseGetPosition.mockReturnValue({ + position: null, + isLoading: false, + hasError: false, + hasFetched: true, + refetch: jest.fn(), + }); + render(); + expect(mockUseGetPosition).toHaveBeenCalledWith(undefined); + }); + + it('renders performance section labels and stat testIDs when opted in with position', () => { + const { getByTestId, getByText } = render( + , + ); + expect( + getByText('rewards.perps_trading_campaign.performance_title'), + ).toBeDefined(); + expect( + getByTestId(PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.PERFORMANCE_PNL), + ).toBeDefined(); + expect( + getByTestId(PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.PERFORMANCE_VOLUME), + ).toBeDefined(); + expect( + getByTestId(PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.PERFORMANCE_MARGIN), + ).toBeDefined(); + }); + + it('shows last-computed when position has a timestamp', () => { + const { getByTestId } = render(); + const el = getByTestId(PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.LAST_COMPUTED); + expect(el.props.children).toBe( + 'rewards.perps_trading_campaign.last_updated', + ); + }); + + it('hides last-computed when there is no position', () => { + mockUseGetPosition.mockReturnValue({ + position: null, + isLoading: false, + hasError: false, + hasFetched: true, + refetch: jest.fn(), + }); + const { queryByTestId } = render(); + expect( + queryByTestId(PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.LAST_COMPUTED), + ).toBeNull(); + }); + + it("shows You're qualified card under performance when active and user is qualified", () => { + const { getByTestId, queryByTestId } = render( + , + ); + expect( + getByTestId(PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.QUALIFIED_CARD), + ).toBeDefined(); + expect( + queryByTestId(PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.QUALIFY_FOR_RANK_CARD), + ).toBeNull(); + }); + + it('hides qualification cards when campaign is complete and shows last-computed after performance when position exists', () => { + const completeCampaign = { + ...mockCampaign, + endDate: '2020-01-01T00:00:00Z', + }; + mockUseSelector.mockImplementation((selector: (s: unknown) => unknown) => + selector({ + rewards: { campaigns: [completeCampaign] }, + }), + ); + const { getByTestId, queryByTestId } = render( + , + ); + expect( + queryByTestId(PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.QUALIFIED_CARD), + ).toBeNull(); + expect( + queryByTestId(PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.QUALIFY_FOR_RANK_CARD), + ).toBeNull(); + const last = getByTestId(PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.LAST_COMPUTED); + const qualified = queryByTestId( + PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.QUALIFIED_CARD, + ); + expect(qualified).toBeNull(); + expect(last).toBeDefined(); + }); + + it('shows Qualify for rank card when pending and notional is below threshold', () => { + mockUseGetPosition.mockReturnValue({ + position: { + ...basePosition, + qualified: false, + notionalVolume: 5_000, + marginDeployed: 500, + }, + isLoading: false, + hasError: false, + hasFetched: true, + refetch: jest.fn(), + }); + const { getByTestId, queryByTestId } = render( + , + ); + expect( + getByTestId(PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.QUALIFY_FOR_RANK_CARD), + ).toBeDefined(); + expect( + queryByTestId(PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.QUALIFIED_CARD), + ).toBeNull(); + }); + + it('shows error banner when hasError is true and no position data', () => { + mockUseGetPosition.mockReturnValue({ + position: null, + isLoading: false, + hasError: true, + hasFetched: true, + refetch: jest.fn(), + }); + const { getByTestId } = render(); + expect(getByTestId('rewards-error-banner')).toBeDefined(); + }); + + it('hides error banner when hasError is false', () => { + const { queryByTestId } = render(); + expect(queryByTestId('rewards-error-banner')).toBeNull(); + }); + + it('hides error banner when there is an error but position data is already loaded', () => { + mockUseGetPosition.mockReturnValue({ + position: basePosition, + isLoading: false, + hasError: true, + hasFetched: true, + refetch: jest.fn(), + }); + const { queryByTestId } = render(); + expect(queryByTestId('rewards-error-banner')).toBeNull(); + }); +}); diff --git a/app/components/UI/Rewards/Views/PerpsTradingCampaignStatsView.tsx b/app/components/UI/Rewards/Views/PerpsTradingCampaignStatsView.tsx new file mode 100644 index 00000000000..8e6213ed14d --- /dev/null +++ b/app/components/UI/Rewards/Views/PerpsTradingCampaignStatsView.tsx @@ -0,0 +1,278 @@ +import React, { useMemo } from 'react'; +import { ScrollView } from 'react-native'; +import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { + Box, + BoxAlignItems, + BoxFlexDirection, + FontWeight, + Icon, + IconName, + IconColor, + IconSize, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import { useSelector } from 'react-redux'; +import HeaderCompactStandard from '../../../../component-library/components-temp/HeaderCompactStandard'; +import ErrorBoundary from '../../../Views/ErrorBoundary'; +import RewardsErrorBanner from '../components/RewardsErrorBanner'; +import PerpsTradingCampaignStatsHeader from '../components/Campaigns/PerpsTradingCampaignStatsHeader'; +import { StatCell } from '../components/Campaigns/OndoCampaignStatsSummary'; +import { useGetPerpsTradingCampaignLeaderboardPosition } from '../hooks/useGetPerpsTradingCampaignLeaderboardPosition'; +import { useGetCampaignParticipantStatus } from '../hooks/useGetCampaignParticipantStatus'; +import { strings } from '../../../../../locales/i18n'; +import Routes from '../../../../constants/navigation/Routes'; +import { selectCampaignById } from '../../../../reducers/rewards/selectors'; +import { getCampaignMechanicsButtonProps } from '../utils/campaignHeaderUtils'; +import { PERPS_QUALIFICATION_NOTIONAL_USD } from '../utils/perpsCampaignConstants'; +import { + formatRewardsTimeOnly, + formatSignedUsd, + formatUsd, +} from '../utils/formatUtils'; +import { getCampaignStatus } from '../components/Campaigns/CampaignTile.utils'; + +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +type PerpsTradingCampaignStatsRouteParams = { + RewardsPerpsTradingCampaignStats: { campaignId: string }; +}; + +export const PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS = { + CONTAINER: 'perps-campaign-stats-view-container', + PERFORMANCE_PNL: 'perps-campaign-stats-view-performance-pnl', + PERFORMANCE_VOLUME: 'perps-campaign-stats-view-performance-volume', + PERFORMANCE_MARGIN: 'perps-campaign-stats-view-performance-margin', + QUALIFIED_CARD: 'perps-campaign-stats-view-qualified-card', + QUALIFY_FOR_RANK_CARD: 'perps-campaign-stats-view-qualify-for-rank-card', + LAST_COMPUTED: 'perps-campaign-stats-view-last-computed', +} as const; + +const CheckIcon: React.FC = () => ( + +); + +const PerpsTradingCampaignStatsView: React.FC = () => { + const tw = useTailwind(); + const navigation = useNavigation(); + const route = + useRoute< + RouteProp< + PerpsTradingCampaignStatsRouteParams, + 'RewardsPerpsTradingCampaignStats' + > + >(); + const { campaignId } = route.params; + + const selectCampaign = useMemo( + () => selectCampaignById(campaignId), + [campaignId], + ); + const campaign = useSelector(selectCampaign); + + const { status: participantStatusData } = + useGetCampaignParticipantStatus(campaignId); + const isOptedIn = participantStatusData?.optedIn === true; + + const { position, isLoading, hasError, refetch } = + useGetPerpsTradingCampaignLeaderboardPosition( + isOptedIn ? campaignId : undefined, + ); + + const pnlValue = position ? formatSignedUsd(position.pnl) : '—'; + const pnlColor = position + ? position.pnl >= 0 + ? TextColor.SuccessDefault + : TextColor.ErrorDefault + : TextColor.TextDefault; + + const volumeValue = position ? formatUsd(position.notionalVolume) : '—'; + const marginValue = position ? formatUsd(position.marginDeployed) : '—'; + const isQualified = position != null && position.qualified; + const isPending = position != null && !position.qualified; + + const isCampaignComplete = + campaign != null && getCampaignStatus(campaign) === 'complete'; + + const notionalGap = position + ? Math.max(0, PERPS_QUALIFICATION_NOTIONAL_USD - position.notionalVolume) + : 0; + + const showQualifiedCard = + !isCampaignComplete && isQualified && position != null; + + const showQualifyForRankCard = + !isCampaignComplete && isPending && position != null && notionalGap > 0; + + const positionError = hasError && !position; + + return ( + + + navigation.goBack()} + backButtonProps={{ testID: 'perps-stats-back-button' }} + endButtonIconProps={getCampaignMechanicsButtonProps( + campaign != null, + () => + navigation.navigate(Routes.REWARDS_CAMPAIGN_MECHANICS, { + campaignId, + }), + 'perps-stats-mechanics-button', + )} + includesTopInset + /> + + + + + + + + {strings('rewards.perps_trading_campaign.performance_title')} + + + + + + + + + : undefined} + testID={PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.PERFORMANCE_VOLUME} + /> + : undefined} + testID={PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.PERFORMANCE_MARGIN} + /> + + + {showQualifiedCard && ( + + + {strings( + 'rewards.perps_trading_campaign.stats_qualified_title', + )} + + + {strings( + 'rewards.perps_trading_campaign.stats_qualified_description', + )} + + + )} + + {showQualifyForRankCard && ( + + + + {strings( + 'rewards.perps_trading_campaign.stats_qualify_for_rank_title', + )} + + + + {strings( + 'rewards.perps_trading_campaign.stats_qualify_for_rank_description', + { + notionalRemaining: formatUsd(notionalGap), + }, + )} + + + )} + + {/* ── Last updated ── */} + {position?.computedAt && ( + + {strings('rewards.perps_trading_campaign.last_updated', { + time: formatRewardsTimeOnly(new Date(position.computedAt)), + })} + + )} + + {/* ── Error banner ── */} + {positionError && ( + + )} + + + + + ); +}; + +export default PerpsTradingCampaignStatsView; diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignEndedStats.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignEndedStats.tsx index 59b768e2f23..d9cfefa4068 100644 --- a/app/components/UI/Rewards/components/Campaigns/CampaignEndedStats.tsx +++ b/app/components/UI/Rewards/components/Campaigns/CampaignEndedStats.tsx @@ -8,7 +8,7 @@ import { TextVariant, } from '@metamask/design-system-react-native'; import type { CampaignLeaderboardDto } from '../../../../../core/Engine/controllers/rewards-controller/types'; -import { StatCell } from './CampaignStatsSummary'; +import { StatCell } from './OndoCampaignStatsSummary'; import RewardsErrorBanner from '../RewardsErrorBanner'; import { strings } from '../../../../../../locales/i18n'; import { formatCompactUsd, formatPercentChange } from '../../utils/formatUtils'; diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignLeaderboard.test.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignLeaderboard.test.tsx new file mode 100644 index 00000000000..bcf271f5968 --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/CampaignLeaderboard.test.tsx @@ -0,0 +1,153 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import { Skeleton } from '@metamask/design-system-react-native'; +import { + CampaignLeaderboardEntryRow, + CampaignLeaderboardNeighborSeparator, + CampaignLeaderboardSkeleton, + CAMPAIGN_LEADERBOARD_SHARED_TEST_IDS, +} from './CampaignLeaderboard'; + +jest.mock('@metamask/design-system-react-native', () => { + const ReactActual = jest.requireActual('react'); + const actual = jest.requireActual('@metamask/design-system-react-native'); + const { View } = jest.requireActual('react-native'); + /** Avoid Skeleton Animated/act noise while keeping type identity for row counts. */ + function SkeletonMock(props: object) { + return ReactActual.createElement(View, props); + } + SkeletonMock.displayName = 'Skeleton'; + return { + ...actual, + Skeleton: SkeletonMock, + }; +}); + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => ({ style: (...args: unknown[]) => args }), +})); + +jest.mock('../../../../../images/rewards/crown.svg', () => 'CrownIcon'); + +jest.mock('./OndoCampaignStatsSummary', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + PendingTag: ({ testID }: { testID?: string }) => + ReactActual.createElement(View, { testID }), + }; +}); + +const IDS = CAMPAIGN_LEADERBOARD_SHARED_TEST_IDS; + +const baseEntry = { + rank: 7, + referralCode: 'USER01', + qualified: true, +}; + +describe('CampaignLeaderboardEntryRow', () => { + it('renders padded rank, referral code, and formatted metric', () => { + const formatPrimaryMetric = jest.fn(() => '+12.5%'); + const isPositivePrimaryMetric = jest.fn(() => true); + + const { getByText } = render( + , + ); + + expect(getByText('07')).toBeDefined(); + expect(getByText('USER01')).toBeDefined(); + expect(getByText('+12.5%')).toBeDefined(); + expect(formatPrimaryMetric).toHaveBeenCalledWith(baseEntry); + expect(isPositivePrimaryMetric).toHaveBeenCalledWith(baseEntry); + }); + + it('sets row testID from shared ENTRY_ROW and rank', () => { + const { getByTestId } = render( + 'm'} + isPositivePrimaryMetric={() => true} + />, + ); + + expect(getByTestId(`${IDS.ENTRY_ROW}-3`)).toBeDefined(); + }); + + it('shows pending tag when current user is unqualified and campaign is active', () => { + const { getByTestId } = render( + '$0.00'} + isPositivePrimaryMetric={() => false} + />, + ); + + expect(getByTestId(IDS.PENDING_TAG)).toBeDefined(); + }); + + it('hides pending tag when campaign is complete', () => { + const { queryByTestId } = render( + '$0.00'} + isPositivePrimaryMetric={() => false} + />, + ); + + expect(queryByTestId(IDS.PENDING_TAG)).toBeNull(); + }); + + it('hides pending tag when row is not the current user', () => { + const { queryByTestId } = render( + '$0.00'} + isPositivePrimaryMetric={() => false} + />, + ); + + expect(queryByTestId(IDS.PENDING_TAG)).toBeNull(); + }); +}); + +describe('CampaignLeaderboardSkeleton', () => { + it('uses shared LOADING testID', () => { + const { getByTestId } = render(); + + expect(getByTestId(IDS.LOADING)).toBeDefined(); + }); + + it('renders default skeleton row count (10 rows × 3 skeletons each)', () => { + const { UNSAFE_getAllByType } = render(); + + expect(UNSAFE_getAllByType(Skeleton)).toHaveLength(30); + }); + + it('respects skeletonRowCount', () => { + const { UNSAFE_getAllByType } = render( + , + ); + + expect(UNSAFE_getAllByType(Skeleton)).toHaveLength(12); + }); +}); + +describe('CampaignLeaderboardNeighborSeparator', () => { + it('uses shared NEIGHBOR_SEPARATOR testID and ellipsis label', () => { + const { getByTestId, getByText } = render( + , + ); + + expect(getByTestId(IDS.NEIGHBOR_SEPARATOR)).toBeDefined(); + expect(getByText('•••')).toBeDefined(); + }); +}); diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignLeaderboard.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignLeaderboard.tsx new file mode 100644 index 00000000000..45b9fa6eb2e --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/CampaignLeaderboard.tsx @@ -0,0 +1,177 @@ +import React from 'react'; +import { + Box, + BoxFlexDirection, + BoxAlignItems, + BoxJustifyContent, + Text, + TextColor, + TextVariant, + FontWeight, + Skeleton, +} from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import CrownIcon from '../../../../../images/rewards/crown.svg'; +import { PendingTag } from './OndoCampaignStatsSummary'; + +/** Shared testIDs for leaderboard rows, pending tag, separator, and skeleton (Ondo + Perps). */ +export const CAMPAIGN_LEADERBOARD_SHARED_TEST_IDS = { + ENTRY_ROW: 'campaign-leaderboard-entry-row', + PENDING_TAG: 'campaign-leaderboard-pending-tag', + NEIGHBOR_SEPARATOR: 'campaign-leaderboard-neighbor-separator', + LOADING: 'campaign-leaderboard-loading', +} as const; + +/** Fields required to render a campaign leaderboard row (Ondo, Perps, etc.). */ +export interface CampaignLeaderboardRowEntry { + rank: number; + referralCode: string; + qualified: boolean; +} + +export interface CampaignLeaderboardEntryRowProps< + T extends CampaignLeaderboardRowEntry, +> { + entry: T; + isCurrentUser?: boolean; + showCrown?: boolean; + /** When true, hides the pending tag for the current user’s row (campaign ended). */ + isCampaignComplete?: boolean; + formatPrimaryMetric: (entry: T) => string; + isPositivePrimaryMetric: (entry: T) => boolean; +} + +export function CampaignLeaderboardEntryRow< + T extends CampaignLeaderboardRowEntry, +>({ + entry, + isCurrentUser = false, + showCrown = false, + isCampaignComplete = false, + formatPrimaryMetric, + isPositivePrimaryMetric, +}: CampaignLeaderboardEntryRowProps) { + const isPositive = isPositivePrimaryMetric(entry); + const textColor = isCurrentUser + ? isPositive + ? TextColor.SuccessDefault + : TextColor.ErrorDefault + : TextColor.TextDefault; + const isPending = !entry.qualified; + const rowBg = isCurrentUser + ? isPending + ? 'bg-muted' + : isPositive + ? 'bg-success-muted' + : 'bg-error-muted' + : ''; + + const showPendingTag = isCurrentUser && isPending && !isCampaignComplete; + + return ( + + + + {String(entry.rank).padStart(2, '0')} + + + + {entry.referralCode} + + {showCrown && entry.rank <= 5 && ( + + )} + + {showPendingTag && ( + + )} + + + {formatPrimaryMetric(entry)} + + + ); +} + +export interface CampaignLeaderboardSkeletonProps { + /** Number of placeholder rows (default 10; Perps uses 5). */ + skeletonRowCount?: number; +} + +const DEFAULT_SKELETON_ROW_COUNT = 10; + +export const CampaignLeaderboardSkeleton: React.FC< + CampaignLeaderboardSkeletonProps +> = ({ skeletonRowCount = DEFAULT_SKELETON_ROW_COUNT }) => { + const tw = useTailwind(); + const rows = Array.from( + { length: skeletonRowCount }, + (_, index) => index + 1, + ); + + return ( + + + {rows.map((i) => ( + + + + + + + + + + ))} + + + ); +}; + +export const CampaignLeaderboardNeighborSeparator: React.FC = () => ( + + + + ••• + + + +); diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignTile.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignTile.tsx index 579ebae406d..9d717e57db3 100644 --- a/app/components/UI/Rewards/components/Campaigns/CampaignTile.tsx +++ b/app/components/UI/Rewards/components/Campaigns/CampaignTile.tsx @@ -42,6 +42,7 @@ interface CampaignTileProps { * Tapping behavior is determined by campaign type: * - ONDO_HOLDING: navigates to Ondo campaign details * - SEASON_1: navigates to season one campaign details + * - PERPS_TRADING: navigates to Perps Trading campaign details * - Unsupported types: non-interactive unless onPress is provided * - With onPress: executes custom handler regardless of type */ @@ -59,7 +60,9 @@ const CampaignTile: React.FC = ({ campaign, onPress }) => { const { status: participantStatus, isLoading: isParticipantStatusLoading } = useGetCampaignParticipantStatus( - campaignStatus === 'active' && campaign.type === CampaignType.ONDO_HOLDING + campaignStatus === 'active' && + (campaign.type === CampaignType.ONDO_HOLDING || + campaign.type === CampaignType.PERPS_TRADING) ? campaign.id : undefined, ); @@ -110,6 +113,19 @@ const CampaignTile: React.FC = ({ campaign, onPress }) => { navigation.navigate(Routes.REWARDS_SEASON_ONE_CAMPAIGN_DETAILS_VIEW, { campaignId: campaign.id, }); + } else if (campaign.type === CampaignType.PERPS_TRADING) { + if (shouldShowTour) { + navigation.navigate(Routes.REWARDS_CAMPAIGN_TOUR_STEP, { + campaignId: campaign.id, + }); + } else { + navigation.navigate( + Routes.REWARDS_PERPS_TRADING_CAMPAIGN_DETAILS_VIEW, + { + campaignId: campaign.id, + }, + ); + } } }; diff --git a/app/components/UI/Rewards/components/Campaigns/LeaderboardPositionHeader.tsx b/app/components/UI/Rewards/components/Campaigns/LeaderboardPositionHeader.tsx index 66db7e8a976..7ebb591e7c3 100644 --- a/app/components/UI/Rewards/components/Campaigns/LeaderboardPositionHeader.tsx +++ b/app/components/UI/Rewards/components/Campaigns/LeaderboardPositionHeader.tsx @@ -14,7 +14,11 @@ import { TextVariant, } from '@metamask/design-system-react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import { StatCell, PendingTag, IneligibleTag } from './CampaignStatsSummary'; +import { + StatCell, + PendingTag, + IneligibleTag, +} from './OndoCampaignStatsSummary'; import { strings } from '../../../../../../locales/i18n'; export const LEADERBOARD_POSITION_HEADER_TEST_IDS = { @@ -23,6 +27,7 @@ export const LEADERBOARD_POSITION_HEADER_TEST_IDS = { RETURN_VALUE: 'leaderboard-position-header-return', TIER_VALUE: 'leaderboard-position-header-tier', PRIZE_POOL_VALUE: 'leaderboard-position-header-prize-pool', + COMPUTED_AT: 'leaderboard-position-header-computed-at', PENDING_TAG: 'leaderboard-position-header-pending-tag', INELIGIBLE_TAG: 'leaderboard-position-header-ineligible-tag', QUALIFIED_ICON: 'leaderboard-position-header-qualified-icon', @@ -41,6 +46,8 @@ interface LeaderboardPositionHeaderProps { showPrizePool?: boolean; prizePoolValue?: string; prizePoolLoading?: boolean; + showComputedAt?: boolean; + computedAt?: string | null; } const LeaderboardPositionHeader: React.FC = ({ @@ -58,6 +65,7 @@ const LeaderboardPositionHeader: React.FC = ({ prizePoolLoading = false, }) => { const tw = useTailwind(); + const showSubtextRow = showReturn && Boolean(returnValue); return ( = ({ {isLoading ? ( - + <> + + {showSubtextRow && ( + + )} + ) : ( <> = ({ > {rank} - {showReturn && returnValue && ( - - {returnValue} - + + {showReturn && returnValue && ( + + {returnValue} + + )} + + )} )} diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignStatsSummary.test.tsx b/app/components/UI/Rewards/components/Campaigns/OndoCampaignStatsSummary.test.tsx similarity index 77% rename from app/components/UI/Rewards/components/Campaigns/CampaignStatsSummary.test.tsx rename to app/components/UI/Rewards/components/Campaigns/OndoCampaignStatsSummary.test.tsx index 41ee6d2e016..2ba425c2bf6 100644 --- a/app/components/UI/Rewards/components/Campaigns/CampaignStatsSummary.test.tsx +++ b/app/components/UI/Rewards/components/Campaigns/OndoCampaignStatsSummary.test.tsx @@ -2,12 +2,12 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react-native'; import { Text as RNText } from 'react-native'; import { TextColor } from '@metamask/design-system-react-native'; -import CampaignStatsSummary, { +import OndoCampaignStatsSummary, { IneligibleTag, PendingTag, StatCell, - CAMPAIGN_STATS_SUMMARY_TEST_IDS, -} from './CampaignStatsSummary'; + ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS, +} from './OndoCampaignStatsSummary'; import type { CampaignLeaderboardPositionDto, OndoGmPortfolioSummaryDto, @@ -168,67 +168,69 @@ const baseProps = { }, }; -describe('CampaignStatsSummary', () => { +describe('OndoCampaignStatsSummary', () => { beforeEach(() => { jest.clearAllMocks(); }); it('renders all stats when both position and summary are provided', () => { - const { getByTestId } = render(); + const { getByTestId } = render(); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.CONTAINER), + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.CONTAINER), ).toBeDefined(); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.RETURN).props.children, + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.RETURN).props.children, ).toBe('+7.01%'); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE).props.children, + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE).props + .children, ).toBe('$13,057.58'); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.RANK).props.children, + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.RANK).props.children, ).toBe('05'); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.TIER).props.children, + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.TIER).props.children, ).toBe('Silver'); }); it('displays dash for rank and tier when leaderboard position is null but return from portfolio', () => { const { getByTestId } = render( - , + , ); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.RETURN).props.children, + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.RETURN).props.children, ).toBe('+7.01%'); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.RANK).props.children, + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.RANK).props.children, ).toBe('-'); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.TIER).props.children, + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.TIER).props.children, ).toBe('-'); }); it('displays dash for return when portfolio summary is null', () => { const { getByTestId } = render( - , + , ); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.RETURN).props.children, + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.RETURN).props.children, ).toBe('-'); }); it('displays dash for market value when portfolio summary is null', () => { const { getByTestId } = render( - , + , ); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE).props.children, + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE).props + .children, ).toBe('-'); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.RANK).props.children, + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.RANK).props.children, ).toBe('05'); }); @@ -240,7 +242,7 @@ describe('CampaignStatsSummary', () => { }; const { getByTestId } = render( - { ); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE).props.color, + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE).props + .color, ).toBe(TextColor.ErrorDefault); }); it('uses success color for market value when portfolioPnl is positive', () => { - const { getByTestId } = render(); + const { getByTestId } = render(); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE).props.color, + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE).props + .color, ).toBe(TextColor.SuccessDefault); }); it('omits valueColor for market value when portfolioSummary is null', () => { const { getByTestId } = render( - , + , ); // Returns '-' and uses the StatCell default color (TextDefault) expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE).props.children, + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE).props + .children, ).toBe('-'); }); @@ -278,14 +283,14 @@ describe('CampaignStatsSummary', () => { }; const { getByTestId } = render( - , ); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.RETURN).props.children, + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.RETURN).props.children, ).toBe('-5.00%'); }); @@ -299,39 +304,39 @@ describe('CampaignStatsSummary', () => { }; const { getByTestId, getAllByText } = render( - , ); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.PENDING_TAG), + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.PENDING_TAG), ).toBeOnTheScreen(); expect(getAllByText('Pending')).toHaveLength(1); }); it('renders check icon on rank cell and no Pending tags when qualified is true', () => { const { getByTestId, queryAllByText, queryByTestId } = render( - , + , ); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.QUALIFIED_TAG), + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.QUALIFIED_TAG), ).toBeOnTheScreen(); expect(queryAllByText('Pending')).toHaveLength(0); expect( - queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.PENDING_TAG), + queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.PENDING_TAG), ).toBeNull(); }); it('does not render tags when leaderboardPosition is null', () => { const { queryByTestId } = render( - , + , ); expect( - queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.PENDING_TAG), + queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.PENDING_TAG), ).toBeNull(); }); @@ -339,7 +344,7 @@ describe('CampaignStatsSummary', () => { it('shows skeletons for leaderboard cells when leaderboard is loading with no data', () => { const { queryByTestId } = render( - { ); // Return and market value still render since portfolio is fine - expect(queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.RETURN)).toBeDefined(); - expect(queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.RANK)).toBeNull(); - expect(queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.TIER)).toBeNull(); expect( - queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE), + queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.RETURN), + ).toBeDefined(); + expect(queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.RANK)).toBeNull(); + expect(queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.TIER)).toBeNull(); + expect( + queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE), ).toBeDefined(); }); it('shows stale leaderboard data instead of skeletons when loading with existing data', () => { const { getByTestId } = render( - , ); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.RETURN).props.children, + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.RETURN).props.children, ).toBe('+7.01%'); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.RANK).props.children, + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.RANK).props.children, ).toBe('05'); }); @@ -375,7 +382,7 @@ describe('CampaignStatsSummary', () => { it('shows skeleton for market value cell when portfolio is loading with no data', () => { const { queryByTestId } = render( - { ); expect( - queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE), + queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE), ).toBeNull(); // Return also shows skeleton since it now comes from portfolio - expect(queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.RETURN)).toBeNull(); + expect( + queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.RETURN), + ).toBeNull(); // Leaderboard cells still render - expect(queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.RANK)).toBeDefined(); + expect( + queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.RANK), + ).toBeDefined(); }); it('shows stale market value data instead of skeleton when loading with existing data', () => { const { getByTestId } = render( - , ); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE).props.children, + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE).props + .children, ).toBe('$13,057.58'); }); @@ -408,7 +420,7 @@ describe('CampaignStatsSummary', () => { it('shows all skeletons when both sources are loading with no data', () => { const { queryByTestId } = render( - { />, ); - expect(queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.RETURN)).toBeNull(); expect( - queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE), + queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.RETURN), + ).toBeNull(); + expect( + queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE), ).toBeNull(); - expect(queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.RANK)).toBeNull(); - expect(queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.TIER)).toBeNull(); + expect(queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.RANK)).toBeNull(); + expect(queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.TIER)).toBeNull(); }); // ── Leaderboard error ───────────────────────────────────────────── it('shows stats error banner when leaderboard fails with no data', () => { const { getByTestId } = render( - { ); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.STATS_ERROR), + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.STATS_ERROR), ).toBeDefined(); }); it('calls both refetches on stats error retry when leaderboard fails', () => { const { getByTestId } = render( - { ); fireEvent.press( - getByTestId(`${CAMPAIGN_STATS_SUMMARY_TEST_IDS.STATS_ERROR}-retry`), + getByTestId(`${ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.STATS_ERROR}-retry`), ); expect(mockLeaderboardRefetch).toHaveBeenCalledTimes(1); expect(mockPortfolioRefetch).toHaveBeenCalledTimes(1); @@ -459,14 +473,14 @@ describe('CampaignStatsSummary', () => { it('hides stats error when stale leaderboard data exists', () => { const { queryByTestId } = render( - , ); expect( - queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.STATS_ERROR), + queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.STATS_ERROR), ).toBeNull(); }); @@ -474,7 +488,7 @@ describe('CampaignStatsSummary', () => { it('shows stats error banner when portfolio fails with no data', () => { const { getByTestId } = render( - { ); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.STATS_ERROR), + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.STATS_ERROR), ).toBeDefined(); }); it('calls both refetches on stats error retry when portfolio fails', () => { const { getByTestId } = render( - { ); fireEvent.press( - getByTestId(`${CAMPAIGN_STATS_SUMMARY_TEST_IDS.STATS_ERROR}-retry`), + getByTestId(`${ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.STATS_ERROR}-retry`), ); expect(mockPortfolioRefetch).toHaveBeenCalledTimes(1); expect(mockLeaderboardRefetch).toHaveBeenCalledTimes(1); @@ -506,7 +520,7 @@ describe('CampaignStatsSummary', () => { it('shows a single stats error banner when both sources fail with no data', () => { const { getAllByTestId } = render( - { ); expect( - getAllByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.STATS_ERROR), + getAllByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.STATS_ERROR), ).toHaveLength(1); }); @@ -529,21 +543,21 @@ describe('CampaignStatsSummary', () => { qualifiedDays: 0, }; const { getByTestId, getAllByText } = render( - , ); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.INELIGIBLE_TAG), + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.INELIGIBLE_TAG), ).toBeOnTheScreen(); expect(getAllByText('Ineligible')).toHaveLength(1); }); it('shows dash for rank and tier when isIneligible=true even with leaderboard data', () => { const { getByTestId } = render( - { />, ); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.RANK).props.children, + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.RANK).props.children, ).toBe('-'); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.TIER).props.children, + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.TIER).props.children, ).toBe('-'); }); it('shows not-eligible banner when isIneligible=true', () => { const { getByTestId, getByText } = render( - { />, ); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.NOT_ELIGIBLE_BANNER), + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.NOT_ELIGIBLE_BANNER), ).toBeOnTheScreen(); expect(getByText('Not eligible')).toBeOnTheScreen(); }); it('hides pending tags when isIneligible=true', () => { const { queryAllByText } = render( - { it('does not show ineligible tags when isIneligible=false', () => { const { queryAllByText, queryByTestId } = render( - { ); expect(queryAllByText('Ineligible')).toHaveLength(0); expect( - queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.NOT_ELIGIBLE_BANNER), + queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.NOT_ELIGIBLE_BANNER), ).toBeNull(); }); it('does not show not-eligible banner when isIneligible defaults to false', () => { - const { queryByTestId } = render(); + const { queryByTestId } = render( + , + ); expect( - queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.NOT_ELIGIBLE_BANNER), + queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.NOT_ELIGIBLE_BANNER), ).toBeNull(); }); @@ -623,10 +639,14 @@ describe('CampaignStatsSummary', () => { it('hides IneligibleTag from rank cell suffix when isCampaignComplete=true', () => { const { queryByTestId } = render( - , + , ); expect( - queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.INELIGIBLE_TAG), + queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.INELIGIBLE_TAG), ).toBeNull(); }); @@ -637,20 +657,20 @@ describe('CampaignStatsSummary', () => { qualifiedDays: 3, }; const { queryByTestId } = render( - , ); expect( - queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.PENDING_TAG), + queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.PENDING_TAG), ).toBeNull(); }); it('hides qualified card when isCampaignComplete=true', () => { const { queryByText } = render( - { it('hides not-eligible banner when isCampaignComplete=true', () => { const { queryByTestId } = render( - , + , ); expect( - queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.NOT_ELIGIBLE_BANNER), + queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.NOT_ELIGIBLE_BANNER), ).toBeNull(); }); @@ -675,7 +699,7 @@ describe('CampaignStatsSummary', () => { qualifiedDays: 4, }; const { queryByText } = render( - { it('hides market value cell when isCampaignComplete=true', () => { const { queryByTestId } = render( - , + , ); expect( - queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE), + queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE), ).toBeNull(); }); it('shows market value cell when isCampaignComplete=false', () => { const { getByTestId } = render( - , + , ); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE), + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE), ).toBeDefined(); }); it('shows outcome banner when isCampaignComplete=true and outcome is provided', () => { const { getByTestId } = render( - { it('does not show outcome banner when isCampaignComplete=false', () => { const { queryByTestId } = render( - { it('shows the qualified explainer card when qualified and tierMinDeposit is set', () => { const { getByText } = render( - , + , ); expect(getByText('You are qualified')).toBeOnTheScreen(); expect(getByText(/Qualified copy/)).toBeOnTheScreen(); @@ -744,7 +768,7 @@ describe('CampaignStatsSummary', () => { qualifiedDays: 4, }; const { getByText } = render( - { qualifiedDays: 10, }; const { queryByText } = render( - = ({ ); }; -export const CAMPAIGN_STATS_SUMMARY_TEST_IDS = { +export const ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS = { CONTAINER: 'campaign-stats-summary-container', RETURN: 'campaign-stats-summary-return', MARKET_VALUE: 'campaign-stats-summary-market-value', @@ -121,7 +121,7 @@ interface DataSourceState { refetch: () => void; } -interface CampaignStatsSummaryProps { +interface OndoCampaignStatsSummaryProps { leaderboardPosition: CampaignLeaderboardPositionDto | null; portfolioSummary: OndoGmPortfolioSummaryDto | null; leaderboard: DataSourceState; @@ -136,7 +136,7 @@ interface CampaignStatsSummaryProps { onWinnerPress?: () => void; } -const CampaignStatsSummary: React.FC = ({ +const OndoCampaignStatsSummary: React.FC = ({ leaderboardPosition, portfolioSummary, leaderboard, @@ -184,29 +184,32 @@ const CampaignStatsSummary: React.FC = ({ : formatTierDisplayName(leaderboardPosition.projectedTier); return ( - + {/* Rank | Tier */} ) : !isCampaignComplete && isPending ? ( ) : isQualified ? ( ) : undefined } @@ -215,7 +218,7 @@ const CampaignStatsSummary: React.FC = ({ label={strings('rewards.ondo_campaign_stats.label_tier')} value={tierValue} isLoading={leaderboardLoading} - testID={CAMPAIGN_STATS_SUMMARY_TEST_IDS.TIER} + testID={ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.TIER} /> @@ -226,7 +229,7 @@ const CampaignStatsSummary: React.FC = ({ value={returnValue} isLoading={portfolioLoading} valueColor={returnColor} - testID={CAMPAIGN_STATS_SUMMARY_TEST_IDS.RETURN} + testID={ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.RETURN} /> {!isCampaignComplete && ( = ({ value={marketValue} isLoading={portfolioLoading} valueColor={returnColor} - testID={CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE} + testID={ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE} /> )} @@ -272,7 +275,7 @@ const CampaignStatsSummary: React.FC = ({ {!isCampaignComplete && isIneligible && ( {strings('rewards.ondo_campaign_stats.not_eligible_title')} @@ -337,11 +340,11 @@ const CampaignStatsSummary: React.FC = ({ portfolio.refetch(); }} confirmButtonLabel={strings('rewards.ondo_campaign_stats.retry')} - testID={CAMPAIGN_STATS_SUMMARY_TEST_IDS.STATS_ERROR} + testID={ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.STATS_ERROR} /> )} ); }; -export default CampaignStatsSummary; +export default OndoCampaignStatsSummary; diff --git a/app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.tsx b/app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.tsx index c70ec6cc636..43c1aa1b231 100644 --- a/app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.tsx +++ b/app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.tsx @@ -12,15 +12,17 @@ import { TextColor, TextVariant, FontWeight, - Skeleton, } from '@metamask/design-system-react-native'; -import { useTailwind } from '@metamask/design-system-twrnc-preset'; import type { CampaignLeaderboardEntry } from '../../../../../core/Engine/controllers/rewards-controller/types'; import { strings } from '../../../../../../locales/i18n'; import Routes from '../../../../../constants/navigation/Routes'; import RewardsErrorBanner from '../RewardsErrorBanner'; -import CrownIcon from '../../../../../images/rewards/crown.svg'; -import { PendingTag } from './CampaignStatsSummary'; +import { + CampaignLeaderboardEntryRow, + CampaignLeaderboardNeighborSeparator, + CampaignLeaderboardSkeleton, + CAMPAIGN_LEADERBOARD_SHARED_TEST_IDS, +} from './CampaignLeaderboard'; import { formatRateOfReturn, formatTierDisplayName, @@ -30,13 +32,14 @@ export const CAMPAIGN_LEADERBOARD_TEST_IDS = { CONTAINER: 'campaign-leaderboard-container', TIER_TOGGLE: 'campaign-leaderboard-tier-toggle', LIST: 'campaign-leaderboard-list', - ENTRY_ROW: 'campaign-leaderboard-entry-row', - PENDING_TAG: 'campaign-leaderboard-pending-tag', - NEIGHBOR_SEPARATOR: 'campaign-leaderboard-neighbor-separator', - LOADING: 'campaign-leaderboard-loading', + ENTRY_ROW: CAMPAIGN_LEADERBOARD_SHARED_TEST_IDS.ENTRY_ROW, + PENDING_TAG: CAMPAIGN_LEADERBOARD_SHARED_TEST_IDS.PENDING_TAG, + NEIGHBOR_SEPARATOR: CAMPAIGN_LEADERBOARD_SHARED_TEST_IDS.NEIGHBOR_SEPARATOR, + LOADING: CAMPAIGN_LEADERBOARD_SHARED_TEST_IDS.LOADING, ERROR: 'campaign-leaderboard-error', EMPTY: 'campaign-leaderboard-empty', NOT_YET_COMPUTED: 'campaign-leaderboard-not-yet-computed', + LAST_COMPUTED: 'campaign-leaderboard-last-computed', } as const; const MAX_ENTRIES_LIMIT = 20; @@ -72,137 +75,6 @@ interface CampaignLeaderboardProps { hideTierHeader?: boolean; } -/** - * LeaderboardEntryRow displays a single leaderboard entry - */ -const LeaderboardEntryRow: React.FC<{ - entry: CampaignLeaderboardEntry; - isCurrentUser?: boolean; - showCrown?: boolean; - isCampaignComplete?: boolean; -}> = ({ - entry, - isCurrentUser = false, - showCrown = false, - isCampaignComplete = false, -}) => { - const isPositiveReturn = entry.rateOfReturn >= 0; - const textColor = isCurrentUser - ? isPositiveReturn - ? TextColor.SuccessDefault - : TextColor.ErrorDefault - : TextColor.TextDefault; - const isPending = !entry.qualified; - const rowBg = isCurrentUser - ? isPending - ? 'bg-muted' - : isPositiveReturn - ? 'bg-success-muted' - : 'bg-error-muted' - : ''; - - return ( - - - - {String(entry.rank).padStart(2, '0')} - - - - {entry.referralCode} - - {showCrown && entry.rank <= 5 && ( - - )} - - {isCurrentUser && isPending && !isCampaignComplete && ( - - )} - - - {formatRateOfReturn(entry.rateOfReturn)} - - - ); -}; - -/** - * LeaderboardSkeleton displays loading skeleton for the leaderboard section - */ -const LeaderboardSkeleton: React.FC = () => { - const tw = useTailwind(); - - return ( - - {/* Leaderboard rows skeleton */} - - {[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((i) => ( - - - - - - - - - - ))} - - - ); -}; - -/** - * OndoLeaderboard displays the leaderboard tiers and entries for a campaign. - * Position-specific data (user rank, tier, deposited value) is handled separately - * by the OndoLeaderboardPosition component. - */ -const NeighborSeparator: React.FC = () => ( - - - - ••• - - - -); - const OndoLeaderboard: React.FC = ({ tierNames, selectedTier, @@ -305,7 +177,7 @@ const OndoLeaderboard: React.FC = ({ ); if (isLoading && entries.length === 0) { - return ; + return ; } if (hasError && entries.length === 0) { @@ -399,24 +271,30 @@ const OndoLeaderboard: React.FC = ({ {visibleEntries.length > 0 ? ( {visibleEntries.map((entry) => ( - formatRateOfReturn(e.rateOfReturn)} + isPositivePrimaryMetric={(e) => e.rateOfReturn >= 0} /> ))} {showSplitView && userPosition && ( <> - + {userPosition.neighbors.map((entry) => ( - + formatRateOfReturn(e.rateOfReturn) + } + isPositivePrimaryMetric={(e) => e.rateOfReturn >= 0} /> ))} diff --git a/app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.utils.test.ts b/app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.utils.test.ts index ae2b7f7ac1d..ef0a551d155 100644 --- a/app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.utils.test.ts +++ b/app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.utils.test.ts @@ -1,6 +1,5 @@ import { buildLeaderboardUserPosition, - formatComputedAt, formatRateOfReturn, formatTierDisplayName, getCampaignTierNames, @@ -9,13 +8,20 @@ import { import type { CampaignLeaderboardPositionDto } from '../../../../../core/Engine/controllers/rewards-controller/types'; jest.mock('../../../../../../locales/i18n', () => ({ - strings: (key: string) => { + strings: (key: string, params?: Record) => { const t: Record = { 'rewards.ondo_campaign_leaderboard.tier_starter': 'Bronze', 'rewards.ondo_campaign_leaderboard.tier_mid': 'Silver', 'rewards.ondo_campaign_leaderboard.tier_upper': 'Platinum', + 'rewards.perps_trading_campaign.last_updated': 'Last updated: {{time}}', }; - return t[key] ?? key; + let template = t[key] ?? key; + if (params) { + for (const [paramKey, value] of Object.entries(params)) { + template = template.split(`{{${paramKey}}}`).join(value); + } + } + return template; }, default: { locale: 'en-US' }, })); @@ -47,35 +53,6 @@ describe('OndoLeaderboard.utils', () => { }); }); - describe('formatComputedAt', () => { - beforeEach(() => { - jest.useFakeTimers(); - jest.setSystemTime(new Date('2024-03-20T12:00:00.000Z')); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it('returns empty string for null', () => { - expect(formatComputedAt(null)).toBe(''); - }); - - it('returns empty string for empty string', () => { - expect(formatComputedAt('')).toBe(''); - }); - - it('returns a non-empty string for a valid ISO timestamp', () => { - const result = formatComputedAt('2024-03-20T12:00:00.000Z'); - expect(result).toBeTruthy(); - expect(typeof result).toBe('string'); - }); - - it('returns empty string for an unparseable value', () => { - expect(formatComputedAt('not-a-date')).toBe(''); - }); - }); - describe('formatTierDisplayName', () => { it('maps STARTER to Bronze', () => { expect(formatTierDisplayName('STARTER')).toBe('Bronze'); diff --git a/app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.utils.ts b/app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.utils.ts index 11c7e36b140..bceab2d2e8d 100644 --- a/app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.utils.ts +++ b/app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.utils.ts @@ -6,10 +6,7 @@ import type { } from '../../../../../core/Engine/controllers/rewards-controller/types'; // Re-export shared helpers so existing consumers keep working -export { - formatPercentChange as formatRateOfReturn, - formatComputedAt, -} from '../../utils/formatUtils'; +export { formatPercentChange as formatRateOfReturn } from '../../utils/formatUtils'; // ── Tier display names ────────────────────────────────────────────────── diff --git a/app/components/UI/Rewards/components/Campaigns/OndoPortfolio.test.tsx b/app/components/UI/Rewards/components/Campaigns/OndoPortfolio.test.tsx index c8c0e11a814..ce235b5c58b 100644 --- a/app/components/UI/Rewards/components/Campaigns/OndoPortfolio.test.tsx +++ b/app/components/UI/Rewards/components/Campaigns/OndoPortfolio.test.tsx @@ -169,10 +169,6 @@ jest.mock('../../../../../util/ondoGeoRestrictions', () => ({ isGeoRestricted: jest.fn(() => false), })); -jest.mock('./OndoLeaderboard.utils', () => ({ - formatComputedAt: jest.fn(), -})); - jest.mock('../../../../../images/rewards/rewards-no-positions.svg', () => { const ReactActual = jest.requireActual('react'); const { View } = jest.requireActual('react-native'); diff --git a/app/components/UI/Rewards/components/Campaigns/OndoPrizePool.test.tsx b/app/components/UI/Rewards/components/Campaigns/OndoPrizePool.test.tsx index 1cc026ed32c..bd1e1c9cb21 100644 --- a/app/components/UI/Rewards/components/Campaigns/OndoPrizePool.test.tsx +++ b/app/components/UI/Rewards/components/Campaigns/OndoPrizePool.test.tsx @@ -1,9 +1,6 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react-native'; -import OndoPrizePool, { - ONDO_PRIZE_POOL_TEST_IDS, - getCurrentPrize, -} from './OndoPrizePool'; +import OndoPrizePool, { ONDO_PRIZE_POOL_TEST_IDS } from './OndoPrizePool'; jest.mock('@metamask/design-system-react-native', () => { const actual = jest.requireActual('@metamask/design-system-react-native'); @@ -218,40 +215,3 @@ describe('OndoPrizePool', () => { expect(mockRefetch).toHaveBeenCalledTimes(1); }); }); - -describe('getCurrentPrize', () => { - it('returns $25,000 for $0 deposits', () => { - expect(getCurrentPrize(0)).toBe(25_000); - }); - - it('returns $25,000 for deposits below $1.5M', () => { - expect(getCurrentPrize(500_000)).toBe(25_000); - expect(getCurrentPrize(1_499_999)).toBe(25_000); - }); - - it('returns $50,000 at exactly $1.5M', () => { - expect(getCurrentPrize(1_500_000)).toBe(50_000); - }); - - it('returns $50,000 for deposits between $1.5M and $3.5M', () => { - expect(getCurrentPrize(2_000_000)).toBe(50_000); - expect(getCurrentPrize(3_499_999)).toBe(50_000); - }); - - it('returns $75,000 at exactly $3.5M', () => { - expect(getCurrentPrize(3_500_000)).toBe(75_000); - }); - - it('returns $75,000 for deposits between $3.5M and $6M', () => { - expect(getCurrentPrize(4_500_000)).toBe(75_000); - expect(getCurrentPrize(5_999_999)).toBe(75_000); - }); - - it('returns $100,000 at exactly $6M', () => { - expect(getCurrentPrize(6_000_000)).toBe(100_000); - }); - - it('returns $100,000 for deposits above $6M', () => { - expect(getCurrentPrize(10_000_000)).toBe(100_000); - }); -}); diff --git a/app/components/UI/Rewards/components/Campaigns/OndoPrizePool.tsx b/app/components/UI/Rewards/components/Campaigns/OndoPrizePool.tsx index a614713eb56..c734e835ff8 100644 --- a/app/components/UI/Rewards/components/Campaigns/OndoPrizePool.tsx +++ b/app/components/UI/Rewards/components/Campaigns/OndoPrizePool.tsx @@ -13,6 +13,7 @@ import { import { useTailwind } from '@metamask/design-system-twrnc-preset'; import RewardsErrorBanner from '../RewardsErrorBanner'; import { formatCompactUsd, formatUsd } from '../../utils/formatUtils'; +import { computePrizePoolProgress } from '../../utils/prizePoolUtils'; import { strings } from '../../../../../../locales/i18n'; export const ONDO_PRIZE_POOL_TEST_IDS = { @@ -30,50 +31,6 @@ export const BREAKPOINTS = [ { deposit: 6_000_000, prize: 100_000 }, ] as const; -export function getCurrentPrize(totalDeposited: number): number { - for (let i = BREAKPOINTS.length - 1; i >= 0; i--) { - if (totalDeposited >= BREAKPOINTS[i].deposit) { - return BREAKPOINTS[i].prize; - } - } - return BREAKPOINTS[0].prize; -} - -function computeProgress(totalDeposited: number) { - let currentIndex = 0; - for (let i = BREAKPOINTS.length - 1; i >= 0; i--) { - if (totalDeposited >= BREAKPOINTS[i].deposit) { - currentIndex = i; - break; - } - } - - const current = BREAKPOINTS[currentIndex]; - const next = BREAKPOINTS[currentIndex + 1]; - - if (!next) { - return { - progress: 1, - currentPrize: current.prize, - nextPrize: null, - nextThreshold: current.deposit, - isMaxTier: true, - }; - } - - const rangeDeposit = next.deposit - current.deposit; - const progressInRange = totalDeposited - current.deposit; - const progress = Math.min(progressInRange / rangeDeposit, 1); - - return { - progress, - currentPrize: current.prize, - nextPrize: next.prize, - nextThreshold: next.deposit, - isMaxTier: false, - }; -} - interface OndoPrizePoolProps { totalUsdDeposited: string | null; isLoading: boolean; @@ -103,7 +60,11 @@ const OndoPrizePool: React.FC = ({ isMaxTier: false, }; } - return computeProgress(parseFloat(totalUsdDeposited)); + return computePrizePoolProgress( + BREAKPOINTS, + parseFloat(totalUsdDeposited), + (m) => m.deposit, + ); }, [totalUsdDeposited]); const progressPercent: `${number}%` = `${Math.round(progress * 100)}%`; diff --git a/app/components/UI/Rewards/components/Campaigns/PerpsCampaignStatsSummary.test.tsx b/app/components/UI/Rewards/components/Campaigns/PerpsCampaignStatsSummary.test.tsx new file mode 100644 index 00000000000..4039d10506b --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/PerpsCampaignStatsSummary.test.tsx @@ -0,0 +1,206 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import { TextColor } from '@metamask/design-system-react-native'; +import PerpsCampaignStatsSummary, { + PERPS_CAMPAIGN_STATS_SUMMARY_TEST_IDS, +} from './PerpsCampaignStatsSummary'; +import type { PerpsTradingCampaignLeaderboardPositionDto } from '../../../../../core/Engine/controllers/rewards-controller/types'; + +jest.mock('@metamask/design-system-react-native', () => { + const actual = jest.requireActual('@metamask/design-system-react-native'); + const ReactActual = jest.requireActual('react'); + const RN = jest.requireActual('react-native'); + return { + ...actual, + Text: (props: Record) => + ReactActual.createElement(RN.Text, props, props.children), + }; +}); + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => ({ style: (...args: unknown[]) => args }), +})); + +jest.mock('../../../../../../locales/i18n', () => ({ + strings: (key: string) => key, +})); + +const TEST_IDS = PERPS_CAMPAIGN_STATS_SUMMARY_TEST_IDS; + +const mockLeaderboard = { + campaignId: 'c1', + computedAt: '2025-01-01T00:00:00.000Z', + entries: [], + totalParticipants: 0, +}; + +const basePosition: PerpsTradingCampaignLeaderboardPositionDto = { + rank: 7, + pnl: 1500.25, + notionalVolume: 30_000, + marginDeployed: 2000, + qualified: true, + neighbors: [], + computedAt: '2025-01-01T00:00:00.000Z', +}; + +describe('PerpsCampaignStatsSummary', () => { + it('renders container and four stat labels', () => { + const { getByTestId, getByText } = render( + , + ); + + expect(getByTestId(TEST_IDS.CONTAINER)).toBeDefined(); + expect( + getByText('rewards.perps_trading_campaign.label_rank'), + ).toBeDefined(); + expect(getByText('rewards.perps_trading_campaign.label_pnl')).toBeDefined(); + expect( + getByText('rewards.perps_trading_campaign.label_volume'), + ).toBeDefined(); + expect( + getByText('rewards.perps_trading_campaign.label_margin'), + ).toBeDefined(); + expect(getByText('07')).toBeDefined(); + expect(getByText('+$1,500.25')).toBeDefined(); + }); + + it('uses success color for non-negative pnl', () => { + const { getByTestId } = render( + , + ); + const pnlCell = getByTestId(TEST_IDS.PNL); + expect(pnlCell.props.color).toBe(TextColor.SuccessDefault); + }); + + it('uses error color for negative pnl', () => { + const { getByTestId } = render( + , + ); + const pnlCell = getByTestId(TEST_IDS.PNL); + expect(pnlCell.props.color).toBe(TextColor.ErrorDefault); + }); + + it('renders em dashes when position is null', () => { + const { getAllByText } = render( + , + ); + expect(getAllByText('—').length).toBeGreaterThanOrEqual(4); + }); + + it('shows pending tag on rank when campaign is active and user is not qualified', () => { + const { getByTestId, queryByTestId } = render( + , + ); + expect(getByTestId(TEST_IDS.PENDING_TAG)).toBeDefined(); + expect(queryByTestId(TEST_IDS.QUALIFIED_TAG)).toBeNull(); + }); + + it('shows qualified check on rank when user is qualified', () => { + const { getByTestId, queryByTestId } = render( + , + ); + expect(getByTestId(TEST_IDS.QUALIFIED_TAG)).toBeDefined(); + expect(queryByTestId(TEST_IDS.PENDING_TAG)).toBeNull(); + }); + + it('does not show pending tag on rank when campaign is complete and user is not qualified', () => { + const { queryByTestId } = render( + , + ); + expect(queryByTestId(TEST_IDS.PENDING_TAG)).toBeNull(); + expect(queryByTestId(TEST_IDS.QUALIFIED_TAG)).toBeNull(); + }); + + it('shows qualified check when campaign is complete and user is qualified', () => { + const { getByTestId, queryByTestId } = render( + , + ); + expect(getByTestId(TEST_IDS.QUALIFIED_TAG)).toBeDefined(); + expect(queryByTestId(TEST_IDS.PENDING_TAG)).toBeNull(); + }); + + it("shows You're qualified card when campaign is active and user is qualified", () => { + const { getByTestId, queryByTestId } = render( + , + ); + expect(getByTestId(TEST_IDS.QUALIFIED_CARD)).toBeDefined(); + expect(queryByTestId(TEST_IDS.QUALIFY_FOR_RANK_CARD)).toBeNull(); + }); + + it("hides You're qualified card when campaign is complete", () => { + const { queryByTestId } = render( + , + ); + expect(queryByTestId(TEST_IDS.QUALIFIED_CARD)).toBeNull(); + }); + + it('shows Qualify for rank card when pending and below qualification thresholds', () => { + const { getByTestId, queryByTestId } = render( + , + ); + expect(getByTestId(TEST_IDS.QUALIFY_FOR_RANK_CARD)).toBeDefined(); + expect(queryByTestId(TEST_IDS.QUALIFIED_CARD)).toBeNull(); + }); + + it('hides Qualify for rank card when notional volume already meets threshold even if still pending', () => { + const { queryByTestId } = render( + , + ); + expect(queryByTestId(TEST_IDS.QUALIFY_FOR_RANK_CARD)).toBeNull(); + }); +}); diff --git a/app/components/UI/Rewards/components/Campaigns/PerpsCampaignStatsSummary.tsx b/app/components/UI/Rewards/components/Campaigns/PerpsCampaignStatsSummary.tsx new file mode 100644 index 00000000000..16a2608dfa0 --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/PerpsCampaignStatsSummary.tsx @@ -0,0 +1,187 @@ +import React from 'react'; +import { + Box, + BoxAlignItems, + BoxFlexDirection, + FontWeight, + Icon, + IconColor, + IconName, + IconSize, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import type { + PerpsTradingCampaignLeaderboardDto, + PerpsTradingCampaignLeaderboardPositionDto, +} from '../../../../../core/Engine/controllers/rewards-controller/types'; +import { strings } from '../../../../../../locales/i18n'; +import { formatSignedUsd, formatUsd } from '../../utils/formatUtils'; +import { PERPS_QUALIFICATION_NOTIONAL_USD } from '../../utils/perpsCampaignConstants'; +import { PendingTag, StatCell } from './OndoCampaignStatsSummary'; + +const PERPS_NOTIONAL_THRESHOLD_LABEL = formatUsd( + PERPS_QUALIFICATION_NOTIONAL_USD, +); + +export const PERPS_CAMPAIGN_STATS_SUMMARY_TEST_IDS = { + CONTAINER: 'perps-campaign-stats-summary-container', + RANK: 'perps-campaign-stats-summary-rank', + PNL: 'perps-campaign-stats-summary-pnl', + NOTIONAL_VOLUME: 'perps-campaign-stats-summary-notional-volume', + MARGIN_DEPLOYED: 'perps-campaign-stats-summary-margin-deployed', + PENDING_TAG: 'perps-campaign-stats-summary-pending-tag', + QUALIFIED_TAG: 'perps-campaign-stats-summary-qualified-tag', + QUALIFIED_CARD: 'perps-campaign-stats-summary-qualified-card', + QUALIFY_FOR_RANK_CARD: 'perps-campaign-stats-summary-qualify-for-rank-card', +} as const; + +export interface PerpsCampaignStatsSummaryProps { + leaderboardPosition: PerpsTradingCampaignLeaderboardPositionDto | null; + /** Passed for future use (e.g. leaderboard-level metadata); stats values come from `leaderboardPosition`. */ + leaderboard: PerpsTradingCampaignLeaderboardDto | null; + /** When false, pending (not yet qualified) users see a {@link PendingTag} next to rank. */ + isCampaignComplete?: boolean; +} + +const PerpsCampaignStatsSummary: React.FC = ({ + leaderboardPosition, + leaderboard: _leaderboard, + isCampaignComplete = false, +}) => { + const isPending = + leaderboardPosition != null && !leaderboardPosition.qualified; + const isQualified = + leaderboardPosition != null && leaderboardPosition.qualified; + + const rankDisplay = leaderboardPosition + ? String(leaderboardPosition.rank).padStart(2, '0') + : '—'; + + const pnlDisplay = leaderboardPosition + ? formatSignedUsd(leaderboardPosition.pnl) + : '—'; + + const pnlColor = leaderboardPosition + ? leaderboardPosition.pnl >= 0 + ? TextColor.SuccessDefault + : TextColor.ErrorDefault + : TextColor.TextDefault; + + const volumeDisplay = leaderboardPosition + ? formatUsd(leaderboardPosition.notionalVolume) + : '—'; + + const marginDisplay = leaderboardPosition + ? formatUsd(leaderboardPosition.marginDeployed) + : '—'; + + const notionalGap = leaderboardPosition + ? Math.max( + 0, + PERPS_QUALIFICATION_NOTIONAL_USD - leaderboardPosition.notionalVolume, + ) + : 0; + + const showQualifiedCard = + !isCampaignComplete && isQualified && leaderboardPosition != null; + + const showQualifyForRankCard = + !isCampaignComplete && + isPending && + leaderboardPosition != null && + notionalGap > 0; + + return ( + + + + ) : isQualified ? ( + + ) : undefined + } + /> + + + + + + + + {showQualifiedCard && ( + + + {strings('rewards.perps_trading_campaign.stats_qualified_title')} + + + {strings( + 'rewards.perps_trading_campaign.stats_qualified_description', + )} + + + )} + + {showQualifyForRankCard && ( + + + + {strings( + 'rewards.perps_trading_campaign.stats_qualify_for_rank_title', + )} + + + + {strings( + 'rewards.perps_trading_campaign.stats_qualify_for_rank_description', + { + notionalRemaining: formatUsd(notionalGap), + }, + )} + + + )} + + ); +}; + +export default PerpsCampaignStatsSummary; diff --git a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignCTA.test.tsx b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignCTA.test.tsx new file mode 100644 index 00000000000..45efd5f19a9 --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignCTA.test.tsx @@ -0,0 +1,210 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import { useSelector } from 'react-redux'; +import PerpsTradingCampaignCTA from './PerpsTradingCampaignCTA'; +import { CAMPAIGN_CTA_TEST_IDS } from './CampaignOptInCta'; +import { + type CampaignDto, + CampaignType, +} from '../../../../../core/Engine/controllers/rewards-controller/types'; +import { selectPerpsEligibility } from '../../../Perps/selectors/perpsController'; + +jest.mock('@metamask/design-system-react-native', () => { + const actual = jest.requireActual('@metamask/design-system-react-native'); + return { ...actual }; +}); + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => ({ style: (...args: unknown[]) => args }), +})); + +const mockHandleDeeplink = jest.fn(); +jest.mock('../../../../../core/DeeplinkManager', () => ({ + handleDeeplink: (...args: unknown[]) => mockHandleDeeplink(...args), +})); + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +const mockShowToast = jest.fn(); +const mockEntriesClosed = jest.fn(() => ({ variant: 'icon' })); + +jest.mock('../../hooks/useRewardsToast', () => ({ + __esModule: true, + default: () => ({ + showToast: mockShowToast, + RewardsToastOptions: { + success: jest.fn(), + error: jest.fn(), + entriesClosed: mockEntriesClosed, + }, + }), +})); + +jest.mock('./CampaignOptInSheet', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: () => + ReactActual.createElement(View, { testID: 'campaign-opt-in-sheet' }), + }; +}); + +jest.mock('../../../../../../locales/i18n', () => ({ + strings: (key: string) => { + const map: Record = { + 'rewards.perps_trading_campaign.open_position_cta': 'Open Position', + 'rewards.campaign_details.join_campaign': 'Join Campaign', + 'rewards.campaign.geo_locked_cta': 'Geo locked', + 'rewards.campaign.geo_locked_toast_title': 'Not available', + 'rewards.campaign.geo_locked_toast_description': 'Region restricted', + }; + return map[key] ?? key; + }, +})); + +const mockUseSelector = useSelector as jest.MockedFunction; + +function buildCampaign(overrides: Partial = {}): CampaignDto { + return { + id: 'perps-campaign-1', + type: CampaignType.PERPS_TRADING, + name: 'Perps Trading Campaign', + startDate: '2025-06-01T00:00:00.000Z', + endDate: '2026-12-31T23:59:59.999Z', + termsAndConditions: null, + excludedRegions: [], + details: null, + featured: true, + showUpcomingDate: false, + ...overrides, + }; +} + +const notOptedIn = { + status: { optedIn: false, participantCount: 0 } as const, + isLoading: false, +}; + +const optedIn = { + status: { optedIn: true, participantCount: 1 } as const, + isLoading: false, +}; + +describe('PerpsTradingCampaignCTA', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2025-08-15T12:00:00.000Z')); + jest.clearAllMocks(); + mockHandleDeeplink.mockResolvedValue(undefined); + mockUseSelector.mockImplementation((selector) => { + if (selector === selectPerpsEligibility) { + return true; + } + return undefined; + }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('renders nothing while participant status is loading', () => { + const { queryByTestId } = render( + , + ); + + expect(queryByTestId(CAMPAIGN_CTA_TEST_IDS.CTA_BUTTON)).toBeNull(); + }); + + it('renders nothing when campaign is upcoming', () => { + const { queryByTestId } = render( + , + ); + + expect(queryByTestId(CAMPAIGN_CTA_TEST_IDS.CTA_BUTTON)).toBeNull(); + }); + + it('renders nothing when campaign is complete', () => { + const { queryByTestId } = render( + , + ); + + expect(queryByTestId(CAMPAIGN_CTA_TEST_IDS.CTA_BUTTON)).toBeNull(); + }); + + it('when opted in, shows Open Position and calls handleDeeplink with perps market-list URL', () => { + const { getByTestId, getByText } = render( + , + ); + + expect(getByText('Open Position')).toBeOnTheScreen(); + fireEvent.press(getByTestId(CAMPAIGN_CTA_TEST_IDS.CTA_BUTTON)); + + expect(mockHandleDeeplink).toHaveBeenCalledWith({ + uri: 'https://link.metamask.io/perps?screen=market-list', + }); + }); + + it('when not opted in and geo-ineligible, shows geo locked CTA and toast on press', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectPerpsEligibility) { + return false; + } + return undefined; + }); + + const { getByTestId, getByText } = render( + , + ); + + expect(getByText('Geo locked')).toBeOnTheScreen(); + fireEvent.press(getByTestId(CAMPAIGN_CTA_TEST_IDS.CTA_BUTTON)); + + expect(mockEntriesClosed).toHaveBeenCalledWith( + 'Not available', + 'Region restricted', + ); + expect(mockShowToast).toHaveBeenCalledWith({ variant: 'icon' }); + expect(mockHandleDeeplink).not.toHaveBeenCalled(); + }); + + it('when not opted in and eligible, shows Join Campaign and opens opt-in sheet', () => { + const { getByTestId, getByText, queryByTestId } = render( + , + ); + + expect(queryByTestId('campaign-opt-in-sheet')).toBeNull(); + expect(getByText('Join Campaign')).toBeOnTheScreen(); + + fireEvent.press(getByTestId(CAMPAIGN_CTA_TEST_IDS.CTA_BUTTON)); + + expect(getByTestId('campaign-opt-in-sheet')).toBeOnTheScreen(); + }); +}); diff --git a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignCTA.tsx b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignCTA.tsx new file mode 100644 index 00000000000..43f39e96ee0 --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignCTA.tsx @@ -0,0 +1,131 @@ +import React, { useCallback, useState } from 'react'; +import { + Box, + Button, + ButtonSize, + ButtonVariant, + IconName, +} from '@metamask/design-system-react-native'; +import type { CampaignDto } from '../../../../../core/Engine/controllers/rewards-controller/types'; +import type { UseGetCampaignParticipantStatusResult } from '../../hooks/useGetCampaignParticipantStatus'; +import { getCampaignStatus } from './CampaignTile.utils'; +import { strings } from '../../../../../../locales/i18n'; +import CampaignOptInSheet from './CampaignOptInSheet'; +import { CAMPAIGN_CTA_TEST_IDS } from './CampaignOptInCta'; +import { selectPerpsEligibility } from '../../../Perps/selectors/perpsController'; +import { useSelector } from 'react-redux'; +import useRewardsToast from '../../hooks/useRewardsToast'; +import { handleDeeplink } from '../../../../../core/DeeplinkManager'; + +interface PerpsTradingCampaignCTAProps { + campaign: CampaignDto; + participantStatus: Pick< + UseGetCampaignParticipantStatusResult, + 'status' | 'isLoading' + >; +} + +const PerpsTradingCampaignCTA: React.FC = ({ + campaign, + participantStatus, +}) => { + const { showToast, RewardsToastOptions } = useRewardsToast(); + const isPerpsEligible = useSelector(selectPerpsEligibility); + const [isOptInSheetOpen, setIsOptInSheetOpen] = useState(false); + + const campaignStatus = getCampaignStatus(campaign); + const isLoading = participantStatus.isLoading; + const isOptedIn = participantStatus?.status?.optedIn === true; + + const handleGeoLockedPress = useCallback(() => { + showToast( + RewardsToastOptions.entriesClosed( + strings('rewards.campaign.geo_locked_toast_title'), + strings('rewards.campaign.geo_locked_toast_description'), + ), + ); + }, [showToast, RewardsToastOptions]); + + const handleJoinPress = useCallback(() => { + setIsOptInSheetOpen(true); + }, []); + + const handleOpenPosition = useCallback(async () => { + await handleDeeplink({ + uri: 'https://link.metamask.io/perps?screen=market-list', + }); + }, []); + + if (isLoading) { + return null; + } + + // Campaign complete — no CTA (leaderboard section handles it) + if (campaignStatus === 'complete') { + return null; + } + + if (campaignStatus !== 'active') { + return null; + } + + // Opted in — show "Open Position" + if (isOptedIn) { + return ( + + + + ); + } + + // Not opted in — geo-restricted + if (!isPerpsEligible) { + return ( + + + + ); + } + + // Not opted in — eligible + return ( + <> + + + + {isOptInSheetOpen && ( + setIsOptInSheetOpen(false)} + /> + )} + + ); +}; + +export default PerpsTradingCampaignCTA; diff --git a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignLeaderboard.test.tsx b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignLeaderboard.test.tsx new file mode 100644 index 00000000000..8b15a8f46ca --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignLeaderboard.test.tsx @@ -0,0 +1,198 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import PerpsTradingCampaignLeaderboard, { + PERPS_CAMPAIGN_LEADERBOARD_TEST_IDS, +} from './PerpsTradingCampaignLeaderboard'; +import type { PerpsTradingCampaignLeaderboardEntry } from '../../../../../core/Engine/controllers/rewards-controller/types'; + +jest.mock('@metamask/design-system-react-native', () => { + const actual = jest.requireActual('@metamask/design-system-react-native'); + return { ...actual }; +}); + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => ({ style: (...args: unknown[]) => args }), +})); + +jest.mock('../../../../../../locales/i18n', () => ({ + strings: (key: string) => key, +})); + +jest.mock('../../utils/formatUtils', () => ({ + formatSignedUsd: (value: number) => `$${value.toFixed(2)}`, +})); + +jest.mock('../RewardsErrorBanner', () => { + const ReactActual = jest.requireActual('react'); + return { + __esModule: true, + default: ({ children }: { children?: React.ReactNode }) => + ReactActual.createElement(ReactActual.Fragment, null, children), + }; +}); + +jest.mock('../../../../../images/rewards/crown.svg', () => 'CrownIcon'); + +const mockNavigate = jest.fn(); +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ + navigate: mockNavigate, + }), +})); + +jest.mock('../../../../../constants/navigation/Routes', () => ({ + __esModule: true, + default: { + BROWSER: { HOME: 'BrowserHome', VIEW: 'BrowserView' }, + }, +})); + +const TEST_IDS = PERPS_CAMPAIGN_LEADERBOARD_TEST_IDS; + +const createPerpsEntry = ( + overrides: Partial = {}, +): PerpsTradingCampaignLeaderboardEntry => ({ + rank: 1, + referralCode: 'REF001', + pnl: 100, + qualified: true, + ...overrides, +}); + +const defaultProps = { + entries: [ + createPerpsEntry({ rank: 1, referralCode: 'A' }), + createPerpsEntry({ rank: 2, referralCode: 'B' }), + ], + isLoading: false, + hasError: false, +}; + +describe('PerpsTradingCampaignLeaderboard', () => { + beforeEach(() => { + mockNavigate.mockClear(); + }); + + it('renders container and list with entry testIDs', () => { + const { getByTestId } = render( + , + ); + expect(getByTestId(TEST_IDS.CONTAINER)).toBeDefined(); + expect(getByTestId(TEST_IDS.LIST)).toBeDefined(); + expect(getByTestId(`${TEST_IDS.ENTRY_ROW}-1`)).toBeDefined(); + expect(getByTestId(TEST_IDS.POWERED_BY)).toBeDefined(); + }); + + it('navigates to in-app browser with HyperTracker attribution URL when brand is pressed', () => { + const { getByText } = render( + , + ); + fireEvent.press( + getByText( + 'rewards.perps_trading_campaign.leaderboard_hypertracker_brand', + ), + ); + + expect(mockNavigate).toHaveBeenCalledWith( + 'BrowserHome', + expect.objectContaining({ + screen: 'BrowserView', + params: expect.objectContaining({ + newTabUrl: + 'https://hypertracker.io?utm_source=metamask&utm_medium=leaderboard&utm_campaign=partner-attribution', + }), + }), + ); + }); + + describe('split view top count (preview vs full, ranks 21–22 vs other)', () => { + const tenEntries = Array.from({ length: 10 }, (_, i) => + createPerpsEntry({ + rank: i + 1, + referralCode: `S${String(i + 1).padStart(3, '0')}`, + pnl: 10 - i, + }), + ); + + it('preview mode shows top 3 then separator and neighbors when rank is outside range', () => { + const { getByTestId, queryByTestId } = render( + , + ); + + expect(getByTestId(`${TEST_IDS.ENTRY_ROW}-1`)).toBeDefined(); + expect(getByTestId(`${TEST_IDS.ENTRY_ROW}-2`)).toBeDefined(); + expect(getByTestId(`${TEST_IDS.ENTRY_ROW}-3`)).toBeDefined(); + expect(queryByTestId(`${TEST_IDS.ENTRY_ROW}-4`)).toBeNull(); + expect(getByTestId(TEST_IDS.NEIGHBOR_SEPARATOR)).toBeDefined(); + expect(getByTestId(`${TEST_IDS.ENTRY_ROW}-250`)).toBeDefined(); + }); + + const twentyFiveEntries = Array.from({ length: 25 }, (_, i) => + createPerpsEntry({ + rank: i + 1, + referralCode: `R${String(i + 1).padStart(3, '0')}`, + pnl: 1000 - i, + }), + ); + + it('full mode shows 18 top rows when user rank is 21 (reduced top strip)', () => { + const { getByTestId, queryByTestId } = render( + , + ); + + expect(getByTestId(`${TEST_IDS.ENTRY_ROW}-1`)).toBeDefined(); + expect(getByTestId(`${TEST_IDS.ENTRY_ROW}-18`)).toBeDefined(); + expect(queryByTestId(`${TEST_IDS.ENTRY_ROW}-19`)).toBeNull(); + expect(getByTestId(TEST_IDS.NEIGHBOR_SEPARATOR)).toBeDefined(); + expect(getByTestId(`${TEST_IDS.ENTRY_ROW}-20`)).toBeDefined(); + }); + + it('full mode shows 20 top rows when user rank is 23 (standard strip)', () => { + const { getByTestId, queryByTestId } = render( + , + ); + + expect(getByTestId(`${TEST_IDS.ENTRY_ROW}-20`)).toBeDefined(); + expect(queryByTestId(`${TEST_IDS.ENTRY_ROW}-21`)).toBeNull(); + expect(getByTestId(TEST_IDS.NEIGHBOR_SEPARATOR)).toBeDefined(); + expect(getByTestId(`${TEST_IDS.ENTRY_ROW}-22`)).toBeDefined(); + }); + }); +}); diff --git a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignLeaderboard.tsx b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignLeaderboard.tsx new file mode 100644 index 00000000000..8537bdc3051 --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignLeaderboard.tsx @@ -0,0 +1,240 @@ +import React, { useCallback, useMemo } from 'react'; +import { useNavigation } from '@react-navigation/native'; +import { + Box, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import type { PerpsTradingCampaignLeaderboardEntry } from '../../../../../core/Engine/controllers/rewards-controller/types'; +import { strings } from '../../../../../../locales/i18n'; +import RewardsErrorBanner from '../RewardsErrorBanner'; +import { formatSignedUsd } from '../../utils/formatUtils'; +import { + CampaignLeaderboardEntryRow, + CampaignLeaderboardNeighborSeparator, + CampaignLeaderboardSkeleton, + CAMPAIGN_LEADERBOARD_SHARED_TEST_IDS, +} from './CampaignLeaderboard'; +import Routes from '../../../../../constants/navigation/Routes'; +import { HYPERTRACKER_ATTRIBUTION_URL } from '../../utils/perpsCampaignConstants'; + +export const PERPS_CAMPAIGN_LEADERBOARD_TEST_IDS = { + CONTAINER: 'perps-campaign-leaderboard-container', + LIST: 'perps-campaign-leaderboard-list', + ENTRY_ROW: CAMPAIGN_LEADERBOARD_SHARED_TEST_IDS.ENTRY_ROW, + PENDING_TAG: CAMPAIGN_LEADERBOARD_SHARED_TEST_IDS.PENDING_TAG, + NEIGHBOR_SEPARATOR: CAMPAIGN_LEADERBOARD_SHARED_TEST_IDS.NEIGHBOR_SEPARATOR, + LOADING: CAMPAIGN_LEADERBOARD_SHARED_TEST_IDS.LOADING, + ERROR: 'perps-campaign-leaderboard-error', + EMPTY: 'perps-campaign-leaderboard-empty', + NOT_YET_COMPUTED: 'perps-campaign-leaderboard-not-yet-computed', + TOTAL_PARTICIPANTS: 'perps-campaign-leaderboard-total-participants', + POWERED_BY: 'perps-campaign-leaderboard-powered-by', +} as const; + +const MAX_ENTRIES_LIMIT = 20; +const SPLIT_VIEW_TOP_COUNT_PREVIEW = 3; +/** Ranks just below the first page: show one fewer top rows to keep split view from crowding the neighbor block. */ +const FULL_SPLIT_TOP_REDUCED_AT_RANKS: readonly number[] = [21, 22]; + +interface UserPosition { + rank: number; + neighbors: PerpsTradingCampaignLeaderboardEntry[]; +} + +export interface PerpsTradingCampaignLeaderboardProps { + entries: PerpsTradingCampaignLeaderboardEntry[]; + isLoading: boolean; + hasError: boolean; + isLeaderboardNotYetComputed?: boolean; + onRetry?: () => void; + currentUserReferralCode?: string | null; + maxEntries?: number; + userPosition?: UserPosition | null; + campaignId?: string; + isCampaignComplete?: boolean; +} + +const PerpsTradingCampaignLeaderboard: React.FC< + PerpsTradingCampaignLeaderboardProps +> = ({ + entries, + isLoading, + hasError, + isLeaderboardNotYetComputed = false, + onRetry, + currentUserReferralCode, + maxEntries, + userPosition, + isCampaignComplete = false, +}) => { + const navigation = useNavigation(); + + const handleHyperTrackerPress = useCallback(() => { + navigation.navigate(Routes.BROWSER.HOME, { + screen: Routes.BROWSER.VIEW, + params: { + newTabUrl: HYPERTRACKER_ATTRIBUTION_URL, + timestamp: Date.now(), + }, + }); + }, [navigation]); + + const isPreview = maxEntries != null; + + const effectiveMaxEntries = + maxEntries != null && maxEntries <= MAX_ENTRIES_LIMIT + ? maxEntries + : MAX_ENTRIES_LIMIT; + + /** Top rows above the neighbor separator in split view (preview: 3; full: 18 for rank 21–22, else 20). */ + const splitViewTopCount = useMemo(() => { + if (isPreview) { + return SPLIT_VIEW_TOP_COUNT_PREVIEW; + } + const rank = userPosition?.rank; + if (rank == null) { + return MAX_ENTRIES_LIMIT; + } + return FULL_SPLIT_TOP_REDUCED_AT_RANKS.includes(rank) + ? MAX_ENTRIES_LIMIT - 2 + : MAX_ENTRIES_LIMIT; + }, [isPreview, userPosition?.rank]); + + const showSplitView = useMemo(() => { + if (!userPosition) return false; + return ( + userPosition.rank > effectiveMaxEntries && + userPosition.neighbors.length > 0 + ); + }, [userPosition, effectiveMaxEntries]); + + const visibleEntries = useMemo(() => { + if (showSplitView) { + return entries.slice(0, splitViewTopCount); + } + return entries.slice(0, effectiveMaxEntries); + }, [entries, effectiveMaxEntries, showSplitView, splitViewTopCount]); + + const isCurrentUser = useCallback( + (entry: PerpsTradingCampaignLeaderboardEntry) => + !!currentUserReferralCode && + entry.referralCode === currentUserReferralCode, + [currentUserReferralCode], + ); + + if (isLoading && entries.length === 0) { + return ; + } + + if (hasError && entries.length === 0) { + return ( + + + + ); + } + + if (isLeaderboardNotYetComputed && !isLoading && entries.length === 0) { + return ( + + + {strings( + 'rewards.perps_trading_campaign.leaderboard_not_yet_computed', + )} + + + ); + } + + if (entries.length === 0) { + return ( + + + {strings( + 'rewards.perps_trading_campaign.leaderboard_not_yet_computed', + )} + + + ); + } + + return ( + + {/* Leaderboard list */} + + {visibleEntries.map((entry) => ( + formatSignedUsd(e.pnl)} + isPositivePrimaryMetric={(e) => e.pnl >= 0} + /> + ))} + {showSplitView && userPosition && ( + <> + + {userPosition.neighbors.map((entry) => ( + formatSignedUsd(e.pnl)} + isPositivePrimaryMetric={(e) => e.pnl >= 0} + /> + ))} + + )} + + + {strings( + 'rewards.perps_trading_campaign.leaderboard_powered_by_prefix', + )} + + {strings( + 'rewards.perps_trading_campaign.leaderboard_hypertracker_brand', + )} + + + + ); +}; + +export default PerpsTradingCampaignLeaderboard; diff --git a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignPrizePool.test.tsx b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignPrizePool.test.tsx new file mode 100644 index 00000000000..360a3b52187 --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignPrizePool.test.tsx @@ -0,0 +1,271 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import PerpsTradingCampaignPrizePool, { + PERPS_PRIZE_POOL_TEST_IDS, +} from './PerpsTradingCampaignPrizePool'; + +jest.mock('@metamask/design-system-react-native', () => { + const actual = jest.requireActual('@metamask/design-system-react-native'); + return { ...actual }; +}); + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => ({ style: (...args: unknown[]) => args }), +})); + +jest.mock('../RewardsErrorBanner', () => { + const ReactActual = jest.requireActual('react'); + const { View, Text, Pressable } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ + title, + onConfirm, + confirmButtonLabel, + testID, + }: { + title: string; + description: string; + onConfirm?: () => void; + confirmButtonLabel?: string; + testID?: string; + }) => + ReactActual.createElement( + View, + { testID }, + ReactActual.createElement(Text, null, title), + confirmButtonLabel && + ReactActual.createElement( + Pressable, + { onPress: onConfirm, testID: `${testID}-retry` }, + ReactActual.createElement(Text, null, confirmButtonLabel), + ), + ), + }; +}); + +jest.mock('../../../../../../locales/i18n', () => ({ + strings: (key: string, params?: Record) => { + const t: Record = { + 'rewards.perps_trading_campaign.prize_pool_error_title': + 'Prize pool unavailable', + 'rewards.perps_trading_campaign.prize_pool_error_description': + 'Could not load prize pool.', + 'rewards.perps_trading_campaign.prize_pool_retry_button': 'Retry', + 'rewards.perps_trading_campaign.prize_pool_current_label': 'Current', + 'rewards.perps_trading_campaign.prize_pool_next_label': 'Next', + 'rewards.perps_trading_campaign.prize_pool_volume_subtext': + '{{current}} of {{target}} volume', + 'rewards.perps_trading_campaign.prize_pool_max_tier_subtext': + '{{maxThreshold}}+ volume — all milestones reached', + 'rewards.perps_trading_campaign.prize_pool_max_badge': 'Max prize', + }; + let result = t[key] ?? key; + if (params) { + Object.entries(params).forEach(([k, v]) => { + result = result.replace(`{{${k}}}`, v); + }); + } + return result; + }, + default: { locale: 'en-US' }, +})); + +jest.mock('../../utils/formatUtils', () => ({ + formatUsd: (value: string | number) => + `$${Number(value).toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`, + formatCompactUsd: (value: number) => { + if (value >= 1_000_000) { + return `$${(value / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`; + } + if (value >= 1_000) { + return `$${(value / 1_000).toFixed(0)}K`; + } + return `$${value}`; + }, +})); + +const mockRefetch = jest.fn(); + +const baseProps = { + totalNotionalVolume: '7500000' as string | null, + isLoading: false, + hasError: false, + refetch: mockRefetch, +}; + +describe('PerpsTradingCampaignPrizePool', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders container, progress bar, and subtext when data is provided', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(PERPS_PRIZE_POOL_TEST_IDS.CONTAINER)).toBeDefined(); + expect(getByTestId(PERPS_PRIZE_POOL_TEST_IDS.PROGRESS_BAR)).toBeDefined(); + expect(getByTestId(PERPS_PRIZE_POOL_TEST_IDS.SUBTEXT)).toBeDefined(); + }); + + it('shows current and next prize between $5M and $10M notional', () => { + const { getByText } = render( + , + ); + + expect(getByText('$15,000.00')).toBeDefined(); + expect(getByText('$20,000.00')).toBeDefined(); + }); + + it('computes 50% progress halfway between $5M and $10M volume', () => { + const { getByTestId } = render( + , + ); + + const progressBar = getByTestId(PERPS_PRIZE_POOL_TEST_IDS.PROGRESS_BAR); + const innerBar = progressBar.props.children; + expect(innerBar.props.style).toEqual({ width: '50%' }); + }); + + it('shows max badge and full progress at $40M notional (top tier)', () => { + const { getByTestId, getByText, queryByText } = render( + , + ); + + expect(getByTestId(PERPS_PRIZE_POOL_TEST_IDS.MAX_BADGE)).toBeDefined(); + expect(getByText('Max prize')).toBeDefined(); + expect(getByText('$50,000.00')).toBeDefined(); + expect(queryByText('Next')).toBeNull(); + + const progressBar = getByTestId(PERPS_PRIZE_POOL_TEST_IDS.PROGRESS_BAR); + const innerBar = progressBar.props.children; + expect(innerBar.props.style).toEqual({ width: '100%' }); + + const subtext = getByTestId(PERPS_PRIZE_POOL_TEST_IDS.SUBTEXT); + expect(subtext.props.children).toBe( + '$40M+ volume — all milestones reached', + ); + }); + + it('does not show max badge below top tier', () => { + const { queryByTestId } = render( + , + ); + + expect(queryByTestId(PERPS_PRIZE_POOL_TEST_IDS.MAX_BADGE)).toBeNull(); + }); + + it('with null volume and not loading, shows first-tier defaults ($10k → $15k)', () => { + const { getByText, getByTestId } = render( + , + ); + + expect(getByText('$10,000.00')).toBeDefined(); + expect(getByText('$15,000.00')).toBeDefined(); + const progressBar = getByTestId(PERPS_PRIZE_POOL_TEST_IDS.PROGRESS_BAR); + const innerBar = progressBar.props.children; + expect(innerBar.props.style).toEqual({ width: '0%' }); + }); + + it('with zero notional string uses first milestone segment (0% in range to $5M)', () => { + const { getByTestId } = render( + , + ); + + const progressBar = getByTestId(PERPS_PRIZE_POOL_TEST_IDS.PROGRESS_BAR); + const innerBar = progressBar.props.children; + expect(innerBar.props.style).toEqual({ width: '0%' }); + }); + + it('shows skeleton when loading with no volume data', () => { + const { getByTestId, queryByTestId } = render( + , + ); + + expect(getByTestId(PERPS_PRIZE_POOL_TEST_IDS.CONTAINER)).toBeDefined(); + expect(queryByTestId(PERPS_PRIZE_POOL_TEST_IDS.PROGRESS_BAR)).toBeNull(); + expect(queryByTestId(PERPS_PRIZE_POOL_TEST_IDS.SUBTEXT)).toBeNull(); + }); + + it('shows stale content when loading but volume already exists', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(PERPS_PRIZE_POOL_TEST_IDS.PROGRESS_BAR)).toBeDefined(); + expect(getByTestId(PERPS_PRIZE_POOL_TEST_IDS.SUBTEXT)).toBeDefined(); + }); + + it('shows error banner when hasError and no volume data', () => { + const { getByTestId, queryByTestId } = render( + , + ); + + expect(getByTestId(PERPS_PRIZE_POOL_TEST_IDS.ERROR_BANNER)).toBeDefined(); + expect(queryByTestId(PERPS_PRIZE_POOL_TEST_IDS.PROGRESS_BAR)).toBeNull(); + }); + + it('hides error banner when hasError but stale volume exists', () => { + const { queryByTestId, getByTestId } = render( + , + ); + + expect(queryByTestId(PERPS_PRIZE_POOL_TEST_IDS.ERROR_BANNER)).toBeNull(); + expect(getByTestId(PERPS_PRIZE_POOL_TEST_IDS.PROGRESS_BAR)).toBeDefined(); + }); + + it('calls refetch when error retry is pressed', () => { + const { getByTestId } = render( + , + ); + + fireEvent.press( + getByTestId(`${PERPS_PRIZE_POOL_TEST_IDS.ERROR_BANNER}-retry`), + ); + expect(mockRefetch).toHaveBeenCalledTimes(1); + }); + + it('renders volume subtext with compact amounts', () => { + const { getByTestId } = render( + , + ); + + const subtext = getByTestId(PERPS_PRIZE_POOL_TEST_IDS.SUBTEXT); + expect(subtext.props.children).toBe('$7.5M of $10M volume'); + }); +}); diff --git a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignPrizePool.tsx b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignPrizePool.tsx new file mode 100644 index 00000000000..c639cd74baa --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignPrizePool.tsx @@ -0,0 +1,196 @@ +import React, { useMemo } from 'react'; +import { + Box, + BoxAlignItems, + BoxFlexDirection, + BoxJustifyContent, + FontWeight, + Skeleton, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import RewardsErrorBanner from '../RewardsErrorBanner'; +import { formatCompactUsd, formatUsd } from '../../utils/formatUtils'; +import { computePrizePoolProgress } from '../../utils/prizePoolUtils'; +import { strings } from '../../../../../../locales/i18n'; + +export const PERPS_PRIZE_POOL_TEST_IDS = { + CONTAINER: 'perps-prize-pool-container', + PROGRESS_BAR: 'perps-prize-pool-progress-bar', + MAX_BADGE: 'perps-prize-pool-max-badge', + SUBTEXT: 'perps-prize-pool-subtext', + ERROR_BANNER: 'perps-prize-pool-error-banner', +} as const; + +// $10k base prize, scales by $5k per $5M notional volume, up to $50k at $40M +export const PERPS_PRIZE_POOL_MILESTONES = [ + { notionalVolume: 0, prize: 10_000 }, + { notionalVolume: 5_000_000, prize: 15_000 }, + { notionalVolume: 10_000_000, prize: 20_000 }, + { notionalVolume: 15_000_000, prize: 25_000 }, + { notionalVolume: 20_000_000, prize: 30_000 }, + { notionalVolume: 25_000_000, prize: 35_000 }, + { notionalVolume: 30_000_000, prize: 40_000 }, + { notionalVolume: 35_000_000, prize: 45_000 }, + { notionalVolume: 40_000_000, prize: 50_000 }, +] as const; + +interface PerpsTradingCampaignPrizePoolProps { + totalNotionalVolume: string | null; + isLoading: boolean; + hasError: boolean; + refetch: () => void; +} + +const PerpsTradingCampaignPrizePool: React.FC< + PerpsTradingCampaignPrizePoolProps +> = ({ totalNotionalVolume, isLoading, hasError, refetch }) => { + const tw = useTailwind(); + + const showSkeleton = isLoading && !totalNotionalVolume; + const showError = hasError && !totalNotionalVolume; + + const { progress, currentPrize, nextPrize, nextThreshold, isMaxTier } = + useMemo(() => { + if (!totalNotionalVolume) { + return { + progress: 0, + currentPrize: PERPS_PRIZE_POOL_MILESTONES[0].prize, + nextPrize: PERPS_PRIZE_POOL_MILESTONES[1].prize as number | null, + nextThreshold: PERPS_PRIZE_POOL_MILESTONES[1].notionalVolume, + isMaxTier: false, + }; + } + return computePrizePoolProgress( + PERPS_PRIZE_POOL_MILESTONES, + parseFloat(totalNotionalVolume), + (m) => m.notionalVolume, + ); + }, [totalNotionalVolume]); + + const progressPercent: `${number}%` = `${Math.round(progress * 100)}%`; + const currentVolume = totalNotionalVolume + ? parseFloat(totalNotionalVolume) + : 0; + + if (showError) { + return ( + + + + ); + } + + if (showSkeleton) { + return ( + + + + + ); + } + + return ( + + + + + {strings('rewards.perps_trading_campaign.prize_pool_current_label')} + + + {formatUsd(currentPrize)} + + + {isMaxTier ? ( + + + + {strings('rewards.perps_trading_campaign.prize_pool_max_badge')} + + + + ) : nextPrize !== null ? ( + + + {strings('rewards.perps_trading_campaign.prize_pool_next_label')} + + + {formatUsd(nextPrize)} + + + ) : null} + + + + + + + + {isMaxTier + ? strings( + 'rewards.perps_trading_campaign.prize_pool_max_tier_subtext', + { + maxThreshold: formatCompactUsd(nextThreshold), + }, + ) + : strings( + 'rewards.perps_trading_campaign.prize_pool_volume_subtext', + { + current: formatCompactUsd(currentVolume), + target: formatCompactUsd(nextThreshold), + }, + )} + + + ); +}; + +export default PerpsTradingCampaignPrizePool; diff --git a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignStatsHeader.test.tsx b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignStatsHeader.test.tsx new file mode 100644 index 00000000000..d103b8e8c58 --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignStatsHeader.test.tsx @@ -0,0 +1,162 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import { TextColor } from '@metamask/design-system-react-native'; +import PerpsTradingCampaignStatsHeader, { + PERPS_STATS_HEADER_TEST_IDS, +} from './PerpsTradingCampaignStatsHeader'; +import type { PerpsTradingCampaignLeaderboardPositionDto } from '../../../../../core/Engine/controllers/rewards-controller/types'; + +jest.mock('@metamask/design-system-react-native', () => { + const actual = jest.requireActual('@metamask/design-system-react-native'); + const ReactActual = jest.requireActual('react'); + const RN = jest.requireActual('react-native'); + return { + ...actual, + Text: (props: Record) => + ReactActual.createElement(RN.Text, props, props.children), + }; +}); + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => ({ style: (...args: unknown[]) => args }), +})); + +jest.mock('../../../../../../locales/i18n', () => ({ + strings: (key: string) => key, +})); + +jest.mock('../../utils/formatUtils', () => { + const fmt = (n: number) => + n.toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); + return { + formatSignedUsd: (value: number) => { + if (value < 0) { + return `-$${fmt(Math.abs(value))}`; + } + if (value > 0) { + return `+$${fmt(value)}`; + } + return '$0.00'; + }, + formatRewardsTimeOnly: () => 'time-stub', + }; +}); + +const TEST_IDS = PERPS_STATS_HEADER_TEST_IDS; + +const basePosition: PerpsTradingCampaignLeaderboardPositionDto = { + rank: 7, + pnl: 1500.25, + notionalVolume: 30_000, + marginDeployed: 2000, + qualified: true, + neighbors: [], + computedAt: '2025-01-01T00:00:00.000Z', +}; + +describe('PerpsTradingCampaignStatsHeader', () => { + it('renders container and your-rank label', () => { + const { getByTestId, getByText } = render( + , + ); + expect(getByTestId(TEST_IDS.CONTAINER)).toBeDefined(); + expect( + getByText('rewards.perps_trading_campaign.label_your_rank'), + ).toBeDefined(); + }); + + it('shows padded rank, positive PnL with success color, and qualified icon when qualified', () => { + const { getByTestId, getByText, queryByTestId } = render( + , + ); + const rank = getByTestId(TEST_IDS.RANK_VALUE); + expect(rank.props.children).toBe('07'); + const pnl = getByTestId(TEST_IDS.PNL_VALUE); + expect(pnl.props.color).toBe(TextColor.SuccessDefault); + expect(getByText('+$1,500.25', { exact: true })).toBeDefined(); + expect(getByTestId(TEST_IDS.QUALIFIED_ICON)).toBeDefined(); + expect(queryByTestId(TEST_IDS.PENDING_TAG)).toBeNull(); + }); + + it('uses error color and minus sign in display for negative PnL', () => { + const { getByTestId } = render( + , + ); + const pnl = getByTestId(TEST_IDS.PNL_VALUE); + expect(pnl.props.color).toBe(TextColor.ErrorDefault); + }); + + it('shows pending tag and no qualified icon when not qualified', () => { + const { getByTestId, queryByTestId } = render( + , + ); + expect(getByTestId(TEST_IDS.PENDING_TAG)).toBeDefined(); + expect(queryByTestId(TEST_IDS.QUALIFIED_ICON)).toBeNull(); + }); + + it('shows em dashes for rank and PnL when position is null', () => { + const { getByTestId } = render( + , + ); + const rank = getByTestId(TEST_IDS.RANK_VALUE); + const pnl = getByTestId(TEST_IDS.PNL_VALUE); + expect(rank.props.children).toBe('—'); + expect(pnl.props.children).toBe('—'); + expect(pnl.props.color).toBe(TextColor.TextDefault); + }); + + it('hides PnL and computed-at subtext when showPnl and showComputedAt are false', () => { + const { queryByTestId } = render( + , + ); + expect(queryByTestId(TEST_IDS.PNL_VALUE)).toBeNull(); + expect(queryByTestId(TEST_IDS.COMPUTED_AT)).toBeNull(); + }); + + it('shows computed-at line when showComputedAt is true and position has a timestamp', () => { + const { getByTestId } = render( + , + ); + const computed = getByTestId(TEST_IDS.COMPUTED_AT); + expect(computed.props.children).toBe( + 'rewards.perps_trading_campaign.last_updated', + ); + }); + + it('omits computed-at when formatted label is empty', () => { + const { queryByTestId } = render( + , + ); + expect(queryByTestId(TEST_IDS.COMPUTED_AT)).toBeNull(); + }); + + it('skips rank and PnL testIDs and shows loading skeletons when isLoading is true', () => { + const { queryByTestId, getByTestId } = render( + , + ); + expect(getByTestId(TEST_IDS.CONTAINER)).toBeDefined(); + expect(queryByTestId(TEST_IDS.RANK_VALUE)).toBeNull(); + expect(queryByTestId(TEST_IDS.PNL_VALUE)).toBeNull(); + }); +}); diff --git a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignStatsHeader.tsx b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignStatsHeader.tsx new file mode 100644 index 00000000000..63c4bdee9a7 --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignStatsHeader.tsx @@ -0,0 +1,149 @@ +import React from 'react'; +import { + Box, + BoxAlignItems, + BoxFlexDirection, + FontWeight, + Icon, + IconColor, + IconName, + IconSize, + Skeleton, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { PendingTag } from './OndoCampaignStatsSummary'; +import type { PerpsTradingCampaignLeaderboardPositionDto } from '../../../../../core/Engine/controllers/rewards-controller/types'; +import { strings } from '../../../../../../locales/i18n'; +import { + formatRewardsTimeOnly, + formatSignedUsd, +} from '../../utils/formatUtils'; + +export const PERPS_STATS_HEADER_TEST_IDS = { + CONTAINER: 'perps-stats-header-container', + RANK_VALUE: 'perps-stats-header-rank', + PNL_VALUE: 'perps-stats-header-pnl', + COMPUTED_AT: 'perps-stats-header-computed-at', + PENDING_TAG: 'perps-stats-header-pending-tag', + QUALIFIED_ICON: 'perps-stats-header-qualified-icon', +} as const; + +interface PerpsTradingCampaignStatsHeaderProps { + position: PerpsTradingCampaignLeaderboardPositionDto | null; + isLoading?: boolean; + /** When true, shows PnL under the rank in BodySm (same pattern as return in LeaderboardPositionHeader). */ + showPnl?: boolean; + /** When true, shows formatted `computedAt` time on the same row as PnL, right-aligned in alternative text color. */ + showComputedAt?: boolean; +} + +const PerpsTradingCampaignStatsHeader: React.FC< + PerpsTradingCampaignStatsHeaderProps +> = ({ + position, + isLoading = false, + showPnl = true, + showComputedAt = true, +}) => { + const tw = useTailwind(); + + const isPending = position != null && !position.qualified; + const isQualified = position != null && position.qualified; + + const rankValue = position ? String(position.rank).padStart(2, '0') : '—'; + const pnlValue = position ? formatSignedUsd(position.pnl) : '—'; + const pnlColor = position + ? position.pnl >= 0 + ? TextColor.SuccessDefault + : TextColor.ErrorDefault + : TextColor.TextDefault; + + const computedAtLabel = position?.computedAt + ? strings('rewards.perps_trading_campaign.last_updated', { + time: formatRewardsTimeOnly(new Date(position.computedAt)), + }) + : ''; + + const showSubtextRow = showPnl || showComputedAt; + + return ( + + + + + {strings('rewards.perps_trading_campaign.label_your_rank')} + + {isPending && ( + + )} + {isQualified && ( + + )} + + + {isLoading ? ( + <> + + {showSubtextRow && ( + + )} + + ) : ( + <> + + {rankValue} + + {showSubtextRow && ( + + + {showPnl && ( + + {pnlValue} + + )} + + {showComputedAt && computedAtLabel.length > 0 && ( + + {computedAtLabel} + + )} + + )} + + )} + + + ); +}; + +export default PerpsTradingCampaignStatsHeader; diff --git a/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboard.test.ts b/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboard.test.ts new file mode 100644 index 00000000000..007822dca57 --- /dev/null +++ b/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboard.test.ts @@ -0,0 +1,244 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import { useSelector, useDispatch } from 'react-redux'; +import { useGetPerpsTradingCampaignLeaderboard } from './useGetPerpsTradingCampaignLeaderboard'; +import Engine from '../../../../core/Engine'; +import { + selectPerpsTradingCampaignLeaderboard, + selectPerpsTradingCampaignLeaderboardLoading, + selectPerpsTradingCampaignLeaderboardError, +} from '../../../../reducers/rewards/selectors'; +import { + setPerpsTradingCampaignLeaderboard, + setPerpsTradingCampaignLeaderboardLoading, + setPerpsTradingCampaignLeaderboardError, +} from '../../../../reducers/rewards'; +import type { PerpsTradingCampaignLeaderboardDto } from '../../../../core/Engine/controllers/rewards-controller/types'; + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), + useDispatch: jest.fn(), +})); + +jest.mock('../../../../core/Engine', () => ({ + controllerMessenger: { call: jest.fn() }, +})); + +jest.mock('../../../../reducers/rewards/selectors', () => ({ + selectPerpsTradingCampaignLeaderboard: jest.fn(), + selectPerpsTradingCampaignLeaderboardLoading: jest.fn(), + selectPerpsTradingCampaignLeaderboardError: jest.fn(), +})); + +jest.mock('../../../../reducers/rewards', () => ({ + setPerpsTradingCampaignLeaderboard: jest.fn((payload) => ({ + type: 'rewards/setPerpsTradingCampaignLeaderboard', + payload, + })), + setPerpsTradingCampaignLeaderboardLoading: jest.fn((payload) => ({ + type: 'rewards/setPerpsTradingCampaignLeaderboardLoading', + payload, + })), + setPerpsTradingCampaignLeaderboardError: jest.fn((payload) => ({ + type: 'rewards/setPerpsTradingCampaignLeaderboardError', + payload, + })), +})); + +const mockCall = Engine.controllerMessenger.call as jest.MockedFunction< + typeof Engine.controllerMessenger.call +>; +const mockUseSelector = useSelector as jest.MockedFunction; +const mockUseDispatch = useDispatch as jest.MockedFunction; + +const CAMPAIGN_ID = 'campaign-123'; +const MOCK_LEADERBOARD: PerpsTradingCampaignLeaderboardDto = { + campaignId: CAMPAIGN_ID, + computedAt: '2024-03-20T12:00:00.000Z', + entries: [ + { rank: 1, referralCode: 'ABC123', pnl: 1500, qualified: true }, + { rank: 2, referralCode: 'DEF456', pnl: 800, qualified: true }, + ], + totalParticipants: 50, +}; + +interface SelectorState { + leaderboard: PerpsTradingCampaignLeaderboardDto | null; + isLoading: boolean; + hasError: boolean; +} + +function setupSelectors(state: SelectorState) { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectPerpsTradingCampaignLeaderboard) + return state.leaderboard; + if (selector === selectPerpsTradingCampaignLeaderboardLoading) + return state.isLoading; + if (selector === selectPerpsTradingCampaignLeaderboardError) + return state.hasError; + return undefined; + }); +} + +describe('useGetPerpsTradingCampaignLeaderboard', () => { + const mockDispatch = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + mockUseDispatch.mockReturnValue(mockDispatch); + setupSelectors({ leaderboard: null, isLoading: false, hasError: false }); + }); + + it('does not fetch when campaignId is undefined but resets loading and error', async () => { + renderHook(() => useGetPerpsTradingCampaignLeaderboard(undefined)); + + expect(mockCall).not.toHaveBeenCalled(); + expect(mockDispatch).toHaveBeenCalledWith( + setPerpsTradingCampaignLeaderboardLoading(false), + ); + expect(mockDispatch).toHaveBeenCalledWith( + setPerpsTradingCampaignLeaderboardError(false), + ); + }); + + it('fetches leaderboard and dispatches actions on success', async () => { + mockCall.mockResolvedValueOnce(MOCK_LEADERBOARD as never); + + renderHook(() => useGetPerpsTradingCampaignLeaderboard(CAMPAIGN_ID)); + + await act(async () => { + await Promise.resolve(); + }); + + expect(mockDispatch).toHaveBeenCalledWith( + setPerpsTradingCampaignLeaderboardLoading(true), + ); + expect(mockDispatch).toHaveBeenCalledWith( + setPerpsTradingCampaignLeaderboardError(false), + ); + expect(mockCall).toHaveBeenCalledWith( + 'RewardsController:getPerpsTradingCampaignLeaderboard', + CAMPAIGN_ID, + ); + expect(mockDispatch).toHaveBeenCalledWith( + setPerpsTradingCampaignLeaderboard(MOCK_LEADERBOARD), + ); + expect(mockDispatch).toHaveBeenCalledWith( + setPerpsTradingCampaignLeaderboardLoading(false), + ); + }); + + it('dispatches error action on non-404 fetch failure', async () => { + mockCall.mockRejectedValueOnce(new Error('Network error') as never); + + renderHook(() => useGetPerpsTradingCampaignLeaderboard(CAMPAIGN_ID)); + + await act(async () => { + await Promise.resolve(); + }); + + expect(mockDispatch).toHaveBeenCalledWith( + setPerpsTradingCampaignLeaderboardError(true), + ); + expect(mockDispatch).toHaveBeenCalledWith( + setPerpsTradingCampaignLeaderboardLoading(false), + ); + }); + + it('returns isLeaderboardNotYetComputed true on 404 error', async () => { + mockCall.mockRejectedValueOnce( + new Error('leaderboard failed: 404') as never, + ); + + const { result } = renderHook(() => + useGetPerpsTradingCampaignLeaderboard(CAMPAIGN_ID), + ); + + await act(async () => { + await Promise.resolve(); + }); + + expect(result.current.isLeaderboardNotYetComputed).toBe(true); + expect(mockDispatch).not.toHaveBeenCalledWith( + setPerpsTradingCampaignLeaderboardError(true), + ); + }); + + it('returns leaderboard data from selector', () => { + setupSelectors({ + leaderboard: MOCK_LEADERBOARD, + isLoading: false, + hasError: false, + }); + + const { result } = renderHook(() => + useGetPerpsTradingCampaignLeaderboard(CAMPAIGN_ID), + ); + + expect(result.current.leaderboard).toEqual(MOCK_LEADERBOARD); + }); + + it('returns loading state from selector', () => { + setupSelectors({ leaderboard: null, isLoading: true, hasError: false }); + + const { result } = renderHook(() => + useGetPerpsTradingCampaignLeaderboard(CAMPAIGN_ID), + ); + + expect(result.current.isLoading).toBe(true); + }); + + it('returns error state from selector', () => { + setupSelectors({ leaderboard: null, isLoading: false, hasError: true }); + + const { result } = renderHook(() => + useGetPerpsTradingCampaignLeaderboard(CAMPAIGN_ID), + ); + + expect(result.current.hasError).toBe(true); + }); + + it('refetch function re-fetches the leaderboard', async () => { + mockCall.mockResolvedValue(MOCK_LEADERBOARD as never); + + const { result } = renderHook(() => + useGetPerpsTradingCampaignLeaderboard(CAMPAIGN_ID), + ); + + await act(async () => { + await Promise.resolve(); + }); + + mockDispatch.mockClear(); + + await act(async () => { + await result.current.refetch(); + }); + + expect(mockCall).toHaveBeenCalledTimes(2); + expect(mockDispatch).toHaveBeenCalledWith( + setPerpsTradingCampaignLeaderboardLoading(true), + ); + }); + + it('returns isLeaderboardNotYetComputed false initially', () => { + const { result } = renderHook(() => + useGetPerpsTradingCampaignLeaderboard(undefined), + ); + + expect(result.current.isLeaderboardNotYetComputed).toBe(false); + }); + + it('returns isLeaderboardNotYetComputed false on non-404 error', async () => { + mockCall.mockRejectedValueOnce(new Error('Server error') as never); + + const { result } = renderHook(() => + useGetPerpsTradingCampaignLeaderboard(CAMPAIGN_ID), + ); + + await act(async () => { + await Promise.resolve(); + }); + + expect(result.current.isLeaderboardNotYetComputed).toBe(false); + }); +}); diff --git a/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboard.ts b/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboard.ts new file mode 100644 index 00000000000..6e7e7e8b953 --- /dev/null +++ b/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboard.ts @@ -0,0 +1,76 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import Engine from '../../../../core/Engine'; +import { + selectPerpsTradingCampaignLeaderboard, + selectPerpsTradingCampaignLeaderboardLoading, + selectPerpsTradingCampaignLeaderboardError, +} from '../../../../reducers/rewards/selectors'; +import { + setPerpsTradingCampaignLeaderboard, + setPerpsTradingCampaignLeaderboardLoading, + setPerpsTradingCampaignLeaderboardError, +} from '../../../../reducers/rewards'; +import type { PerpsTradingCampaignLeaderboardDto } from '../../../../core/Engine/controllers/rewards-controller/types'; + +export interface UseGetPerpsTradingCampaignLeaderboardResult { + leaderboard: PerpsTradingCampaignLeaderboardDto | null; + isLoading: boolean; + hasError: boolean; + isLeaderboardNotYetComputed: boolean; + refetch: () => Promise; +} + +export const useGetPerpsTradingCampaignLeaderboard = ( + campaignId: string | undefined, +): UseGetPerpsTradingCampaignLeaderboardResult => { + const dispatch = useDispatch(); + const leaderboard = useSelector(selectPerpsTradingCampaignLeaderboard); + const isLoading = useSelector(selectPerpsTradingCampaignLeaderboardLoading); + const hasError = useSelector(selectPerpsTradingCampaignLeaderboardError); + const [isLeaderboardNotYetComputed, setIsLeaderboardNotYetComputed] = + useState(false); + + const fetchLeaderboard = useCallback(async (): Promise => { + if (!campaignId) { + dispatch(setPerpsTradingCampaignLeaderboardLoading(false)); + dispatch(setPerpsTradingCampaignLeaderboardError(false)); + setIsLeaderboardNotYetComputed(false); + return; + } + + try { + dispatch(setPerpsTradingCampaignLeaderboardLoading(true)); + dispatch(setPerpsTradingCampaignLeaderboardError(false)); + setIsLeaderboardNotYetComputed(false); + const result = await Engine.controllerMessenger.call( + 'RewardsController:getPerpsTradingCampaignLeaderboard', + campaignId, + ); + dispatch(setPerpsTradingCampaignLeaderboard(result)); + } catch (error) { + const is404 = error instanceof Error && error.message.includes('404'); + if (is404) { + setIsLeaderboardNotYetComputed(true); + } else { + dispatch(setPerpsTradingCampaignLeaderboardError(true)); + } + } finally { + dispatch(setPerpsTradingCampaignLeaderboardLoading(false)); + } + }, [dispatch, campaignId]); + + useEffect(() => { + fetchLeaderboard(); + }, [fetchLeaderboard]); + + return { + leaderboard, + isLoading, + hasError, + isLeaderboardNotYetComputed, + refetch: fetchLeaderboard, + }; +}; + +export default useGetPerpsTradingCampaignLeaderboard; diff --git a/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboardPosition.test.ts b/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboardPosition.test.ts new file mode 100644 index 00000000000..320a3921c75 --- /dev/null +++ b/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboardPosition.test.ts @@ -0,0 +1,260 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import { useSelector, useDispatch } from 'react-redux'; +import { useGetPerpsTradingCampaignLeaderboardPosition } from './useGetPerpsTradingCampaignLeaderboardPosition'; +import Engine from '../../../../core/Engine'; +import { + selectRewardsSubscriptionId, + selectCampaignParticipantOptedIn, +} from '../../../../selectors/rewards'; +import { selectPerpsTradingCampaignLeaderboardPositionById } from '../../../../reducers/rewards/selectors'; +import { setPerpsTradingCampaignLeaderboardPosition } from '../../../../reducers/rewards'; +import { useInvalidateByRewardEvents } from './useInvalidateByRewardEvents'; +import type { PerpsTradingCampaignLeaderboardPositionDto } from '../../../../core/Engine/controllers/rewards-controller/types'; + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), + useDispatch: jest.fn(), +})); + +jest.mock('../../../../core/Engine', () => ({ + controllerMessenger: { call: jest.fn() }, +})); + +jest.mock('./useInvalidateByRewardEvents', () => ({ + useInvalidateByRewardEvents: jest.fn(), +})); + +jest.mock('../../../../selectors/rewards', () => ({ + selectRewardsSubscriptionId: jest.fn(), + selectCampaignParticipantOptedIn: jest.fn(), +})); + +jest.mock('../../../../reducers/rewards/selectors', () => ({ + selectPerpsTradingCampaignLeaderboardPositionById: jest.fn(), +})); + +jest.mock('../../../../reducers/rewards', () => ({ + setPerpsTradingCampaignLeaderboardPosition: jest.fn((payload) => ({ + type: 'rewards/setPerpsTradingCampaignLeaderboardPosition', + payload, + })), +})); + +const mockCall = Engine.controllerMessenger.call as jest.MockedFunction< + typeof Engine.controllerMessenger.call +>; +const mockUseInvalidateByRewardEvents = + useInvalidateByRewardEvents as jest.MockedFunction< + typeof useInvalidateByRewardEvents + >; +const mockUseSelector = useSelector as jest.MockedFunction; +const mockUseDispatch = useDispatch as jest.MockedFunction; +const mockSelectPositionById = + selectPerpsTradingCampaignLeaderboardPositionById as jest.MockedFunction< + typeof selectPerpsTradingCampaignLeaderboardPositionById + >; +const mockSelectCampaignParticipantOptedIn = + selectCampaignParticipantOptedIn as jest.MockedFunction< + typeof selectCampaignParticipantOptedIn + >; + +const CAMPAIGN_ID = 'campaign-123'; +const SUBSCRIPTION_ID = 'sub-456'; +const MOCK_POSITION: PerpsTradingCampaignLeaderboardPositionDto = { + rank: 5, + pnl: 1500, + notionalVolume: 30000, + marginDeployed: 2000, + qualified: true, + neighbors: [], + computedAt: '2024-03-20T12:00:00.000Z', +}; + +interface SelectorState { + subscriptionId: string | null; + position: PerpsTradingCampaignLeaderboardPositionDto | null; + isOptedIn?: boolean; +} + +function setupSelectors(state: SelectorState) { + const isOptedIn = state.isOptedIn ?? true; + const mockPositionSelector = jest.fn().mockReturnValue(state.position); + const mockOptedInSelector = jest.fn().mockReturnValue(isOptedIn); + mockSelectPositionById.mockReturnValue(mockPositionSelector); + mockSelectCampaignParticipantOptedIn.mockReturnValue(mockOptedInSelector); + + mockUseSelector.mockImplementation((selector) => { + if (selector === selectRewardsSubscriptionId) return state.subscriptionId; + if (selector === mockPositionSelector) return state.position; + if (selector === mockOptedInSelector) return isOptedIn; + return undefined; + }); +} + +describe('useGetPerpsTradingCampaignLeaderboardPosition', () => { + const mockDispatch = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + mockUseDispatch.mockReturnValue(mockDispatch); + setupSelectors({ subscriptionId: SUBSCRIPTION_ID, position: null }); + }); + + it('does not fetch when subscriptionId is missing', async () => { + setupSelectors({ subscriptionId: null, position: null }); + + const { result } = renderHook(() => + useGetPerpsTradingCampaignLeaderboardPosition(CAMPAIGN_ID), + ); + + expect(mockCall).not.toHaveBeenCalled(); + expect(result.current.isLoading).toBe(false); + expect(result.current.hasError).toBe(false); + }); + + it('does not fetch when not opted in', async () => { + setupSelectors({ + subscriptionId: SUBSCRIPTION_ID, + position: null, + isOptedIn: false, + }); + + const { result } = renderHook(() => + useGetPerpsTradingCampaignLeaderboardPosition(CAMPAIGN_ID), + ); + + expect(mockCall).not.toHaveBeenCalled(); + expect(result.current.isLoading).toBe(false); + expect(result.current.hasError).toBe(false); + }); + + it('does not fetch when campaignId is undefined', async () => { + const { result } = renderHook(() => + useGetPerpsTradingCampaignLeaderboardPosition(undefined), + ); + + expect(mockCall).not.toHaveBeenCalled(); + expect(result.current.isLoading).toBe(false); + expect(result.current.hasError).toBe(false); + }); + + it('fetches position and dispatches actions on success', async () => { + mockCall.mockResolvedValueOnce(MOCK_POSITION as never); + + renderHook(() => + useGetPerpsTradingCampaignLeaderboardPosition(CAMPAIGN_ID), + ); + + await act(async () => { + await Promise.resolve(); + }); + + expect(mockCall).toHaveBeenCalledWith( + 'RewardsController:getPerpsTradingCampaignLeaderboardPosition', + CAMPAIGN_ID, + SUBSCRIPTION_ID, + ); + expect(mockDispatch).toHaveBeenCalledWith( + setPerpsTradingCampaignLeaderboardPosition({ + subscriptionId: SUBSCRIPTION_ID, + campaignId: CAMPAIGN_ID, + position: MOCK_POSITION, + }), + ); + }); + + it('sets hasError on fetch failure', async () => { + mockCall.mockRejectedValueOnce(new Error('Network error') as never); + + const { result } = renderHook(() => + useGetPerpsTradingCampaignLeaderboardPosition(CAMPAIGN_ID), + ); + + await act(async () => { + await Promise.resolve(); + }); + + expect(result.current.hasError).toBe(true); + }); + + it('returns position data from selector', () => { + setupSelectors({ + subscriptionId: SUBSCRIPTION_ID, + position: MOCK_POSITION, + }); + + const { result } = renderHook(() => + useGetPerpsTradingCampaignLeaderboardPosition(CAMPAIGN_ID), + ); + + expect(result.current.position).toEqual(MOCK_POSITION); + }); + + it('returns null position when not loaded', () => { + setupSelectors({ subscriptionId: SUBSCRIPTION_ID, position: null }); + + const { result } = renderHook(() => + useGetPerpsTradingCampaignLeaderboardPosition(CAMPAIGN_ID), + ); + + expect(result.current.position).toBeNull(); + }); + + it('refetch function re-fetches the position', async () => { + mockCall.mockResolvedValue(MOCK_POSITION as never); + + const { result } = renderHook(() => + useGetPerpsTradingCampaignLeaderboardPosition(CAMPAIGN_ID), + ); + + await act(async () => { + await Promise.resolve(); + }); + + mockDispatch.mockClear(); + + await act(async () => { + await result.current.refetch(); + }); + + expect(mockCall).toHaveBeenCalledTimes(2); + }); + + it('calls selectPerpsTradingCampaignLeaderboardPositionById with subscriptionId and campaignId', () => { + renderHook(() => + useGetPerpsTradingCampaignLeaderboardPosition(CAMPAIGN_ID), + ); + + expect(mockSelectPositionById).toHaveBeenCalledWith( + SUBSCRIPTION_ID, + CAMPAIGN_ID, + ); + }); + + it('subscribes to leaderboardPositionInvalidated to auto-refetch', () => { + renderHook(() => + useGetPerpsTradingCampaignLeaderboardPosition(CAMPAIGN_ID), + ); + + expect(mockUseInvalidateByRewardEvents).toHaveBeenCalledWith( + expect.arrayContaining([ + 'RewardsController:leaderboardPositionInvalidated', + ]), + expect.any(Function), + ); + }); + + it('sets hasFetched after successful fetch', async () => { + mockCall.mockResolvedValueOnce(MOCK_POSITION as never); + + const { result } = renderHook(() => + useGetPerpsTradingCampaignLeaderboardPosition(CAMPAIGN_ID), + ); + + await act(async () => { + await Promise.resolve(); + }); + + expect(result.current.hasFetched).toBe(true); + }); +}); diff --git a/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboardPosition.ts b/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboardPosition.ts new file mode 100644 index 00000000000..aaa206cc69f --- /dev/null +++ b/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboardPosition.ts @@ -0,0 +1,83 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import Engine from '../../../../core/Engine'; +import { + selectRewardsSubscriptionId, + selectCampaignParticipantOptedIn, +} from '../../../../selectors/rewards'; +import { selectPerpsTradingCampaignLeaderboardPositionById } from '../../../../reducers/rewards/selectors'; +import { setPerpsTradingCampaignLeaderboardPosition } from '../../../../reducers/rewards'; +import type { PerpsTradingCampaignLeaderboardPositionDto } from '../../../../core/Engine/controllers/rewards-controller/types'; +import { useInvalidateByRewardEvents } from './useInvalidateByRewardEvents'; + +export interface UseGetPerpsTradingCampaignLeaderboardPositionResult { + position: PerpsTradingCampaignLeaderboardPositionDto | null; + isLoading: boolean; + hasError: boolean; + hasFetched: boolean; + refetch: () => Promise; +} + +export const useGetPerpsTradingCampaignLeaderboardPosition = ( + campaignId: string | undefined, +): UseGetPerpsTradingCampaignLeaderboardPositionResult => { + const dispatch = useDispatch(); + const subscriptionId = useSelector(selectRewardsSubscriptionId); + const isOptedIn = useSelector( + selectCampaignParticipantOptedIn(subscriptionId, campaignId), + ); + const position = useSelector( + selectPerpsTradingCampaignLeaderboardPositionById( + subscriptionId ?? undefined, + campaignId, + ), + ); + const [isLoading, setIsLoading] = useState(false); + const [hasError, setHasError] = useState(false); + const [hasFetched, setHasFetched] = useState(false); + + const fetchPosition = useCallback(async (): Promise => { + if (!subscriptionId || !campaignId || !isOptedIn) { + setIsLoading(false); + setHasError(false); + setHasFetched(false); + return; + } + + try { + setIsLoading(true); + setHasError(false); + const result = await Engine.controllerMessenger.call( + 'RewardsController:getPerpsTradingCampaignLeaderboardPosition', + campaignId, + subscriptionId, + ); + dispatch( + setPerpsTradingCampaignLeaderboardPosition({ + subscriptionId, + campaignId, + position: result, + }), + ); + } catch { + setHasError(true); + } finally { + setIsLoading(false); + setHasFetched(true); + } + }, [dispatch, subscriptionId, campaignId, isOptedIn]); + + useEffect(() => { + fetchPosition(); + }, [fetchPosition]); + + const invalidationEvents = useMemo( + () => ['RewardsController:leaderboardPositionInvalidated'] as const, + [], + ); + useInvalidateByRewardEvents(invalidationEvents, fetchPosition); + + return { position, isLoading, hasError, hasFetched, refetch: fetchPosition }; +}; + +export default useGetPerpsTradingCampaignLeaderboardPosition; diff --git a/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignVolume.test.ts b/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignVolume.test.ts new file mode 100644 index 00000000000..dacbffded39 --- /dev/null +++ b/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignVolume.test.ts @@ -0,0 +1,196 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import { useSelector, useDispatch } from 'react-redux'; +import { useGetPerpsTradingCampaignVolume } from './useGetPerpsTradingCampaignVolume'; +import Engine from '../../../../core/Engine'; +import { + selectPerpsTradingCampaignVolume, + selectPerpsTradingCampaignVolumeLoading, + selectPerpsTradingCampaignVolumeError, +} from '../../../../reducers/rewards/selectors'; +import { + setPerpsTradingCampaignVolume, + setPerpsTradingCampaignVolumeLoading, + setPerpsTradingCampaignVolumeError, +} from '../../../../reducers/rewards'; +import type { PerpsTradingCampaignVolumeDto } from '../../../../core/Engine/controllers/rewards-controller/types'; + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), + useDispatch: jest.fn(), +})); + +jest.mock('../../../../core/Engine', () => ({ + controllerMessenger: { call: jest.fn() }, +})); + +jest.mock('../../../../reducers/rewards/selectors', () => ({ + selectPerpsTradingCampaignVolume: jest.fn(), + selectPerpsTradingCampaignVolumeLoading: jest.fn(), + selectPerpsTradingCampaignVolumeError: jest.fn(), +})); + +jest.mock('../../../../reducers/rewards', () => ({ + setPerpsTradingCampaignVolume: jest.fn((payload) => ({ + type: 'rewards/setPerpsTradingCampaignVolume', + payload, + })), + setPerpsTradingCampaignVolumeLoading: jest.fn((payload) => ({ + type: 'rewards/setPerpsTradingCampaignVolumeLoading', + payload, + })), + setPerpsTradingCampaignVolumeError: jest.fn((payload) => ({ + type: 'rewards/setPerpsTradingCampaignVolumeError', + payload, + })), +})); + +const mockCall = Engine.controllerMessenger.call as jest.MockedFunction< + typeof Engine.controllerMessenger.call +>; +const mockUseSelector = useSelector as jest.MockedFunction; +const mockUseDispatch = useDispatch as jest.MockedFunction; + +const CAMPAIGN_ID = 'campaign-123'; +const MOCK_VOLUME: PerpsTradingCampaignVolumeDto = { + totalUsdVolume: '5000000', +}; + +interface SelectorState { + volume: PerpsTradingCampaignVolumeDto | null; + isLoading: boolean; + hasError: boolean; +} + +function setupSelectors(state: SelectorState) { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectPerpsTradingCampaignVolume) return state.volume; + if (selector === selectPerpsTradingCampaignVolumeLoading) + return state.isLoading; + if (selector === selectPerpsTradingCampaignVolumeError) + return state.hasError; + return undefined; + }); +} + +describe('useGetPerpsTradingCampaignVolume', () => { + const mockDispatch = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + mockUseDispatch.mockReturnValue(mockDispatch); + setupSelectors({ volume: null, isLoading: false, hasError: false }); + }); + + it('does not fetch when campaignId is undefined but resets loading and error', async () => { + renderHook(() => useGetPerpsTradingCampaignVolume(undefined)); + + expect(mockCall).not.toHaveBeenCalled(); + expect(mockDispatch).toHaveBeenCalledWith( + setPerpsTradingCampaignVolumeLoading(false), + ); + expect(mockDispatch).toHaveBeenCalledWith( + setPerpsTradingCampaignVolumeError(false), + ); + }); + + it('fetches volume and dispatches actions on success', async () => { + mockCall.mockResolvedValueOnce(MOCK_VOLUME as never); + + renderHook(() => useGetPerpsTradingCampaignVolume(CAMPAIGN_ID)); + + await act(async () => { + await Promise.resolve(); + }); + + expect(mockDispatch).toHaveBeenCalledWith( + setPerpsTradingCampaignVolumeLoading(true), + ); + expect(mockDispatch).toHaveBeenCalledWith( + setPerpsTradingCampaignVolumeError(false), + ); + expect(mockCall).toHaveBeenCalledWith( + 'RewardsController:getPerpsTradingCampaignVolume', + CAMPAIGN_ID, + ); + expect(mockDispatch).toHaveBeenCalledWith( + setPerpsTradingCampaignVolume(MOCK_VOLUME), + ); + expect(mockDispatch).toHaveBeenCalledWith( + setPerpsTradingCampaignVolumeLoading(false), + ); + }); + + it('dispatches error action on fetch failure', async () => { + mockCall.mockRejectedValueOnce(new Error('Network error') as never); + + renderHook(() => useGetPerpsTradingCampaignVolume(CAMPAIGN_ID)); + + await act(async () => { + await Promise.resolve(); + }); + + expect(mockDispatch).toHaveBeenCalledWith( + setPerpsTradingCampaignVolumeError(true), + ); + expect(mockDispatch).toHaveBeenCalledWith( + setPerpsTradingCampaignVolumeLoading(false), + ); + }); + + it('returns volume data from selector', () => { + setupSelectors({ + volume: MOCK_VOLUME, + isLoading: false, + hasError: false, + }); + + const { result } = renderHook(() => + useGetPerpsTradingCampaignVolume(CAMPAIGN_ID), + ); + + expect(result.current.volume).toEqual(MOCK_VOLUME); + }); + + it('returns loading state from selector', () => { + setupSelectors({ volume: null, isLoading: true, hasError: false }); + + const { result } = renderHook(() => + useGetPerpsTradingCampaignVolume(CAMPAIGN_ID), + ); + + expect(result.current.isLoading).toBe(true); + }); + + it('returns error state from selector', () => { + setupSelectors({ volume: null, isLoading: false, hasError: true }); + + const { result } = renderHook(() => + useGetPerpsTradingCampaignVolume(CAMPAIGN_ID), + ); + + expect(result.current.hasError).toBe(true); + }); + + it('refetch function re-fetches the volume', async () => { + mockCall.mockResolvedValue(MOCK_VOLUME as never); + + const { result } = renderHook(() => + useGetPerpsTradingCampaignVolume(CAMPAIGN_ID), + ); + + await act(async () => { + await Promise.resolve(); + }); + + mockDispatch.mockClear(); + + await act(async () => { + await result.current.refetch(); + }); + + expect(mockCall).toHaveBeenCalledTimes(2); + expect(mockDispatch).toHaveBeenCalledWith( + setPerpsTradingCampaignVolumeLoading(true), + ); + }); +}); diff --git a/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignVolume.ts b/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignVolume.ts new file mode 100644 index 00000000000..b5a2974dbd0 --- /dev/null +++ b/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignVolume.ts @@ -0,0 +1,59 @@ +import { useCallback, useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import Engine from '../../../../core/Engine'; +import { + selectPerpsTradingCampaignVolume, + selectPerpsTradingCampaignVolumeLoading, + selectPerpsTradingCampaignVolumeError, +} from '../../../../reducers/rewards/selectors'; +import { + setPerpsTradingCampaignVolume, + setPerpsTradingCampaignVolumeLoading, + setPerpsTradingCampaignVolumeError, +} from '../../../../reducers/rewards'; + +export interface UseGetPerpsTradingCampaignVolumeResult { + volume: ReturnType; + isLoading: boolean; + hasError: boolean; + refetch: () => Promise; +} + +export const useGetPerpsTradingCampaignVolume = ( + campaignId: string | undefined, +): UseGetPerpsTradingCampaignVolumeResult => { + const dispatch = useDispatch(); + const volume = useSelector(selectPerpsTradingCampaignVolume); + const isLoading = useSelector(selectPerpsTradingCampaignVolumeLoading); + const hasError = useSelector(selectPerpsTradingCampaignVolumeError); + + const fetchVolume = useCallback(async (): Promise => { + if (!campaignId) { + dispatch(setPerpsTradingCampaignVolumeLoading(false)); + dispatch(setPerpsTradingCampaignVolumeError(false)); + return; + } + + try { + dispatch(setPerpsTradingCampaignVolumeLoading(true)); + dispatch(setPerpsTradingCampaignVolumeError(false)); + const result = await Engine.controllerMessenger.call( + 'RewardsController:getPerpsTradingCampaignVolume', + campaignId, + ); + dispatch(setPerpsTradingCampaignVolume(result)); + } catch { + dispatch(setPerpsTradingCampaignVolumeError(true)); + } finally { + dispatch(setPerpsTradingCampaignVolumeLoading(false)); + } + }, [dispatch, campaignId]); + + useEffect(() => { + fetchVolume(); + }, [fetchVolume]); + + return { volume, isLoading, hasError, refetch: fetchVolume }; +}; + +export default useGetPerpsTradingCampaignVolume; diff --git a/app/components/UI/Rewards/utils/formatUtils.test.ts b/app/components/UI/Rewards/utils/formatUtils.test.ts index afeadfd37d1..8424cb81242 100644 --- a/app/components/UI/Rewards/utils/formatUtils.test.ts +++ b/app/components/UI/Rewards/utils/formatUtils.test.ts @@ -17,7 +17,6 @@ import { validateEmail, formatPercentChange, isPercentChangeNonNegative, - formatComputedAt, getChainHex, getAssetReference, parseCaip19, @@ -44,24 +43,33 @@ jest.mock('../../../../util/date', () => ({ // Mock i18n strings jest.mock('../../../../../locales/i18n', () => ({ - strings: jest.fn((key: string) => { - const t: Record = { - 'rewards.events.to': 'to', - 'rewards.events.type.swap': 'Swap', - 'rewards.events.type.referral_action': 'Referral action', - 'rewards.events.type.sign_up_bonus': 'Sign up bonus', - 'rewards.events.type.loyalty_bonus': 'Loyalty bonus', - 'rewards.events.type.one_time_bonus': 'One-time bonus', - 'rewards.events.type.open_position': 'Opened position', - 'rewards.events.type.close_position': 'Closed position', - 'rewards.events.type.take_profit': 'Take profit', - 'rewards.events.type.stop_loss': 'Stop loss', - 'rewards.events.type.uncategorized_event': 'Uncategorized event', - 'perps.market.long': 'Long', - 'perps.market.short': 'Short', - }; - return t[key] || key; - }), + strings: jest.fn( + (key: string, params: Record | undefined) => { + const t: Record = { + 'rewards.events.to': 'to', + 'rewards.events.type.swap': 'Swap', + 'rewards.events.type.referral_action': 'Referral action', + 'rewards.events.type.sign_up_bonus': 'Sign up bonus', + 'rewards.events.type.loyalty_bonus': 'Loyalty bonus', + 'rewards.events.type.one_time_bonus': 'One-time bonus', + 'rewards.events.type.open_position': 'Opened position', + 'rewards.events.type.close_position': 'Closed position', + 'rewards.events.type.take_profit': 'Take profit', + 'rewards.events.type.stop_loss': 'Stop loss', + 'rewards.events.type.uncategorized_event': 'Uncategorized event', + 'perps.market.long': 'Long', + 'perps.market.short': 'Short', + 'rewards.perps_trading_campaign.last_updated': 'Last updated: {{time}}', + }; + let template = t[key] ?? key; + if (params) { + for (const [paramKey, value] of Object.entries(params)) { + template = template.split(`{{${paramKey}}}`).join(value); + } + } + return template; + }, + ), default: { locale: 'en-US', }, @@ -1445,25 +1453,6 @@ describe('formatUtils', () => { }); }); - describe('formatComputedAt', () => { - it('returns empty string for null', () => { - expect(formatComputedAt(null)).toBe(''); - }); - - it('returns empty string for empty string', () => { - expect(formatComputedAt('')).toBe(''); - }); - - it('returns HH:MM:SS for a valid ISO timestamp', () => { - const result = formatComputedAt('2026-03-28T14:30:45.000Z'); - expect(result).toMatch(/^\d{2}:\d{2}:\d{2}$/); - }); - - it('returns empty string for unparseable value', () => { - expect(formatComputedAt('not-a-date')).toBe(''); - }); - }); - describe('getChainHex', () => { it('extracts hex chain ID from EIP-155 CAIP-19', () => { expect(getChainHex('eip155:1/erc20:0xabc')).toBe('0x1'); @@ -1611,8 +1600,20 @@ describe('formatUtils', () => { expect(formatSignedUsd('0')).toBe('$0.00'); }); - it('returns raw string for non-numeric input', () => { - expect(formatSignedUsd('abc')).toBe('abc'); + it('prepends + for positive number input', () => { + expect(formatSignedUsd(5000)).toBe('+$5,000.00'); + }); + + it('formats negative number input', () => { + expect(formatSignedUsd(-1250.5)).toBe('$-1,250.50'); + }); + + it('returns em dash for non-numeric string', () => { + expect(formatSignedUsd('abc')).toBe('—'); + }); + + it('returns em dash for empty string', () => { + expect(formatSignedUsd('')).toBe('—'); }); }); diff --git a/app/components/UI/Rewards/utils/formatUtils.ts b/app/components/UI/Rewards/utils/formatUtils.ts index ff2390e95c1..19eaf683800 100644 --- a/app/components/UI/Rewards/utils/formatUtils.ts +++ b/app/components/UI/Rewards/utils/formatUtils.ts @@ -8,7 +8,7 @@ import { parseCaipChainId, } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; -import I18n from '../../../../../locales/i18n'; +import I18n, { strings } from '../../../../../locales/i18n'; import { getTimeDifferenceFromNow } from '../../../../util/date'; import formatFiat from '../../../../util/formatFiat'; import { getIntlNumberFormatter } from '../../../../util/intl'; @@ -367,10 +367,10 @@ export const formatCompactUsd = (value: number): string => { * @example formatSignedUsd('-1250.50') // '-$1,250.50' * @example formatSignedUsd(null) // '—' */ -export const formatSignedUsd = (value: string | null): string => { +export const formatSignedUsd = (value: string | number | null): string => { if (value === null) return '—'; - const num = parseFloat(value); - if (Number.isNaN(num)) return value; + const num = typeof value === 'number' ? value : parseFloat(value); + if (Number.isNaN(num)) return '—'; const sign = num > 0 ? '+' : ''; return `${sign}${formatUsd(value)}`; }; @@ -425,22 +425,6 @@ export function formatOrdinalRank(rank: number): string { return `${n}${suffix}`; } -// ── Timestamp formatting ──────────────────────────────────────────────── - -/** - * Formats an ISO 8601 timestamp to `HH:MM:SS`. - * Returns '' for null or unparseable values. - */ -export const formatComputedAt = (isoString: string | null): string => { - if (!isoString) return ''; - const date = new Date(isoString); - if (isNaN(date.getTime())) return ''; - const h = date.getHours().toString().padStart(2, '0'); - const m = date.getMinutes().toString().padStart(2, '0'); - const s = date.getSeconds().toString().padStart(2, '0'); - return `${h}:${m}:${s}`; -}; - // ── CAIP-19 / address helpers ─────────────────────────────────────────── /** diff --git a/app/components/UI/Rewards/utils/perpsCampaignConstants.ts b/app/components/UI/Rewards/utils/perpsCampaignConstants.ts new file mode 100644 index 00000000000..ee988ff0387 --- /dev/null +++ b/app/components/UI/Rewards/utils/perpsCampaignConstants.ts @@ -0,0 +1,9 @@ +/** + * Notional volume (USD) required to qualify for the perps trading competition leaderboard. + * Aligns with backend / UI rules for `qualified` on leaderboard position. + */ +export const PERPS_QUALIFICATION_NOTIONAL_USD = 25_000; + +/** HyperTracker attribution URL for the perps trading campaign leaderboard. */ +export const HYPERTRACKER_ATTRIBUTION_URL = + 'https://hypertracker.io?utm_source=metamask&utm_medium=leaderboard&utm_campaign=partner-attribution'; diff --git a/app/components/UI/Rewards/utils/prizePoolUtils.test.ts b/app/components/UI/Rewards/utils/prizePoolUtils.test.ts new file mode 100644 index 00000000000..6457bf916ec --- /dev/null +++ b/app/components/UI/Rewards/utils/prizePoolUtils.test.ts @@ -0,0 +1,79 @@ +import { computePrizePoolProgress } from './prizePoolUtils'; + +describe('computePrizePoolProgress', () => { + const ondoLike = [ + { deposit: 0, prize: 25_000 }, + { deposit: 1_500_000, prize: 50_000 }, + { deposit: 3_500_000, prize: 75_000 }, + { deposit: 6_000_000, prize: 100_000 }, + ] as const; + + it('returns first-tier defaults when amount is below first threshold above zero', () => { + const result = computePrizePoolProgress( + ondoLike, + 1_000_000, + (m) => m.deposit, + ); + expect(result.currentPrize).toBe(25_000); + expect(result.nextPrize).toBe(50_000); + expect(result.nextThreshold).toBe(1_500_000); + expect(result.isMaxTier).toBe(false); + expect(result.progress).toBeCloseTo(1_000_000 / 1_500_000); + }); + + it('returns max tier when amount meets final milestone', () => { + const result = computePrizePoolProgress( + ondoLike, + 6_000_000, + (m) => m.deposit, + ); + expect(result.progress).toBe(1); + expect(result.currentPrize).toBe(100_000); + expect(result.nextPrize).toBeNull(); + expect(result.nextThreshold).toBe(6_000_000); + expect(result.isMaxTier).toBe(true); + }); + + it('interpolates progress within a tier (perps-style notionalVolume)', () => { + const perpsLike = [ + { notionalVolume: 0, prize: 10_000 }, + { notionalVolume: 5_000_000, prize: 15_000 }, + { notionalVolume: 10_000_000, prize: 20_000 }, + ] as const; + + const mid = 7_500_000; + const result = computePrizePoolProgress( + perpsLike, + mid, + (m) => m.notionalVolume, + ); + expect(result.currentPrize).toBe(15_000); + expect(result.nextPrize).toBe(20_000); + expect(result.nextThreshold).toBe(10_000_000); + expect(result.progress).toBe(0.5); + }); + + it('returns zero progress at the start of the first tier', () => { + const result = computePrizePoolProgress(ondoLike, 0, (m) => m.deposit); + expect(result.progress).toBe(0); + expect(result.currentPrize).toBe(25_000); + expect(result.nextThreshold).toBe(1_500_000); + }); + + it('returns expected currentPrize at each Ondo-style tier boundary', () => { + const cp = (amount: number) => + computePrizePoolProgress(ondoLike, amount, (m) => m.deposit).currentPrize; + + expect(cp(0)).toBe(25_000); + expect(cp(500_000)).toBe(25_000); + expect(cp(1_499_999)).toBe(25_000); + expect(cp(1_500_000)).toBe(50_000); + expect(cp(2_000_000)).toBe(50_000); + expect(cp(3_499_999)).toBe(50_000); + expect(cp(3_500_000)).toBe(75_000); + expect(cp(4_500_000)).toBe(75_000); + expect(cp(5_999_999)).toBe(75_000); + expect(cp(6_000_000)).toBe(100_000); + expect(cp(10_000_000)).toBe(100_000); + }); +}); diff --git a/app/components/UI/Rewards/utils/prizePoolUtils.ts b/app/components/UI/Rewards/utils/prizePoolUtils.ts new file mode 100644 index 00000000000..5f31c32c109 --- /dev/null +++ b/app/components/UI/Rewards/utils/prizePoolUtils.ts @@ -0,0 +1,49 @@ +export interface PrizePoolProgressResult { + progress: number; + currentPrize: number; + nextPrize: number | null; + nextThreshold: number; + isMaxTier: boolean; +} + +/** + * Computes progress toward the next prize tier from sorted milestones (ascending threshold). + */ +export function computePrizePoolProgress( + milestones: readonly T[], + totalAmount: number, + getThreshold: (m: T) => number, +): PrizePoolProgressResult { + let currentIndex = 0; + for (let i = milestones.length - 1; i >= 0; i--) { + if (totalAmount >= getThreshold(milestones[i])) { + currentIndex = i; + break; + } + } + + const current = milestones[currentIndex]; + const next = milestones[currentIndex + 1]; + + if (!next) { + return { + progress: 1, + currentPrize: current.prize, + nextPrize: null, + nextThreshold: getThreshold(current), + isMaxTier: true, + }; + } + + const rangeAmount = getThreshold(next) - getThreshold(current); + const progressInRange = totalAmount - getThreshold(current); + const progress = Math.min(progressInRange / rangeAmount, 1); + + return { + progress, + currentPrize: current.prize, + nextPrize: next.prize, + nextThreshold: getThreshold(next), + isMaxTier: false, + }; +} diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts index ec03391eee0..323016d1752 100644 --- a/app/constants/navigation/Routes.ts +++ b/app/constants/navigation/Routes.ts @@ -115,6 +115,11 @@ const Routes = { REWARDS_ONDO_CAMPAIGN_PORTFOLIO_VIEW: 'RewardsOndoCampaignPortfolioView', REWARDS_ONDO_CAMPAIGN_STATS: 'RewardsOndoCampaignStats', REWARDS_CAMPAIGN_TOUR_STEP: 'RewardsCampaignTourStep', + REWARDS_PERPS_TRADING_CAMPAIGN_DETAILS_VIEW: + 'RewardsPerpsTradingCampaignDetails', + REWARDS_PERPS_TRADING_CAMPAIGN_LEADERBOARD: + 'RewardsPerpsTradingCampaignLeaderboard', + REWARDS_PERPS_TRADING_CAMPAIGN_STATS: 'RewardsPerpsTradingCampaignStats', TRENDING_VIEW: 'TrendingView', TRENDING_FEED: 'TrendingFeed', WHATS_HAPPENING_DETAIL: 'WhatsHappeningDetailView', diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController-method-action-types.ts b/app/core/Engine/controllers/rewards-controller/RewardsController-method-action-types.ts index f5fda37b14b..be1f9d95265 100644 --- a/app/core/Engine/controllers/rewards-controller/RewardsController-method-action-types.ts +++ b/app/core/Engine/controllers/rewards-controller/RewardsController-method-action-types.ts @@ -702,6 +702,47 @@ export type RewardsControllerInvalidateSubscriptionCacheAction = { handler: RewardsController['invalidateSubscriptionCache']; }; +/** + * Get the perps trading campaign leaderboard. + * This is a public endpoint - no authentication required. + * Results are cached for 5 minutes. + * + * @param campaignId - The campaign ID to get leaderboard for. + * @returns The leaderboard entries and metadata. + */ +export type RewardsControllerGetPerpsTradingCampaignLeaderboardAction = { + type: `RewardsController:getPerpsTradingCampaignLeaderboard`; + handler: RewardsController['getPerpsTradingCampaignLeaderboard']; +}; + +/** + * Get the current user's position on the perps trading campaign leaderboard. + * This is an authenticated endpoint. + * Results are cached for 5 minutes. + * + * @param campaignId - The campaign ID to get position for. + * @param subscriptionId - The subscription ID for authentication. + * @returns The user's leaderboard position, or null if not found. + */ +export type RewardsControllerGetPerpsTradingCampaignLeaderboardPositionAction = + { + type: `RewardsController:getPerpsTradingCampaignLeaderboardPosition`; + handler: RewardsController['getPerpsTradingCampaignLeaderboardPosition']; + }; + +/** + * Get the perps trading campaign aggregate volume (public stats). + * This is a public endpoint - no authentication required. + * Results are cached for 1 minute. + * + * @param campaignId - The campaign ID to get volume for. + * @returns Current aggregate notional volume for the campaign. + */ +export type RewardsControllerGetPerpsTradingCampaignVolumeAction = { + type: `RewardsController:getPerpsTradingCampaignVolume`; + handler: RewardsController['getPerpsTradingCampaignVolume']; +}; + /** * Union of all RewardsController action types. */ @@ -771,4 +812,7 @@ export type RewardsControllerMethodActions = | RewardsControllerApplyBonusCodeAction | RewardsControllerGetClientVersionRequirementsAction | RewardsControllerInvalidateReferralDetailsCacheAction - | RewardsControllerInvalidateSubscriptionCacheAction; + | RewardsControllerInvalidateSubscriptionCacheAction + | RewardsControllerGetPerpsTradingCampaignLeaderboardAction + | RewardsControllerGetPerpsTradingCampaignLeaderboardPositionAction + | RewardsControllerGetPerpsTradingCampaignVolumeAction; diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts b/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts index 4c61d9b9d8e..ae1187bb7ad 100644 --- a/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts +++ b/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts @@ -16124,6 +16124,9 @@ describe('RewardsController', () => { ondoCampaignLeaderboard: {}, ondoCampaignLeaderboardPositions: {}, ondoCampaignPortfolio: {}, + perpsTradingCampaignLeaderboard: {}, + perpsTradingCampaignLeaderboardPositions: {}, + perpsTradingCampaignVolume: {}, pointsEstimateHistory: [], pointsEvents: {}, seasonStatuses: {}, @@ -16150,6 +16153,9 @@ describe('RewardsController', () => { ondoCampaignLeaderboard: {}, ondoCampaignLeaderboardPositions: {}, ondoCampaignPortfolio: {}, + perpsTradingCampaignLeaderboard: {}, + perpsTradingCampaignLeaderboardPositions: {}, + perpsTradingCampaignVolume: {}, pointsEstimateHistory: [], pointsEvents: {}, rewardsEnvUrl: null, @@ -16181,6 +16187,9 @@ describe('RewardsController', () => { ondoCampaignLeaderboard: {}, ondoCampaignLeaderboardPositions: {}, ondoCampaignPortfolio: {}, + perpsTradingCampaignLeaderboard: {}, + perpsTradingCampaignLeaderboardPositions: {}, + perpsTradingCampaignVolume: {}, pointsEvents: {}, rewardsEnvUrl: null, seasonStatuses: {}, diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController.ts b/app/core/Engine/controllers/rewards-controller/RewardsController.ts index b9667dce4e7..33aa24818e1 100644 --- a/app/core/Engine/controllers/rewards-controller/RewardsController.ts +++ b/app/core/Engine/controllers/rewards-controller/RewardsController.ts @@ -29,6 +29,9 @@ import { type OndoGmPortfolioState, type OndoGmCampaignDepositsDto, type OndoGmCampaignParticipantOutcomeDto, + type PerpsTradingCampaignLeaderboardDto, + type PerpsTradingCampaignLeaderboardPositionDto, + type PerpsTradingCampaignVolumeDto, type PaginatedOndoGmActivityDto, type OndoGmActivityState, type PointsEstimateHistoryEntry, @@ -141,6 +144,16 @@ const ONDO_CAMPAIGN_ACTIVITY_CACHE_THRESHOLD_MS = 1000 * 60 * 1; // 1 minute cac // Campaign participant outcome cache threshold const ONDO_CAMPAIGN_PARTICIPANT_OUTCOME_CACHE_THRESHOLD_MS = 1000 * 60 * 10; // 10 minutes +// Perps Trading Campaign leaderboard cache threshold (HyperTracker refreshes every ~15 min) +const PERPS_TRADING_CAMPAIGN_LEADERBOARD_CACHE_THRESHOLD_MS = 1000 * 60 * 5; // 5 minutes + +// Perps Trading Campaign leaderboard position cache threshold +const PERPS_TRADING_CAMPAIGN_LEADERBOARD_POSITION_CACHE_THRESHOLD_MS = + 1000 * 60 * 5; // 5 minutes + +// Perps Trading Campaign volume cache threshold +const PERPS_TRADING_CAMPAIGN_VOLUME_CACHE_THRESHOLD_MS = 1000 * 60 * 1; // 1 minute + // Opt-in status stale threshold for not opted-in accounts to force a fresh check const NOT_OPTED_IN_OIS_STALE_CACHE_THRESHOLD_MS = 1000 * 60 * 60; // 1 hour @@ -253,6 +266,24 @@ const metadata: StateMetadata = { includeInDebugSnapshot: false, usedInUi: true, }, + perpsTradingCampaignLeaderboard: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, + perpsTradingCampaignLeaderboardPositions: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, + perpsTradingCampaignVolume: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, pointsEstimateHistory: { includeInStateLogs: true, persist: true, @@ -295,6 +326,9 @@ export const getRewardsControllerDefaultState = (): RewardsControllerState => ({ ondoCampaignPortfolio: {}, ondoCampaignActivity: {}, ondoCampaignDeposits: {}, + perpsTradingCampaignLeaderboard: {}, + perpsTradingCampaignLeaderboardPositions: {}, + perpsTradingCampaignVolume: {}, pointsEstimateHistory: [], rewardsEnvUrl: null, }); @@ -409,6 +443,9 @@ const MESSENGER_EXPOSED_METHODS = [ 'getOndoCampaignActivity', 'getOndoCampaignPortfolioPosition', 'getOndoCampaignParticipantOutcome', + 'getPerpsTradingCampaignLeaderboard', + 'getPerpsTradingCampaignLeaderboardPosition', + 'getPerpsTradingCampaignVolume', 'getOptInStatus', 'getPerpsDiscountForAccount', 'getPointsEvents', @@ -4428,4 +4465,186 @@ export class RewardsController extends BaseController< seasonId || 'all seasons', ); } + + /** + * Get the perps trading campaign leaderboard. + * This is a public endpoint - no authentication required. + * Results are cached for 5 minutes. + * @param campaignId - The campaign ID to get leaderboard for. + * @returns The leaderboard entries and metadata. + */ + async getPerpsTradingCampaignLeaderboard( + campaignId: string, + ): Promise { + if (!this.isRewardsFeatureEnabled()) { + return { campaignId, computedAt: '', entries: [], totalParticipants: 0 }; + } + + const result = await wrapWithCache({ + key: campaignId, + ttl: PERPS_TRADING_CAMPAIGN_LEADERBOARD_CACHE_THRESHOLD_MS, + readCache: (k) => { + const cached = this.state.perpsTradingCampaignLeaderboard[k]; + if (!cached) return undefined; + return { + payload: { + campaignId: cached.campaignId, + computedAt: cached.computedAt, + entries: cached.entries, + totalParticipants: cached.totalParticipants, + }, + lastFetched: cached.lastFetched, + }; + }, + fetchFresh: async () => { + Logger.log( + 'RewardsController: Fetching fresh perps trading campaign leaderboard via API call', + ); + return (await this.messenger.call( + 'RewardsDataService:getPerpsTradingCampaignLeaderboard', + campaignId, + )) as PerpsTradingCampaignLeaderboardDto; + }, + writeCache: (k, payload) => { + this.update((state) => { + state.perpsTradingCampaignLeaderboard[k] = { + campaignId: payload.campaignId, + computedAt: payload.computedAt, + entries: payload.entries, + totalParticipants: payload.totalParticipants, + lastFetched: Date.now(), + }; + }); + }, + }); + return result; + } + + /** + * Get the current user's position on the perps trading campaign leaderboard. + * This is an authenticated endpoint. + * Results are cached for 5 minutes. + * @param campaignId - The campaign ID to get position for. + * @param subscriptionId - The subscription ID for authentication. + * @returns The user's leaderboard position, or null if not found. + */ + async getPerpsTradingCampaignLeaderboardPosition( + campaignId: string, + subscriptionId: string, + ): Promise { + if (!this.isRewardsFeatureEnabled()) { + return null; + } + + const key = `${subscriptionId}:${campaignId}`; + const result = + await wrapWithCache({ + key, + ttl: PERPS_TRADING_CAMPAIGN_LEADERBOARD_POSITION_CACHE_THRESHOLD_MS, + readCache: (k) => { + const cached = this.state.perpsTradingCampaignLeaderboardPositions[k]; + if (!cached) return undefined; + if ('notFound' in cached) { + return { payload: null, lastFetched: cached.lastFetched }; + } + return { + payload: { + rank: cached.rank, + pnl: cached.pnl, + notionalVolume: cached.notionalVolume, + marginDeployed: cached.marginDeployed, + qualified: cached.qualified, + neighbors: cached.neighbors, + computedAt: cached.computedAt, + }, + lastFetched: cached.lastFetched, + }; + }, + fetchFresh: async () => + this.#withAuthRetry(async () => { + Logger.log( + 'RewardsController: Fetching fresh perps trading campaign leaderboard position via API call', + ); + return (await this.messenger.call( + 'RewardsDataService:getPerpsTradingCampaignLeaderboardPosition', + campaignId, + subscriptionId, + )) as PerpsTradingCampaignLeaderboardPositionDto | null; + }, subscriptionId), + writeCache: (k, payload) => { + if (payload === null) { + this.update((state) => { + state.perpsTradingCampaignLeaderboardPositions[k] = { + notFound: true, + lastFetched: Date.now(), + }; + }); + } else { + this.update((state) => { + state.perpsTradingCampaignLeaderboardPositions[k] = { + rank: payload.rank, + pnl: payload.pnl, + notionalVolume: payload.notionalVolume, + marginDeployed: payload.marginDeployed, + qualified: payload.qualified, + neighbors: payload.neighbors, + computedAt: payload.computedAt, + lastFetched: Date.now(), + }; + }); + } + }, + }); + return result; + } + + /** + * Get the perps trading campaign aggregate volume (public stats). + * This is a public endpoint - no authentication required. + * Results are cached for 1 minute. + * @param campaignId - The campaign ID to get volume for. + * @returns Current aggregate notional volume for the campaign. + */ + async getPerpsTradingCampaignVolume( + campaignId: string, + ): Promise { + if (!this.isRewardsFeatureEnabled()) { + return { + totalUsdVolume: '0', + }; + } + + const result = await wrapWithCache({ + key: campaignId, + ttl: PERPS_TRADING_CAMPAIGN_VOLUME_CACHE_THRESHOLD_MS, + readCache: (k) => { + const cached = this.state.perpsTradingCampaignVolume[k]; + if (!cached) return undefined; + return { + payload: { + totalUsdVolume: cached.totalUsdVolume, + }, + lastFetched: cached.lastFetched, + }; + }, + fetchFresh: async () => { + Logger.log( + 'RewardsController: Fetching fresh perps trading campaign volume via API call', + ); + return (await this.messenger.call( + 'RewardsDataService:getPerpsTradingCampaignVolume', + campaignId, + )) as PerpsTradingCampaignVolumeDto; + }, + writeCache: (k, payload) => { + this.update((state) => { + state.perpsTradingCampaignVolume[k] = { + totalUsdVolume: payload.totalUsdVolume, + lastFetched: Date.now(), + }; + }); + }, + }); + return result; + } } diff --git a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts index 0893163f302..a7c8b574492 100644 --- a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts +++ b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts @@ -5087,4 +5087,141 @@ describe('RewardsDataService', () => { ).rejects.toThrow('Get Ondo GM participant outcome failed: 500'); }); }); + + describe('getPerpsTradingCampaignLeaderboard', () => { + const mockCampaignId = 'perps-campaign-api-1'; + const mockLeaderboard = { + campaignId: mockCampaignId, + computedAt: '2025-08-15T12:00:00.000Z', + entries: [], + totalParticipants: 0, + }; + + beforeEach(() => { + mockFetch.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue(mockLeaderboard), + } as unknown as Response); + }); + + it('calls the public perps leaderboard endpoint with GET and returns data', async () => { + const result = + await service.getPerpsTradingCampaignLeaderboard(mockCampaignId); + + expect(mockFetch).toHaveBeenCalledWith( + `https://uat.rewards.test/perps-trading/${mockCampaignId}/leaderboard`, + expect.objectContaining({ method: 'GET' }), + ); + expect(result).toEqual(mockLeaderboard); + }); + + it('throws when response is not ok', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 502 } as Response); + + await expect( + service.getPerpsTradingCampaignLeaderboard(mockCampaignId), + ).rejects.toThrow('Get perps trading campaign leaderboard failed: 502'); + }); + }); + + describe('getPerpsTradingCampaignLeaderboardPosition', () => { + const mockCampaignId = 'perps-campaign-api-2'; + const mockSubscriptionId = 'sub-perps-1'; + const mockToken = 'test-bearer-token'; + const mockPosition = { + rank: 4, + pnl: 12.5, + notionalVolume: 8000, + marginDeployed: 1200, + qualified: true, + neighbors: [], + computedAt: '2025-08-15T12:00:00.000Z', + }; + + beforeEach(() => { + mockGetSubscriptionToken.mockResolvedValue({ + success: true, + token: mockToken, + }); + mockFetch.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue(mockPosition), + } as unknown as Response); + }); + + it('calls the authenticated me endpoint and returns the position', async () => { + const result = await service.getPerpsTradingCampaignLeaderboardPosition( + mockCampaignId, + mockSubscriptionId, + ); + + expect(mockFetch).toHaveBeenCalledWith( + `https://uat.rewards.test/perps-trading/${mockCampaignId}/leaderboard/me`, + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + 'rewards-access-token': mockToken, + }), + }), + ); + expect(result).toEqual(mockPosition); + }); + + it('returns null on 404', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 404 } as Response); + + const result = await service.getPerpsTradingCampaignLeaderboardPosition( + mockCampaignId, + mockSubscriptionId, + ); + + expect(result).toBeNull(); + }); + + it('throws on non-404 error responses', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 503 } as Response); + + await expect( + service.getPerpsTradingCampaignLeaderboardPosition( + mockCampaignId, + mockSubscriptionId, + ), + ).rejects.toThrow( + 'Get perps trading campaign leaderboard position failed: 503', + ); + }); + }); + + describe('getPerpsTradingCampaignVolume', () => { + const mockCampaignId = 'perps-campaign-api-3'; + const mockVolume = { + totalUsdVolume: '2500000', + }; + + beforeEach(() => { + mockFetch.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue(mockVolume), + } as unknown as Response); + }); + + it('calls the public volume stats endpoint with GET and returns data', async () => { + const result = + await service.getPerpsTradingCampaignVolume(mockCampaignId); + + expect(mockFetch).toHaveBeenCalledWith( + `https://uat.rewards.test/perps-trading/${mockCampaignId}/stats/total-volume`, + expect.objectContaining({ method: 'GET' }), + ); + expect(result).toEqual(mockVolume); + }); + + it('throws when response is not ok', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 500 } as Response); + + await expect( + service.getPerpsTradingCampaignVolume(mockCampaignId), + ).rejects.toThrow('Get perps trading campaign volume failed: 500'); + }); + }); }); diff --git a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts index dec6ea02b0d..1170e963d60 100644 --- a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts +++ b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts @@ -35,6 +35,9 @@ import type { PaginatedOndoGmActivityDto, OndoGmCampaignDepositsDto, OndoGmCampaignParticipantOutcomeDto, + PerpsTradingCampaignLeaderboardDto, + PerpsTradingCampaignLeaderboardPositionDto, + PerpsTradingCampaignVolumeDto, } from '../types'; import { getSubscriptionToken } from '../utils/multi-subscription-token-vault'; import Logger from '../../../../../util/Logger'; @@ -263,6 +266,21 @@ export interface RewardsDataServiceGetOndoCampaignParticipantOutcomeAction { handler: RewardsDataService['getOndoCampaignParticipantOutcome']; } +export interface RewardsDataServiceGetPerpsTradingCampaignLeaderboardAction { + type: `${typeof SERVICE_NAME}:getPerpsTradingCampaignLeaderboard`; + handler: RewardsDataService['getPerpsTradingCampaignLeaderboard']; +} + +export interface RewardsDataServiceGetPerpsTradingCampaignLeaderboardPositionAction { + type: `${typeof SERVICE_NAME}:getPerpsTradingCampaignLeaderboardPosition`; + handler: RewardsDataService['getPerpsTradingCampaignLeaderboardPosition']; +} + +export interface RewardsDataServiceGetPerpsTradingCampaignVolumeAction { + type: `${typeof SERVICE_NAME}:getPerpsTradingCampaignVolume`; + handler: RewardsDataService['getPerpsTradingCampaignVolume']; +} + export interface RewardsDataServiceGetRewardsEnvUrlAction { type: `${typeof SERVICE_NAME}:getRewardsEnvUrl`; handler: RewardsDataService['getRewardsEnvUrl']; @@ -334,7 +352,10 @@ export type RewardsDataServiceActions = | RewardsDataServiceGetOndoCampaignActivityAction | RewardsDataServiceGetOndoCampaignActivityLastUpdatedAction | RewardsDataServiceGetOndoCampaignDepositsAction - | RewardsDataServiceGetOndoCampaignParticipantOutcomeAction; + | RewardsDataServiceGetOndoCampaignParticipantOutcomeAction + | RewardsDataServiceGetPerpsTradingCampaignLeaderboardAction + | RewardsDataServiceGetPerpsTradingCampaignLeaderboardPositionAction + | RewardsDataServiceGetPerpsTradingCampaignVolumeAction; export type RewardsDataServiceMessenger = Messenger< typeof SERVICE_NAME, @@ -509,6 +530,18 @@ export class RewardsDataService { `${SERVICE_NAME}:getOndoCampaignParticipantOutcome`, this.getOndoCampaignParticipantOutcome.bind(this), ); + this.#messenger.registerActionHandler( + `${SERVICE_NAME}:getPerpsTradingCampaignLeaderboard`, + this.getPerpsTradingCampaignLeaderboard.bind(this), + ); + this.#messenger.registerActionHandler( + `${SERVICE_NAME}:getPerpsTradingCampaignLeaderboardPosition`, + this.getPerpsTradingCampaignLeaderboardPosition.bind(this), + ); + this.#messenger.registerActionHandler( + `${SERVICE_NAME}:getPerpsTradingCampaignVolume`, + this.getPerpsTradingCampaignVolume.bind(this), + ); this.#messenger.registerActionHandler( `${SERVICE_NAME}:getRewardsEnvUrl`, this.getRewardsEnvUrl.bind(this), @@ -1720,4 +1753,78 @@ export class RewardsDataService { return (await response.json()) as OndoGmCampaignParticipantOutcomeDto; } + + /** + * Get the Perps Trading Campaign leaderboard (top 20 entries, no tiers). + * Public endpoint — no authentication required. + * @param campaignId - The campaign ID. + */ + async getPerpsTradingCampaignLeaderboard( + campaignId: string, + ): Promise { + const response = await this.makeRequest( + `/perps-trading/${campaignId}/leaderboard`, + { method: 'GET' }, + ); + + if (!response.ok) { + throw new Error( + `Get perps trading campaign leaderboard failed: ${response.status}`, + ); + } + + return (await response.json()) as PerpsTradingCampaignLeaderboardDto; + } + + /** + * Get the current user's position on the Perps Trading Campaign leaderboard. + * Authenticated endpoint. + * @param campaignId - The campaign ID. + * @param subscriptionId - The subscription ID for authentication. + * @returns The user's position, or null if not found (404). + */ + async getPerpsTradingCampaignLeaderboardPosition( + campaignId: string, + subscriptionId: string, + ): Promise { + const response = await this.makeRequest( + `/perps-trading/${campaignId}/leaderboard/me`, + { method: 'GET' }, + subscriptionId, + ); + + if (response.status === 404) { + return null; + } + + if (!response.ok) { + throw new Error( + `Get perps trading campaign leaderboard position failed: ${response.status}`, + ); + } + + return (await response.json()) as PerpsTradingCampaignLeaderboardPositionDto; + } + + /** + * Get the Perps Trading Campaign volume stats. + * Public endpoint — no authentication required. + * @param campaignId - The campaign ID. + */ + async getPerpsTradingCampaignVolume( + campaignId: string, + ): Promise { + const response = await this.makeRequest( + `/perps-trading/${campaignId}/stats/total-volume`, + { method: 'GET' }, + ); + + if (!response.ok) { + throw new Error( + `Get perps trading campaign volume failed: ${response.status}`, + ); + } + + return (await response.json()) as PerpsTradingCampaignVolumeDto; + } } diff --git a/app/core/Engine/controllers/rewards-controller/types.ts b/app/core/Engine/controllers/rewards-controller/types.ts index 8be5b549819..16cd347e36f 100644 --- a/app/core/Engine/controllers/rewards-controller/types.ts +++ b/app/core/Engine/controllers/rewards-controller/types.ts @@ -698,6 +698,108 @@ export type OndoGmCampaignDepositsState = { lastFetched: number; }; +// ─── Perps Trading Campaign ──────────────────────────────────────────────── + +/** + * A single entry in the Perps Trading Campaign leaderboard (no tiers). + */ +export interface PerpsTradingCampaignLeaderboardEntry { + rank: number; + referralCode: string; + /** Signed USD PnL for the campaign window */ + pnl: number; + /** true when notional volume ≥ $25k AND margin deployed ≥ $1k */ + qualified: boolean; +} + +/** + * Response DTO for GET /perps-trading/:campaignId/leaderboard (public, no auth). + */ +export interface PerpsTradingCampaignLeaderboardDto { + campaignId: string; + /** ISO timestamp — display as "last updated" (refreshes ~every 15 min) */ + computedAt: string; + entries: PerpsTradingCampaignLeaderboardEntry[]; + totalParticipants: number; +} + +/** + * Response DTO for GET /perps-trading/:campaignId/leaderboard/me (authenticated). + */ +export interface PerpsTradingCampaignLeaderboardPositionDto { + rank: number; + /** Signed USD PnL */ + pnl: number; + /** Cumulative notional volume traded during the competition window (USD) */ + notionalVolume: number; + /** Cumulative initial margin deployed during the competition window (USD) */ + marginDeployed: number; + qualified: boolean; + neighbors: PerpsTradingCampaignLeaderboardEntry[]; + computedAt: string; +} + +/** + * Response DTO for GET /perps-trading/:campaignId/stats/volume (public). + */ +export interface PerpsTradingCampaignVolumeDto { + /** Current aggregate notional volume across all participants (USD string) */ + totalUsdVolume: string; +} + +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type PerpsTradingCampaignLeaderboardState = { + campaignId: string; + computedAt: string; + entries: { + rank: number; + referralCode: string; + pnl: number; + qualified: boolean; + }[]; + totalParticipants: number; + lastFetched: number; +}; + +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type PerpsTradingCampaignLeaderboardPositionFoundState = { + rank: number; + pnl: number; + notionalVolume: number; + marginDeployed: number; + qualified: boolean; + neighbors: { + rank: number; + referralCode: string; + pnl: number; + qualified: boolean; + }[]; + computedAt: string; + lastFetched: number; +}; + +/** Sentinel stored when the API returns null (user not on leaderboard), so the TTL is respected. */ +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type PerpsTradingCampaignLeaderboardPositionNotFoundState = { + notFound: true; + lastFetched: number; +}; + +export type PerpsTradingCampaignLeaderboardPositionState = + | PerpsTradingCampaignLeaderboardPositionFoundState + | PerpsTradingCampaignLeaderboardPositionNotFoundState; + +/** + * Cached campaign volume (explicit shape for Json / StateConstraint compatibility). + */ +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type PerpsTradingCampaignVolumeState = { + totalUsdVolume: string; + lastFetched: number; +}; + +// ─── End Perps Trading Campaign ──────────────────────────────────────────── + /** * State for cached leaderboard data in the controller */ @@ -1847,6 +1949,18 @@ export type RewardsControllerState = { }; /** Ondo campaign deposits keyed by campaignId (public endpoint). */ ondoCampaignDeposits: { [campaignId: string]: OndoGmCampaignDepositsState }; + /** Perps Trading Campaign leaderboard keyed by campaignId (public endpoint). */ + perpsTradingCampaignLeaderboard: { + [campaignId: string]: PerpsTradingCampaignLeaderboardState; + }; + /** Perps Trading Campaign leaderboard position keyed by compositeId (subscriptionId:campaignId). */ + perpsTradingCampaignLeaderboardPositions: { + [compositeId: string]: PerpsTradingCampaignLeaderboardPositionState; + }; + /** Perps Trading Campaign volume keyed by campaignId (public endpoint). */ + perpsTradingCampaignVolume: { + [campaignId: string]: PerpsTradingCampaignVolumeState; + }; /** * History of points estimates for Customer Support diagnostics. * Stores the last N successful estimates to verify user-reported discrepancies. diff --git a/app/core/Engine/messengers/rewards-controller-messenger/index.ts b/app/core/Engine/messengers/rewards-controller-messenger/index.ts index a6c6e538c66..34d15885a4c 100644 --- a/app/core/Engine/messengers/rewards-controller-messenger/index.ts +++ b/app/core/Engine/messengers/rewards-controller-messenger/index.ts @@ -66,6 +66,9 @@ import { RewardsDataServiceGetOndoCampaignActivityLastUpdatedAction, RewardsDataServiceGetOndoCampaignDepositsAction, RewardsDataServiceGetOndoCampaignParticipantOutcomeAction, + RewardsDataServiceGetPerpsTradingCampaignLeaderboardAction, + RewardsDataServiceGetPerpsTradingCampaignLeaderboardPositionAction, + RewardsDataServiceGetPerpsTradingCampaignVolumeAction, } from '../../controllers/rewards-controller/services/rewards-data-service'; import { RootMessenger } from '../../types'; @@ -118,7 +121,10 @@ type AllowedActions = | RewardsDataServiceGetOndoCampaignActivityAction | RewardsDataServiceGetOndoCampaignActivityLastUpdatedAction | RewardsDataServiceGetOndoCampaignDepositsAction - | RewardsDataServiceGetOndoCampaignParticipantOutcomeAction; + | RewardsDataServiceGetOndoCampaignParticipantOutcomeAction + | RewardsDataServiceGetPerpsTradingCampaignLeaderboardAction + | RewardsDataServiceGetPerpsTradingCampaignLeaderboardPositionAction + | RewardsDataServiceGetPerpsTradingCampaignVolumeAction; // Don't reexport as per guidelines type AllowedEvents = @@ -193,12 +199,15 @@ export function getRewardsControllerMessenger( 'RewardsDataService:getOndoCampaignActivityLastUpdated', 'RewardsDataService:getOndoCampaignDeposits', 'RewardsDataService:getOndoCampaignParticipantOutcome', + 'RewardsDataService:getPerpsTradingCampaignLeaderboard', + 'RewardsDataService:getPerpsTradingCampaignLeaderboardPosition', + 'RewardsDataService:getPerpsTradingCampaignVolume', ], events: [ 'AccountTreeController:selectedAccountGroupChange', 'KeyringController:unlock', ], - }); + } as Parameters[0]); return messenger; } diff --git a/app/reducers/rewards/index.test.ts b/app/reducers/rewards/index.test.ts index 27741555808..9ee33ec2753 100644 --- a/app/reducers/rewards/index.test.ts +++ b/app/reducers/rewards/index.test.ts @@ -41,6 +41,13 @@ import rewardsReducer, { setOndoCampaignDeposits, setOndoCampaignDepositsLoading, setOndoCampaignDepositsError, + setPerpsTradingCampaignLeaderboard, + setPerpsTradingCampaignLeaderboardLoading, + setPerpsTradingCampaignLeaderboardError, + setPerpsTradingCampaignLeaderboardPosition, + setPerpsTradingCampaignVolume, + setPerpsTradingCampaignVolumeLoading, + setPerpsTradingCampaignVolumeError, bulkLinkStarted, bulkLinkAccountResult, bulkLinkCompleted, @@ -66,6 +73,8 @@ import { CampaignLeaderboardPositionDto, OndoGmPortfolioDto, OndoGmActivityEntryDto, + PerpsTradingCampaignLeaderboardDto, + PerpsTradingCampaignLeaderboardPositionDto, } from '../../core/Engine/controllers/rewards-controller/types'; import { AccountGroupId } from '@metamask/account-api'; import { brandColor } from '@metamask/design-tokens'; @@ -2078,6 +2087,13 @@ describe('rewardsReducer', () => { versionGuardMinimumMobileVersion: null, versionGuardLoading: false, versionGuardError: false, + perpsTradingCampaignLeaderboard: null, + perpsTradingCampaignLeaderboardLoading: false, + perpsTradingCampaignLeaderboardError: false, + perpsTradingCampaignLeaderboardPositions: {}, + perpsTradingCampaignVolume: null, + perpsTradingCampaignVolumeLoading: false, + perpsTradingCampaignVolumeError: false, pendingDeeplink: null, dismissedCampaignOutcomeToasts: {}, }; @@ -2200,6 +2216,13 @@ describe('rewardsReducer', () => { versionGuardMinimumMobileVersion: null, versionGuardLoading: false, versionGuardError: false, + perpsTradingCampaignLeaderboard: null, + perpsTradingCampaignLeaderboardLoading: false, + perpsTradingCampaignLeaderboardError: false, + perpsTradingCampaignLeaderboardPositions: {}, + perpsTradingCampaignVolume: null, + perpsTradingCampaignVolumeLoading: false, + perpsTradingCampaignVolumeError: false, pendingDeeplink: null, dismissedCampaignOutcomeToasts: {}, }; @@ -5576,6 +5599,187 @@ describe('setOndoCampaignActivity', () => { }); }); +const mockPerpsLeaderboard: PerpsTradingCampaignLeaderboardDto = { + campaignId: 'perps-c-1', + computedAt: '2025-08-15T12:00:00.000Z', + entries: [], + totalParticipants: 42, +}; + +const mockPerpsPosition: PerpsTradingCampaignLeaderboardPositionDto = { + rank: 2, + pnl: 100, + notionalVolume: 5000, + marginDeployed: 1000, + qualified: true, + neighbors: [], + computedAt: '2025-08-15T12:00:00.000Z', +}; + +describe('setPerpsTradingCampaignLeaderboard', () => { + it('sets leaderboard data and clears error', () => { + const stateWithError: RewardsState = { + ...initialState, + perpsTradingCampaignLeaderboardError: true, + }; + + const state = rewardsReducer( + stateWithError, + setPerpsTradingCampaignLeaderboard(mockPerpsLeaderboard), + ); + + expect(state.perpsTradingCampaignLeaderboard).toEqual(mockPerpsLeaderboard); + expect(state.perpsTradingCampaignLeaderboardError).toBe(false); + }); + + it('sets leaderboard to null', () => { + const stateWithData: RewardsState = { + ...initialState, + perpsTradingCampaignLeaderboard: mockPerpsLeaderboard, + }; + + const state = rewardsReducer( + stateWithData, + setPerpsTradingCampaignLeaderboard(null), + ); + + expect(state.perpsTradingCampaignLeaderboard).toBeNull(); + }); +}); + +describe('setPerpsTradingCampaignLeaderboardLoading', () => { + it('sets loading to true when no leaderboard is cached', () => { + const state = rewardsReducer( + initialState, + setPerpsTradingCampaignLeaderboardLoading(true), + ); + + expect(state.perpsTradingCampaignLeaderboardLoading).toBe(true); + }); + + it('does not set loading to true when leaderboard is already present', () => { + const stateWithLeaderboard: RewardsState = { + ...initialState, + perpsTradingCampaignLeaderboard: mockPerpsLeaderboard, + perpsTradingCampaignLeaderboardLoading: false, + }; + + const state = rewardsReducer( + stateWithLeaderboard, + setPerpsTradingCampaignLeaderboardLoading(true), + ); + + expect(state.perpsTradingCampaignLeaderboardLoading).toBe(false); + }); + + it('clears loading to false', () => { + const stateWithLoading: RewardsState = { + ...initialState, + perpsTradingCampaignLeaderboardLoading: true, + }; + + const state = rewardsReducer( + stateWithLoading, + setPerpsTradingCampaignLeaderboardLoading(false), + ); + + expect(state.perpsTradingCampaignLeaderboardLoading).toBe(false); + }); +}); + +describe('setPerpsTradingCampaignLeaderboardError', () => { + it('sets and clears the error flag', () => { + const withError = rewardsReducer( + initialState, + setPerpsTradingCampaignLeaderboardError(true), + ); + expect(withError.perpsTradingCampaignLeaderboardError).toBe(true); + + const cleared = rewardsReducer( + withError, + setPerpsTradingCampaignLeaderboardError(false), + ); + expect(cleared.perpsTradingCampaignLeaderboardError).toBe(false); + }); +}); + +describe('setPerpsTradingCampaignLeaderboardPosition', () => { + it('stores a position for subscription + campaign and removes on null', () => { + let state = rewardsReducer( + initialState, + setPerpsTradingCampaignLeaderboardPosition({ + subscriptionId: 'sub-p', + campaignId: 'camp-p', + position: mockPerpsPosition, + }), + ); + + expect( + state.perpsTradingCampaignLeaderboardPositions['sub-p:camp-p'], + ).toEqual(mockPerpsPosition); + + state = rewardsReducer( + state, + setPerpsTradingCampaignLeaderboardPosition({ + subscriptionId: 'sub-p', + campaignId: 'camp-p', + position: null, + }), + ); + + expect( + state.perpsTradingCampaignLeaderboardPositions['sub-p:camp-p'], + ).toBeUndefined(); + }); +}); + +describe('perps trading campaign volume', () => { + const mockVolume: RewardsState['perpsTradingCampaignVolume'] = { + totalUsdVolume: '1000000', + }; + + it('setPerpsTradingCampaignVolume sets data and clears error', () => { + const stateWithError: RewardsState = { + ...initialState, + perpsTradingCampaignVolumeError: true, + }; + + const state = rewardsReducer( + stateWithError, + setPerpsTradingCampaignVolume(mockVolume), + ); + + expect(state.perpsTradingCampaignVolume).toEqual(mockVolume); + expect(state.perpsTradingCampaignVolumeError).toBe(false); + }); + + it('setPerpsTradingCampaignVolumeLoading(true) is skipped when volume is cached', () => { + const stateWithVolume: RewardsState = { + ...initialState, + perpsTradingCampaignVolume: mockVolume, + perpsTradingCampaignVolumeLoading: false, + }; + + const state = rewardsReducer( + stateWithVolume, + setPerpsTradingCampaignVolumeLoading(true), + ); + + expect(state.perpsTradingCampaignVolumeLoading).toBe(false); + }); + + it('setPerpsTradingCampaignVolumeError toggles the flag', () => { + const on = rewardsReducer( + initialState, + setPerpsTradingCampaignVolumeError(true), + ); + expect(on.perpsTradingCampaignVolumeError).toBe(true); + + const off = rewardsReducer(on, setPerpsTradingCampaignVolumeError(false)); + expect(off.perpsTradingCampaignVolumeError).toBe(false); + }); +}); + describe('ondoCampaignDeposits', () => { it('setOndoCampaignDeposits sets data and clears error', () => { const deposits = { totalUsdDeposited: '1250000.000000' }; diff --git a/app/reducers/rewards/index.ts b/app/reducers/rewards/index.ts index 0833317ace0..b733c5c10d8 100644 --- a/app/reducers/rewards/index.ts +++ b/app/reducers/rewards/index.ts @@ -17,6 +17,9 @@ import { OndoGmPortfolioDto, OndoGmActivityEntryDto, OndoGmCampaignDepositsDto, + PerpsTradingCampaignLeaderboardDto, + PerpsTradingCampaignLeaderboardPositionDto, + PerpsTradingCampaignVolumeDto, } from '../../core/Engine/controllers/rewards-controller/types'; import { OnboardingStep } from './types'; import { AccountGroupId } from '@metamask/account-api'; @@ -165,6 +168,22 @@ export interface RewardsState { ondoCampaignDepositsLoading: boolean; ondoCampaignDepositsError: boolean; + // Perps Trading Campaign leaderboard + perpsTradingCampaignLeaderboard: PerpsTradingCampaignLeaderboardDto | null; + perpsTradingCampaignLeaderboardLoading: boolean; + perpsTradingCampaignLeaderboardError: boolean; + + // Perps Trading Campaign leaderboard position (user's own position) + perpsTradingCampaignLeaderboardPositions: Record< + string, + PerpsTradingCampaignLeaderboardPositionDto + >; + + // Perps Trading Campaign volume (public stats; UI derives prize-pool display from notional volume) + perpsTradingCampaignVolume: PerpsTradingCampaignVolumeDto | null; + perpsTradingCampaignVolumeLoading: boolean; + perpsTradingCampaignVolumeError: boolean; + // Pending deeplink navigation intent, stored in Redux so it survives the // UnmountOnBlur remount of RewardsHome when navigating from outside the tab. pendingDeeplink: PendingDeeplink | null; @@ -179,7 +198,7 @@ export interface RewardsState { */ export interface PendingDeeplink { page?: 'campaigns' | 'musd' | 'benefits'; - campaign?: 'ondo' | 'season1'; + campaign?: 'ondo' | 'season1' | 'perps-comp'; } export const initialState: RewardsState = { @@ -278,6 +297,15 @@ export const initialState: RewardsState = { ondoCampaignDepositsLoading: false, ondoCampaignDepositsError: false, + // Perps Trading Campaign initial state + perpsTradingCampaignLeaderboard: null, + perpsTradingCampaignLeaderboardLoading: false, + perpsTradingCampaignLeaderboardError: false, + perpsTradingCampaignLeaderboardPositions: {}, + perpsTradingCampaignVolume: null, + perpsTradingCampaignVolumeLoading: false, + perpsTradingCampaignVolumeError: false, + pendingDeeplink: null, dismissedCampaignOutcomeToasts: {}, @@ -700,6 +728,72 @@ const rewardsSlice = createSlice({ state.ondoCampaignDepositsError = action.payload; }, + // Perps Trading Campaign leaderboard reducers + setPerpsTradingCampaignLeaderboard: ( + state, + action: PayloadAction, + ) => { + state.perpsTradingCampaignLeaderboard = action.payload; + state.perpsTradingCampaignLeaderboardError = false; + }, + setPerpsTradingCampaignLeaderboardLoading: ( + state, + action: PayloadAction, + ) => { + if (action.payload && state.perpsTradingCampaignLeaderboard) { + return; + } + state.perpsTradingCampaignLeaderboardLoading = action.payload; + }, + setPerpsTradingCampaignLeaderboardError: ( + state, + action: PayloadAction, + ) => { + state.perpsTradingCampaignLeaderboardError = action.payload; + }, + + // Perps Trading Campaign leaderboard position reducers + setPerpsTradingCampaignLeaderboardPosition: ( + state, + action: PayloadAction<{ + subscriptionId: string; + campaignId: string; + position: PerpsTradingCampaignLeaderboardPositionDto | null; + }>, + ) => { + const key = `${action.payload.subscriptionId}:${action.payload.campaignId}`; + if (action.payload.position) { + state.perpsTradingCampaignLeaderboardPositions[key] = + action.payload.position; + } else { + delete state.perpsTradingCampaignLeaderboardPositions[key]; + } + }, + + // Perps Trading Campaign volume reducers + setPerpsTradingCampaignVolume: ( + state, + action: PayloadAction, + ) => { + state.perpsTradingCampaignVolume = action.payload; + state.perpsTradingCampaignVolumeError = false; + }, + setPerpsTradingCampaignVolumeLoading: ( + state, + action: PayloadAction, + ) => { + if (action.payload && state.perpsTradingCampaignVolume) { + return; + } + state.perpsTradingCampaignVolumeLoading = action.payload; + }, + setPerpsTradingCampaignVolumeError: ( + state, + action: PayloadAction, + ) => { + state.perpsTradingCampaignVolumeError = action.payload; + }, + // Bulk link reducers bulkLinkStarted: ( state, @@ -909,6 +1003,14 @@ export const { setOndoCampaignDeposits, setOndoCampaignDepositsLoading, setOndoCampaignDepositsError, + // Perps Trading Campaign actions + setPerpsTradingCampaignLeaderboard, + setPerpsTradingCampaignLeaderboardLoading, + setPerpsTradingCampaignLeaderboardError, + setPerpsTradingCampaignLeaderboardPosition, + setPerpsTradingCampaignVolume, + setPerpsTradingCampaignVolumeLoading, + setPerpsTradingCampaignVolumeError, // Bulk link actions bulkLinkStarted, bulkLinkAccountResult, diff --git a/app/reducers/rewards/selectors.ts b/app/reducers/rewards/selectors.ts index c5d7e2bd356..78fc6bc1d76 100644 --- a/app/reducers/rewards/selectors.ts +++ b/app/reducers/rewards/selectors.ts @@ -310,3 +310,34 @@ export const selectPendingDeeplink = (state: RootState) => export const selectDismissedCampaignOutcomeToasts = (state: RootState) => state.rewards.dismissedCampaignOutcomeToasts; + +// Perps Trading Campaign leaderboard selectors +export const selectPerpsTradingCampaignLeaderboard = (state: RootState) => + state.rewards.perpsTradingCampaignLeaderboard; + +export const selectPerpsTradingCampaignLeaderboardLoading = ( + state: RootState, +) => state.rewards.perpsTradingCampaignLeaderboardLoading; + +export const selectPerpsTradingCampaignLeaderboardError = (state: RootState) => + state.rewards.perpsTradingCampaignLeaderboardError; + +// Perps Trading Campaign leaderboard position selectors +export const selectPerpsTradingCampaignLeaderboardPositionById = + (subscriptionId: string | undefined, campaignId: string | undefined) => + (state: RootState) => + subscriptionId && campaignId + ? (state.rewards.perpsTradingCampaignLeaderboardPositions[ + `${subscriptionId}:${campaignId}` + ] ?? null) + : null; + +// Perps Trading Campaign prize pool selectors +export const selectPerpsTradingCampaignVolume = (state: RootState) => + state.rewards.perpsTradingCampaignVolume; + +export const selectPerpsTradingCampaignVolumeLoading = (state: RootState) => + state.rewards.perpsTradingCampaignVolumeLoading; + +export const selectPerpsTradingCampaignVolumeError = (state: RootState) => + state.rewards.perpsTradingCampaignVolumeError; diff --git a/app/util/test/initial-background-state.json b/app/util/test/initial-background-state.json index 2a23440052f..b6c21b31582 100644 --- a/app/util/test/initial-background-state.json +++ b/app/util/test/initial-background-state.json @@ -729,6 +729,9 @@ "ondoCampaignActivity": {}, "ondoCampaignDeposits": {}, "ondoCampaignLeaderboard": {}, + "perpsTradingCampaignLeaderboard": {}, + "perpsTradingCampaignLeaderboardPositions": {}, + "perpsTradingCampaignVolume": {}, "campaignParticipantStatus": {}, "unlockedRewards": {}, "seasonStatuses": {}, diff --git a/locales/languages/en.json b/locales/languages/en.json index fd5fa26939b..2048dfc1354 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -8593,6 +8593,46 @@ "cancel": "Cancel", "confirm": "I understand" }, + "perps_trading_campaign": { + "title": "Perps Trading Competition", + "stats_title": "Stats", + "performance_title": "Performance", + "label_rank": "Rank", + "label_your_rank": "Your rank", + "label_pnl": "PnL", + "label_volume": "Volume", + "label_notional_volume": "Notional volume", + "label_margin": "Margin", + "label_margin_deployed": "Margin deployed", + "pending": "Pending", + "qualified": "Qualified", + "open_position_cta": "Open Position", + "leaderboard_title": "Leaderboard", + "leaderboard_total_participants": "{{count}} participants", + "last_updated": "Last updated: {{time}}", + "leaderboard_error_loading": "Failed to load leaderboard", + "leaderboard_error_loading_description": "Something went wrong while loading the leaderboard. Please try again.", + "leaderboard_not_yet_computed": "Leaderboard hasn't been computed yet. Check back soon.", + "leaderboard_powered_by_prefix": "Powered by ", + "leaderboard_hypertracker_brand": "HyperTracker", + "prize_pool_title": "Prize pool", + "prize_pool_current_label": "Current", + "prize_pool_next_label": "Next", + "prize_pool_volume_subtext": "{{current}} of {{target}} volume", + "prize_pool_max_tier_subtext": "{{maxThreshold}}+ volume — all milestones reached", + "prize_pool_max_badge": "Max", + "prize_pool_error_title": "Failed to load prize pool", + "prize_pool_error_description": "There was an error loading the prize pool. Please try again.", + "prize_pool_retry_button": "Retry", + "stats_notional_volume_threshold": "${{amount}} required", + "stats_qualified_title": "You're qualified", + "stats_qualified_description": "Keep trading to improve your PnL and climb the leaderboard before the competition ends.", + "stats_qualify_for_rank_title": "Qualify for this rank", + "stats_qualify_for_rank_description": "Trade {{notionalRemaining}} more in notional volume to become eligible.", + "stats_error_title": "Unable to load stats", + "stats_error_description": "We had a problem loading your stats. Please try again.", + "stats_retry": "Retry" + }, "campaigns_preview": { "title": "Campaigns", "coming_soon": "Coming soon", From 576ce807c09abec0798bd86c2bcf346fab7699f3 Mon Sep 17 00:00:00 2001 From: Jyoti Puri Date: Tue, 5 May 2026 16:02:43 +0530 Subject: [PATCH 8/9] feat: display 5 year projected balance on money account deposit page (#29607) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Show 5 year projected balane on money account deposit page. ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/CONF-1296 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** Screenshot 2026-05-01 at 5 54 30 PM ## **Pre-merge author checklist** - [X] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [X] I've completed the PR template to the best of my ability - [X] I've included tests if applicable - [X] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [X] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > Low risk UI-only change that adds a derived display value based on APY; main risk is incorrect math/formatting or missing APY data causing the line to disappear. > > **Overview** > Shows a *projected 5-year balance* line on the custom amount confirmation when the transaction type is `moneyAccountDeposit`, replacing the usual `PayTokenAmount` display in that case. > > Adds a new `ProjectedFiveYearBalance` component that pulls vault APY via `useMoneyAccountBalance`, computes 5-year compounding with `BigNumber`, formats via `useFiatFormatter`, and safely returns `null` for loading/invalid inputs; includes unit tests and a new i18n string `confirm.custom_amount.projected_five_year_balance`. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 8c256e1b2911058f7422dd09c32f983c885b0613. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../custom-amount-info.test.tsx | 14 ++ .../custom-amount-info/custom-amount-info.tsx | 17 +- .../projected-five-year-balance/index.ts | 2 + .../projected-five-year-balance.test.tsx | 145 ++++++++++++++++++ .../projected-five-year-balance.tsx | 56 +++++++ locales/languages/en.json | 3 +- 6 files changed, 230 insertions(+), 7 deletions(-) create mode 100644 app/components/Views/confirmations/components/projected-five-year-balance/index.ts create mode 100644 app/components/Views/confirmations/components/projected-five-year-balance/projected-five-year-balance.test.tsx create mode 100644 app/components/Views/confirmations/components/projected-five-year-balance/projected-five-year-balance.tsx diff --git a/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.test.tsx b/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.test.tsx index 55e69ffe02a..13c14514deb 100644 --- a/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.test.tsx +++ b/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.test.tsx @@ -64,6 +64,20 @@ jest.mock('../../../hooks/pay/useTransactionPayWithdraw', () => ({ })), })); jest.mock('../../../../../../util/transaction-controller', () => ({})); +jest.mock('../../../../../UI/Money/hooks/useMoneyAccountBalance', () => ({ + __esModule: true, + default: () => ({ + vaultApyQuery: { data: { apy: 5.5 }, isLoading: false }, + }), +})); +jest.mock( + '../../../../../UI/SimulationDetails/FiatDisplay/useFiatFormatter', + () => ({ + __esModule: true, + default: () => (value: { toString: () => string }) => + `$${Number(value.toString()).toFixed(2)}`, + }), +); jest.mock('../../../../../../core/Engine', () => ({ context: { TransactionPayController: { diff --git a/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.tsx b/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.tsx index 95ff6dc6f93..225da89ba8d 100644 --- a/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.tsx +++ b/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.tsx @@ -2,6 +2,7 @@ import React, { ReactNode, memo, useCallback, useState } from 'react'; import { toCaipAssetType } from '@metamask/utils'; import { TransactionType } from '@metamask/transaction-controller'; import { PayTokenAmount, PayTokenAmountSkeleton } from '../../pay-token-amount'; +import { ProjectedFiveYearBalance } from '../../projected-five-year-balance'; import { PayWithRow, PayWithRowSkeleton } from '../../rows/pay-with-row'; import { BridgeFeeRow } from '../../rows/bridge-fee-row'; import { BridgeTimeRow } from '../../rows/bridge-time-row'; @@ -208,12 +209,16 @@ export const CustomAmountInfo: React.FC = memo( onPress={handleAmountPress} disabled={!hasTokens} /> - {!hidePayTokenAmount && disablePay !== true && ( - - )} + {!hidePayTokenAmount && + disablePay !== true && + (isMoneyAccountDeposit ? ( + + ) : ( + + ))} {!hidePayTokenAmount && children} ); +} + +describe('ProjectedFiveYearBalance', () => { + const formatFiat = jest.fn( + (value: BigNumber) => `$${value.toFixed(2, BigNumber.ROUND_HALF_UP)}`, + ); + + beforeEach(() => { + jest.clearAllMocks(); + useFiatFormatterMock.mockReturnValue(formatFiat); + }); + + it('renders label and projected balance for $1,000 at 5% APY over 5 years (~$1,276.28)', () => { + mockBalance({ apy: 5 }); + + const { getByTestId, getByText } = render( + , + ); + + expect(getByTestId('projected-five-year-balance')).toBeOnTheScreen(); + expect(getByText(LABEL, { exact: false })).toBeOnTheScreen(); + // 1000 * (1.05)^5 = 1276.2815625 + expect(getByText('$1276.28')).toBeOnTheScreen(); + }); + + it('matches the Figma example: $1,000 at the design APY rounds to $1,114.36 when APY=2.18', () => { + mockBalance({ apy: 2.18 }); + + const { getByText } = render( + , + ); + + // 1000 * (1.0218)^5 ≈ 1113.86 — sanity-checks the compounding formula + // tracks the figma direction (label + green dollar amount); exact APY/value + // is product-driven, this just guards the math. + expect(getByText(/^\$1\d{3}\.\d{2}$/)).toBeOnTheScreen(); + }); + + it('returns null while APY is loading', () => { + mockBalance({ apy: undefined, isLoading: true }); + + const { queryByTestId } = render( + , + ); + + expect(queryByTestId('projected-five-year-balance')).toBeNull(); + }); + + it('returns null when APY data is unavailable', () => { + mockBalance({ apy: undefined }); + + const { queryByTestId } = render( + , + ); + + expect(queryByTestId('projected-five-year-balance')).toBeNull(); + }); + + it('returns null when APY is negative', () => { + mockBalance({ apy: -1 }); + + const { queryByTestId } = render( + , + ); + + expect(queryByTestId('projected-five-year-balance')).toBeNull(); + }); + + it('returns null when APY is not finite', () => { + mockBalance({ apy: Number.POSITIVE_INFINITY }); + + const { queryByTestId } = render( + , + ); + + expect(queryByTestId('projected-five-year-balance')).toBeNull(); + }); + + it('renders $0.00 when apy is 0% (compounding identity)', () => { + mockBalance({ apy: 0 }); + + const { getByText } = render(); + + expect(getByText('$0.00')).toBeOnTheScreen(); + }); + + it('treats empty amountFiat as zero', () => { + mockBalance({ apy: 5 }); + + const { getByText } = render(); + + expect(getByText('$0.00')).toBeOnTheScreen(); + }); + + it('passes a BigNumber to the fiat formatter', () => { + mockBalance({ apy: 5 }); + + render(); + + expect(formatFiat).toHaveBeenCalledTimes(1); + const passed = formatFiat.mock.calls[0][0]; + expect(BigNumber.isBigNumber(passed)).toBe(true); + // 1000 * 1.05^5 = 1276.2815625 + expect(passed.toFixed(4)).toBe('1276.2816'); + }); + + it('returns null when amountFiat is non-numeric', () => { + mockBalance({ apy: 5 }); + + const { queryByTestId } = render( + , + ); + + expect(queryByTestId('projected-five-year-balance')).toBeNull(); + }); +}); diff --git a/app/components/Views/confirmations/components/projected-five-year-balance/projected-five-year-balance.tsx b/app/components/Views/confirmations/components/projected-five-year-balance/projected-five-year-balance.tsx new file mode 100644 index 00000000000..527af80e6ed --- /dev/null +++ b/app/components/Views/confirmations/components/projected-five-year-balance/projected-five-year-balance.tsx @@ -0,0 +1,56 @@ +import React, { useMemo } from 'react'; +import { View } from 'react-native'; +import BigNumber from 'bignumber.js'; +import { + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import useMoneyAccountBalance from '../../../../UI/Money/hooks/useMoneyAccountBalance'; +import useFiatFormatter from '../../../../UI/SimulationDetails/FiatDisplay/useFiatFormatter'; +import { strings } from '../../../../../../locales/i18n'; + +const PROJECTION_YEARS = 5; + +export interface ProjectedFiveYearBalanceProps { + amountFiat: string; +} + +export function ProjectedFiveYearBalance({ + amountFiat, +}: ProjectedFiveYearBalanceProps) { + const { vaultApyQuery } = useMoneyAccountBalance(); + const formatFiat = useFiatFormatter(); + + const projected = useMemo(() => { + const apy = vaultApyQuery.data?.apy; + if (typeof apy !== 'number' || !isFinite(apy) || apy < 0) { + return null; + } + + const amount = new BigNumber(amountFiat || '0'); + if (!amount.isFinite()) { + return null; + } + + const growthFactor = new BigNumber(1).plus( + new BigNumber(apy).dividedBy(100), + ); + return amount.multipliedBy(growthFactor.pow(PROJECTION_YEARS)); + }, [amountFiat, vaultApyQuery.data?.apy]); + + if (vaultApyQuery.isLoading || projected === null) { + return null; + } + + return ( + + + {strings('confirm.custom_amount.projected_five_year_balance')}{' '} + + {formatFiat(projected)} + + + + ); +} diff --git a/locales/languages/en.json b/locales/languages/en.json index 2048dfc1354..62fc87108e4 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -7107,7 +7107,8 @@ "custom_amount": { "buy_button": "Buy crypto", "buy_predict": "Add funds to your wallet to use Predictions.", - "buy_perps": "Add funds to your wallet to use Perps." + "buy_perps": "Add funds to your wallet to use Perps.", + "projected_five_year_balance": "Projected 5-year balance:" }, "unlimited": "Unlimited", "all": "All", From dd0a6abf9ce563f1b35c92304d118e061ec7d36e Mon Sep 17 00:00:00 2001 From: Harry Le <43307514+LeVinhGithub@users.noreply.github.com> Date: Tue, 5 May 2026 17:50:28 +0700 Subject: [PATCH 9/9] test: MMQA -1712-[Mobile] Update the mobile e2e tests to account for home page redesign enabled (#29100) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Multiple E2E tests were disabling the `homepageRedesignV1` and `homepageSectionsV1` feature flags to work around the redesigned homepage UI. This caused a test/production parity gap — tests were running against the old homepage while production users see the new redesigned version. This PR removes all `homepageRedesignV1: false` / `homepageSectionsV1: false` mock overrides and adapts the affected tests to work with the new redesigned homepage. The key change is that network switching now requires navigating to `TokensFullView` first (via the Tokens section header) since the network filter control bar only exists there, not on the homepage itself. ### Changes - Removed `homepageRedesignV1` and `homepageSectionsV1` flag overrides from 7 test files and 1 mock helper - Added `navigateToTokensFullView()`, `navigateBackFromTokensFullView()`, and `openNetworkManagerFromHomepage()` methods to the `NetworkManager` page object - Updated network switching flows in `per-dapp-selected-network`, `batch-transaction`, `stake-action-smoke`, `network-manager`, and `network-manager2` specs to go through `TokensFullView` - Added `tapSelectAllPopularNetworks()` steps in `incoming-transactions` tests to handle single-network fixtures - Added Sepolia-specific token fixture via `withTokens()` for the custom network filter test ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: [MMQA-1712](https://consensyssoftware.atlassian.net/browse/MMQA-1712) ## **Manual testing steps** Scenario 1: Network manager tests pass with redesigned homepage - Given the app launches with homepage redesign feature flags enabled (production defaults) - When user navigates to the Tokens section from the homepage - Then the TokensFullView screen is displayed with a network filter control bar - And user can open the network manager and switch networks Scenario 2: Incoming transactions test with single-network fixture - Given the app launches with only Ethereum enabled in the network map - When user navigates to TokensFullView and selects all popular networks - And user navigates back to homepage and taps the Activity tab - Then incoming transactions are displayed correctly Scenario 3: Dapp per-network transaction with redesigned homepage - Given the app is connected to a dapp on a custom Localhost network - When user switches networks via TokensFullView network manager - Then the dapp retains its per-dapp selected network - And transactions are confirmed on the correct network ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. [MMQA-1712]: https://consensyssoftware.atlassian.net/browse/MMQA-1712?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --- > [!NOTE] > **Low Risk** > Low risk since changes are limited to E2E test code and mocks, but it may cause CI failures if the redesigned homepage navigation or selectors change again. > > **Overview** > Updates multiple mobile E2E smoke specs to run against the **redesigned homepage defaults** by removing overrides that forced `homepageRedesignV1`/`homepageSectionsV1` off. > > Introduces new `NetworkManager` page-object helpers to reach the network filter via `TokensFullView`, and rewires network-switching steps across confirmation, network-manager, stake, and incoming-transactions tests (including selecting *all popular networks* where single-network fixtures would hide activity). > > Adjusts fixtures/mocks to match the new flows (e.g., `withPopularNetworks()` usage, removing explicit `withNetworkEnabledMap` in the mUSD fixture, and updating the mUSD happy-path assertion to the Cash section container). > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 61dc1b775afde0700a8adb4ccd1b6a2003c82fc2. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../mock-responses/musd/musd-mocks.ts | 2 - tests/page-objects/wallet/NetworkManager.ts | 44 ++++++++- tests/page-objects/wallet/WalletView.ts | 5 + .../confirmations/send/send-btc-token.spec.ts | 12 --- .../7702/batch-transaction.spec.ts | 10 +- .../per-dapp-selected-network.spec.ts | 18 ++-- tests/smoke/networks/network-manager.spec.ts | 97 ++++--------------- tests/smoke/networks/network-manager2.spec.ts | 80 +++++++-------- tests/smoke/stake/stake-action-smoke.spec.ts | 14 +-- tests/smoke/wallet/helpers/musd-fixture.ts | 2 +- .../wallet/incoming-transactions.spec.ts | 21 +++- .../wallet/musd-conversion-happy-path.spec.ts | 4 +- 12 files changed, 144 insertions(+), 165 deletions(-) diff --git a/tests/api-mocking/mock-responses/musd/musd-mocks.ts b/tests/api-mocking/mock-responses/musd/musd-mocks.ts index 3e1e1172ca5..4764a34093a 100644 --- a/tests/api-mocking/mock-responses/musd/musd-mocks.ts +++ b/tests/api-mocking/mock-responses/musd/musd-mocks.ts @@ -147,8 +147,6 @@ export async function setupMusdMocks( earnMusdConvertibleTokensAllowlist: { '*': ['USDC'] }, earnMusdConversionMinAssetBalanceRequired: 0.01, earnMusdConversionGeoBlockedCountries: { blockedRegions: ['GB'] }, - homepageRedesignV1: { enabled: false, minimumVersion: '0.0.0' }, - homepageSectionsV1: { enabled: false, minimumVersion: '0.0.0' }, }); await setupMockRequest(mockServer, { diff --git a/tests/page-objects/wallet/NetworkManager.ts b/tests/page-objects/wallet/NetworkManager.ts index d9a943128e2..f62e9c5bb2f 100644 --- a/tests/page-objects/wallet/NetworkManager.ts +++ b/tests/page-objects/wallet/NetworkManager.ts @@ -8,7 +8,10 @@ import { NetworkManagerSelectorText, } from '../../../app/components/UI/NetworkMultiSelector/NetworkManager.testIds'; import TestHelpers from '../../helpers'; -import { WalletViewSelectorsIDs } from '../../../app/components/Views/Wallet/WalletView.testIds'; +import { + WalletViewSelectorsIDs, + WalletViewSelectorsText, +} from '../../../app/components/Views/Wallet/WalletView.testIds'; class NetworkManager { /** @@ -192,6 +195,45 @@ class NetworkManager { await this.waitForNetworkManagerToLoad(); } + /** + * Navigate to the TokensFullView (via the homepage Tokens section header) + * so that the network filter control bar becomes accessible. + */ + async navigateToTokensFullView(): Promise { + const tokensSectionHeader = Matchers.getElementByText( + WalletViewSelectorsText.TOKENS_SECTION, + ); + await Gestures.waitAndTap(tokensSectionHeader, { + checkStability: true, + elemDescription: 'Tokens Section Header (navigate to full view)', + }); + } + + /** + * Navigate back from TokensFullView to the homepage. + */ + async navigateBackFromTokensFullView(): Promise { + const backButton = Matchers.getElementByID( + WalletViewSelectorsIDs.BACK_BUTTON, + ); + await Gestures.waitAndTap(backButton, { + elemDescription: 'Back button (return from TokensFullView)', + }); + } + + /** + * Open the network manager from the redesigned homepage. + * The TOKEN_NETWORK_FILTER control only exists in TokensFullView, + * so this navigates there first, then opens the network manager sheet. + */ + async openNetworkManagerFromHomepage(): Promise { + await this.navigateToTokensFullView(); + await Gestures.waitAndTap(this.openNetworkManagerButton, { + elemDescription: 'Open Network Manager Button (from TokensFullView)', + }); + await this.waitForNetworkManagerToLoad(); + } + /** * Check if the network manager is currently visible */ diff --git a/tests/page-objects/wallet/WalletView.ts b/tests/page-objects/wallet/WalletView.ts index 609abbbacf3..88698d2804f 100644 --- a/tests/page-objects/wallet/WalletView.ts +++ b/tests/page-objects/wallet/WalletView.ts @@ -3,6 +3,7 @@ import { WalletViewSelectorsText, } from '../../../app/components/Views/Wallet/WalletView.testIds'; import { EARN_TEST_IDS } from '../../../app/components/UI/Earn/constants/testIds'; +import { CashGetMusdEmptyStateSelectors } from '../../../app/components/Views/Homepage/Sections/Cash/CashGetMusdEmptyState.testIds'; import { SECONDARY_BALANCE_BUTTON_TEST_ID } from '../../../app/components/UI/AssetElement/index.constants'; import { PredictTabViewSelectorsIDs, @@ -339,6 +340,10 @@ class WalletView { ); } + get cashGetMusdContainer(): DetoxElement { + return Matchers.getElementByID(CashGetMusdEmptyStateSelectors.CONTAINER); + } + get getMusdButton(): DetoxElement { return Matchers.getElementByText('Get mUSD'); } diff --git a/tests/smoke/confirmations/send/send-btc-token.spec.ts b/tests/smoke/confirmations/send/send-btc-token.spec.ts index a54820eea77..388ce6182a3 100644 --- a/tests/smoke/confirmations/send/send-btc-token.spec.ts +++ b/tests/smoke/confirmations/send/send-btc-token.spec.ts @@ -5,8 +5,6 @@ import { withFixtures } from '../../../framework/fixtures/FixtureHelper'; import FixtureBuilder from '../../../framework/fixtures/FixtureBuilder'; import { loginToApp } from '../../../flows/wallet.flow'; import Assertions from '../../../framework/Assertions'; -import { Mockttp } from 'mockttp'; -import { setupRemoteFeatureFlagsMock } from '../../../api-mocking/helpers/remoteFeatureFlagsHelper'; const TOKEN = 'Bitcoin'; @@ -16,16 +14,6 @@ describe(SmokeConfirmations('Send Bitcoin'), () => { { fixture: new FixtureBuilder().build(), restartDevice: true, - testSpecificMock: async (mockServer: Mockttp) => { - await setupRemoteFeatureFlagsMock( - mockServer, - { - homepageRedesignV1: { enabled: false, minimumVersion: '0.0.0' }, - homepageSectionsV1: { enabled: false, minimumVersion: '0.0.0' }, - }, - 1000, - ); - }, }, async () => { await loginToApp(); diff --git a/tests/smoke/confirmations/transactions/7702/batch-transaction.spec.ts b/tests/smoke/confirmations/transactions/7702/batch-transaction.spec.ts index 693796babfc..427e723676a 100644 --- a/tests/smoke/confirmations/transactions/7702/batch-transaction.spec.ts +++ b/tests/smoke/confirmations/transactions/7702/batch-transaction.spec.ts @@ -6,6 +6,7 @@ import ConfirmationUITypes from '../../../../page-objects/Browser/Confirmations/ import FixtureBuilder from '../../../../framework/fixtures/FixtureBuilder'; import FooterActions from '../../../../page-objects/Browser/Confirmations/FooterActions'; import NetworkListModal from '../../../../page-objects/Network/NetworkListModal'; +import NetworkManager from '../../../../page-objects/wallet/NetworkManager'; import RowComponents from '../../../../page-objects/Browser/Confirmations/RowComponents'; import SwitchAccountModal from '../../../../page-objects/wallet/SwitchAccountModal'; import TabBarComponent from '../../../../page-objects/wallet/TabBarComponent'; @@ -46,9 +47,11 @@ const localNodeOptions = [ ]; async function changeNetworkFromNetworkListModal() { - await WalletView.tapTokenNetworkFilter(); + await NetworkManager.navigateToTokensFullView(); + await NetworkManager.openNetworkManager(); await NetworkListModal.tapOnCustomTab(); await NetworkListModal.changeNetworkTo(LOCAL_CHAIN_NAME); + await NetworkManager.navigateBackFromTokensFullView(); } async function checkConfirmationPage() { @@ -95,10 +98,7 @@ describe(SmokeConfirmations('7702 - smart account'), () => { }); await setupRemoteFeatureFlagsMock( mockServer, - Object.assign({}, ...confirmationFeatureFlags, { - homepageRedesignV1: { enabled: false, minimumVersion: '0.0.0' }, - homepageSectionsV1: { enabled: false, minimumVersion: '0.0.0' }, - }), + Object.assign({}, ...confirmationFeatureFlags), ); }; beforeAll(async () => { diff --git a/tests/smoke/confirmations/transactions/per-dapp-selected-network.spec.ts b/tests/smoke/confirmations/transactions/per-dapp-selected-network.spec.ts index 756528f1b21..73327d5eb7a 100644 --- a/tests/smoke/confirmations/transactions/per-dapp-selected-network.spec.ts +++ b/tests/smoke/confirmations/transactions/per-dapp-selected-network.spec.ts @@ -9,8 +9,8 @@ import ConfirmationFooterActions from '../../../page-objects/Browser/Confirmatio import ConfirmationUITypes from '../../../page-objects/Browser/Confirmations/ConfirmationUITypes'; import TestDApp from '../../../page-objects/Browser/TestDApp'; import NetworkListModal from '../../../page-objects/Network/NetworkListModal'; +import NetworkManager from '../../../page-objects/wallet/NetworkManager'; import TabBarComponent from '../../../page-objects/wallet/TabBarComponent'; -import WalletView from '../../../page-objects/wallet/WalletView'; import { SmokeConfirmations } from '../../../tags.js'; import Assertions from '../../../framework/Assertions'; import { loginToApp } from '../../../flows/wallet.flow'; @@ -21,24 +21,24 @@ import { confirmationFeatureFlags } from '../../../api-mocking/mock-responses/fe import { Mockttp } from 'mockttp'; import { LocalNode } from '../../../framework/types'; import { AnvilManager } from '../../../seeder/anvil-manager'; +import WalletView from '../../../page-objects/wallet/WalletView'; const LOCAL_CHAIN_ID = '0x539'; const LOCAL_CHAIN_NAME = 'Localhost'; async function changeNetworkFromNetworkListModal(networkName: string) { await TabBarComponent.tapWallet(); - await WalletView.tapTokenNetworkFilter(); + await NetworkManager.navigateToTokensFullView(); + await NetworkManager.openNetworkManager(); await NetworkListModal.changeNetworkTo(networkName); + await NetworkManager.navigateBackFromTokensFullView(); } describe(SmokeConfirmations('Dapp Network Switching'), () => { const testSpecificMock = async (mockServer: Mockttp) => { await setupRemoteFeatureFlagsMock( mockServer, - Object.assign({}, ...confirmationFeatureFlags, { - homepageRedesignV1: { enabled: false, minimumVersion: '0.0.0' }, - homepageSectionsV1: { enabled: false, minimumVersion: '0.0.0' }, - }), + Object.assign({}, ...confirmationFeatureFlags), ); }; @@ -137,11 +137,13 @@ describe(SmokeConfirmations('Dapp Network Switching'), () => { // Change the network to Localhost in app (custom network) await TabBarComponent.tapWallet(); - await WalletView.tapTokenNetworkFilter(); + await NetworkManager.navigateToTokensFullView(); + await NetworkManager.openNetworkManager(); await NetworkListModal.tapOnCustomTab(); await NetworkListModal.selectNetworkInCustomTab(LOCAL_CHAIN_NAME); - // Check activity tab (already on wallet from helper, just navigate) + await NetworkManager.navigateBackFromTokensFullView(); + await TabBarComponent.tapActivity(); await Assertions.expectTextDisplayed('Confirmed'); }, diff --git a/tests/smoke/networks/network-manager.spec.ts b/tests/smoke/networks/network-manager.spec.ts index 1013e787bc4..9e9c9793dfd 100644 --- a/tests/smoke/networks/network-manager.spec.ts +++ b/tests/smoke/networks/network-manager.spec.ts @@ -5,19 +5,8 @@ import { withFixtures } from '../../framework/fixtures/FixtureHelper'; import NetworkManager from '../../page-objects/wallet/NetworkManager'; import { NetworkToCaipChainId } from '../../../app/components/UI/NetworkMultiSelector/NetworkMultiSelector.constants'; import Assertions from '../../framework/Assertions'; -import { Mockttp } from 'mockttp'; -import { setupRemoteFeatureFlagsMock } from '../../api-mocking/helpers/remoteFeatureFlagsHelper'; -import { remoteFeatureFlagHomepageSectionsV1Enabled } from '../../api-mocking/mock-responses/feature-flags-mocks'; -import WalletView from '../../page-objects/wallet/WalletView'; -import TokensFullView from '../../page-objects/wallet/HomeSections'; describe(SmokeNetworkAbstractions('Network Manager'), () => { - const testSpecificMock = async (mockServer: Mockttp) => { - await setupRemoteFeatureFlagsMock(mockServer, { - ...remoteFeatureFlagHomepageSectionsV1Enabled(), - }); - }; - beforeAll(async () => { jest.setTimeout(170000); }); @@ -25,21 +14,13 @@ describe(SmokeNetworkAbstractions('Network Manager'), () => { it('should reflect the correct enabled networks state in the network manager', async () => { await withFixtures( { - fixture: new FixtureBuilder().build(), + fixture: new FixtureBuilder().withPopularNetworks().build(), restartDevice: true, - testSpecificMock, }, async () => { await loginToApp(); - await Assertions.expectElementToBeVisible(WalletView.container, { - description: 'Wallet homepage should be visible', - }); - - await WalletView.tapOnNewTokensSection(); - await TokensFullView.waitForVisible(); - - await NetworkManager.openNetworkManager(); + await NetworkManager.openNetworkManagerFromHomepage(); await Assertions.expectElementToBeVisible( NetworkManager.popularNetworksContainer, @@ -47,10 +28,11 @@ describe(SmokeNetworkAbstractions('Network Manager'), () => { // Default fixture starts with Polygon as the active chain, so a single // network is selected rather than "all networks" await Assertions.expectElementToBeVisible( - NetworkManager.selectAllPopularNetworksNotSelected, + NetworkManager.selectAllPopularNetworksSelected, ); - // Verify individual networks reflect their selected/not-selected state + // Verify individual popular networks are in the "not selected" state + // (since "Select All" is selected, individual rows show as not-selected) const popularNetworks = [ NetworkToCaipChainId.ETHEREUM, NetworkToCaipChainId.LINEA, @@ -67,28 +49,16 @@ describe(SmokeNetworkAbstractions('Network Manager'), () => { it('should reflect the enabled networks state in the network manager, when all popular networks are selected', async () => { await withFixtures( { - fixture: new FixtureBuilder().build(), + fixture: new FixtureBuilder().withPopularNetworks().build(), restartDevice: true, - testSpecificMock, }, async () => { await loginToApp(); + await NetworkManager.openNetworkManagerFromHomepage(); + // verify popular networks container is visible + await NetworkManager.checkPopularNetworksContainerIsVisible(); - await Assertions.expectElementToBeVisible(WalletView.container, { - description: 'Wallet homepage should be visible', - }); - - await WalletView.tapOnNewTokensSection(); - await TokensFullView.waitForVisible(); - - await NetworkManager.openNetworkManager(); - - // Default fixture starts with Polygon selected — tap "Select All" to - // move into the all-networks-selected state. This dismisses the sheet. - await NetworkManager.tapSelectAllPopularNetworks(); - - // Re-open to verify the all-selected state persisted - await NetworkManager.openNetworkManager(); + // verify all popular networks are selected await NetworkManager.checkAllPopularNetworksIsSelected(); }, ); @@ -99,33 +69,25 @@ describe(SmokeNetworkAbstractions('Network Manager'), () => { { fixture: new FixtureBuilder().build(), restartDevice: true, - testSpecificMock, }, async () => { await loginToApp(); + // Navigate to TokensFullView then open network manager + await NetworkManager.openNetworkManagerFromHomepage(); - await Assertions.expectElementToBeVisible(WalletView.container, { - description: 'Wallet homepage should be visible', - }); - - await WalletView.tapOnNewTokensSection(); - await TokensFullView.waitForVisible(); - - // Default fixture starts with Polygon selected (known bug with - // homepageSectionsV1 flag). Select Ethereum and verify the control bar. - await NetworkManager.openNetworkManager(); + // Select Ethereum — sheet closes, lands back on TokensFullView await NetworkManager.tapNetwork(NetworkToCaipChainId.ETHEREUM); await NetworkManager.checkBaseControlBarText( NetworkToCaipChainId.ETHEREUM, ); - // Re-open and verify Ethereum is marked as selected + // Re-open network manager (already in TokensFullView) await NetworkManager.openNetworkManager(); await NetworkManager.checkNetworkIsSelected( NetworkToCaipChainId.ETHEREUM, ); - // Switch to Linea and verify the control bar updates + // Select Linea and check if Ethereum is deselected await NetworkManager.tapNetwork(NetworkToCaipChainId.LINEA); await NetworkManager.checkBaseControlBarText( NetworkToCaipChainId.LINEA, @@ -145,32 +107,24 @@ describe(SmokeNetworkAbstractions('Network Manager'), () => { { fixture: new FixtureBuilder().build(), restartDevice: true, - testSpecificMock, }, async () => { await loginToApp(); - - await Assertions.expectElementToBeVisible(WalletView.container, { - description: 'Wallet homepage should be visible', - }); - - await WalletView.tapOnNewTokensSection(); - await TokensFullView.waitForVisible(); - - await NetworkManager.openNetworkManager(); + // Navigate to TokensFullView then open network manager + await NetworkManager.openNetworkManagerFromHomepage(); + await NetworkManager.checkPopularNetworksContainerIsVisible(); // Tap custom networks tab and check custom networks container is visible await NetworkManager.tapCustomNetworksTab(); await NetworkManager.checkCustomNetworksContainerIsVisible(); - // Tap localhost network and check base control bar text + // Tap localhost network — sheet closes, lands back on TokensFullView await NetworkManager.tapNetwork(NetworkToCaipChainId.LOCALHOST); await NetworkManager.checkBaseControlBarText( NetworkToCaipChainId.LOCALHOST, ); - // Re-open and verify network manager defaults back to custom tab - // since the last selected network was a custom network + // Re-open network manager (already in TokensFullView) await NetworkManager.openNetworkManager(); await NetworkManager.checkCustomNetworksContainerIsVisible(); }, @@ -182,19 +136,10 @@ describe(SmokeNetworkAbstractions('Network Manager'), () => { { fixture: new FixtureBuilder().build(), restartDevice: true, - testSpecificMock, }, async () => { await loginToApp(); - - await Assertions.expectElementToBeVisible(WalletView.container, { - description: 'Wallet homepage should be visible', - }); - - await WalletView.tapOnNewTokensSection(); - await TokensFullView.waitForVisible(); - - await NetworkManager.openNetworkManager(); + await NetworkManager.openNetworkManagerFromHomepage(); await NetworkManager.checkPopularNetworksContainerIsVisible(); }, ); diff --git a/tests/smoke/networks/network-manager2.spec.ts b/tests/smoke/networks/network-manager2.spec.ts index 7d53ece2e3b..767a4db7ac6 100644 --- a/tests/smoke/networks/network-manager2.spec.ts +++ b/tests/smoke/networks/network-manager2.spec.ts @@ -15,19 +15,9 @@ import ConnectBottomSheet from '../../page-objects/Browser/ConnectBottomSheet'; import { CustomNetworks } from '../../resources/networks.e2e'; import { Mockttp } from 'mockttp'; import { setupRemoteFeatureFlagsMock } from '../../api-mocking/helpers/remoteFeatureFlagsHelper'; -import { remoteFeatureFlagHomepageSectionsV1Enabled } from '../../api-mocking/mock-responses/feature-flags-mocks'; -import WalletView from '../../page-objects/wallet/WalletView'; -import TokensFullView from '../../page-objects/wallet/HomeSections'; const POLYGON = CustomNetworks.Tenderly.Polygon.providerConfig.nickname; -const testSpecificMock = async (mockServer: Mockttp) => { - await setupRemoteFeatureFlagsMock(mockServer, { - carouselBanners: false, - ...remoteFeatureFlagHomepageSectionsV1Enabled(), - }); -}; - describe(SmokeNetworkAbstractions('Network Manager'), () => { beforeAll(async () => { jest.setTimeout(170000); @@ -43,8 +33,6 @@ describe(SmokeNetworkAbstractions('Network Manager'), () => { const solanaTestMock = async (mockServer: Mockttp) => { await setupRemoteFeatureFlagsMock(mockServer, { carouselBanners: false, - homepageRedesignV1: { enabled: false, minimumVersion: '0.0.0' }, - homepageSectionsV1: { enabled: false, minimumVersion: '0.0.0' }, }); }; @@ -89,11 +77,13 @@ describe(SmokeNetworkAbstractions('Network Manager'), () => { async () => { await loginToApp(); - // Open network manager directly (old homepage flow, no TokensFullView) - await NetworkManager.openNetworkManager(); + // Navigate to TokensFullView, then open network manager + await NetworkManager.openNetworkManagerFromHomepage(); + await NetworkManager.waitForNetworkManagerToLoad(); + await NetworkManager.checkPopularNetworksContainerIsVisible(); await NetworkManager.checkTabIsSelected('Popular'); - // Select Solana network + // Select Solana network — sheet closes, lands on TokensFullView await NetworkManager.tapNetwork(NetworkToCaipChainId.SOLANA); // Verify SOL is visible in the Solana-filtered view @@ -134,23 +124,17 @@ describe(SmokeNetworkAbstractions('Network Manager'), () => { ]) .build(), restartDevice: true, - testSpecificMock, }, async () => { await loginToApp(); - await Assertions.expectElementToBeVisible(WalletView.container, { - description: 'Wallet homepage should be visible', - }); - - await WalletView.tapOnNewTokensSection(); - await TokensFullView.waitForVisible(); - - // Open network manager and verify initial state - await NetworkManager.openNetworkManager(); + // Navigate to TokensFullView, then open network manager + await NetworkManager.openNetworkManagerFromHomepage(); + await NetworkManager.waitForNetworkManagerToLoad(); + await NetworkManager.checkPopularNetworksContainerIsVisible(); await NetworkManager.checkTabIsSelected('Popular'); - // Select Ethereum network + // Select Ethereum network — sheet closes, lands on TokensFullView await NetworkManager.tapNetwork(NetworkToCaipChainId.ETHEREUM); await NetworkManager.checkBaseControlBarText( NetworkToCaipChainId.ETHEREUM, @@ -195,29 +179,34 @@ describe(SmokeNetworkAbstractions('Network Manager'), () => { name: 'LineaETH', }, ]) + .withTokens( + [ + { + address: '0x0000000000000000000000000000000000000000', + symbol: 'SepoliaETH', + decimals: 18, + name: 'SepoliaETH', + }, + ], + '0xaa36a7', // Sepolia chain ID + ) .build(), restartDevice: true, - testSpecificMock, }, async () => { await loginToApp(); - await Assertions.expectElementToBeVisible(WalletView.container, { - description: 'Wallet homepage should be visible', - }); - - await WalletView.tapOnNewTokensSection(); - await TokensFullView.waitForVisible(); - - // Open network manager and verify initial state - await NetworkManager.openNetworkManager(); + // Navigate to TokensFullView, then open network manager + await NetworkManager.openNetworkManagerFromHomepage(); + await NetworkManager.waitForNetworkManagerToLoad(); + await NetworkManager.checkPopularNetworksContainerIsVisible(); // Switch to custom networks tab await NetworkManager.tapCustomNetworksTab(); await NetworkManager.checkCustomNetworksContainerIsVisible(); await NetworkManager.checkTabIsSelected('Custom'); - // Select a custom network (Linea Sepolia) + // Select a custom network (Linea Sepolia) — sheet closes, lands on TokensFullView await NetworkManager.tapNetwork(NetworkToCaipChainId.ETHEREUM_SEPOLIA); await NetworkManager.checkBaseControlBarText( @@ -243,8 +232,6 @@ describe(SmokeNetworkAbstractions('Network Manager'), () => { const dappTestMock = async (mockServer: Mockttp) => { await setupRemoteFeatureFlagsMock(mockServer, { carouselBanners: false, - homepageRedesignV1: { enabled: false, minimumVersion: '0.0.0' }, - homepageSectionsV1: { enabled: false, minimumVersion: '0.0.0' }, }); }; @@ -269,17 +256,21 @@ describe(SmokeNetworkAbstractions('Network Manager'), () => { async () => { await loginToApp(); - // Step 1: Open the network manager from the wallet homepage directly - // (homepageSectionsV1 is disabled for this test — old tab bar is active) - await NetworkManager.openNetworkManager(); + // Step 1: Navigate to TokensFullView, then select Ethereum + await NetworkManager.openNetworkManagerFromHomepage(); + await NetworkManager.waitForNetworkManagerToLoad(); + await NetworkManager.checkPopularNetworksContainerIsVisible(); await NetworkManager.checkTabIsSelected('Popular'); - // Select Ethereum as the active network + // Select Ethereum as the active network — sheet closes, lands on TokensFullView await NetworkManager.tapNetwork(NetworkToCaipChainId.ETHEREUM); await NetworkManager.checkBaseControlBarText( NetworkToCaipChainId.ETHEREUM, ); + // Go back to homepage before navigating to browser + await NetworkManager.navigateBackFromTokensFullView(); + // Step 2: Navigate to dapp and request network addition await navigateToBrowserView(); await Browser.navigateToTestDApp(); @@ -311,7 +302,8 @@ describe(SmokeNetworkAbstractions('Network Manager'), () => { await Browser.tapCloseBrowserButton(); await TabBarComponent.tapWallet(); - // Verify Ethereum is still the active network (preservation) + // Navigate to TokensFullView to verify Ethereum is still the active network + await NetworkManager.navigateToTokensFullView(); await NetworkManager.checkBaseControlBarText( NetworkToCaipChainId.ETHEREUM, ); diff --git a/tests/smoke/stake/stake-action-smoke.spec.ts b/tests/smoke/stake/stake-action-smoke.spec.ts index b0fe488ccf0..90dfd41c37d 100644 --- a/tests/smoke/stake/stake-action-smoke.spec.ts +++ b/tests/smoke/stake/stake-action-smoke.spec.ts @@ -9,6 +9,7 @@ import FixtureBuilder, { } from '../../framework/fixtures/FixtureBuilder'; import WalletView from '../../page-objects/wallet/WalletView'; import NetworkListModal from '../../page-objects/Network/NetworkListModal'; +import NetworkManager from '../../page-objects/wallet/NetworkManager'; import { SmokeStake } from '../../tags'; import Assertions from '../../framework/Assertions'; import StakeView from '../../page-objects/Stake/StakeView'; @@ -16,7 +17,6 @@ import { AnvilPort } from '../../framework/fixtures/FixtureUtils'; import { AnvilManager } from '../../seeder/anvil-manager'; import { Mockttp } from 'mockttp'; import { setupMockRequest } from '../../api-mocking/helpers/mockHelpers'; -import { setupRemoteFeatureFlagsMock } from '../../api-mocking/helpers/remoteFeatureFlagsHelper'; describe(SmokeStake('Stake from Actions'), (): void => { const FIRST_ROW: number = 0; @@ -61,11 +61,6 @@ describe(SmokeStake('Stake from Actions'), (): void => { ], restartDevice: true, testSpecificMock: async (mockServer: Mockttp) => { - await setupRemoteFeatureFlagsMock(mockServer, { - homepageRedesignV1: { enabled: false, minimumVersion: '0.0.0' }, - homepageSectionsV1: { enabled: false, minimumVersion: '0.0.0' }, - }); - // Mock Accounts API V4 (flat array) so the app reports correct ETH balance. // Without this, the default mock returns 0 balance and the Earn button // is hidden (StakeButton returns null when balanceFiatNumber < 0.01). @@ -175,11 +170,12 @@ describe(SmokeStake('Stake from Actions'), (): void => { // Go back to Home tab await TabBarComponent.tapHome(); - // Open network picker and select Localhost - await WalletView.tapTokenNetworkFilter(); + // Navigate to TokensFullView and filter by Localhost + await NetworkManager.navigateToTokensFullView(); + await NetworkManager.openNetworkManager(); await NetworkListModal.changeNetworkTo('Localhost'); - // Verify staked asset in wallet + // Verify staked asset in wallet (now in TokensFullView) await Assertions.expectTextDisplayed('Staked Ethereum'); await Assertions.expectTextDisplayed('1 ETH'); await Assertions.expectTextDisplayed('$4,291.85'); diff --git a/tests/smoke/wallet/helpers/musd-fixture.ts b/tests/smoke/wallet/helpers/musd-fixture.ts index 6141e1c3605..a1cb9f112a7 100644 --- a/tests/smoke/wallet/helpers/musd-fixture.ts +++ b/tests/smoke/wallet/helpers/musd-fixture.ts @@ -64,6 +64,7 @@ export async function createMusdFixture( ]; return new FixtureBuilder() + .withPopularNetworks() .withNetworkController({ chainId: CHAIN_IDS.MAINNET, rpcUrl: `http://localhost:${rpcPort}`, @@ -71,7 +72,6 @@ export async function createMusdFixture( nickname: 'Ethereum Mainnet', ticker: 'ETH', }) - .withNetworkEnabledMap({ eip155: { [CHAIN_IDS.MAINNET]: true } }) .withMetaMetricsOptIn() .withTokensForAllPopularNetworks(baseTokens) .withTokenRates( diff --git a/tests/smoke/wallet/incoming-transactions.spec.ts b/tests/smoke/wallet/incoming-transactions.spec.ts index 6fc378af2b5..b5c46a49497 100644 --- a/tests/smoke/wallet/incoming-transactions.spec.ts +++ b/tests/smoke/wallet/incoming-transactions.spec.ts @@ -20,8 +20,8 @@ import TabBarComponent from '../../page-objects/wallet/TabBarComponent'; import ToastModal from '../../page-objects/wallet/ToastModal'; import { MockApiEndpoint, TestSpecificMock } from '../../framework/types'; import { setupMockRequest } from '../../api-mocking/helpers/mockHelpers'; -import { setupRemoteFeatureFlagsMock } from '../../api-mocking/helpers/remoteFeatureFlagsHelper'; import UnifiedTransactionsView from '../../page-objects/Transactions/UnifiedTransactionsView'; +import NetworkManager from '../../page-objects/wallet/NetworkManager'; // EVM-only account tree to prevent Solana snap from fetching live transactions const EVM_ONLY_ACCOUNT_TREE = { @@ -125,10 +125,6 @@ function createAccountsTestSpecificMock( transactions: Record[] = [], ): TestSpecificMock { return async (mockServer: Mockttp) => { - await setupRemoteFeatureFlagsMock(mockServer, { - homepageRedesignV1: { enabled: false, minimumVersion: '0.0.0' }, - homepageSectionsV1: { enabled: false, minimumVersion: '0.0.0' }, - }); const mock = mockAccountsApi(transactions); await setupMockRequest(mockServer, { requestMethod: 'GET', @@ -192,6 +188,11 @@ describe(SmokeWalletPlatform('Incoming Transactions'), () => { }, async () => { await loginToApp(); + await NetworkManager.navigateToTokensFullView(); + await NetworkManager.openNetworkManager(); + await NetworkManager.tapSelectAllPopularNetworks(); + await NetworkManager.navigateBackFromTokensFullView(); + await TabBarComponent.tapActivity(); await UnifiedTransactionsView.swipeDown(); await Assertions.expectTextDisplayed('Received ETH'); @@ -249,6 +250,11 @@ describe(SmokeWalletPlatform('Incoming Transactions'), () => { }, async () => { await loginToApp(); + await NetworkManager.navigateToTokensFullView(); + await NetworkManager.openNetworkManager(); + await NetworkManager.tapSelectAllPopularNetworks(); + await NetworkManager.navigateBackFromTokensFullView(); + await TabBarComponent.tapActivity(); await UnifiedTransactionsView.swipeDown(); await Assertions.expectTextDisplayed('Sent ETH'); @@ -274,6 +280,11 @@ describe(SmokeWalletPlatform('Incoming Transactions'), () => { }, async () => { await loginToApp(); + await NetworkManager.navigateToTokensFullView(); + await NetworkManager.openNetworkManager(); + await NetworkManager.tapSelectAllPopularNetworks(); + await NetworkManager.navigateBackFromTokensFullView(); + await TabBarComponent.tapActivity(); await UnifiedTransactionsView.swipeDown(); await Assertions.expectTextNotDisplayed('Received ETH'); diff --git a/tests/smoke/wallet/musd-conversion-happy-path.spec.ts b/tests/smoke/wallet/musd-conversion-happy-path.spec.ts index 8be43612e22..2c601717318 100644 --- a/tests/smoke/wallet/musd-conversion-happy-path.spec.ts +++ b/tests/smoke/wallet/musd-conversion-happy-path.spec.ts @@ -76,10 +76,10 @@ describe(SmokeWalletPlatform('mUSD Conversion Happy Path'), () => { // Verify mUSD CTA is visible and tap Get mUSD await Assertions.expectElementToBeVisible( - WalletView.musdConversionCta, + WalletView.cashGetMusdContainer, { timeout: 30000, - description: 'mUSD conversion CTA should be visible', + description: 'Cash section Get mUSD container should be visible', }, ); await WalletView.tapGetMusdButton();