Skip to content

Commit 61eab45

Browse files
abretonc7sVGR-GIT
andauthored
feat(perps): integrate MetaMask Points rewards and fee discounts for Perps trading (#19808)
## **Description** This PR implements MetaMask Points rewards integration for Perps trading, including tier-based fee discounts and points estimation display. Users now see their estimated points earnings and receive fee discounts based on their MetaMask Points tier before executing trades. ### What is the reason for the change? - TAT-1221: Users need tier-based MetaMask builder fee discounts (Tier 1-3: 10bps, Tier 4-5: 5bps, Tier 6-7: 3.5bps) - TAT-1223: Users need to see estimated points earnings before trading ### What is the improvement/solution? - Integrated RewardsController for fee discount and points estimation - Added RewardPointsDisplay component with fox icon animation states - Implemented fee discount visualization with fox icon in fee row - Added comprehensive error handling with tooltips - Supports development simulation with magic numbers (41, 42, 43) ## **Changelog** CHANGELOG entry: Added MetaMask Points rewards integration to Perps trading with tier-based fee discounts and points estimation display ## **Related issues** Fixes: - [TAT-1221](https://consensyssoftware.atlassian.net/browse/TAT-1221) - [TAT-1223](https://consensyssoftware.atlassian.net/browse/TAT-1223) - [TAT-1226](https://consensyssoftware.atlassian.net/browse/TAT-1426) ## **Manual testing steps** ```gherkin Feature: MetaMask Points Rewards Integration Scenario: User sees fee discount for their tier Given user has MetaMask Points tier 4-5 And user is on Perps order view with valid order amount When user enters order details Then user sees orange fox icon with "-X%" discount in fee row And total fee reflects the discount amount Scenario: User sees estimated points for trade Given user has opted into rewards program And user is on Perps order view When user enters valid order amount and leverage Then user sees orange fox icon with estimated points number And points animate on value changes (refresh state) Scenario: User sees error state handling Given rewards API is unavailable When user attempts to view rewards information Then user sees greyed out fox icon with "Couldn't load" text And user can tap info icon to see error details Scenario: Development simulation testing Given __DEV__ mode is enabled When user enters amount "41" Then user sees simulated 20% fee discount When user enters amount "42" Then user sees error state When user enters amount "43" Then user sees loading state ``` ## **Screenshots/Recordings** ### **Before** - No rewards integration - Standard fees without discounts - No points estimation ### **After** - Fee discount displayed with fox icon and percentage - Points estimation with animated fox icon - Error states with tooltips - Proper alignment and design system compliance https://github.com/user-attachments/assets/6101166d-0565-4859-b37b-6d1569bc0afd ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- [TAT-1221]: https://consensyssoftware.atlassian.net/browse/TAT-1221?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --------- Co-authored-by: Rik Van Gulck <vangulckrik@gmail.com>
1 parent 363cd23 commit 61eab45

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

50 files changed

+5809
-458
lines changed

app/components/UI/Bridge/hooks/useRewardsIconAnimation/useRewardsIconAnimation.test.ts

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,4 +399,146 @@ describe('useRewardsIconAnimation', () => {
399399
expect(mockFireState).toHaveBeenCalledWith('State Machine 1', 'Disable');
400400
});
401401
});
402+
403+
describe('refresh animation trigger', () => {
404+
it('triggers Refresh when isRefresh is true and has positive points', () => {
405+
// Act
406+
renderHookWithProvider(() =>
407+
useRewardsIconAnimation({
408+
...defaultParams,
409+
isRefresh: true,
410+
estimatedPoints: 100,
411+
}),
412+
);
413+
414+
// Assert
415+
expect(mockFireState).toHaveBeenCalledWith('State Machine 1', 'Refresh');
416+
});
417+
418+
it('does not trigger Refresh when estimatedPoints is 0', () => {
419+
// Act
420+
renderHookWithProvider(() =>
421+
useRewardsIconAnimation({
422+
...defaultParams,
423+
isRefresh: true,
424+
estimatedPoints: 0,
425+
}),
426+
);
427+
428+
// Assert
429+
expect(mockFireState).not.toHaveBeenCalled();
430+
});
431+
432+
it('does not trigger Refresh when estimatedPoints is null', () => {
433+
// Act
434+
renderHookWithProvider(() =>
435+
useRewardsIconAnimation({
436+
...defaultParams,
437+
isRefresh: true,
438+
estimatedPoints: null,
439+
}),
440+
);
441+
442+
// Assert
443+
expect(mockFireState).not.toHaveBeenCalled();
444+
});
445+
446+
it('prioritizes loading state over refresh', () => {
447+
// Act
448+
renderHookWithProvider(() =>
449+
useRewardsIconAnimation({
450+
...defaultParams,
451+
isRewardsLoading: true,
452+
isRefresh: true,
453+
estimatedPoints: 100,
454+
}),
455+
);
456+
457+
// Assert
458+
expect(mockFireState).toHaveBeenCalledWith('State Machine 1', 'Disable');
459+
});
460+
461+
it('prioritizes error state over refresh', () => {
462+
// Act
463+
renderHookWithProvider(() =>
464+
useRewardsIconAnimation({
465+
...defaultParams,
466+
hasRewardsError: true,
467+
isRefresh: true,
468+
estimatedPoints: 100,
469+
}),
470+
);
471+
472+
// Assert
473+
expect(mockFireState).toHaveBeenCalledWith('State Machine 1', 'Disable');
474+
});
475+
});
476+
477+
describe('additional edge cases for complete coverage', () => {
478+
it('handles when shouldShowRewardsRow is false with refresh state', () => {
479+
// Act
480+
renderHookWithProvider(() =>
481+
useRewardsIconAnimation({
482+
...defaultParams,
483+
shouldShowRewardsRow: false,
484+
isRefresh: true,
485+
estimatedPoints: 100,
486+
}),
487+
);
488+
489+
// Assert
490+
expect(mockFireState).not.toHaveBeenCalled();
491+
});
492+
493+
it('handles points change from 0 to positive value', () => {
494+
// Arrange
495+
mockPreviousPointsRef.current = 0 as any;
496+
497+
// Act
498+
renderHookWithProvider(() =>
499+
useRewardsIconAnimation({
500+
...defaultParams,
501+
estimatedPoints: 50,
502+
}),
503+
);
504+
505+
// Assert
506+
expect(mockFireState).toHaveBeenCalledWith('State Machine 1', 'Start');
507+
expect(mockPreviousPointsRef.current).toBe(50);
508+
});
509+
510+
it('handles points change from positive to 0', () => {
511+
// Arrange
512+
mockPreviousPointsRef.current = 100 as any;
513+
514+
// Act
515+
renderHookWithProvider(() =>
516+
useRewardsIconAnimation({
517+
...defaultParams,
518+
estimatedPoints: 0,
519+
}),
520+
);
521+
522+
// Assert
523+
expect(mockFireState).not.toHaveBeenCalled();
524+
expect(mockPreviousPointsRef.current).toBe(0);
525+
});
526+
527+
it('handles points change from null to positive', () => {
528+
// Arrange
529+
mockPreviousPointsRef.current = null as any;
530+
531+
// Act
532+
renderHookWithProvider(() =>
533+
useRewardsIconAnimation({
534+
...defaultParams,
535+
estimatedPoints: 75,
536+
}),
537+
);
538+
539+
// Assert
540+
expect(mockFireState).toHaveBeenCalledWith('State Machine 1', 'Start');
541+
expect(mockPreviousPointsRef.current).toBe(75);
542+
});
543+
});
402544
});

