Skip to content

Commit 74325fd

Browse files
VGR-GITsophieqguclaude
authored
feat(rewards): expose VIP perps fee discount via getPerpsDiscountForAccount (#30024)
## Summary Keep the existing `RewardsController.getPerpsDiscountForAccount` contract (still returns a discount in basis points; perps still applies it via `metamaskFeeRate = base * (1 - discount/10000)`), and only swap *how* the discount is derived. When the account's active subscription has `features.vip.enabled === true`, the controller calls the new authenticated `GET /vip/fees`, reads `hyperliquid.builderFeeBips`, and converts it to a discount fraction using the base fee the caller passes in. Non-VIP accounts now receive a 0 discount; the legacy `/public/rewards/perps-fee-discount` endpoint and its data-service method are removed. Targets [#29888 (`vip-feature-icon-menu`)](#29888) because it relies on the new `SubscriptionDto.features.vip.enabled` flag and the per-subscription VIP state introduced there. - Adds `RewardsDataService.getVipFees` (auth'd, mirrors [va-mmcx-rewards#546](consensys-vertical-apps/va-mmcx-rewards#546) DTOs) - Adds per-subscription cache slot `RewardsControllerState.vipPerpsFees` (5-min TTL; stores raw bips so the cache is independent of `baseFeeBips`) - Extends `getPerpsDiscountForAccount(account, baseFeeBips)` — perps callers pass `BUILDER_FEE_CONFIG.MaxFeeDecimal * BASIS_POINTS_DIVISOR` so the controller stays a pure transformer (no perps constant import inside rewards) - Removes `getPerpsDiscount` data service method, `RewardsDataServiceGetPerpsDiscountAction`, `#getPerpsFeeDiscountData`, `GetPerpsDiscountDto`, `PerpsDiscountData` - Simplifies `getHasAccountOptedIn` to rely on cached state only - Updates the three perps call sites (`RewardsIntegrationService`, `usePerpsOrderFees`, `usePerpsCloseAllCalculations`) and the mobile infrastructure adapter ## **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 ## Screenshots - Opening position (tier with 5 perps bips fee, so 50 percent discount) <img width="464" height="877" alt="Screenshot_2026-05-12_13-45-27" src="https://github.com/user-attachments/assets/359fdefb-9234-43d0-83f4-88509822469d" /> <img width="466" height="910" alt="Screenshot_2026-05-12_13-45-36" src="https://github.com/user-attachments/assets/943fbbad-88d2-4d2d-b984-98e090f5ead1" /> - Opening position (tier with 0.5 perps bips fee so 95 percent discount) <img width="464" height="877" alt="Screenshot_2026-05-12_13-41-57" src="https://github.com/user-attachments/assets/af76267b-2001-4faf-96f4-1d8d4f4c60c5" /> <img width="464" height="877" alt="Screenshot_2026-05-12_13-42-02" src="https://github.com/user-attachments/assets/cd8c0475-9800-4d9f-a52e-b8192d10a0d2" /> - Closing position (tier with 5 perps bip fee, so 50 percent discount) <img width="472" height="936" alt="image" src="https://github.com/user-attachments/assets/a5462f66-b5a7-470d-a570-e64de9c68e90" /> 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes the perps fee-discount source and contract (adds `baseFeeBips`, introduces `null` for unhydrated/failed VIP lookups), which can affect fee calculations and caching behavior across perps UI flows. > > **Overview** > Perps fee discounts are now derived from VIP subscription data: `RewardsController.getPerpsDiscountForAccount(account, baseFeeBips)` calls a new authenticated `GET /vip/fees`, converts the returned absolute VIP builder fee into a discount fraction vs the caller-provided base fee, and caches the raw VIP builder fee per subscription (`vipPerpsFees`, 5‑min TTL). > > The legacy public perps-discount endpoint and related data-service/action/types/caching logic are removed, and `getHasAccountOptedIn` no longer falls back to perps-discount API calls. > > All perps call sites (`RewardsIntegrationService`, `usePerpsOrderFees`, `usePerpsCloseAllCalculations`, `mobileInfrastructure`) are updated to pass `baseFeeBips` and to treat `null` as *unhydrated/unavailable* (don’t cache; retry on subsequent calculations), with new tests covering these cases and VIP fee edge-condition handling. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit cb9ccbc. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: sophieqgu <sophieqgu@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent de98c47 commit 74325fd

19 files changed

Lines changed: 782 additions & 759 deletions

app/components/UI/Perps/adapters/mobileInfrastructure.test.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -215,12 +215,14 @@ describe('createMobileInfrastructure', () => {
215215
const caipAccountId =
216216
'eip155:42161:0x1234' as `${string}:${string}:${string}`;
217217

218-
const result =
219-
await infra.rewards.getPerpsDiscountForAccount(caipAccountId);
218+
const result = await infra.rewards.getPerpsDiscountForAccount(
219+
caipAccountId,
220+
10,
221+
);
220222

221223
expect(
222224
Engine.context.RewardsController.getPerpsDiscountForAccount,
223-
).toHaveBeenCalledWith(caipAccountId);
225+
).toHaveBeenCalledWith(caipAccountId, 10);
224226
expect(result).toBe(5);
225227
});
226228
});

