Skip to content

Commit 1c7df7f

Browse files
authored
fix(rewards): crown logic in perps leaderboard (#29887)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until this PR meets the canonical Definition of Ready For Review in `docs/readme/ready-for-review.md`. In short: the template must be materially complete (not just section titles present), all status checks must be currently passing, and the only expected follow-up commits must be reviewer-driven. --> ## **Description** Fix wrong number of crowns shown in Perps leaderboard <!-- Write a short description of the changes included in this pull request, also include relevant motivation and context. Have in mind the following questions: 1. What is the reason for the change? 2. What is the improvement/solution? --> ## **Changelog** <!-- If this PR is not End-User-Facing and should not show up in the CHANGELOG, you can choose to either: 1. Write `CHANGELOG entry: null` 2. Label with `no-changelog` If this PR is End-User-Facing, please write a short User-Facing description in the past tense like: `CHANGELOG entry: Added a new tab for users to see their NFTs` `CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker` (This helps the Release Engineer do their job more quickly and accurately) --> CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** <!-- [screenshots/recordings] --> ## **Pre-merge author checklist** <!-- Every checklist item must be consciously assessed before marking this PR as "Ready for review". A checked box means you deliberately considered that responsibility, not that you literally performed every action listed. Unchecked boxes are ambiguous: they are not an implicit "N/A" and they are not a silent "skip". See `docs/readme/ready-for-review.md` for the full checklist semantics. --> - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [x] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [x] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [x] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** <!-- Reviewer checklist items follow the same semantics as the author checklist: an unchecked box is ambiguous, a checked box means the reviewer consciously assessed that responsibility. See `docs/readme/ready-for-review.md`. --> - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Low risk UI logic change that only affects when the crown icon renders; covered by updated/added unit tests for winner thresholds and preview mode. > > **Overview** > Fixes crown rendering so leaderboards only show crowns for *actual winner ranks*. > > `CampaignLeaderboardEntryRow` now renders a crown strictly based on the `showCrown` prop (no internal rank check), and `OndoLeaderboard`/`PerpsTradingCampaignLeaderboard` now compute `showCrown` using new constants (`ONDO_GM_TIER_MAX_WINNERS = 5`, `PERPS_TRADING_MAX_WINNERS = 20`) while still disabling crowns in preview mode. > > Adds/updates tests to assert correct crown behavior for winner thresholds and preview vs full views across the shared row, Ondo, and Perps leaderboards. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 900c4bd. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 4517630 commit 1c7df7f

8 files changed

Lines changed: 121 additions & 8 deletions

app/components/UI/Rewards/components/Campaigns/CampaignLeaderboard.test.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ jest.mock('./OndoCampaignStatsSummary', () => {
4141
});
4242

4343
const IDS = CAMPAIGN_LEADERBOARD_SHARED_TEST_IDS;
44+
const CrownIcon = 'CrownIcon' as unknown as React.ComponentType;
4445

4546
const baseEntry = {
4647
rank: 7,
@@ -68,6 +69,31 @@ describe('CampaignLeaderboardEntryRow', () => {
6869
expect(isPositivePrimaryMetric).toHaveBeenCalledWith(baseEntry);
6970
});
7071

72+
it('shows crown based only on showCrown', () => {
73+
const { UNSAFE_queryAllByType } = render(
74+
<CampaignLeaderboardEntryRow
75+
entry={baseEntry}
76+
showCrown
77+
formatPrimaryMetric={() => '+12.5%'}
78+
isPositivePrimaryMetric={() => true}
79+
/>,
80+
);
81+
82+
expect(UNSAFE_queryAllByType(CrownIcon)).toHaveLength(1);
83+
});
84+
85+
it('hides crown when showCrown is false', () => {
86+
const { UNSAFE_queryAllByType } = render(
87+
<CampaignLeaderboardEntryRow
88+
entry={{ ...baseEntry, rank: 1 }}
89+
formatPrimaryMetric={() => '+12.5%'}
90+
isPositivePrimaryMetric={() => true}
91+
/>,
92+
);
93+
94+
expect(UNSAFE_queryAllByType(CrownIcon)).toHaveLength(0);
95+
});
96+
7197
it('sets row testID from shared ENTRY_ROW and rank', () => {
7298
const { getByTestId } = render(
7399
<CampaignLeaderboardEntryRow

app/components/UI/Rewards/components/Campaigns/CampaignLeaderboard.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,9 +92,7 @@ export function CampaignLeaderboardEntryRow<
9292
>
9393
{entry.referralCode}
9494
</Text>
95-
{showCrown && entry.rank <= 5 && (
96-
<CrownIcon name="crown" width={14} height={14} />
97-
)}
95+
{showCrown && <CrownIcon name="crown" width={14} height={14} />}
9896
</Box>
9997
{showPendingTag && (
10098
<PendingTag

app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.test.tsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import OndoLeaderboard, {
44
CAMPAIGN_LEADERBOARD_TEST_IDS,
55
} from './OndoLeaderboard';
66
import type { CampaignLeaderboardEntry } from '../../../../../core/Engine/controllers/rewards-controller/types';
7+
import { ONDO_GM_TIER_MAX_WINNERS } from '../../utils/ondoCampaignConstants';
78
import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics';
89
import {
910
createMockUseAnalyticsHook,
@@ -32,6 +33,8 @@ jest.mock('@metamask/design-system-twrnc-preset', () => {
3233
return { useTailwind: () => tw };
3334
});
3435

36+
jest.mock('../../../../../images/rewards/crown.svg', () => 'CrownIcon');
37+
3538
jest.mock('../RewardsErrorBanner', () => {
3639
const ReactActual = jest.requireActual('react');
3740
const { View, Text, Pressable } = jest.requireActual('react-native');
@@ -89,6 +92,8 @@ jest.mock('../../../../../../locales/i18n', () => ({
8992
},
9093
}));
9194

95+
const CrownIcon = 'CrownIcon' as unknown as React.ComponentType;
96+
9297
const createMockEntry = (
9398
overrides: Partial<CampaignLeaderboardEntry> = {},
9499
): CampaignLeaderboardEntry => ({
@@ -337,6 +342,40 @@ describe('OndoLeaderboard', () => {
337342

338343
expect(getByText('No entries in this tier')).toBeDefined();
339344
});
345+
346+
it('shows crown in full view for Ondo winner ranks only', () => {
347+
const entries = [
348+
createMockEntry({
349+
rank: ONDO_GM_TIER_MAX_WINNERS,
350+
referralCode: 'WINNER',
351+
}),
352+
createMockEntry({
353+
rank: ONDO_GM_TIER_MAX_WINNERS + 1,
354+
referralCode: 'NEXT',
355+
}),
356+
];
357+
const { UNSAFE_queryAllByType } = render(
358+
<OndoLeaderboard {...defaultProps} entries={entries} />,
359+
);
360+
361+
expect(UNSAFE_queryAllByType(CrownIcon)).toHaveLength(1);
362+
});
363+
364+
it('hides crown in preview mode for Ondo winner ranks', () => {
365+
const entries = [
366+
createMockEntry({ rank: 1, referralCode: 'AAA111' }),
367+
createMockEntry({ rank: 2, referralCode: 'BBB222' }),
368+
];
369+
const { UNSAFE_queryAllByType } = render(
370+
<OndoLeaderboard
371+
{...defaultProps}
372+
entries={entries}
373+
maxEntries={entries.length}
374+
/>,
375+
);
376+
377+
expect(UNSAFE_queryAllByType(CrownIcon)).toHaveLength(0);
378+
});
340379
});
341380

342381
describe('currentUserReferralCode highlighting', () => {

app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
formatRateOfReturn,
2828
formatTierDisplayName,
2929
} from './OndoLeaderboard.utils';
30+
import { ONDO_GM_TIER_MAX_WINNERS } from '../../utils/ondoCampaignConstants';
3031

3132
export const CAMPAIGN_LEADERBOARD_TEST_IDS = {
3233
CONTAINER: 'campaign-leaderboard-container',
@@ -275,7 +276,7 @@ const OndoLeaderboard: React.FC<CampaignLeaderboardProps> = ({
275276
key={`${entry.rank}-${entry.referralCode}`}
276277
entry={entry}
277278
isCurrentUser={isCurrentUser(entry)}
278-
showCrown={!isPreview}
279+
showCrown={!isPreview && entry.rank <= ONDO_GM_TIER_MAX_WINNERS}
279280
isCampaignComplete={isCampaignComplete}
280281
formatPrimaryMetric={(e) => formatRateOfReturn(e.rateOfReturn)}
281282
isPositivePrimaryMetric={(e) => e.rateOfReturn >= 0}
@@ -289,7 +290,9 @@ const OndoLeaderboard: React.FC<CampaignLeaderboardProps> = ({
289290
key={`neighbor-${entry.rank}-${entry.referralCode}`}
290291
entry={entry}
291292
isCurrentUser={isCurrentUser(entry)}
292-
showCrown={!isPreview}
293+
showCrown={
294+
!isPreview && entry.rank <= ONDO_GM_TIER_MAX_WINNERS
295+
}
293296
isCampaignComplete={isCampaignComplete}
294297
formatPrimaryMetric={(e) =>
295298
formatRateOfReturn(e.rateOfReturn)

app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignLeaderboard.test.tsx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import PerpsTradingCampaignLeaderboard, {
44
PERPS_CAMPAIGN_LEADERBOARD_TEST_IDS,
55
} from './PerpsTradingCampaignLeaderboard';
66
import type { PerpsTradingCampaignLeaderboardEntry } from '../../../../../core/Engine/controllers/rewards-controller/types';
7+
import { PERPS_TRADING_MAX_WINNERS } from '../../utils/perpsCampaignConstants';
78

89
jest.mock('@metamask/design-system-react-native', () => {
910
const actual = jest.requireActual('@metamask/design-system-react-native');
@@ -50,6 +51,7 @@ jest.mock('../../../../../constants/navigation/Routes', () => ({
5051
}));
5152

5253
const TEST_IDS = PERPS_CAMPAIGN_LEADERBOARD_TEST_IDS;
54+
const CrownIcon = 'CrownIcon' as unknown as React.ComponentType;
5355

5456
const createPerpsEntry = (
5557
overrides: Partial<PerpsTradingCampaignLeaderboardEntry> = {},
@@ -107,6 +109,40 @@ describe('PerpsTradingCampaignLeaderboard', () => {
107109
);
108110
});
109111

112+
it('shows crown in full view for perps winner ranks only', () => {
113+
const entries = [
114+
createPerpsEntry({
115+
rank: PERPS_TRADING_MAX_WINNERS,
116+
referralCode: 'WINNER',
117+
}),
118+
createPerpsEntry({
119+
rank: PERPS_TRADING_MAX_WINNERS + 1,
120+
referralCode: 'NEXT',
121+
}),
122+
];
123+
const { UNSAFE_queryAllByType } = render(
124+
<PerpsTradingCampaignLeaderboard {...defaultProps} entries={entries} />,
125+
);
126+
127+
expect(UNSAFE_queryAllByType(CrownIcon)).toHaveLength(1);
128+
});
129+
130+
it('hides crown in preview mode for perps winner ranks', () => {
131+
const entries = [
132+
createPerpsEntry({ rank: 1, referralCode: 'AAA111' }),
133+
createPerpsEntry({ rank: 2, referralCode: 'BBB222' }),
134+
];
135+
const { UNSAFE_queryAllByType } = render(
136+
<PerpsTradingCampaignLeaderboard
137+
{...defaultProps}
138+
entries={entries}
139+
maxEntries={entries.length}
140+
/>,
141+
);
142+
143+
expect(UNSAFE_queryAllByType(CrownIcon)).toHaveLength(0);
144+
});
145+
110146
describe('split view top count (preview vs full, ranks 21–22 vs other)', () => {
111147
const tenEntries = Array.from({ length: 10 }, (_, i) =>
112148
createPerpsEntry({

app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignLeaderboard.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@ import {
1717
CAMPAIGN_LEADERBOARD_SHARED_TEST_IDS,
1818
} from './CampaignLeaderboard';
1919
import Routes from '../../../../../constants/navigation/Routes';
20-
import { HYPERTRACKER_ATTRIBUTION_URL } from '../../utils/perpsCampaignConstants';
20+
import {
21+
HYPERTRACKER_ATTRIBUTION_URL,
22+
PERPS_TRADING_MAX_WINNERS,
23+
} from '../../utils/perpsCampaignConstants';
2124

2225
export const PERPS_CAMPAIGN_LEADERBOARD_TEST_IDS = {
2326
CONTAINER: 'perps-campaign-leaderboard-container',
@@ -191,7 +194,7 @@ const PerpsTradingCampaignLeaderboard: React.FC<
191194
key={`${entry.rank}-${entry.referralCode}`}
192195
entry={entry}
193196
isCurrentUser={isCurrentUser(entry)}
194-
showCrown={!isPreview}
197+
showCrown={!isPreview && entry.rank <= PERPS_TRADING_MAX_WINNERS}
195198
isCampaignComplete={isCampaignComplete}
196199
formatPrimaryMetric={(e) => formatSignedUsd(e.pnl)}
197200
isPositivePrimaryMetric={(e) => e.pnl >= 0}
@@ -205,7 +208,9 @@ const PerpsTradingCampaignLeaderboard: React.FC<
205208
key={`neighbor-${entry.rank}-${entry.referralCode}`}
206209
entry={entry}
207210
isCurrentUser={isCurrentUser(entry)}
208-
showCrown={!isPreview}
211+
showCrown={
212+
!isPreview && entry.rank <= PERPS_TRADING_MAX_WINNERS
213+
}
209214
isCampaignComplete={isCampaignComplete}
210215
formatPrimaryMetric={(e) => formatSignedUsd(e.pnl)}
211216
isPositivePrimaryMetric={(e) => e.pnl >= 0}

app/components/UI/Rewards/utils/ondoCampaignConstants.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ import { getCampaignStatus } from '../components/Campaigns/CampaignTile.utils';
88
*/
99
export const ONDO_GM_REQUIRED_QUALIFIED_DAYS = 10;
1010

11+
/** Maximum winners per tier for Ondo GM campaigns. */
12+
export const ONDO_GM_TIER_MAX_WINNERS = 5;
13+
1114
/**
1215
* Returns true when the active campaign no longer has enough calendar days
1316
* remaining for the required qualifying-day count to be accumulated.

app/components/UI/Rewards/utils/perpsCampaignConstants.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
*/
55
export const PERPS_QUALIFICATION_NOTIONAL_USD = 25_000;
66

7+
/** Maximum winners for the perps trading campaign. */
8+
export const PERPS_TRADING_MAX_WINNERS = 20;
9+
710
/** HyperTracker attribution URL for the perps trading campaign leaderboard. */
811
export const HYPERTRACKER_ATTRIBUTION_URL =
912
'https://hypertracker.io?utm_source=metamask&utm_medium=leaderboard&utm_campaign=partner-attribution';

0 commit comments

Comments
 (0)