app/components/UI/Bridge/hooks/useRewardsIconAnimation/useRewardsIconAnimation.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ interface UseRewardsIconAnimationParams {
1414
estimatedPoints: number | null;
1515
hasRewardsError: boolean;
1616
shouldShowRewardsRow: boolean;
17+
isRefresh?: boolean;
1718
}
1819

1920
interface UseRewardsIconAnimationResult {
@@ -25,6 +26,7 @@ export const useRewardsIconAnimation = ({
2526
estimatedPoints,
2627
hasRewardsError,
2728
shouldShowRewardsRow,
29+
isRefresh = false,
2830
}: UseRewardsIconAnimationParams): UseRewardsIconAnimationResult => {
2931
const riveRef = useRef<RiveRef>(null);
3032
const previousPointsRef = useRef<number | null>(null);
@@ -53,6 +55,15 @@ export const useRewardsIconAnimation = ({
5355
return;
5456
}
5557

58+
if (isRefresh && currentPoints && currentPoints > 0) {
59+
// Refresh state - trigger spin animation
60+
riveRef.current.fireState(
61+
STATE_MACHINE_NAME,
62+
RewardsIconTriggers.Refresh,
63+
);
64+
return;
65+
}
66+
5667
if (currentPoints && currentPoints > 0) {
5768
// Has points - trigger Start
5869
riveRef.current.fireState(
@@ -71,6 +82,7 @@ export const useRewardsIconAnimation = ({
7182
hasRewardsError,
7283
isRewardsLoading,
7384
shouldShowRewardsRow,
85+
isRefresh,
7486
]);
7587

7688
return {

app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.test.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
defaultPerpsClosePositionMock,
1414
defaultPerpsEventTrackingMock,
1515
defaultMinimumOrderAmountMock,
16+
defaultPerpsRewardsMock,
1617
} from '../../__mocks__/perpsHooksMocks';
1718
import { strings } from '../../../../../../locales/i18n';
1819
import {
@@ -36,6 +37,7 @@ jest.mock('../../hooks', () => ({
3637
usePerpsClosePosition: jest.fn(),
3738
usePerpsMarketData: jest.fn(),
3839
usePerpsToasts: jest.fn(),
40+
usePerpsRewards: jest.fn(),
3941
}));
4042

4143
jest.mock('../../hooks/stream', () => ({
@@ -127,6 +129,7 @@ describe('PerpsClosePositionView', () => {
127129
const usePerpsMarketDataMock =
128130
jest.requireMock('../../hooks').usePerpsMarketData;
129131
const usePerpsToastsMock = jest.requireMock('../../hooks').usePerpsToasts;
132+
const usePerpsRewardsMock = jest.requireMock('../../hooks').usePerpsRewards;
130133

131134
beforeEach(() => {
132135
jest.resetAllMocks();
@@ -161,6 +164,9 @@ describe('PerpsClosePositionView', () => {
161164

162165
// Setup usePerpsToasts mock
163166
usePerpsToastsMock.mockReturnValue(defaultPerpsToastsMock);
167+
168+
// Setup usePerpsRewards mock
169+
usePerpsRewardsMock.mockReturnValue(defaultPerpsRewardsMock);
164170
});
165171

166172
describe('Component Rendering', () => {

app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.tsx

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
usePerpsClosePositionValidation,
3939
usePerpsClosePosition,
4040
usePerpsToasts,
41+
usePerpsRewards,
4142
} from '../../hooks';
4243
import { usePerpsLivePrices } from '../../hooks/stream';
4344
import { formatPositionSize, formatPrice } from '../../utils/formatUtils';
@@ -68,6 +69,7 @@ import ListItemColumn, {
6869
WidthType,
6970
} from '../../../../../component-library/components/List/ListItemColumn';
7071
import PerpsOrderHeader from '../../components/PerpsOrderHeader';
72+
import PerpsFeesDisplay from '../../components/PerpsFeesDisplay';
7173

7274
const PerpsClosePositionView: React.FC = () => {
7375
const theme = useTheme();
@@ -178,6 +180,19 @@ const PerpsClosePositionView: React.FC = () => {
178180
orderType,
179181
amount: closingValue.toString(),
180182
isMaker: false, // Closing positions are typically taker orders
183+
coin: position.coin,
184+
isClosing: true, // This is a position closing operation
185+
});
186+
187+
// Simple boolean calculation for rewards state
188+
const hasValidAmount = closePercentage > 0 && closingValue > 0;
189+
190+
// Get rewards state using the new hook
191+
const rewardsState = usePerpsRewards({
192+
feeResults,
193+
hasValidAmount,
194+
isFeesLoading: feeResults.isLoadingMetamaskFee,
195+
orderAmount: closingValue.toString(),
181196
});
182197

183198
// Calculate what user will receive (initial margin + P&L at effective price - fees)
@@ -469,9 +484,13 @@ const PerpsClosePositionView: React.FC = () => {
469484
</TouchableOpacity>
470485
</View>
471486
<View style={styles.summaryValue}>
472-
<Text variant={TextVariant.BodyMD}>
473-
-{formatPrice(feeResults.totalFee, { maximumDecimals: 2 })}
474-
</Text>
487+
<PerpsFeesDisplay
488+
feeDiscountPercentage={rewardsState.feeDiscountPercentage}
489+
formatFeeText={`-${formatPrice(feeResults.totalFee, {
490+
maximumDecimals: 2,
491+
})}`}
492+
variant={TextVariant.BodyMD}
493+
/>
475494
</View>
476495
</View>
477496

@@ -744,6 +763,8 @@ const PerpsClosePositionView: React.FC = () => {
744763
data: {
745764
metamaskFeeRate: feeResults.metamaskFeeRate,
746765
protocolFeeRate: feeResults.protocolFeeRate,
766+
originalMetamaskFeeRate: feeResults.originalMetamaskFeeRate,
767+
feeDiscountPercentage: feeResults.feeDiscountPercentage,
747768
},
748769
}
749770
: {})}

app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.styles.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,5 +100,8 @@ const createStyles = (colors: Colors) =>
100100
paddingHorizontal: 16,
101101
backgroundColor: colors.background.default,
102102
},
103+
pointsRightContainer: {
104+
alignItems: 'flex-end',
105+
},
103106
});
104107
export default createStyles;

0 commit comments

Comments
 (0)