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/actions/setup-e2e-env/action.yml b/.github/actions/setup-e2e-env/action.yml
index 0e54460e06e..07669962f93 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
@@ -387,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' }}
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
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/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/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/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/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 }),
+ },
+ });
+}
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..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",
@@ -8593,6 +8594,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",
diff --git a/package.json b/package.json
index 8b22e7d6c60..5b02f70ebb9 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",
@@ -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/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();
diff --git a/yarn.lock b/yarn.lock
index 2b86a672343..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
@@ -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
@@ -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"