app/components/UI/Perps/adapters/mobileInfrastructure.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,9 +302,11 @@ export function createMobileInfrastructure(): PerpsPlatformDependencies {
302302
rewards: {
303303
getPerpsDiscountForAccount(
304304
caipAccountId: `${string}:${string}:${string}`,
305+
baseFeeBips: number,
305306
) {
306307
return Engine.context.RewardsController.getPerpsDiscountForAccount(
307308
caipAccountId,
309+
baseFeeBips,
308310
);
309311
},
310312
},

app/components/UI/Perps/hooks/usePerpsCloseAllCalculations.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -490,6 +490,47 @@ describe('usePerpsCloseAllCalculations', () => {
490490
expect(result.current.avgFeeDiscountPercentage).toBe(65);
491491
});
492492

493+
it('does not apply a discount and allows retry when controller returns null (unhydrated)', async () => {
494+
// Arrange: subscription state not hydrated yet
495+
mockGetPerpsDiscount.mockResolvedValue(null);
496+
497+
const positions = [createMockPosition({ symbol: 'BTC' })];
498+
const priceData = { BTC: { price: '51000' } };
499+
500+
mockCalculateFees.mockResolvedValue(
501+
createMockFeeResult({
502+
feeAmount: 275,
503+
metamaskFeeRate: 0.01,
504+
metamaskFeeAmount: 250,
505+
protocolFeeRate: 0.001,
506+
protocolFeeAmount: 25,
507+
}),
508+
);
509+
510+
// Act
511+
const { result, rerender } = renderHook(
512+
({ pos }: { pos: Position[] }) =>
513+
usePerpsCloseAllCalculations({ positions: pos, priceData }),
514+
{ initialProps: { pos: positions } },
515+
);
516+
517+
await waitFor(() => expect(result.current.isLoading).toBe(false));
518+
519+
// No discount applied; original rate matches base rate
520+
expect(result.current.avgFeeDiscountPercentage).toBeUndefined();
521+
expect(result.current.avgMetamaskFeeRate).toBeCloseTo(0.01, 4);
522+
expect(result.current.totalFees).toBe(275);
523+
524+
// Hydration completes and a positions change retries the fetch
525+
mockGetPerpsDiscount.mockResolvedValueOnce(6500);
526+
rerender({ pos: [...positions] });
527+
528+
await waitFor(() =>
529+
expect(result.current.avgFeeDiscountPercentage).toBe(65),
530+
);
531+
expect(mockGetPerpsDiscount).toHaveBeenCalledTimes(2);
532+
});
533+
493534
it('handles discount fetch errors gracefully', async () => {
494535
// Arrange: Discount API fails
495536
mockGetPerpsDiscount.mockRejectedValue(new Error('API error'));

app/components/UI/Perps/hooks/usePerpsCloseAllCalculations.ts

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { useMemo, useState, useEffect, useRef } from 'react';
22
import { useSelector } from 'react-redux';
33
import {
4+
BASIS_POINTS_DIVISOR,
5+
BUILDER_FEE_CONFIG,
46
formatAccountToCaipAccountId,
57
type Position,
68
type FeeCalculationResult,
@@ -12,6 +14,7 @@ import type {
1214
import Engine from '../../../../core/Engine';
1315
import { selectSelectedAccountGroupEvmInternalAccount } from '../../../../selectors/multichainAccounts/accountTreeController';
1416
import { selectChainId } from '../../../../selectors/networkController';
17+
import { DevLogger } from '../../../../core/SDKConnect/utils/DevLogger';
1518

1619
/**
1720
* Aggregated calculations result for closing all positions
@@ -105,6 +108,9 @@ export function usePerpsCloseAllCalculations({
105108
const isComponentMountedRef = useRef(true);
106109
const discountFetchCounterRef = useRef(0);
107110
const calculationCounterRef = useRef(0);
111+
// Tracks the account the freeze was set for, so position changes don't
112+
// spuriously reset it but account switches do.
113+
const discountAccountKeyRef = useRef<string | undefined>(undefined);
108114

109115
// State for per-position calculations
110116
const [perPositionResults, setPerPositionResults] = useState<
@@ -143,12 +149,19 @@ export function usePerpsCloseAllCalculations({
143149
);
144150

145151
// Fetch account-level fee discount (applies uniformly to all positions)
146-
// Freeze mechanism prevents refetching when only positions change
152+
// Freeze mechanism prevents refetching once we have a hydrated answer.
153+
// Positions are included in deps so an unhydrated result (null) gets a
154+
// retry on the next positions change instead of locking in no-discount.
147155
useEffect(() => {
148156
// Increment counter to invalidate any in-flight requests
149157
const currentFetchId = ++discountFetchCounterRef.current;
150-
// Reset freeze when account changes (allow refetch for new account)
151-
hasValidDiscountRef.current = false;
158+
// Only reset freeze when the account actually changes — keep it across
159+
// positions changes so a successful fetch isn't re-run on every tick.
160+
const accountKey = `${selectedAddress ?? ''}-${currentChainId ?? ''}`;
161+
if (discountAccountKeyRef.current !== accountKey) {
162+
hasValidDiscountRef.current = false;
163+
discountAccountKeyRef.current = accountKey;
164+
}
152165

153166
async function fetchFeeDiscount() {
154167
// Skip if already have valid discount for this account (freeze guard)
@@ -188,15 +201,28 @@ export function usePerpsCloseAllCalculations({
188201
const discountBips =
189202
await Engine.context.RewardsController.getPerpsDiscountForAccount(
190203
caipAccountId,
204+
BUILDER_FEE_CONFIG.MaxFeeDecimal * BASIS_POINTS_DIVISOR,
191205
);
192206

193207
// Only update state if this is still the latest fetch and component is mounted
194208
if (
195209
discountFetchCounterRef.current === currentFetchId &&
196210
isComponentMountedRef.current
197211
) {
198-
setFeeDiscountBips(discountBips);
199-
hasValidDiscountRef.current = true;
212+
if (discountBips === null) {
213+
// Subscription state hasn't hydrated yet — don't cache the
214+
// no-discount value. Freeze stays off so the next positions
215+
// change retries the fetch.
216+
DevLogger.log(
217+
'Rewards: fee discount unhydrated for close-all flow, will retry on next positions change',
218+
{ selectedAddress, currentChainId },
219+
);
220+
setFeeDiscountBips(0);
221+
hasValidDiscountRef.current = false;
222+
} else {
223+
setFeeDiscountBips(discountBips);
224+
hasValidDiscountRef.current = true;
225+
}
200226
}
201227
} catch (error) {
202228
console.warn('Failed to fetch fee discount:', error);
@@ -214,7 +240,7 @@ export function usePerpsCloseAllCalculations({
214240
fetchFeeDiscount().catch((error) => {
215241
console.error('Unhandled error in fetchFeeDiscount:', error);
216242
});
217-
}, [selectedAddress, currentChainId]);
243+
}, [selectedAddress, currentChainId, positions]);
218244

219245
// Per-position fee and rewards calculation
220246
// This ensures accurate coin-specific rewards calculation

app/components/UI/Perps/hooks/usePerpsOrderFees.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,38 @@ describe('usePerpsOrderFees', () => {
497497
expect(result.current.originalMetamaskFeeRate).toBe(0.01);
498498
// The hook should apply discount internally
499499
});
500+
501+
it('does not apply a discount when controller returns null discountBips', async () => {
502+
mockEngineContext.RewardsController.getPerpsDiscountForAccount.mockResolvedValueOnce(
503+
null,
504+
);
505+
506+
const mockFeeResult: FeeCalculationResult = {
507+
feeRate: 0.01045,
508+
feeAmount: 1045,
509+
protocolFeeRate: 0.00045,
510+
metamaskFeeRate: 0.01,
511+
};
512+
mockCalculateFees.mockResolvedValue(mockFeeResult);
513+
514+
const { result } = renderHook(
515+
() =>
516+
usePerpsOrderFees({
517+
orderType: 'market',
518+
amount: '100000',
519+
}),
520+
{ wrapper: createWrapper() },
521+
);
522+
523+
await waitFor(() => {
524+
expect(result.current.isLoadingMetamaskFee).toBe(false);
525+
});
526+
527+
expect(result.current.feeDiscountPercentage).toBeUndefined();
528+
expect(result.current.metamaskFeeRate).toBe(0.01);
529+
expect(result.current.originalMetamaskFeeRate).toBe(0.01);
530+
expect(result.current.metamaskFee).toBe(1000); // 100000 * 0.01, undiscounted
531+
});
500532
});
501533

502534
describe('Loading states', () => {

app/components/UI/Perps/hooks/usePerpsOrderFees.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import {
1212
EstimatedPointsDto,
1313
} from '../../../../core/Engine/controllers/rewards-controller/types';
1414
import {
15+
BASIS_POINTS_DIVISOR,
16+
BUILDER_FEE_CONFIG,
1517
PerpsMeasurementName,
1618
PERFORMANCE_CONFIG,
1719
formatAccountToCaipAccountId,
@@ -182,8 +184,17 @@ export function usePerpsOrderFees({
182184

183185
const { RewardsController } = Engine.context;
184186
const feeDiscountStartTime = performance.now();
185-
const discountBips =
186-
await RewardsController.getPerpsDiscountForAccount(caipAccountId);
187+
const discountBips = await RewardsController.getPerpsDiscountForAccount(
188+
caipAccountId,
189+
BUILDER_FEE_CONFIG.MaxFeeDecimal * BASIS_POINTS_DIVISOR,
190+
);
191+
if (discountBips === null) {
192+
DevLogger.log('Rewards: No fee discount available', {
193+
address,
194+
caipAccountId,
195+
});
196+
return { discountBips: undefined };
197+
}
187198
const feeDiscountDuration = performance.now() - feeDiscountStartTime;
188199

189200
// Measure fee discount API call performance

app/controllers/perps/services/RewardsIntegrationService.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ describe('RewardsIntegrationService', () => {
6868
expect(result).toBe(6500);
6969
expect(mockDeps.rewards.getPerpsDiscountForAccount).toHaveBeenCalledWith(
7070
expect.stringMatching(/^eip155:1:0x/),
71+
10,
7172
);
7273
expect(mockDeps.debugLogger.log).toHaveBeenCalledWith(
7374
'RewardsIntegrationService: Fee discount calculated',
@@ -89,6 +90,23 @@ describe('RewardsIntegrationService', () => {
8990
expect(result).toBe(0);
9091
});
9192

93+
it('returns undefined when rewards subscription state has not hydrated yet', async () => {
94+
setupMessengerDefaults();
95+
(
96+
mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock
97+
).mockResolvedValue(null);
98+
99+
const result = await service.calculateUserFeeDiscount();
100+
101+
expect(result).toBeUndefined();
102+
expect(mockDeps.debugLogger.log).toHaveBeenCalledWith(
103+
'RewardsIntegrationService: Fee discount unavailable (subscription state not hydrated)',
104+
expect.objectContaining({
105+
caipAccountId: expect.any(String),
106+
}),
107+
);
108+
});
109+
92110
it('returns undefined when no EVM account found', async () => {
93111
setupMessengerDefaults({
94112
'AccountTreeController:getAccountsFromSelectedAccountGroup': [],

app/controllers/perps/services/RewardsIntegrationService.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
import {
2+
BASIS_POINTS_DIVISOR,
3+
BUILDER_FEE_CONFIG,
4+
} from '../constants/hyperLiquidConfig';
15
import { PERPS_CONSTANTS } from '../constants/perpsConfig';
26
import type { PerpsPlatformDependencies } from '../types';
37
import type { PerpsControllerMessengerBase } from '../types/messenger';
@@ -118,9 +122,23 @@ export class RewardsIntegrationService {
118122
return undefined;
119123
}
120124

121-
// Use rewards via DI (no RewardsController in Core yet)
122-
const discountBips =
123-
await this.#deps.rewards.getPerpsDiscountForAccount(caipAccountId);
125+
// Use rewards via DI (no RewardsController in Core yet).
126+
// The rewards controller needs the perps MetaMask builder base fee in
127+
// bips to convert an absolute VIP fee into a discount fraction.
128+
const discountBips = await this.#deps.rewards.getPerpsDiscountForAccount(
129+
caipAccountId,
130+
BUILDER_FEE_CONFIG.MaxFeeDecimal * BASIS_POINTS_DIVISOR,
131+
);
132+
133+
// null = subscription state not hydrated yet; surface as undefined so
134+
// callers don't treat it as a definitive "no discount" answer.
135+
if (discountBips === null) {
136+
this.#deps.debugLogger.log(
137+
'RewardsIntegrationService: Fee discount unavailable (subscription state not hydrated)',
138+
{ address: evmAccount.address, caipAccountId },
139+
);
140+
return undefined;
141+
}
124142

125143
this.#deps.debugLogger.log(
126144
'RewardsIntegrationService: Fee discount calculated',

app/controllers/perps/types/index.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1589,11 +1589,17 @@ export type PerpsPlatformDependencies = {
15891589
rewards: {
15901590
/**
15911591
* Get fee discount for an account from the RewardsController.
1592-
* Returns discount in basis points (e.g., 6500 = 65% discount)
1592+
* Returns discount in basis points (e.g., 6500 = 65% discount), or null
1593+
* when subscription state hasn't hydrated yet — callers should skip
1594+
* caching null results and retry on the next fee calculation.
1595+
*
1596+
* Pass the perps MetaMask builder base fee in bips so the rewards
1597+
* controller can convert an absolute VIP fee into a discount fraction.
15931598
*/
15941599
getPerpsDiscountForAccount(
15951600
caipAccountId: `${string}:${string}:${string}`,
1596-
): Promise<number>;
1601+
baseFeeBips: number,
1602+
): Promise<number | null>;
15971603
};
15981604
};
15991605

app/core/Engine/controllers/rewards-controller/RewardsController-method-action-types.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,10 +171,19 @@ export type RewardsControllerGetOptInStatusAction = {
171171
};
172172

173173
/**
174-
* Get perps fee discount for an account with caching and threshold logic
174+
* Get perps fee discount for an account.
175+
*
176+
* When the account's active subscription has VIP enabled, this calls the
177+
* authenticated `/vip/fees` endpoint and converts the absolute VIP builder
178+
* fee into a discount fraction relative to `baseFeeBips`. Non-VIP accounts
179+
* receive no discount.
175180
*
176181
* @param account - The account address in CAIP-10 format
177-
* @returns Promise<number> - The discount in basis points
182+
* @param baseFeeBips - The perps MetaMask builder base fee in basis points
183+
* that the caller would apply absent any discount. Used to convert the VIP
184+
* absolute fee into a discount fraction (caller owns the source of truth
185+
* for the base fee; the controller is a pure transformer).
186+
* @returns Promise resolving to the discount in basis points (0-10000), or null when we can't determine the discount.
178187
*/
179188
export type RewardsControllerGetPerpsDiscountForAccountAction = {
180189
type: `RewardsController:getPerpsDiscountForAccount`;

0 commit comments

Comments
 (0)