From e7420e32232e992a8361ab1cdb22d777c2ca2a05 Mon Sep 17 00:00:00 2001 From: geositta Date: Tue, 29 Sep 2026 17:38:06 -0500 Subject: [PATCH 01/15] feat(perps): accept targeted participation through the existing rewards DI method and carry it through fee resolution and logging, with no new fee source --- packages/perps-controller/src/index.ts | 1 + .../src/services/RewardsIntegrationService.ts | 57 ++++++++++++++----- packages/perps-controller/src/types/index.ts | 45 +++++++++++++-- 3 files changed, 85 insertions(+), 18 deletions(-) diff --git a/packages/perps-controller/src/index.ts b/packages/perps-controller/src/index.ts index ee272db3633..ee7a7e22d3b 100644 --- a/packages/perps-controller/src/index.ts +++ b/packages/perps-controller/src/index.ts @@ -317,6 +317,7 @@ export type { PerpsSubscriptionFeeWaiverStatus, PerpsFeeSource, PerpsFeeResolution, + RewardsDiscountResponse, UpdatePositionTPSLParams, Order, Funding, diff --git a/packages/perps-controller/src/services/RewardsIntegrationService.ts b/packages/perps-controller/src/services/RewardsIntegrationService.ts index c49e8c96bd2..717cf4403e7 100644 --- a/packages/perps-controller/src/services/RewardsIntegrationService.ts +++ b/packages/perps-controller/src/services/RewardsIntegrationService.ts @@ -17,6 +17,7 @@ import type { PerpsPlatformDependencies, PerpsSubscriptionBenefits, PerpsSubscriptionFeeWaiverStatus, + RewardsDiscountResponse, } from '../types/index.js'; import type { PerpsControllerMessengerBase } from '../types/messenger.js'; import { getSelectedEvmAccountFromMessenger } from '../utils/accountUtils.js'; @@ -45,6 +46,16 @@ type BenefitsSnapshot = { fetchedAt: number; }; +/** + * A normalized rewards discount response. + */ +type NormalizedRewardsDiscount = { + /** Discount in basis points */ + discountBips: number; + /** Whether a targeted rewards grant was applied, when known */ + targetedDiscountApplied?: boolean; +}; + /** * RewardsIntegrationService * @@ -53,9 +64,10 @@ type BenefitsSnapshot = { * * Sources, all in fee basis points (lowest wins): * - `default` — {@link BUILDER_FEE_CONFIG}, the fee with no reductions. - * - `rewards` — VIP and season, collapsed into one discount by + * - `rewards` — VIP, season, and targeted, collapsed into one discount by * `RewardsController` (`rewards.getPerpsDiscountForAccount`), so this service - * does not re-derive the VIP/season split. + * does not re-derive the split. The client mayreport targeted participation + * via a structured response with a boolean flag. * - `subscription` — `0` bips, but only when the eligibility gate passes on a * cached read of the profile's benefits. * @@ -185,22 +197,25 @@ export class RewardsIntegrationService { * @returns The winning fee, its source, and the subscription gate outcome. */ async resolveFee(orderNotionalUsd?: number): Promise { - const rewardsDiscountBips = await this.#calculateRewardsDiscount(); + const rewardsDiscount = await this.#calculateRewardsDiscount(); // Pure cache read: subscription benefits must never start a network request // while an order is being prepared for signing. const subscription = this.getSubscriptionFeeWaiverStatus(); let feeBips = DEFAULT_FEE_BIPS; let source: PerpsFeeSource = 'default'; + let targetedDiscountApplied: boolean | undefined; - if (rewardsDiscountBips !== undefined) { + if (rewardsDiscount !== undefined) { const rewardsFeeBips = - DEFAULT_FEE_BIPS * (1 - rewardsDiscountBips / BASIS_POINTS_DIVISOR); + DEFAULT_FEE_BIPS * (1 - rewardsDiscount.discountBips / BASIS_POINTS_DIVISOR); // `<=` so an equal rewards fee still reports the rewards source, keeping // a resolved 0% discount distinguishable from an unresolved one. if (rewardsFeeBips <= feeBips) { feeBips = rewardsFeeBips; source = 'rewards'; + // Only carry participation when rewards wins + targetedDiscountApplied = rewardsDiscount.targetedDiscountApplied; } } @@ -259,12 +274,14 @@ export class RewardsIntegrationService { feeBips, discountBips, defaultFeeBips: DEFAULT_FEE_BIPS, - rewardsDiscountBips, + rewardsDiscountBips: rewardsDiscount?.discountBips, + rewardsTargetedDiscountApplied: rewardsDiscount?.targetedDiscountApplied, orderNotionalUsd, subscriptionEligible: subscription.eligible, subscriptionReason: subscription.reason, subscriptionWaiverKind, subscriptionCoveredNotionalUsd, + targetedDiscountApplied, }); return { @@ -272,6 +289,7 @@ export class RewardsIntegrationService { discountBips, source, subscription, + targetedDiscountApplied, subscriptionWaiverKind, subscriptionCoveredNotionalUsd, }; @@ -714,11 +732,12 @@ export class RewardsIntegrationService { } /** - * Resolve the rewards (VIP + season) discount for the selected account. + * Resolve the rewards (VIP + season + targeted) discount for the selected account. * - * @returns The discount in basis points, or undefined when unavailable. + * @returns A normalized discount with discountBips and optionally targetedDiscountApplied, + * or undefined when unavailable. */ - async #calculateRewardsDiscount(): Promise { + async #calculateRewardsDiscount(): Promise { try { const evmAccount = getSelectedEvmAccountFromMessenger(this.#messenger); @@ -778,14 +797,14 @@ export class RewardsIntegrationService { // Use rewards via DI (no RewardsController in Core yet). // The rewards controller needs the perps MetaMask builder base fee in // bips to convert an absolute VIP fee into a discount fraction. - const discountBips = await this.#deps.rewards.getPerpsDiscountForAccount( + const discountResponse = await this.#deps.rewards.getPerpsDiscountForAccount( caipAccountId, DEFAULT_FEE_BIPS, ); // null = subscription state not hydrated yet; surface as undefined so // callers don't treat it as a definitive "no discount" answer. - if (discountBips === null) { + if (discountResponse === null) { this.#deps.debugLogger.log( 'RewardsIntegrationService: Fee discount unavailable (subscription state not hydrated)', { address: evmAccount.address, caipAccountId }, @@ -793,17 +812,27 @@ export class RewardsIntegrationService { return undefined; } + // Normalize both legacy numeric responses and structured responses + const normalizedDiscount: NormalizedRewardsDiscount = + typeof discountResponse === 'number' + ? { discountBips: discountResponse } + : { + discountBips: discountResponse.discountBips, + targetedDiscountApplied: discountResponse.targetedDiscountApplied, + }; + this.#deps.debugLogger.log( 'RewardsIntegrationService: Fee discount calculated', { address: evmAccount.address, caipAccountId, - discountBips, - discountPercentage: discountBips / 100, + discountBips: normalizedDiscount.discountBips, + discountPercentage: normalizedDiscount.discountBips / 100, + targetedDiscountApplied: normalizedDiscount.targetedDiscountApplied, }, ); - return discountBips; + return normalizedDiscount; } catch (error) { this.#deps.logger.error( ensureError( diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index 38d1bed141c..0cc6e75b3b3 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -1924,6 +1924,18 @@ export type PerpsFeeResolution = { /** Subscription gate outcome, always populated for observability. */ subscription: PerpsSubscriptionFeeWaiverStatus; + /** + * Whether a targeted rewards discount was applied when the rewards source won. + * + * Omitted when rewards does not win, or when the rewards controller returns + * a legacy numeric response (in which case participation is unknown). + * + * `true` means a targeted grant was applied; `false` means the client reported + * no targeted participation despite the client-owned RewardsController + * combining grants into a final discount. + */ + targetedDiscountApplied?: boolean; + /** * How much of the order the subscription allowance covered, when the * subscription source won. @@ -2848,6 +2860,23 @@ export type PerpsGlobalSnapshotResult = { * Cross-controller communication uses the messenger pattern (messenger.call). * Only rewards remains as DI because RewardsController is not yet in Core. */ +/** + * A rewards discount response from the RewardsController. + * + * When returned, the `discountBips` field contains the discount in basis + * points (e.g., 6500 = 65% discount), and `targetedDiscountApplied` indicates + * whether a targeted rewards grant (separate from VIP/season) was applied. + * + * The client owns the combination of VIP, season, and targeted discounts; + * this controller consumes only the combined result and never reconstructs it. + */ +export type RewardsDiscountResponse = { + /** Discount in basis points (e.g., 6500 = 65%) */ + discountBips: number; + /** True when a targeted rewards grant was applied, false otherwise */ + targetedDiscountApplied: boolean; +}; + export type PerpsPlatformDependencies = { // === Observability (stateless utilities) === logger: PerpsLogger; @@ -2912,17 +2941,25 @@ export type PerpsPlatformDependencies = { rewards: { /** * Get fee discount for an account from the RewardsController. - * Returns discount in basis points (e.g., 6500 = 65% discount), or null - * when subscription state hasn't hydrated yet — callers should skip - * caching null results and retry on the next fee calculation. + * + * Returns either: + * - A numeric discount in basis points (e.g., 6500 = 65% discount) for legacy + * VIP and season discounts (targeted participation is unknown) + * - A structured response with `discountBips` and `targetedDiscountApplied` + * when targeted participation is available + * - `null` when subscription state hasn't hydrated yet * * Pass the perps MetaMask builder base fee in bips so the rewards * controller can convert an absolute VIP fee into a discount fraction. + * + * The client may return either the legacy numeric format or the structured + * format. The controller normalizes both and carries participation only when + * rewards wins the fee resolution. */ getPerpsDiscountForAccount( caipAccountId: `${string}:${string}:${string}`, baseFeeBips: number, - ): Promise; + ): Promise; }; // === Subscription (DI — benefits endpoint is owned by the Subscription team) === From 7853e1845c3227c02b3964557a03cb9f9bc299ca Mon Sep 17 00:00:00 2001 From: geositta Date: Tue, 29 Sep 2026 22:36:19 -0500 Subject: [PATCH 02/15] feat(perps): expose targeted rewards participation in fee previews --- .../src/services/MarketDataService.ts | 17 +- .../src/services/RewardsIntegrationService.ts | 16 +- packages/perps-controller/src/types/index.ts | 23 ++- .../src/PerpsController.operations.test.ts | 43 +++++ .../src/services/MarketDataService.test.ts | 104 ++++++++++++ .../RewardsIntegrationService.test.ts | 152 ++++++++++++++++++ .../tests/src/services/TradingService.test.ts | 39 +++++ 7 files changed, 377 insertions(+), 17 deletions(-) diff --git a/packages/perps-controller/src/services/MarketDataService.ts b/packages/perps-controller/src/services/MarketDataService.ts index 55a71018ab4..8587bb24506 100644 --- a/packages/perps-controller/src/services/MarketDataService.ts +++ b/packages/perps-controller/src/services/MarketDataService.ts @@ -1354,9 +1354,20 @@ export class MarketDataService { // Read-only preview of the same cached benefits snapshot the fee resolver // reads. Surfacing eligibility and the remaining notional must not mutate // the cap or the cache. - return context.subscriptionFeeWaiver - ? { ...priced, subscription: context.subscriptionFeeWaiver } - : priced; + // Match the repricing guards and exclude placements explicitly reported + // as carrying no builder fee. Attribute only the snapshot used above; + // another rewards read could describe a different grant or fee. + const resolutionApplies = + context.feeResolution !== undefined && + fees.metamaskFeeRate !== undefined && + (fees.metamaskFeeRate !== 0 || fees.chargesMetamaskBuilderFee === true); + + return { + ...priced, + ...(resolutionApplies && { feeResolution: context.feeResolution }), + ...(context.subscriptionFeeWaiver && { subscription: context.subscriptionFeeWaiver, + }), + }; } catch (error) { this.#deps.logger.error( ensureError(error, 'MarketDataService.calculateFees'), diff --git a/packages/perps-controller/src/services/RewardsIntegrationService.ts b/packages/perps-controller/src/services/RewardsIntegrationService.ts index 717cf4403e7..7b49baa0a35 100644 --- a/packages/perps-controller/src/services/RewardsIntegrationService.ts +++ b/packages/perps-controller/src/services/RewardsIntegrationService.ts @@ -17,7 +17,6 @@ import type { PerpsPlatformDependencies, PerpsSubscriptionBenefits, PerpsSubscriptionFeeWaiverStatus, - RewardsDiscountResponse, } from '../types/index.js'; import type { PerpsControllerMessengerBase } from '../types/messenger.js'; import { getSelectedEvmAccountFromMessenger } from '../utils/accountUtils.js'; @@ -66,12 +65,13 @@ type NormalizedRewardsDiscount = { * - `default` — {@link BUILDER_FEE_CONFIG}, the fee with no reductions. * - `rewards` — VIP, season, and targeted, collapsed into one discount by * `RewardsController` (`rewards.getPerpsDiscountForAccount`), so this service - * does not re-derive the split. The client mayreport targeted participation + * does not re-derive the split. The client may report targeted participation * via a structured response with a boolean flag. * - `subscription` — `0` bips, but only when the eligibility gate passes on a * cached read of the profile's benefits. * - * On a tie the cheaper-to-explain source wins, in the order + * Rewards wins ties with default. Subscription must be strictly cheaper after + * venue quantization to win, so a tie does not spend subscription allowance. * `subscription` > `rewards` > `default`. * * The benefits cache is stale-while-revalidate: fee resolution is a pure read @@ -228,11 +228,8 @@ export class RewardsIntegrationService { let subscriptionWaiverKind: PerpsFeeResolution['subscriptionWaiverKind']; let subscriptionCoveredNotionalUsd: number | undefined; - // The waiver competes like any other source. A full waiver still wins on - // `<=`, but a partial blend only wins when it is genuinely cheaper than the - // rewards discount — the ADR's requirement that subscription be able to - // lose. - // + // Full and partial waivers compete with the rewards fee. Subscription + // only wins when it produces a genuinely cheaper fee on the wire. // Compared *after* venue quantization, because that is the fee the order // pays. The builder fee is submitted in integer tenths of a basis point, so // a blend like 9.9999 bips is cheaper than the 10-bip default in arithmetic @@ -260,6 +257,7 @@ export class RewardsIntegrationService { ) { feeBips = waiver.feeBips; source = 'subscription'; + targetedDiscountApplied = undefined; subscriptionWaiverKind = waiver.kind === 'partial' ? 'partial' : 'full'; subscriptionCoveredNotionalUsd = waiver.coveredNotionalUsd; } @@ -289,7 +287,7 @@ export class RewardsIntegrationService { discountBips, source, subscription, - targetedDiscountApplied, + ...(targetedDiscountApplied !== undefined && { targetedDiscountApplied }), subscriptionWaiverKind, subscriptionCoveredNotionalUsd, }; diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index 0cc6e75b3b3..582d6f49c36 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -1802,6 +1802,14 @@ export type FeeCalculationResult = { */ chargesMetamaskBuilderFee?: boolean; + /** + * The resolution used to price this preview's MetaMask builder fee. + * Includes the winning source and targeted rewards participation when known. + * Omitted when no resolution applies, the builder fee is unavailable, or the + * placement does not charge a builder fee.Subscription eligibility remains + * available separately even when no resolution is applied. + */ + // Optional detailed breakdown for transparency breakdown?: { baseFeeRate: number; @@ -1897,9 +1905,9 @@ export type PerpsSubscriptionFeeWaiverStatus = { /** * Fee source that won the unified resolver. * - * `rewards` covers both VIP and season discounts: `RewardsController` already - * returns the better of the two as a single discount, so the perps controller - * treats them as one source rather than re-deriving the split. + * `rewards` covers VIP, season, and targeted discounts. The client owned + * `RewardsController` combines them into a single discount; the perps + * controller does not re-derive their contributions. */ export type PerpsFeeSource = 'default' | 'rewards' | 'subscription'; @@ -2943,8 +2951,8 @@ export type PerpsPlatformDependencies = { * Get fee discount for an account from the RewardsController. * * Returns either: - * - A numeric discount in basis points (e.g., 6500 = 65% discount) for legacy - * VIP and season discounts (targeted participation is unknown) + * - A numeric discount in basis points (e.g., 6500 = 65% discount) from + * legacy clients (targeted participation is unknown) * - A structured response with `discountBips` and `targetedDiscountApplied` * when targeted participation is available * - `null` when subscription state hasn't hydrated yet @@ -2952,6 +2960,11 @@ export type PerpsPlatformDependencies = { * Pass the perps MetaMask builder base fee in bips so the rewards * controller can convert an absolute VIP fee into a discount fraction. * + * The client combines VIP, season, and targeted grants into this discount. + * It may change when a grant is added or removed, independently of VIP tier + * or season. The client owns freshness; core does not cache this response. + * Skip caching null results and retry on the next fee calculation. + * * The client may return either the legacy numeric format or the structured * format. The controller normalizes both and carries participation only when * rewards wins the fee resolution. diff --git a/packages/perps-controller/tests/src/PerpsController.operations.test.ts b/packages/perps-controller/tests/src/PerpsController.operations.test.ts index 0abfe6a7ece..9f696604f82 100644 --- a/packages/perps-controller/tests/src/PerpsController.operations.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.operations.test.ts @@ -1727,6 +1727,49 @@ describe('PerpsController', () => { refresh.mockRestore(); }); + it('passes one targeted rewards resolution to the fee preview and returns its attribution', async () => { + const params = { + orderType: 'market' as const, + amount: '100000', + symbol: 'BTC', + }; + const resolution = { + feeBips: 3.5, + discountBips: 6500, + source: 'rewards' as const, + targetedDiscountApplied: true, + subscription: { + eligible: false, + reason: 'no-source' as const, + }, + }; + const resolveFee = jest + .spyOn(RewardsIntegrationService.prototype, 'resolveFee') + .mockResolvedValue(resolution); + jest + .spyOn( + RewardsIntegrationService.prototype, + 'refreshSubscriptionBenefits', + ) + .mockResolvedValue(undefined); + const fees = { metamaskFeeRate: 0.00035, feeResolution: resolution }; + mockMarketDataServiceInstance.calculateFees.mockResolvedValue(fees); + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + expect(await controller.calculateFees(params)).toBe(fees); + expect(resolveFee).toHaveBeenCalledTimes(1); + expect(resolveFee).toHaveBeenCalledWith(1000); + expect(mockMarketDataServiceInstance.calculateFees).toHaveBeenCalledWith( + expect.objectContaining({ + context: expect.objectContaining({ + feeResolution: resolution, + }), + }), + ); + jest.resetAllMocks(); + }); + it('resolves the preview fee against the order notional', async () => { const feeParams = { orderType: 'market' as const, diff --git a/packages/perps-controller/tests/src/services/MarketDataService.test.ts b/packages/perps-controller/tests/src/services/MarketDataService.test.ts index 54c32e45359..619441f7a4b 100644 --- a/packages/perps-controller/tests/src/services/MarketDataService.test.ts +++ b/packages/perps-controller/tests/src/services/MarketDataService.test.ts @@ -10,6 +10,7 @@ import type { Funding, MarketInfo, FeeCalculationResult, + PerpsFeeResolution, FeeCalculationParams, AssetRoute, PerpsPlatformDependencies, @@ -866,6 +867,109 @@ describe('MarketDataService', () => { expect(result).toEqual(mockFees); }); + it('returns the pricing resolution through targeted, non-targeted, legacy and other source previews', async () => { + const resolutions: PerpsFeeResolution[] = [ + { + source: 'rewards', + feeBips: 3.5, + discountBips: 6500, + targetedDiscountApplied: true, + subscription: { eligible: false, reason: 'no-source' }, + }, + { + source: 'rewards', + feeBips: 3.5, + discountBips: 6500, + targetedDiscountApplied: false, + subscription: { eligible: false, reason: 'no-source' }, + }, + { + source: 'rewards', + feeBips: 3.5, + discountBips: 6500, + subscription: { eligible: false, reason: 'no-source' }, + }, + { + source: 'subscription', + feeBips: 0, + discountBips: 10000, + subscription: { eligible: true, reason: 'eligible' }, + }, + { + source: 'default', + feeBips: 10, + discountBips: undefined, + subscription: { eligible: false, reason: 'no-source' }, + }, + ]; + mockProvider.calculateFees.mockResolvedValue({ + metamaskFeeRate: 0.001, + protocolFeeRate: 0.00045, + }); + for (const resolution of resolutions) { + const result = await marketDataService.calculateFees({ + provider: mockProvider, + params: { + orderType: 'market', + symbol: 'BTC', + amount: '1000', + isMaker: false, + }, + context: { ...mockContext, feeResolution: resolution }, + }); + expect(result.feeResolution).toBe(resolution); + expect(result.metamaskFeeRate).toBeCloseTo(resolution.feeBips / 10000); + expect(result.feeRate).toBeCloseTo( + resolution.feeBips / 10000 + 0.00045, + ); + expect(result.feeAmount).toBeCloseTo( + (resolution.feeBips / 10000 + 0.00045) * 1000, + ); + } + expect(mockProvider.calculateFees).toHaveBeenCalledTimes( + resolutions.length, + ); + }); + + it.each([ + { rate: undefined, policy: true, applies: false }, + { rate: 0, policy: undefined, applies: false }, + { rate: 0, policy: false, applies: false }, + { rate: 0, policy: true, applies: true }, + { rate: 0.001, policy: false, applies: false }, + ])( + 'only attributes applicable builder fee previews ($rate, $policy)', + async ({ rate, policy, applies }) => { + const resolution: PerpsFeeResolution = { + source: 'rewards', + feeBips: 3.5, + discountBips: 6500, + targetedDiscountApplied: true, + subscription: { eligible: false, reason: 'no-source' }, + }; + mockProvider.calculateFees.mockResolvedValue({ + metamaskFeeRate: rate, + chargesMetamaskBuilderFee: policy, + }); + const result = await marketDataService.calculateFees({ + provider: mockProvider, + params: { + orderType: 'market', + symbol: 'BTC', + amount: '1000', + isMaker: false, + }, + context: { ...mockContext, feeResolution: resolution }, + }); + if (applies) { + expect(result.feeResolution).toBe(resolution); + expect(result.metamaskFeeRate).toBeCloseTo(0.00035); + } else { + expect(result).not.toHaveProperty('feeResolution'); + } + }, + ); + it('surfaces subscription eligibility and remainingNotionalUsd on the fee preview', async () => { const params: FeeCalculationParams = { orderType: 'market', diff --git a/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts b/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts index 5eaf9cc655b..522c1361a9c 100644 --- a/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts +++ b/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts @@ -1,5 +1,6 @@ import { RewardsIntegrationService } from '../../../src/services/RewardsIntegrationService.js'; import type { PerpsPlatformDependencies } from '../../../src/types/index.js'; +import { quantizeBuilderFeeTenthsBps } from '../../../src/utils/subscriptionFeeWaiver.js'; /* eslint-disable */ import { createMockEvmAccount, @@ -392,6 +393,157 @@ describe('RewardsIntegrationService', () => { }); }); + it.each([true, false])( + 'preserves structured rewards participation (%s) and the numeric discount API', + async (targetedDiscountApplied) => { + const getDiscount = mockDeps.rewards + .getPerpsDiscountForAccount as jest.Mock; + getDiscount.mockResolvedValue({ + discountBips: 6500, + targetedDiscountApplied, + }); + + const resolution = await service.resolveFee(); + + expect(resolution).toMatchObject({ + source: 'rewards', + feeBips: 3.5, + discountBips: 6500, + targetedDiscountApplied, + }); + expect(getDiscount).toHaveBeenCalledTimes(1); + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + 'RewardsIntegrationService: Fee resolved', + expect.objectContaining({ + source: 'rewards', + targetedDiscountApplied, + }), + ); + expect(await service.calculateUserFeeDiscount()).toBe(6500); + }, + ); + + it.each([ + { + discountBips: 6500, + remaining: 1000, + source: 'subscription', + tenths: 0, + }, + { discountBips: 6500, remaining: 500, source: 'rewards', tenths: 35 }, + { + discountBips: 6500, + remaining: 750, + source: 'subscription', + tenths: 25, + }, + { discountBips: 10000, remaining: 1000, source: 'rewards', tenths: 0 }, + { discountBips: 5000, remaining: 500, source: 'rewards', tenths: 50 }, + // The raw blend is cheaper, but rounding the discount still yields 5000. + { discountBips: 5000, remaining: 500.01, source: 'rewards', tenths: 50 }, + { + discountBips: 5000, + remaining: 510, + source: 'subscription', + tenths: 49, + }, + ])( + 'resolves targeted $discountBips against $remaining USD allowance to $source', + async ({ discountBips, remaining, source, tenths }) => { + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue({ + discountBips, + targetedDiscountApplied: true, + }); + wireSubscription( + jest + .fn() + .mockResolvedValue( + createBenefits({ remainingNotionalUsd: remaining }), + ), + ); + await service.refreshSubscriptionBenefits(); + + const resolution = await service.resolveFee(1000); + + expect(resolution.source).toBe(source); + expect(quantizeBuilderFeeTenthsBps(resolution.discountBips ?? 0)).toBe( + tenths, + ); + if (source === 'rewards') { + expect(resolution.targetedDiscountApplied).toBe(true); + } else { + expect(resolution).not.toHaveProperty('targetedDiscountApplied'); + } + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + 'RewardsIntegrationService: Fee resolved', + expect.objectContaining({ + source, + targetedDiscountApplied: source === 'rewards' ? true : undefined, + rewardsTargetedDiscountApplied: true, + }), + ); + }, + ); + + it('observes grant addition, removal and attribution changes without caching the result', async () => { + const getDiscount = mockDeps.rewards + .getPerpsDiscountForAccount as jest.Mock; + for (const response of [ + { discountBips: 2500, targetedDiscountApplied: false }, + { discountBips: 6500, targetedDiscountApplied: true }, + { discountBips: 6500, targetedDiscountApplied: false }, + { discountBips: 2500, targetedDiscountApplied: false }, + ]) { + getDiscount.mockResolvedValueOnce(response); + const resolution = await service.resolveFee(); + expect(resolution).toMatchObject({ source: 'rewards', ...response }); + expect(resolution.feeBips).toBeCloseTo( + 10 * (1 - response.discountBips / 10000), + ); + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + 'RewardsIntegrationService: Fee resolved', + expect.objectContaining(response), + ); + } + expect(getDiscount).toHaveBeenCalledTimes(4); + }); + + it.each(['legacy', 'unavailable', 'error'])( + 'does not retain targeted attribution when the next response is %s', + async (nextResponse) => { + const getDiscount = mockDeps.rewards + .getPerpsDiscountForAccount as jest.Mock; + getDiscount.mockResolvedValueOnce({ + discountBips: 6500, + targetedDiscountApplied: true, + }); + expect((await service.resolveFee()).targetedDiscountApplied).toBe(true); + if (nextResponse === 'error') { + getDiscount.mockRejectedValueOnce(new Error('Rewards unavailable')); + } else { + getDiscount.mockResolvedValueOnce( + nextResponse === 'legacy' ? 0 : null, + ); + } + + const resolution = await service.resolveFee(); + + expect(resolution).not.toHaveProperty('targetedDiscountApplied'); + expect(resolution.source).toBe( + nextResponse === 'legacy' ? 'rewards' : 'default', + ); + expect(resolution.discountBips).toBe( + nextResponse === 'legacy' ? 0 : undefined, + ); + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + 'RewardsIntegrationService: Fee resolved', + expect.objectContaining({ targetedDiscountApplied: undefined }), + ); + }, + ); + it('resolves the subscription source to a 0 bips fee only when the eligibility gate passes', async () => { const cases = [ { benefits: createBenefits(), eligible: true, reason: 'eligible' }, diff --git a/packages/perps-controller/tests/src/services/TradingService.test.ts b/packages/perps-controller/tests/src/services/TradingService.test.ts index 2fcdaedc37d..2c4b336db27 100644 --- a/packages/perps-controller/tests/src/services/TradingService.test.ts +++ b/packages/perps-controller/tests/src/services/TradingService.test.ts @@ -105,6 +105,45 @@ describe('TradingService', () => { }); describe('placeOrder', () => { + it('passes targeted rewards attribution and its discount in the provider resolution, then clears it', async () => { + const resolution: PerpsFeeResolution = { + feeBips: 3.5, + discountBips: 6500, + source: 'rewards', + targetedDiscountApplied: true, + subscription: { eligible: false, reason: 'no-source' }, + }; + const params: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }; + mockProvider.setUserFeeResolution = jest.fn(); + mockRewardsIntegrationService.resolveFee.mockResolvedValue(resolution); + mockProvider.placeOrder.mockImplementation(async () => { + expect(mockProvider.setUserFeeResolution).toHaveBeenLastCalledWith( + resolution, + ); + expect(mockProvider.setUserFeeDiscount).not.toHaveBeenCalled(); + return { success: true }; + }); + + await tradingService.placeOrder({ + provider: mockProvider, + params, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(mockRewardsIntegrationService.resolveFee).toHaveBeenCalledTimes(1); + expect(mockProvider.placeOrder).toHaveBeenCalledWith(params); + expect(mockProvider.setUserFeeResolution).toHaveBeenLastCalledWith( + undefined, + ); + expect(mockProvider.setUserFeeDiscount).not.toHaveBeenCalled(); + }); + it('preserves the subscription source through order construction', async () => { mockProvider.setUserFeeResolution = jest.fn(); const subscriptionResolution: PerpsFeeResolution = { From 94fc540300f9a948c19c9994b14eb952bab24c9a Mon Sep 17 00:00:00 2001 From: geositta Date: Tue, 29 Sep 2026 22:45:29 -0500 Subject: [PATCH 03/15] feat(perps): update changelog and readme --- packages/perps-controller/CHANGELOG.md | 7 +++++++ packages/perps-controller/README.md | 28 ++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 81b930dd8c9..3edf3be9224 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -19,7 +19,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added + - Add `TWAP_SLICE`, `VAULT_CLOSE` and `SPOT_DUST_CONVERSION` to `DETAILED_ORDER_TYPES` for the HyperLiquid order types added in `@nktkas/hyperliquid` 0.33.3 ([#10591](https://github.com/MetaMask/core/pull/10591)) +- Expose the applied builder fee resolution through `FeeCalculationResult.feeResolution`, including its winning source and targeted rewards participation when known. - Add `PerpsController.getMarginModeLock` (and the `PerpsController:getMarginModeLock` messenger action) plus the optional `PerpsProvider.getMarginModeLock`, reporting the margin mode an asset is locked to by an open position or resting order/TWAP so clients can keep their margin-mode picker in sync with what order placement will accept. HyperLiquid implements it; other providers report `not_implemented`. ([#10414](https://github.com/MetaMask/core/pull/10414)) - Implement `getMarginModeLock` for Lighter, reporting the mode an open position binds to its market. ([#10414](https://github.com/MetaMask/core/pull/10414)) - Add optional `supportedMarginModes` to ready order capabilities; HyperLiquid reports `['isolated', 'cross']` for main-DEX markets and `['isolated']` for HIP-3 or isolated-only assets, so clients stop inferring margin-mode support from the provider. ([#10414](https://github.com/MetaMask/core/pull/10414)) @@ -77,6 +79,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Wait up to `PERPS_CONSTANTS.ConnectionTimeoutMs` for the client's follow-up `init()` when a controller action (such as `placeOrder`) was waiting on a `disconnect()`, so an order submitted during a disconnect-then-init reconnect is placed instead of failing with `CLIENT_NOT_INITIALIZED` ([#10589](https://github.com/MetaMask/core/pull/10589)) - If that reconnect switched the selected account, the network or the active provider, the action fails with `PROVIDER_LIFECYCLE_STALE` instead of running under the new context. +### Changed + +- **Breaking:** Widen `PerpsPlatformDependencies.rewards.getPerpsDiscountForAccount` to return `number | RewardsDiscountResponse | null`, allowing clients to supply a combined discount and targeted participation together. +- Existing numeric implementations remain supported. Code consuming the DI result directly must narrow the structured response before doing numeric operations. The client remains responsible for combining VIP, season, and targeted discounts. + ## [18.0.1] ### Fixed diff --git a/packages/perps-controller/README.md b/packages/perps-controller/README.md index 1bf4e25ee94..38b997c7727 100644 --- a/packages/perps-controller/README.md +++ b/packages/perps-controller/README.md @@ -49,6 +49,34 @@ The package exports the controller's parameter, result, provider, and messenger types for client integrations. Provider availability and aggregated routing are controlled by client configuration and feature flags. +## Rewards discounts and fee previews + +The client-owned RewardsController combines VIP, season, and targeted grants into one discount through `PerpsPlatformDependencies.rewards.getPerpsDiscountForAccount`. +Core compares that rewards result against the default fee and subscription waiver; targeted grants are not a fourth fee source. + +The method accepts a CAIP account ID and the base builder fee in basis points. +It returns `Promise`. Existing clients can keep returning a numeric discount. Clients that know targeted participation can return the structured form: + +```typescript +import type { RewardsDiscountResponse } from '@metamask/perps-controller'; +// Example response from the client's getPerpsDiscountForAccount implementation. +const discount: RewardsDiscountResponse = { + discountBips: 6500, // 65% off the base builder fee. + targetedDiscountApplied: true, +}; +``` + +`targetedDiscountApplied: true` means a targeted grant contributes to the returned discount; `false` explicitly reports no targeted participation. +A numeric response leaves participation unknown, even if its amount matches a known VIP discount. `null` means the discount is unavailable, not zero; avoid caching it and retry on the next fee calculation. +`PerpsController.calculateFees` returns an optional `feeResolution` with the winning `source`, discount, and participation. When rewards wins, structured responses preserve their participation boolean; numeric responses omit it. +Default and subscription winners omit the participation field. A grant merely existing on the profile does not mean it was applied to the quote. The whole `feeResolution` field is omitted when the preview cannot apply a resolution or the placement does not charge a builder fee. Subscription eligibility is still +reported separately when available. + +Rewards wins a tie with default. Subscription must be strictly cheaper after venue quantization to win, so ties do not spend subscription allowance. Preview attribution comes from the same resolution used to calculate the quoted rates. + +The client owns discount combination and cache freshness. Grants can change independently of VIP tier or season. Core reads the DI result on every resolution, but does not fetch grants, invalidate client caches, or implement the client UI. +Updated clients must supply participation before their UI can identify targeted discounts; legacy numeric responses cannot provide that attribution. + ## Error codes `PERPS_ERROR_CODES` / `PerpsErrorCode` are the structured codes returned to From 6ea12161244b57dc17fbf59fb81657f2d4f964e9 Mon Sep 17 00:00:00 2001 From: geositta Date: Tue, 29 Sep 2026 22:56:44 -0500 Subject: [PATCH 04/15] chore: linting --- .../src/services/MarketDataService.ts | 3 ++- .../src/services/RewardsIntegrationService.ts | 18 +++++++++++------- .../src/PerpsController.operations.test.ts | 12 ++++++------ 3 files changed, 19 insertions(+), 14 deletions(-) diff --git a/packages/perps-controller/src/services/MarketDataService.ts b/packages/perps-controller/src/services/MarketDataService.ts index 8587bb24506..67d08fff92d 100644 --- a/packages/perps-controller/src/services/MarketDataService.ts +++ b/packages/perps-controller/src/services/MarketDataService.ts @@ -1365,7 +1365,8 @@ export class MarketDataService { return { ...priced, ...(resolutionApplies && { feeResolution: context.feeResolution }), - ...(context.subscriptionFeeWaiver && { subscription: context.subscriptionFeeWaiver, + ...(context.subscriptionFeeWaiver && { + subscription: context.subscriptionFeeWaiver, }), }; } catch (error) { diff --git a/packages/perps-controller/src/services/RewardsIntegrationService.ts b/packages/perps-controller/src/services/RewardsIntegrationService.ts index 7b49baa0a35..22c21317b4c 100644 --- a/packages/perps-controller/src/services/RewardsIntegrationService.ts +++ b/packages/perps-controller/src/services/RewardsIntegrationService.ts @@ -208,7 +208,8 @@ export class RewardsIntegrationService { if (rewardsDiscount !== undefined) { const rewardsFeeBips = - DEFAULT_FEE_BIPS * (1 - rewardsDiscount.discountBips / BASIS_POINTS_DIVISOR); + DEFAULT_FEE_BIPS * + (1 - rewardsDiscount.discountBips / BASIS_POINTS_DIVISOR); // `<=` so an equal rewards fee still reports the rewards source, keeping // a resolved 0% discount distinguishable from an unresolved one. if (rewardsFeeBips <= feeBips) { @@ -287,7 +288,7 @@ export class RewardsIntegrationService { discountBips, source, subscription, - ...(targetedDiscountApplied !== undefined && { targetedDiscountApplied }), + ...(targetedDiscountApplied !== undefined && { targetedDiscountApplied }), subscriptionWaiverKind, subscriptionCoveredNotionalUsd, }; @@ -735,7 +736,9 @@ export class RewardsIntegrationService { * @returns A normalized discount with discountBips and optionally targetedDiscountApplied, * or undefined when unavailable. */ - async #calculateRewardsDiscount(): Promise { + async #calculateRewardsDiscount(): Promise< + NormalizedRewardsDiscount | undefined + > { try { const evmAccount = getSelectedEvmAccountFromMessenger(this.#messenger); @@ -795,10 +798,11 @@ export class RewardsIntegrationService { // Use rewards via DI (no RewardsController in Core yet). // The rewards controller needs the perps MetaMask builder base fee in // bips to convert an absolute VIP fee into a discount fraction. - const discountResponse = await this.#deps.rewards.getPerpsDiscountForAccount( - caipAccountId, - DEFAULT_FEE_BIPS, - ); + const discountResponse = + await this.#deps.rewards.getPerpsDiscountForAccount( + caipAccountId, + DEFAULT_FEE_BIPS, + ); // null = subscription state not hydrated yet; surface as undefined so // callers don't treat it as a definitive "no discount" answer. diff --git a/packages/perps-controller/tests/src/PerpsController.operations.test.ts b/packages/perps-controller/tests/src/PerpsController.operations.test.ts index 9f696604f82..e89d9ad6df8 100644 --- a/packages/perps-controller/tests/src/PerpsController.operations.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.operations.test.ts @@ -1746,12 +1746,12 @@ describe('PerpsController', () => { const resolveFee = jest .spyOn(RewardsIntegrationService.prototype, 'resolveFee') .mockResolvedValue(resolution); - jest - .spyOn( - RewardsIntegrationService.prototype, - 'refreshSubscriptionBenefits', - ) - .mockResolvedValue(undefined); + jest + .spyOn( + RewardsIntegrationService.prototype, + 'refreshSubscriptionBenefits', + ) + .mockResolvedValue(undefined); const fees = { metamaskFeeRate: 0.00035, feeResolution: resolution }; mockMarketDataServiceInstance.calculateFees.mockResolvedValue(fees); markControllerAsInitialized(); From 9bdd67d0893a97148651be289383d28034dc2577 Mon Sep 17 00:00:00 2001 From: geositta Date: Tue, 29 Sep 2026 23:31:53 -0500 Subject: [PATCH 05/15] chore: correct mock value --- .../tests/src/PerpsController.operations.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/perps-controller/tests/src/PerpsController.operations.test.ts b/packages/perps-controller/tests/src/PerpsController.operations.test.ts index e89d9ad6df8..6b3f7336a5a 100644 --- a/packages/perps-controller/tests/src/PerpsController.operations.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.operations.test.ts @@ -1590,7 +1590,7 @@ describe('PerpsController', () => { const feeParams = { orderType: 'market' as const, isMaker: false, - amount: '100000', + amount: '1000', symbol: 'BTC', }; From 6e1fe1cbe4e3a2b093183cbfd155a0c676e51f2f Mon Sep 17 00:00:00 2001 From: geositta Date: Tue, 29 Sep 2026 23:34:34 -0500 Subject: [PATCH 06/15] chore: add builder fee check to market data service --- packages/perps-controller/src/services/MarketDataService.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/perps-controller/src/services/MarketDataService.ts b/packages/perps-controller/src/services/MarketDataService.ts index 67d08fff92d..338b7542665 100644 --- a/packages/perps-controller/src/services/MarketDataService.ts +++ b/packages/perps-controller/src/services/MarketDataService.ts @@ -1360,6 +1360,7 @@ export class MarketDataService { const resolutionApplies = context.feeResolution !== undefined && fees.metamaskFeeRate !== undefined && + fees.chargesMetamaskBuilderFee !== false && (fees.metamaskFeeRate !== 0 || fees.chargesMetamaskBuilderFee === true); return { From a7ffae56303d56887fc9e097244d844366a98ae4 Mon Sep 17 00:00:00 2001 From: geositta Date: Wed, 30 Sep 2026 09:32:59 -0500 Subject: [PATCH 07/15] chore: update amount for test --- .../tests/src/PerpsController.operations.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/perps-controller/tests/src/PerpsController.operations.test.ts b/packages/perps-controller/tests/src/PerpsController.operations.test.ts index 6b3f7336a5a..c1d3f90ed01 100644 --- a/packages/perps-controller/tests/src/PerpsController.operations.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.operations.test.ts @@ -1730,7 +1730,7 @@ describe('PerpsController', () => { it('passes one targeted rewards resolution to the fee preview and returns its attribution', async () => { const params = { orderType: 'market' as const, - amount: '100000', + amount: '1000', symbol: 'BTC', }; const resolution = { From e0b4eabab5ab5946fcd691efda09b4608e6e3d13 Mon Sep 17 00:00:00 2001 From: geositta Date: Wed, 30 Sep 2026 10:45:49 -0500 Subject: [PATCH 08/15] chore: add fee resolution declaration --- packages/perps-controller/src/types/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index 582d6f49c36..6025aa180ea 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -1782,7 +1782,7 @@ export type FeeCalculationResult = { // Total fees (protocol + MetaMask) feeRate?: number; // Total fee rate as decimal (e.g., 0.00145 for 0.145%), undefined when unavailable feeAmount?: number; // Total fee amount in USD (when amount is provided) - + feeResolution?: PerpsFeeResolution; // Protocol-specific base fees protocolFeeRate?: number; // Protocol fee rate (e.g., 0.00045 for HyperLiquid taker), undefined when unavailable protocolFeeAmount?: number; // Protocol fee amount in USD From 1e3a9a8453b56c1a39bfadb7d076f9559fd3d988 Mon Sep 17 00:00:00 2001 From: geositta Date: Wed, 30 Sep 2026 11:40:08 -0500 Subject: [PATCH 09/15] chore: linting changelog --- packages/perps-controller/CHANGELOG.md | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 3edf3be9224..de7b5073242 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -19,7 +19,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - - Add `TWAP_SLICE`, `VAULT_CLOSE` and `SPOT_DUST_CONVERSION` to `DETAILED_ORDER_TYPES` for the HyperLiquid order types added in `@nktkas/hyperliquid` 0.33.3 ([#10591](https://github.com/MetaMask/core/pull/10591)) - Expose the applied builder fee resolution through `FeeCalculationResult.feeResolution`, including its winning source and targeted rewards participation when known. - Add `PerpsController.getMarginModeLock` (and the `PerpsController:getMarginModeLock` messenger action) plus the optional `PerpsProvider.getMarginModeLock`, reporting the margin mode an asset is locked to by an open position or resting order/TWAP so clients can keep their margin-mode picker in sync with what order placement will accept. HyperLiquid implements it; other providers report `not_implemented`. ([#10414](https://github.com/MetaMask/core/pull/10414)) @@ -59,6 +58,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@nktkas/hyperliquid` from `^0.33.1` to `^0.33.3` ([#10591](https://github.com/MetaMask/core/pull/10591)) - Drop the yarn patch on the SDK, which `0.33.3` no longer needs - Bump `deepmerge` from `^4.2.2` to `^4.3.1` ([#10437](https://github.com/MetaMask/core/pull/10437)) +- **Breaking:** Widen `PerpsPlatformDependencies.rewards.getPerpsDiscountForAccount` to return `number | RewardsDiscountResponse | null`, allowing clients to supply a combined discount and targeted participation together. +- Existing numeric implementations remain supported. Code consuming the DI result directly must narrow the structured response before doing numeric operations. The client remains responsible for combining VIP, season, and targeted discounts. ### Removed @@ -79,11 +80,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Wait up to `PERPS_CONSTANTS.ConnectionTimeoutMs` for the client's follow-up `init()` when a controller action (such as `placeOrder`) was waiting on a `disconnect()`, so an order submitted during a disconnect-then-init reconnect is placed instead of failing with `CLIENT_NOT_INITIALIZED` ([#10589](https://github.com/MetaMask/core/pull/10589)) - If that reconnect switched the selected account, the network or the active provider, the action fails with `PROVIDER_LIFECYCLE_STALE` instead of running under the new context. -### Changed - -- **Breaking:** Widen `PerpsPlatformDependencies.rewards.getPerpsDiscountForAccount` to return `number | RewardsDiscountResponse | null`, allowing clients to supply a combined discount and targeted participation together. -- Existing numeric implementations remain supported. Code consuming the DI result directly must narrow the structured response before doing numeric operations. The client remains responsible for combining VIP, season, and targeted discounts. - ## [18.0.1] ### Fixed From 82b810cd21e870d8cab2365b729495c3143a4509 Mon Sep 17 00:00:00 2001 From: geositta Date: Wed, 30 Sep 2026 11:48:56 -0500 Subject: [PATCH 10/15] chore: linting changelog --- packages/perps-controller/CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index de7b5073242..8d9b0fa7162 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -11,6 +11,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **BREAKING:** `OrderFill.pnl` is optional when the venue omits realized PnL. Consumers must preserve missing amounts as unknown when aggregating or displaying fills; only a reported `'0'` is zero ([#10605](https://github.com/MetaMask/core/pull/10605)) +### Added +- Expose the applied builder fee resolution through `FeeCalculationResult.feeResolution`, including its winning source and targeted rewards participation when known. + ### Fixed - Accept Lighter trades that omit position-sign flags, preserve omitted account PnL as unknown, and reject known reductions without realized PnL. Retain side-only fill directions when lifecycle context is ambiguous ([#10605](https://github.com/MetaMask/core/pull/10605)) @@ -20,7 +23,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add `TWAP_SLICE`, `VAULT_CLOSE` and `SPOT_DUST_CONVERSION` to `DETAILED_ORDER_TYPES` for the HyperLiquid order types added in `@nktkas/hyperliquid` 0.33.3 ([#10591](https://github.com/MetaMask/core/pull/10591)) -- Expose the applied builder fee resolution through `FeeCalculationResult.feeResolution`, including its winning source and targeted rewards participation when known. - Add `PerpsController.getMarginModeLock` (and the `PerpsController:getMarginModeLock` messenger action) plus the optional `PerpsProvider.getMarginModeLock`, reporting the margin mode an asset is locked to by an open position or resting order/TWAP so clients can keep their margin-mode picker in sync with what order placement will accept. HyperLiquid implements it; other providers report `not_implemented`. ([#10414](https://github.com/MetaMask/core/pull/10414)) - Implement `getMarginModeLock` for Lighter, reporting the mode an open position binds to its market. ([#10414](https://github.com/MetaMask/core/pull/10414)) - Add optional `supportedMarginModes` to ready order capabilities; HyperLiquid reports `['isolated', 'cross']` for main-DEX markets and `['isolated']` for HIP-3 or isolated-only assets, so clients stop inferring margin-mode support from the provider. ([#10414](https://github.com/MetaMask/core/pull/10414)) From 50f0fd2ea1beeb7944066c34fda8f6294cc53271 Mon Sep 17 00:00:00 2001 From: geositta Date: Wed, 30 Sep 2026 11:54:02 -0500 Subject: [PATCH 11/15] chore: linting changelog --- packages/perps-controller/CHANGELOG.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 8d9b0fa7162..48b4b6028a8 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -7,13 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Expose the applied builder fee resolution through `FeeCalculationResult.feeResolution`, including its winning source and targeted rewards participation when known. + ### Changed - **BREAKING:** `OrderFill.pnl` is optional when the venue omits realized PnL. Consumers must preserve missing amounts as unknown when aggregating or displaying fills; only a reported `'0'` is zero ([#10605](https://github.com/MetaMask/core/pull/10605)) -### Added -- Expose the applied builder fee resolution through `FeeCalculationResult.feeResolution`, including its winning source and targeted rewards participation when known. - ### Fixed - Accept Lighter trades that omit position-sign flags, preserve omitted account PnL as unknown, and reject known reductions without realized PnL. Retain side-only fill directions when lifecycle context is ambiguous ([#10605](https://github.com/MetaMask/core/pull/10605)) From fc00da9caf2d9ba562ce9b71f14b8b490d9a0187 Mon Sep 17 00:00:00 2001 From: geositta Date: Wed, 30 Sep 2026 12:08:59 -0500 Subject: [PATCH 12/15] chore: linting changelog --- packages/perps-controller/CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 48b4b6028a8..cb902fd4e68 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -9,10 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Expose the applied builder fee resolution through `FeeCalculationResult.feeResolution`, including its winning source and targeted rewards participation when known. +- Expose the applied builder fee resolution through `FeeCalculationResult.feeResolution`, including its winning source and targeted rewards participation when known. ([#10587](https://github.com/MetaMask/core/pull/10587)) ### Changed +- **BREAKING:** Widen `PerpsPlatformDependencies.rewards.getPerpsDiscountForAccount` to return `number | RewardsDiscountResponse | null`, allowing clients to supply a combined discount and targeted participation together. + - Existing numeric implementations remain supported. Code consuming the DI result directly must narrow the structured response before doing numeric operations. The client remains responsible for combining VIP, season, and targeted discounts. - **BREAKING:** `OrderFill.pnl` is optional when the venue omits realized PnL. Consumers must preserve missing amounts as unknown when aggregating or displaying fills; only a reported `'0'` is zero ([#10605](https://github.com/MetaMask/core/pull/10605)) ### Fixed @@ -61,8 +63,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@nktkas/hyperliquid` from `^0.33.1` to `^0.33.3` ([#10591](https://github.com/MetaMask/core/pull/10591)) - Drop the yarn patch on the SDK, which `0.33.3` no longer needs - Bump `deepmerge` from `^4.2.2` to `^4.3.1` ([#10437](https://github.com/MetaMask/core/pull/10437)) -- **Breaking:** Widen `PerpsPlatformDependencies.rewards.getPerpsDiscountForAccount` to return `number | RewardsDiscountResponse | null`, allowing clients to supply a combined discount and targeted participation together. -- Existing numeric implementations remain supported. Code consuming the DI result directly must narrow the structured response before doing numeric operations. The client remains responsible for combining VIP, season, and targeted discounts. ### Removed From e69a6109ddc361ed3b0a59c21cbf8398aed57e7f Mon Sep 17 00:00:00 2001 From: geositta Date: Wed, 30 Sep 2026 12:30:41 -0500 Subject: [PATCH 13/15] chore: cleanup --- .../src/services/RewardsIntegrationService.ts | 1 - packages/perps-controller/src/types/index.ts | 5 ++--- .../tests/src/PerpsController.operations.test.ts | 2 +- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/perps-controller/src/services/RewardsIntegrationService.ts b/packages/perps-controller/src/services/RewardsIntegrationService.ts index 22c21317b4c..640ac54ff1a 100644 --- a/packages/perps-controller/src/services/RewardsIntegrationService.ts +++ b/packages/perps-controller/src/services/RewardsIntegrationService.ts @@ -72,7 +72,6 @@ type NormalizedRewardsDiscount = { * * Rewards wins ties with default. Subscription must be strictly cheaper after * venue quantization to win, so a tie does not spend subscription allowance. - * `subscription` > `rewards` > `default`. * * The benefits cache is stale-while-revalidate: fee resolution is a pure read * of the cached snapshot, while preview and lifecycle callers refresh it diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index 6025aa180ea..32379895e38 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -1806,7 +1806,7 @@ export type FeeCalculationResult = { * The resolution used to price this preview's MetaMask builder fee. * Includes the winning source and targeted rewards participation when known. * Omitted when no resolution applies, the builder fee is unavailable, or the - * placement does not charge a builder fee.Subscription eligibility remains + * placement does not charge a builder fee. Subscription eligibility remains * available separately even when no resolution is applied. */ @@ -2951,8 +2951,7 @@ export type PerpsPlatformDependencies = { * Get fee discount for an account from the RewardsController. * * Returns either: - * - A numeric discount in basis points (e.g., 6500 = 65% discount) from - * legacy clients (targeted participation is unknown) + * - A numeric discount in basis points (e.g., 6500 = 65% discount) from legacy clients (targeted participation is unknown) * - A structured response with `discountBips` and `targetedDiscountApplied` * when targeted participation is available * - `null` when subscription state hasn't hydrated yet diff --git a/packages/perps-controller/tests/src/PerpsController.operations.test.ts b/packages/perps-controller/tests/src/PerpsController.operations.test.ts index c1d3f90ed01..d53ffef930f 100644 --- a/packages/perps-controller/tests/src/PerpsController.operations.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.operations.test.ts @@ -1730,8 +1730,8 @@ describe('PerpsController', () => { it('passes one targeted rewards resolution to the fee preview and returns its attribution', async () => { const params = { orderType: 'market' as const, - amount: '1000', symbol: 'BTC', + amount: '1000', }; const resolution = { feeBips: 3.5, From 794ae4a821c542b6dd10b22cd5e98af2e28b6741 Mon Sep 17 00:00:00 2001 From: geositta Date: Thu, 1 Oct 2026 17:50:44 -0500 Subject: [PATCH 14/15] feat(perps-controller): resolve profile grants as independent fee source --- packages/perps-controller/CHANGELOG.md | 7 +- packages/perps-controller/README.md | 43 +- packages/perps-controller/src/index.ts | 2 +- .../src/services/RewardsIntegrationService.ts | 149 ++++--- packages/perps-controller/src/types/index.ts | 79 ++-- .../tests/helpers/serviceMocks.ts | 1 + .../src/PerpsController.operations.test.ts | 3 +- .../src/services/MarketDataService.test.ts | 11 +- .../RewardsIntegrationService.test.ts | 393 ++++++++++++------ .../tests/src/services/TradingService.test.ts | 3 +- 10 files changed, 429 insertions(+), 262 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index cb902fd4e68..60714ead44d 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -9,12 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Expose the applied builder fee resolution through `FeeCalculationResult.feeResolution`, including its winning source and targeted rewards participation when known. ([#10587](https://github.com/MetaMask/core/pull/10587)) +- Add the optional `PerpsPlatformDependencies.rewards.getPerpsTradingFeeGrant()` integration and exported `PerpsTradingFeeGrant` type for independent, expiring production-Hyperliquid fee candidates. ([#10587](https://github.com/MetaMask/core/pull/10587)) +- Expose the applied builder fee resolution through `FeeCalculationResult.feeResolution`, including its winning source. ([#10587](https://github.com/MetaMask/core/pull/10587)) ### Changed -- **BREAKING:** Widen `PerpsPlatformDependencies.rewards.getPerpsDiscountForAccount` to return `number | RewardsDiscountResponse | null`, allowing clients to supply a combined discount and targeted participation together. - - Existing numeric implementations remain supported. Code consuming the DI result directly must narrow the structured response before doing numeric operations. The client remains responsible for combining VIP, season, and targeted discounts. +- **BREAKING:** Add `'grant'` to `PerpsFeeSource`; exhaustive consumers must handle the new winner. Keep `getPerpsDiscountForAccount` as `Promise` for VIP/season and retrieve grants independently through optional `getPerpsTradingFeeGrant`. ([#10587](https://github.com/MetaMask/core/pull/10587)) + - Clients that expose grants return an absolute `feeBips` and Unix-millisecond `expiresAt`. Core retrieves both rewards candidates concurrently, selects the lowest valid fee after venue quantization, and does not cache grants. - **BREAKING:** `OrderFill.pnl` is optional when the venue omits realized PnL. Consumers must preserve missing amounts as unknown when aggregating or displaying fills; only a reported `'0'` is zero ([#10605](https://github.com/MetaMask/core/pull/10605)) ### Fixed diff --git a/packages/perps-controller/README.md b/packages/perps-controller/README.md index 38b997c7727..92732af33b0 100644 --- a/packages/perps-controller/README.md +++ b/packages/perps-controller/README.md @@ -51,31 +51,34 @@ routing are controlled by client configuration and feature flags. ## Rewards discounts and fee previews -The client-owned RewardsController combines VIP, season, and targeted grants into one discount through `PerpsPlatformDependencies.rewards.getPerpsDiscountForAccount`. -Core compares that rewards result against the default fee and subscription waiver; targeted grants are not a fourth fee source. - -The method accepts a CAIP account ID and the base builder fee in basis points. -It returns `Promise`. Existing clients can keep returning a numeric discount. Clients that know targeted participation can return the structured form: +The client-owned RewardsController exposes two independent fee candidates. +`getPerpsDiscountForAccount(account, baseFeeBips)` retains its existing +`Promise` contract for account-scoped VIP and season discounts. +The optional `getPerpsTradingFeeGrant()` has no account or network input and +returns an absolute production-Hyperliquid fee with its expiry: ```typescript -import type { RewardsDiscountResponse } from '@metamask/perps-controller'; -// Example response from the client's getPerpsDiscountForAccount implementation. -const discount: RewardsDiscountResponse = { - discountBips: 6500, // 65% off the base builder fee. - targetedDiscountApplied: true, +import type { PerpsTradingFeeGrant } from '@metamask/perps-controller'; + +const grant: PerpsTradingFeeGrant = { + feeBips: 3, + expiresAt: Date.now() + 60_000, }; ``` -`targetedDiscountApplied: true` means a targeted grant contributes to the returned discount; `false` explicitly reports no targeted participation. -A numeric response leaves participation unknown, even if its amount matches a known VIP discount. `null` means the discount is unavailable, not zero; avoid caching it and retry on the next fee calculation. -`PerpsController.calculateFees` returns an optional `feeResolution` with the winning `source`, discount, and participation. When rewards wins, structured responses preserve their participation boolean; numeric responses omit it. -Default and subscription winners omit the participation field. A grant merely existing on the profile does not mean it was applied to the quote. The whole `feeResolution` field is omitted when the preview cannot apply a resolution or the placement does not charge a builder fee. Subscription eligibility is still -reported separately when available. - -Rewards wins a tie with default. Subscription must be strictly cheaper after venue quantization to win, so ties do not spend subscription allowance. Preview attribution comes from the same resolution used to calculate the quoted rates. - -The client owns discount combination and cache freshness. Grants can change independently of VIP tier or season. Core reads the DI result on every resolution, but does not fetch grants, invalidate client caches, or implement the client UI. -Updated clients must supply participation before their UI can identify targeted discounts; legacy numeric responses cannot provide that attribution. +Core retrieves VIP/season and grant concurrently and isolates failures between +them. It calls the grant method on every resolution and validates that +`feeBips` is finite and non-negative and that `expiresAt` is finite and still +in the future after all candidate work settles. The client owns authentication, +payload validation, and production-Hyperliquid scoping. + +Core compares both candidates with the default fee and cached subscription +waiver. Rewards preserves its existing tie with default. A grant wins only when +strictly cheaper than the current winner after Hyperliquid's tenths-of-a-basis- +point quantization, so rewards wins a quantized VIP/grant tie and default beats +a non-reducing grant. Subscription retains the same strictly-cheaper +quantized-tie policy. `PerpsController.calculateFees` exposes the result through +its optional `feeResolution`; grant winners report `source: 'grant'`. ## Error codes diff --git a/packages/perps-controller/src/index.ts b/packages/perps-controller/src/index.ts index ee7a7e22d3b..abc3cfe7c13 100644 --- a/packages/perps-controller/src/index.ts +++ b/packages/perps-controller/src/index.ts @@ -317,7 +317,7 @@ export type { PerpsSubscriptionFeeWaiverStatus, PerpsFeeSource, PerpsFeeResolution, - RewardsDiscountResponse, + PerpsTradingFeeGrant, UpdatePositionTPSLParams, Order, Funding, diff --git a/packages/perps-controller/src/services/RewardsIntegrationService.ts b/packages/perps-controller/src/services/RewardsIntegrationService.ts index 640ac54ff1a..48de1b3d28b 100644 --- a/packages/perps-controller/src/services/RewardsIntegrationService.ts +++ b/packages/perps-controller/src/services/RewardsIntegrationService.ts @@ -17,6 +17,7 @@ import type { PerpsPlatformDependencies, PerpsSubscriptionBenefits, PerpsSubscriptionFeeWaiverStatus, + PerpsTradingFeeGrant, } from '../types/index.js'; import type { PerpsControllerMessengerBase } from '../types/messenger.js'; import { getSelectedEvmAccountFromMessenger } from '../utils/accountUtils.js'; @@ -45,16 +46,6 @@ type BenefitsSnapshot = { fetchedAt: number; }; -/** - * A normalized rewards discount response. - */ -type NormalizedRewardsDiscount = { - /** Discount in basis points */ - discountBips: number; - /** Whether a targeted rewards grant was applied, when known */ - targetedDiscountApplied?: boolean; -}; - /** * RewardsIntegrationService * @@ -63,15 +54,15 @@ type NormalizedRewardsDiscount = { * * Sources, all in fee basis points (lowest wins): * - `default` — {@link BUILDER_FEE_CONFIG}, the fee with no reductions. - * - `rewards` — VIP, season, and targeted, collapsed into one discount by - * `RewardsController` (`rewards.getPerpsDiscountForAccount`), so this service - * does not re-derive the split. The client may report targeted participation - * via a structured response with a boolean flag. + * - `rewards` — the account-scoped VIP and season discount returned by + * `rewards.getPerpsDiscountForAccount`. + * - `grant` — an independent, expiring production-Hyperliquid fee returned by + * `rewards.getPerpsTradingFeeGrant`. * - `subscription` — `0` bips, but only when the eligibility gate passes on a * cached read of the profile's benefits. * - * Rewards wins ties with default. Subscription must be strictly cheaper after - * venue quantization to win, so a tie does not spend subscription allowance. + * Rewards wins ties with default. Grant and subscription must be strictly + * cheaper after venue quantization to win. * * The benefits cache is stale-while-revalidate: fee resolution is a pure read * of the cached snapshot, while preview and lifecycle callers refresh it @@ -196,29 +187,47 @@ export class RewardsIntegrationService { * @returns The winning fee, its source, and the subscription gate outcome. */ async resolveFee(orderNotionalUsd?: number): Promise { - const rewardsDiscount = await this.#calculateRewardsDiscount(); + const [rewardsDiscount, grantCandidate] = await Promise.all([ + this.#calculateRewardsDiscount(), + this.#getPerpsTradingFeeGrant(), + ]); + // Expiry is deliberately checked only after every concurrent candidate has + // settled. A grant that expires while the account-scoped rewards request is + // in flight must not be selected. + const grant = isValidTradingFeeGrant(grantCandidate, Date.now()) + ? grantCandidate + : undefined; // Pure cache read: subscription benefits must never start a network request // while an order is being prepared for signing. const subscription = this.getSubscriptionFeeWaiverStatus(); let feeBips = DEFAULT_FEE_BIPS; let source: PerpsFeeSource = 'default'; - let targetedDiscountApplied: boolean | undefined; + + const toTenthsBps = (bips: number): number => + quantizeBuilderFeeTenthsBps( + Math.round((1 - bips / DEFAULT_FEE_BIPS) * BASIS_POINTS_DIVISOR), + ); if (rewardsDiscount !== undefined) { const rewardsFeeBips = - DEFAULT_FEE_BIPS * - (1 - rewardsDiscount.discountBips / BASIS_POINTS_DIVISOR); + DEFAULT_FEE_BIPS * (1 - rewardsDiscount / BASIS_POINTS_DIVISOR); // `<=` so an equal rewards fee still reports the rewards source, keeping // a resolved 0% discount distinguishable from an unresolved one. if (rewardsFeeBips <= feeBips) { feeBips = rewardsFeeBips; source = 'rewards'; - // Only carry participation when rewards wins - targetedDiscountApplied = rewardsDiscount.targetedDiscountApplied; } } + // A grant only wins when it lowers the fee the venue will actually charge. + // Rewards therefore keeps a quantized tie, and default keeps a grant that + // does not reduce the on-wire rate. + if (grant && toTenthsBps(grant.feeBips) < toTenthsBps(feeBips)) { + feeBips = grant.feeBips; + source = 'grant'; + } + const waiver = resolveSubscriptionWaiverRate({ status: subscription, maxFeeBips: DEFAULT_FEE_BIPS, @@ -237,11 +246,6 @@ export class RewardsIntegrationService { // would label such an order `source: 'subscription'` with a 0-bip discount // while it pays full price, and would disagree with the cloid marker, which // already gates on the quantized fee. - const toTenthsBps = (bips: number): number => - quantizeBuilderFeeTenthsBps( - Math.round((1 - bips / DEFAULT_FEE_BIPS) * BASIS_POINTS_DIVISOR), - ); - // Subscription must be strictly cheaper on the wire to claim the order. A // tie buys the user nothing — the same rate is already available from the // source that won — while claiming it marks the cloid and spends the @@ -257,7 +261,6 @@ export class RewardsIntegrationService { ) { feeBips = waiver.feeBips; source = 'subscription'; - targetedDiscountApplied = undefined; subscriptionWaiverKind = waiver.kind === 'partial' ? 'partial' : 'full'; subscriptionCoveredNotionalUsd = waiver.coveredNotionalUsd; } @@ -272,14 +275,14 @@ export class RewardsIntegrationService { feeBips, discountBips, defaultFeeBips: DEFAULT_FEE_BIPS, - rewardsDiscountBips: rewardsDiscount?.discountBips, - rewardsTargetedDiscountApplied: rewardsDiscount?.targetedDiscountApplied, + rewardsDiscountBips: rewardsDiscount, + grantFeeBips: grant?.feeBips, + grantExpiresAt: grant?.expiresAt, orderNotionalUsd, subscriptionEligible: subscription.eligible, subscriptionReason: subscription.reason, subscriptionWaiverKind, subscriptionCoveredNotionalUsd, - targetedDiscountApplied, }); return { @@ -287,7 +290,6 @@ export class RewardsIntegrationService { discountBips, source, subscription, - ...(targetedDiscountApplied !== undefined && { targetedDiscountApplied }), subscriptionWaiverKind, subscriptionCoveredNotionalUsd, }; @@ -730,14 +732,11 @@ export class RewardsIntegrationService { } /** - * Resolve the rewards (VIP + season + targeted) discount for the selected account. + * Resolve the VIP and season discount for the selected account. * - * @returns A normalized discount with discountBips and optionally targetedDiscountApplied, - * or undefined when unavailable. + * @returns The numeric discount, or undefined when unavailable. */ - async #calculateRewardsDiscount(): Promise< - NormalizedRewardsDiscount | undefined - > { + async #calculateRewardsDiscount(): Promise { try { const evmAccount = getSelectedEvmAccountFromMessenger(this.#messenger); @@ -797,15 +796,14 @@ export class RewardsIntegrationService { // Use rewards via DI (no RewardsController in Core yet). // The rewards controller needs the perps MetaMask builder base fee in // bips to convert an absolute VIP fee into a discount fraction. - const discountResponse = - await this.#deps.rewards.getPerpsDiscountForAccount( - caipAccountId, - DEFAULT_FEE_BIPS, - ); + const discountBips = await this.#deps.rewards.getPerpsDiscountForAccount( + caipAccountId, + DEFAULT_FEE_BIPS, + ); // null = subscription state not hydrated yet; surface as undefined so // callers don't treat it as a definitive "no discount" answer. - if (discountResponse === null) { + if (discountBips === null) { this.#deps.debugLogger.log( 'RewardsIntegrationService: Fee discount unavailable (subscription state not hydrated)', { address: evmAccount.address, caipAccountId }, @@ -813,27 +811,17 @@ export class RewardsIntegrationService { return undefined; } - // Normalize both legacy numeric responses and structured responses - const normalizedDiscount: NormalizedRewardsDiscount = - typeof discountResponse === 'number' - ? { discountBips: discountResponse } - : { - discountBips: discountResponse.discountBips, - targetedDiscountApplied: discountResponse.targetedDiscountApplied, - }; - this.#deps.debugLogger.log( 'RewardsIntegrationService: Fee discount calculated', { address: evmAccount.address, caipAccountId, - discountBips: normalizedDiscount.discountBips, - discountPercentage: normalizedDiscount.discountBips / 100, - targetedDiscountApplied: normalizedDiscount.targetedDiscountApplied, + discountBips, + discountPercentage: discountBips / 100, }, ); - return normalizedDiscount; + return discountBips; } catch (error) { this.#deps.logger.error( ensureError( @@ -851,6 +839,53 @@ export class RewardsIntegrationService { return undefined; } } + + /** + * Read the independent trading-fee grant without account or network lookup. + * + * @returns The candidate, or null when unsupported or unavailable. + */ + async #getPerpsTradingFeeGrant(): Promise { + if (!this.#deps.rewards.getPerpsTradingFeeGrant) { + return null; + } + + try { + return await this.#deps.rewards.getPerpsTradingFeeGrant(); + } catch (error) { + this.#deps.logger.error( + ensureError(error, 'RewardsIntegrationService.getPerpsTradingFeeGrant'), + { + tags: { feature: PERPS_CONSTANTS.FeatureName }, + context: { + name: 'RewardsIntegrationService.getPerpsTradingFeeGrant', + data: {}, + }, + }, + ); + return null; + } + } +} + +/** + * Validate a trading-fee grant at the time fee selection occurs. + * + * @param grant - Candidate returned by the client-owned RewardsController. + * @param now - Selection time as Unix milliseconds. + * @returns Whether the candidate is finite, non-negative, and unexpired. + */ +function isValidTradingFeeGrant( + grant: PerpsTradingFeeGrant | null, + now: number, +): grant is PerpsTradingFeeGrant { + return Boolean( + grant && + Number.isFinite(grant.feeBips) && + grant.feeBips >= 0 && + Number.isFinite(grant.expiresAt) && + now < grant.expiresAt, + ); } /** diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index 32379895e38..fe44a7770ed 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -1905,11 +1905,21 @@ export type PerpsSubscriptionFeeWaiverStatus = { /** * Fee source that won the unified resolver. * - * `rewards` covers VIP, season, and targeted discounts. The client owned - * `RewardsController` combines them into a single discount; the perps - * controller does not re-derive their contributions. + * `rewards` covers the account-scoped VIP and season discount. `grant` is the + * independent Hyperliquid trading-fee grant supplied by RewardsController. */ -export type PerpsFeeSource = 'default' | 'rewards' | 'subscription'; +export type PerpsFeeSource = 'default' | 'rewards' | 'grant' | 'subscription'; + +/** + * Independent RewardsController fee candidate for production Hyperliquid. + */ +export type PerpsTradingFeeGrant = { + /** Absolute MetaMask builder fee, in basis points. */ + feeBips: number; + + /** Expiry as a Unix timestamp in milliseconds. */ + expiresAt: number; +}; /** * Outcome of the unified fee resolver. @@ -1932,18 +1942,6 @@ export type PerpsFeeResolution = { /** Subscription gate outcome, always populated for observability. */ subscription: PerpsSubscriptionFeeWaiverStatus; - /** - * Whether a targeted rewards discount was applied when the rewards source won. - * - * Omitted when rewards does not win, or when the rewards controller returns - * a legacy numeric response (in which case participation is unknown). - * - * `true` means a targeted grant was applied; `false` means the client reported - * no targeted participation despite the client-owned RewardsController - * combining grants into a final discount. - */ - targetedDiscountApplied?: boolean; - /** * How much of the order the subscription allowance covered, when the * subscription source won. @@ -2868,23 +2866,6 @@ export type PerpsGlobalSnapshotResult = { * Cross-controller communication uses the messenger pattern (messenger.call). * Only rewards remains as DI because RewardsController is not yet in Core. */ -/** - * A rewards discount response from the RewardsController. - * - * When returned, the `discountBips` field contains the discount in basis - * points (e.g., 6500 = 65% discount), and `targetedDiscountApplied` indicates - * whether a targeted rewards grant (separate from VIP/season) was applied. - * - * The client owns the combination of VIP, season, and targeted discounts; - * this controller consumes only the combined result and never reconstructs it. - */ -export type RewardsDiscountResponse = { - /** Discount in basis points (e.g., 6500 = 65%) */ - discountBips: number; - /** True when a targeted rewards grant was applied, false otherwise */ - targetedDiscountApplied: boolean; -}; - export type PerpsPlatformDependencies = { // === Observability (stateless utilities) === logger: PerpsLogger; @@ -2948,30 +2929,30 @@ export type PerpsPlatformDependencies = { // === Rewards (DI — no RewardsController in Core yet) === rewards: { /** - * Get fee discount for an account from the RewardsController. + * Get the VIP and season fee discount for an account. * - * Returns either: - * - A numeric discount in basis points (e.g., 6500 = 65% discount) from legacy clients (targeted participation is unknown) - * - A structured response with `discountBips` and `targetedDiscountApplied` - * when targeted participation is available - * - `null` when subscription state hasn't hydrated yet + * Returns a numeric discount in basis points (e.g., 6500 = 65%), or `null` + * when rewards state has not hydrated yet. * * Pass the perps MetaMask builder base fee in bips so the rewards * controller can convert an absolute VIP fee into a discount fraction. - * - * The client combines VIP, season, and targeted grants into this discount. - * It may change when a grant is added or removed, independently of VIP tier - * or season. The client owns freshness; core does not cache this response. - * Skip caching null results and retry on the next fee calculation. - * - * The client may return either the legacy numeric format or the structured - * format. The controller normalizes both and carries participation only when - * rewards wins the fee resolution. */ getPerpsDiscountForAccount( caipAccountId: `${string}:${string}:${string}`, baseFeeBips: number, - ): Promise; + ): Promise; + + /** + * Get the independent production Hyperliquid trading fee grant. + * + * The client owns grant retrieval, authentication, payload validation, and + * venue scoping. Core validates the numeric fee and expiry, calls this on + * every resolution, and compares it with the other fee sources. + * + * Optional so clients predating grant support retain their existing + * behavior. + */ + getPerpsTradingFeeGrant?(): Promise; }; // === Subscription (DI — benefits endpoint is owned by the Subscription team) === diff --git a/packages/perps-controller/tests/helpers/serviceMocks.ts b/packages/perps-controller/tests/helpers/serviceMocks.ts index 7328ceefa5e..847955f444b 100644 --- a/packages/perps-controller/tests/helpers/serviceMocks.ts +++ b/packages/perps-controller/tests/helpers/serviceMocks.ts @@ -127,6 +127,7 @@ export const createMockInfrastructure = // === Rewards (DI — no RewardsController in Core yet) === rewards: { getPerpsDiscountForAccount: jest.fn().mockResolvedValue(0), + getPerpsTradingFeeGrant: jest.fn().mockResolvedValue(null), }, // === Disk Cache (cold-start persistence) === diff --git a/packages/perps-controller/tests/src/PerpsController.operations.test.ts b/packages/perps-controller/tests/src/PerpsController.operations.test.ts index d53ffef930f..48acf9dfaf5 100644 --- a/packages/perps-controller/tests/src/PerpsController.operations.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.operations.test.ts @@ -1727,7 +1727,7 @@ describe('PerpsController', () => { refresh.mockRestore(); }); - it('passes one targeted rewards resolution to the fee preview and returns its attribution', async () => { + it('passes one rewards resolution to the fee preview and returns it', async () => { const params = { orderType: 'market' as const, symbol: 'BTC', @@ -1737,7 +1737,6 @@ describe('PerpsController', () => { feeBips: 3.5, discountBips: 6500, source: 'rewards' as const, - targetedDiscountApplied: true, subscription: { eligible: false, reason: 'no-source' as const, diff --git a/packages/perps-controller/tests/src/services/MarketDataService.test.ts b/packages/perps-controller/tests/src/services/MarketDataService.test.ts index 619441f7a4b..a289499360a 100644 --- a/packages/perps-controller/tests/src/services/MarketDataService.test.ts +++ b/packages/perps-controller/tests/src/services/MarketDataService.test.ts @@ -867,20 +867,12 @@ describe('MarketDataService', () => { expect(result).toEqual(mockFees); }); - it('returns the pricing resolution through targeted, non-targeted, legacy and other source previews', async () => { + it('returns the pricing resolution through rewards and other source previews', async () => { const resolutions: PerpsFeeResolution[] = [ { source: 'rewards', feeBips: 3.5, discountBips: 6500, - targetedDiscountApplied: true, - subscription: { eligible: false, reason: 'no-source' }, - }, - { - source: 'rewards', - feeBips: 3.5, - discountBips: 6500, - targetedDiscountApplied: false, subscription: { eligible: false, reason: 'no-source' }, }, { @@ -944,7 +936,6 @@ describe('MarketDataService', () => { source: 'rewards', feeBips: 3.5, discountBips: 6500, - targetedDiscountApplied: true, subscription: { eligible: false, reason: 'no-source' }, }; mockProvider.calculateFees.mockResolvedValue({ diff --git a/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts b/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts index 522c1361a9c..eb30cfb524a 100644 --- a/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts +++ b/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts @@ -327,6 +327,15 @@ describe('RewardsIntegrationService', () => { return getPerpsBenefits; }; + const wireGrant = (getPerpsTradingFeeGrant: jest.Mock) => { + ( + mockDeps.rewards as unknown as { + getPerpsTradingFeeGrant?: jest.Mock; + } + ).getPerpsTradingFeeGrant = getPerpsTradingFeeGrant; + return getPerpsTradingFeeGrant; + }; + beforeEach(() => { jest.useFakeTimers(); jest.setSystemTime(NOW); @@ -393,153 +402,301 @@ describe('RewardsIntegrationService', () => { }); }); - it.each([true, false])( - 'preserves structured rewards participation (%s) and the numeric discount API', - async (targetedDiscountApplied) => { - const getDiscount = mockDeps.rewards - .getPerpsDiscountForAccount as jest.Mock; - getDiscount.mockResolvedValue({ - discountBips: 6500, - targetedDiscountApplied, - }); + it('uses a valid grant when VIP and season are unavailable', async () => { + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(null); + const getGrant = wireGrant( + jest.fn().mockResolvedValue({ + feeBips: 3, + expiresAt: NOW + 60_000, + }), + ); - const resolution = await service.resolveFee(); + await expect(service.resolveFee()).resolves.toMatchObject({ + source: 'grant', + feeBips: 3, + discountBips: 7000, + }); + expect(getGrant).toHaveBeenCalledWith(); + }); - expect(resolution).toMatchObject({ - source: 'rewards', - feeBips: 3.5, - discountBips: 6500, - targetedDiscountApplied, - }); - expect(getDiscount).toHaveBeenCalledTimes(1); - expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( - 'RewardsIntegrationService: Fee resolved', - expect.objectContaining({ - source: 'rewards', - targetedDiscountApplied, + it('preserves VIP-only behavior when the optional grant method is absent', async () => { + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(6500); + delete ( + mockDeps.rewards as { + getPerpsTradingFeeGrant?: unknown; + } + ).getPerpsTradingFeeGrant; + + await expect(service.resolveFee()).resolves.toMatchObject({ + source: 'rewards', + feeBips: 3.5, + discountBips: 6500, + }); + }); + + it.each([ + { vipDiscountBips: 6500, grantFeeBips: 2, source: 'grant', feeBips: 2 }, + { + vipDiscountBips: 6500, + grantFeeBips: 4, + source: 'rewards', + feeBips: 3.5, + }, + ])( + 'selects $source when VIP and grant are both available', + async ({ vipDiscountBips, grantFeeBips, source, feeBips }) => { + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(vipDiscountBips); + wireGrant( + jest.fn().mockResolvedValue({ + feeBips: grantFeeBips, + expiresAt: NOW + 60_000, }), ); - expect(await service.calculateUserFeeDiscount()).toBe(6500); + + await expect(service.resolveFee()).resolves.toMatchObject({ + source, + feeBips, + }); }, ); + it('leaves a quantized VIP/grant tie with rewards', async () => { + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(6460); + wireGrant( + jest.fn().mockResolvedValue({ + feeBips: 3.51, + expiresAt: NOW + 60_000, + }), + ); + + const resolution = await service.resolveFee(); + + expect(resolution.source).toBe('rewards'); + expect(quantizeBuilderFeeTenthsBps(resolution.discountBips ?? 0)).toBe( + 35, + ); + }); + + it('uses the default when neither independent rewards source is available', async () => { + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(null); + wireGrant(jest.fn().mockResolvedValue(null)); + + await expect(service.resolveFee()).resolves.toMatchObject({ + source: 'default', + feeBips: DEFAULT_FEE_BIPS, + discountBips: undefined, + }); + }); + + it('accepts a zero-fee grant', async () => { + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(6500); + wireGrant( + jest.fn().mockResolvedValue({ feeBips: 0, expiresAt: NOW + 60_000 }), + ); + + await expect(service.resolveFee()).resolves.toMatchObject({ + source: 'grant', + feeBips: 0, + discountBips: 10000, + }); + }); + + it('ignores an above-default grant', async () => { + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(null); + wireGrant( + jest.fn().mockResolvedValue({ feeBips: 11, expiresAt: NOW + 60_000 }), + ); + + expect((await service.resolveFee()).source).toBe('default'); + }); + + it('ignores expired grants', async () => { + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(null); + wireGrant(jest.fn().mockResolvedValue({ feeBips: 0, expiresAt: NOW })); + + expect((await service.resolveFee()).source).toBe('default'); + }); + + it.each([ + { feeBips: Number.NaN, expiresAt: NOW + 60_000 }, + { feeBips: Number.POSITIVE_INFINITY, expiresAt: NOW + 60_000 }, + { feeBips: -1, expiresAt: NOW + 60_000 }, + { feeBips: 0, expiresAt: Number.NaN }, + { feeBips: 0, expiresAt: Number.POSITIVE_INFINITY }, + ])('ignores a malformed grant %#', async (grant) => { + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(null); + wireGrant(jest.fn().mockResolvedValue(grant)); + + expect((await service.resolveFee()).source).toBe('default'); + }); + + it('keeps VIP when grant retrieval fails', async () => { + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(6500); + wireGrant(jest.fn().mockRejectedValue(new Error('Grant unavailable'))); + + await expect(service.resolveFee()).resolves.toMatchObject({ + source: 'rewards', + feeBips: 3.5, + }); + }); + + it('keeps grant when VIP retrieval fails', async () => { + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockRejectedValue(new Error('VIP unavailable')); + wireGrant( + jest.fn().mockResolvedValue({ feeBips: 2, expiresAt: NOW + 60_000 }), + ); + + await expect(service.resolveFee()).resolves.toMatchObject({ + source: 'grant', + feeBips: 2, + }); + }); + + it('retrieves the grant on every resolution without caching', async () => { + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(null); + const getGrant = wireGrant( + jest + .fn() + .mockResolvedValueOnce({ feeBips: 2, expiresAt: NOW + 60_000 }) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ feeBips: 1, expiresAt: NOW + 60_000 }), + ); + + expect((await service.resolveFee()).feeBips).toBe(2); + expect((await service.resolveFee()).source).toBe('default'); + expect((await service.resolveFee()).feeBips).toBe(1); + expect(getGrant).toHaveBeenCalledTimes(3); + }); + + it('checks grant expiry after the concurrent VIP request settles', async () => { + let releaseVip: (value: number | null) => void = () => undefined; + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockImplementation( + async () => + await new Promise((resolve) => { + releaseVip = resolve; + }), + ); + const getGrant = wireGrant( + jest.fn().mockResolvedValue({ + feeBips: 0, + expiresAt: NOW + 1_000, + }), + ); + + const resolution = service.resolveFee(); + await Promise.resolve(); + expect(getGrant).toHaveBeenCalledTimes(1); + jest.setSystemTime(NOW + 1_000); + releaseVip(6500); + + await expect(resolution).resolves.toMatchObject({ + source: 'rewards', + feeBips: 3.5, + }); + }); + + it('retrieves the grant without a selected account or network', async () => { + setupMessengerDefaults({ + 'AccountTreeController:getAccountsFromSelectedAccountGroup': [], + 'NetworkController:getState': () => { + throw new Error('Network unavailable'); + }, + }); + const getGrant = wireGrant( + jest.fn().mockResolvedValue({ + feeBips: 2, + expiresAt: NOW + 60_000, + }), + ); + + await expect(service.resolveFee()).resolves.toMatchObject({ + source: 'grant', + feeBips: 2, + }); + expect(getGrant).toHaveBeenCalledWith(); + }); + it.each([ { - discountBips: 6500, - remaining: 1000, + grantFeeBips: 4, + remainingNotionalUsd: 1000, source: 'subscription', - tenths: 0, + feeBips: 0, }, - { discountBips: 6500, remaining: 500, source: 'rewards', tenths: 35 }, { - discountBips: 6500, - remaining: 750, - source: 'subscription', - tenths: 25, + grantFeeBips: 4, + remainingNotionalUsd: 500, + source: 'grant', + feeBips: 4, + }, + { + grantFeeBips: 5, + remainingNotionalUsd: 500, + source: 'grant', + feeBips: 5, }, - { discountBips: 10000, remaining: 1000, source: 'rewards', tenths: 0 }, - { discountBips: 5000, remaining: 500, source: 'rewards', tenths: 50 }, - // The raw blend is cheaper, but rounding the discount still yields 5000. - { discountBips: 5000, remaining: 500.01, source: 'rewards', tenths: 50 }, { - discountBips: 5000, - remaining: 510, + grantFeeBips: 5, + remainingNotionalUsd: 510, source: 'subscription', - tenths: 49, + feeBips: 4.9, }, ])( - 'resolves targeted $discountBips against $remaining USD allowance to $source', - async ({ discountBips, remaining, source, tenths }) => { + 'resolves a $grantFeeBips-bip grant against a subscription covering $remainingNotionalUsd USD to $source', + async ({ + grantFeeBips, + remainingNotionalUsd, + source, + feeBips, + }) => { ( mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock - ).mockResolvedValue({ - discountBips, - targetedDiscountApplied: true, - }); + ).mockResolvedValue(null); + wireGrant( + jest.fn().mockResolvedValue({ + feeBips: grantFeeBips, + expiresAt: NOW + 60_000, + }), + ); wireSubscription( jest .fn() - .mockResolvedValue( - createBenefits({ remainingNotionalUsd: remaining }), - ), + .mockResolvedValue(createBenefits({ remainingNotionalUsd })), ); await service.refreshSubscriptionBenefits(); const resolution = await service.resolveFee(1000); expect(resolution.source).toBe(source); + expect(resolution.feeBips).toBeCloseTo(feeBips); expect(quantizeBuilderFeeTenthsBps(resolution.discountBips ?? 0)).toBe( - tenths, - ); - if (source === 'rewards') { - expect(resolution.targetedDiscountApplied).toBe(true); - } else { - expect(resolution).not.toHaveProperty('targetedDiscountApplied'); - } - expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( - 'RewardsIntegrationService: Fee resolved', - expect.objectContaining({ - source, - targetedDiscountApplied: source === 'rewards' ? true : undefined, - rewardsTargetedDiscountApplied: true, - }), - ); - }, - ); - - it('observes grant addition, removal and attribution changes without caching the result', async () => { - const getDiscount = mockDeps.rewards - .getPerpsDiscountForAccount as jest.Mock; - for (const response of [ - { discountBips: 2500, targetedDiscountApplied: false }, - { discountBips: 6500, targetedDiscountApplied: true }, - { discountBips: 6500, targetedDiscountApplied: false }, - { discountBips: 2500, targetedDiscountApplied: false }, - ]) { - getDiscount.mockResolvedValueOnce(response); - const resolution = await service.resolveFee(); - expect(resolution).toMatchObject({ source: 'rewards', ...response }); - expect(resolution.feeBips).toBeCloseTo( - 10 * (1 - response.discountBips / 10000), - ); - expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( - 'RewardsIntegrationService: Fee resolved', - expect.objectContaining(response), - ); - } - expect(getDiscount).toHaveBeenCalledTimes(4); - }); - - it.each(['legacy', 'unavailable', 'error'])( - 'does not retain targeted attribution when the next response is %s', - async (nextResponse) => { - const getDiscount = mockDeps.rewards - .getPerpsDiscountForAccount as jest.Mock; - getDiscount.mockResolvedValueOnce({ - discountBips: 6500, - targetedDiscountApplied: true, - }); - expect((await service.resolveFee()).targetedDiscountApplied).toBe(true); - if (nextResponse === 'error') { - getDiscount.mockRejectedValueOnce(new Error('Rewards unavailable')); - } else { - getDiscount.mockResolvedValueOnce( - nextResponse === 'legacy' ? 0 : null, - ); - } - - const resolution = await service.resolveFee(); - - expect(resolution).not.toHaveProperty('targetedDiscountApplied'); - expect(resolution.source).toBe( - nextResponse === 'legacy' ? 'rewards' : 'default', - ); - expect(resolution.discountBips).toBe( - nextResponse === 'legacy' ? 0 : undefined, - ); - expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( - 'RewardsIntegrationService: Fee resolved', - expect.objectContaining({ targetedDiscountApplied: undefined }), + feeBips * 10, ); }, ); diff --git a/packages/perps-controller/tests/src/services/TradingService.test.ts b/packages/perps-controller/tests/src/services/TradingService.test.ts index 2c4b336db27..45d94b4eb38 100644 --- a/packages/perps-controller/tests/src/services/TradingService.test.ts +++ b/packages/perps-controller/tests/src/services/TradingService.test.ts @@ -105,12 +105,11 @@ describe('TradingService', () => { }); describe('placeOrder', () => { - it('passes targeted rewards attribution and its discount in the provider resolution, then clears it', async () => { + it('passes the rewards resolution to the provider, then clears it', async () => { const resolution: PerpsFeeResolution = { feeBips: 3.5, discountBips: 6500, source: 'rewards', - targetedDiscountApplied: true, subscription: { eligible: false, reason: 'no-source' }, }; const params: OrderParams = { From 0305ce04eb706d00cc8e6877b9c2131f228e6869 Mon Sep 17 00:00:00 2001 From: geositta Date: Thu, 1 Oct 2026 18:28:53 -0500 Subject: [PATCH 15/15] feat: expose winning fee source on previews scope fee grants to routed provider preserve exact builder fee quantization --- packages/perps-controller/CHANGELOG.md | 9 +- packages/perps-controller/README.md | 31 ++- .../perps-controller/src/PerpsController.ts | 9 +- packages/perps-controller/src/index.ts | 1 + .../src/services/MarketDataService.ts | 13 - .../src/services/RewardsIntegrationService.ts | 41 ++- .../src/services/TradingService.ts | 32 ++- packages/perps-controller/src/types/index.ts | 45 ++- .../src/utils/subscriptionFeeWaiver.ts | 15 +- .../src/PerpsController.operations.test.ts | 19 +- .../src/services/MarketDataService.test.ts | 31 ++- .../RewardsIntegrationService.test.ts | 260 +++++++++++++----- .../tests/src/services/TradingService.test.ts | 35 ++- .../src/utils/subscriptionFeeWaiver.test.ts | 43 ++- 14 files changed, 431 insertions(+), 153 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 60714ead44d..52289d1821a 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -9,13 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add the optional `PerpsPlatformDependencies.rewards.getPerpsTradingFeeGrant()` integration and exported `PerpsTradingFeeGrant` type for independent, expiring production-Hyperliquid fee candidates. ([#10587](https://github.com/MetaMask/core/pull/10587)) -- Expose the applied builder fee resolution through `FeeCalculationResult.feeResolution`, including its winning source. ([#10587](https://github.com/MetaMask/core/pull/10587)) +- Add the optional route-aware `PerpsPlatformDependencies.rewards.getPerpsTradingFeeGrant(scope)` integration and exported `PerpsTradingFeeGrant` type for independent, expiring fee candidates. Candidates carry `providerId` and `isTestnet`; Core accepts only an exact match for the routed resolver scope. The corresponding Mobile integration supplies Hyperliquid mainnet candidates, while Core remains provider-agnostic for future routes such as Lighter. ([#10664](https://github.com/MetaMask/core/pull/10664)) +- Add optional `FeeCalculationResult.feeSource`, reporting the winning source (`default`, `rewards`, `grant`, or `subscription`) from the same operation that repriced a fee preview. It is absent when no resolution applies or the placement carries no MetaMask builder fee; submission resolves again against its actual provider route. ([#10664](https://github.com/MetaMask/core/pull/10664)) ### Changed -- **BREAKING:** Add `'grant'` to `PerpsFeeSource`; exhaustive consumers must handle the new winner. Keep `getPerpsDiscountForAccount` as `Promise` for VIP/season and retrieve grants independently through optional `getPerpsTradingFeeGrant`. ([#10587](https://github.com/MetaMask/core/pull/10587)) - - Clients that expose grants return an absolute `feeBips` and Unix-millisecond `expiresAt`. Core retrieves both rewards candidates concurrently, selects the lowest valid fee after venue quantization, and does not cache grants. +- **BREAKING:** Add `'grant'` to `PerpsFeeSource`; exhaustive consumers must handle the new winner. Keep `getPerpsDiscountForAccount` as `Promise` for VIP/season and retrieve grants independently through optional `getPerpsTradingFeeGrant`. ([#10664](https://github.com/MetaMask/core/pull/10664)) + - Clients that expose grants return the requested `providerId` and `isTestnet` with an absolute `feeBips` and Unix-millisecond `expiresAt`. Core retrieves both rewards candidates concurrently, validates fee, expiry, and exact route scope after they settle, selects the lowest valid fee after venue quantization, and does not cache grants. +- Restrict grant retrieval to explicitly scoped previews and submissions. Calls without provider scope retain VIP/season, subscription, and default resolution without invoking the optional grant dependency; scoped calls are provider-agnostic and reject mismatched candidates. ([#10664](https://github.com/MetaMask/core/pull/10664)) - **BREAKING:** `OrderFill.pnl` is optional when the venue omits realized PnL. Consumers must preserve missing amounts as unknown when aggregating or displaying fills; only a reported `'0'` is zero ([#10605](https://github.com/MetaMask/core/pull/10605)) ### Fixed diff --git a/packages/perps-controller/README.md b/packages/perps-controller/README.md index 92732af33b0..4763a47fadf 100644 --- a/packages/perps-controller/README.md +++ b/packages/perps-controller/README.md @@ -54,23 +54,36 @@ routing are controlled by client configuration and feature flags. The client-owned RewardsController exposes two independent fee candidates. `getPerpsDiscountForAccount(account, baseFeeBips)` retains its existing `Promise` contract for account-scoped VIP and season discounts. -The optional `getPerpsTradingFeeGrant()` has no account or network input and -returns an absolute production-Hyperliquid fee with its expiry: +The optional `getPerpsTradingFeeGrant(scope)` receives the routed provider and +network and returns an absolute fee candidate for that exact scope: ```typescript -import type { PerpsTradingFeeGrant } from '@metamask/perps-controller'; +import type { + PerpsFeeResolverScope, + PerpsTradingFeeGrant, +} from '@metamask/perps-controller'; +const scope: PerpsFeeResolverScope = { + providerId: 'hyperliquid', + isTestnet: false, +}; const grant: PerpsTradingFeeGrant = { + ...scope, feeBips: 3, expiresAt: Date.now() + 60_000, }; ``` Core retrieves VIP/season and grant concurrently and isolates failures between -them. It calls the grant method on every resolution and validates that -`feeBips` is finite and non-negative and that `expiresAt` is finite and still -in the future after all candidate work settles. The client owns authentication, -payload validation, and production-Hyperliquid scoping. +them whenever the resolver has an explicit scope. Scope omission fails closed +without calling the grant dependency. After all candidate work settles, Core +validates that `feeBips` is finite and non-negative, `expiresAt` is finite and +still in the future, and the candidate's `providerId` and `isTestnet` exactly +match the requested scope. The client owns authentication, payload validation, +and deciding which routes it supports. The corresponding Mobile integration is +intended to supply candidates only for Hyperliquid mainnet; Core remains +provider-agnostic so clients can add Lighter or other route-scoped grants +without a contract redesign. Core compares both candidates with the default fee and cached subscription waiver. Rewards preserves its existing tie with default. A grant wins only when @@ -78,7 +91,9 @@ strictly cheaper than the current winner after Hyperliquid's tenths-of-a-basis- point quantization, so rewards wins a quantized VIP/grant tie and default beats a non-reducing grant. Subscription retains the same strictly-cheaper quantized-tie policy. `PerpsController.calculateFees` exposes the result through -its optional `feeResolution`; grant winners report `source: 'grant'`. +its optional `feeSource`, populated by the same operation that reprices the +preview. This is preview-only attribution; every submission resolves again +against its actual provider route and may have a different winner. ## Error codes diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index a54363329fb..fc12180c544 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -6005,8 +6005,13 @@ export class PerpsController extends BaseController< const orderNotionalUsd = params.amount ? Number.parseFloat(params.amount) : undefined; - const feeResolution = - await this.#rewardsIntegrationService.resolveFee(orderNotionalUsd); + const feeResolution = await this.#rewardsIntegrationService.resolveFee( + orderNotionalUsd, + { + providerId: provider.protocolId, + isTestnet: this.state.isTestnet, + }, + ); // Taken from the resolution rather than read separately: a second read can // observe a different snapshot if the cache is invalidated or the feature // flag flips between the two, which would surface metadata describing a diff --git a/packages/perps-controller/src/index.ts b/packages/perps-controller/src/index.ts index abc3cfe7c13..b7372f01d21 100644 --- a/packages/perps-controller/src/index.ts +++ b/packages/perps-controller/src/index.ts @@ -316,6 +316,7 @@ export type { PerpsSubscriptionUsage, PerpsSubscriptionFeeWaiverStatus, PerpsFeeSource, + PerpsFeeResolverScope, PerpsFeeResolution, PerpsTradingFeeGrant, UpdatePositionTPSLParams, diff --git a/packages/perps-controller/src/services/MarketDataService.ts b/packages/perps-controller/src/services/MarketDataService.ts index 338b7542665..a3c3e8f56f1 100644 --- a/packages/perps-controller/src/services/MarketDataService.ts +++ b/packages/perps-controller/src/services/MarketDataService.ts @@ -1351,21 +1351,8 @@ export class MarketDataService { chargesBuilderFee: fees.chargesMetamaskBuilderFee, }); - // Read-only preview of the same cached benefits snapshot the fee resolver - // reads. Surfacing eligibility and the remaining notional must not mutate - // the cap or the cache. - // Match the repricing guards and exclude placements explicitly reported - // as carrying no builder fee. Attribute only the snapshot used above; - // another rewards read could describe a different grant or fee. - const resolutionApplies = - context.feeResolution !== undefined && - fees.metamaskFeeRate !== undefined && - fees.chargesMetamaskBuilderFee !== false && - (fees.metamaskFeeRate !== 0 || fees.chargesMetamaskBuilderFee === true); - return { ...priced, - ...(resolutionApplies && { feeResolution: context.feeResolution }), ...(context.subscriptionFeeWaiver && { subscription: context.subscriptionFeeWaiver, }), diff --git a/packages/perps-controller/src/services/RewardsIntegrationService.ts b/packages/perps-controller/src/services/RewardsIntegrationService.ts index 48de1b3d28b..5c3ee5953ad 100644 --- a/packages/perps-controller/src/services/RewardsIntegrationService.ts +++ b/packages/perps-controller/src/services/RewardsIntegrationService.ts @@ -13,6 +13,7 @@ import { } from '../constants/perpsConfig.js'; import type { PerpsFeeResolution, + PerpsFeeResolverScope, PerpsFeeSource, PerpsPlatformDependencies, PerpsSubscriptionBenefits, @@ -56,7 +57,7 @@ type BenefitsSnapshot = { * - `default` — {@link BUILDER_FEE_CONFIG}, the fee with no reductions. * - `rewards` — the account-scoped VIP and season discount returned by * `rewards.getPerpsDiscountForAccount`. - * - `grant` — an independent, expiring production-Hyperliquid fee returned by + * - `grant` — an independent, expiring, route-scoped fee returned by * `rewards.getPerpsTradingFeeGrant`. * - `subscription` — `0` bips, but only when the eligibility gate passes on a * cached read of the profile's benefits. @@ -155,12 +156,15 @@ export class RewardsIntegrationService { * Returns discount in basis points (e.g., 6500 = 65% discount) * * @param orderNotionalUsd - Order notional (USD), when the caller knows it. + * @param scope - Provider route used to request route-scoped fee sources. + * Omission intentionally excludes grants for compatibility callers. * @returns The fee discount in basis points, or undefined if no source resolved. */ async calculateUserFeeDiscount( orderNotionalUsd?: number, + scope?: PerpsFeeResolverScope, ): Promise { - const resolution = await this.resolveFee(orderNotionalUsd); + const resolution = await this.resolveFee(orderNotionalUsd, scope); return resolution.discountBips; } @@ -184,19 +188,27 @@ export class RewardsIntegrationService { * source instead. * * @param orderNotionalUsd - Order notional (USD), when the caller knows it. + * @param scope - Provider route used to request route-scoped fee sources. + * Omission intentionally excludes grants. * @returns The winning fee, its source, and the subscription gate outcome. */ - async resolveFee(orderNotionalUsd?: number): Promise { + async resolveFee( + orderNotionalUsd?: number, + scope?: PerpsFeeResolverScope, + ): Promise { const [rewardsDiscount, grantCandidate] = await Promise.all([ this.#calculateRewardsDiscount(), - this.#getPerpsTradingFeeGrant(), + scope + ? this.#getPerpsTradingFeeGrant(scope) + : Promise.resolve(null), ]); // Expiry is deliberately checked only after every concurrent candidate has // settled. A grant that expires while the account-scoped rewards request is // in flight must not be selected. - const grant = isValidTradingFeeGrant(grantCandidate, Date.now()) - ? grantCandidate - : undefined; + const grant = + scope && isValidTradingFeeGrant(grantCandidate, scope, Date.now()) + ? grantCandidate + : undefined; // Pure cache read: subscription benefits must never start a network request // while an order is being prepared for signing. const subscription = this.getSubscriptionFeeWaiverStatus(); @@ -841,17 +853,20 @@ export class RewardsIntegrationService { } /** - * Read the independent trading-fee grant without account or network lookup. + * Read the independent trading-fee grant for a provider route. * + * @param scope - Exact route requested by the resolver. * @returns The candidate, or null when unsupported or unavailable. */ - async #getPerpsTradingFeeGrant(): Promise { + async #getPerpsTradingFeeGrant( + scope: PerpsFeeResolverScope, + ): Promise { if (!this.#deps.rewards.getPerpsTradingFeeGrant) { return null; } try { - return await this.#deps.rewards.getPerpsTradingFeeGrant(); + return await this.#deps.rewards.getPerpsTradingFeeGrant(scope); } catch (error) { this.#deps.logger.error( ensureError(error, 'RewardsIntegrationService.getPerpsTradingFeeGrant'), @@ -872,15 +887,19 @@ export class RewardsIntegrationService { * Validate a trading-fee grant at the time fee selection occurs. * * @param grant - Candidate returned by the client-owned RewardsController. + * @param scope - Exact provider route requested by the resolver. * @param now - Selection time as Unix milliseconds. - * @returns Whether the candidate is finite, non-negative, and unexpired. + * @returns Whether the candidate is valid, unexpired, and matches the route. */ function isValidTradingFeeGrant( grant: PerpsTradingFeeGrant | null, + scope: PerpsFeeResolverScope, now: number, ): grant is PerpsTradingFeeGrant { return Boolean( grant && + grant.providerId === scope.providerId && + grant.isTestnet === scope.isTestnet && Number.isFinite(grant.feeBips) && grant.feeBips >= 0 && Number.isFinite(grant.expiresAt) && diff --git a/packages/perps-controller/src/services/TradingService.ts b/packages/perps-controller/src/services/TradingService.ts index 6a0c587e132..7a245a733b9 100644 --- a/packages/perps-controller/src/services/TradingService.ts +++ b/packages/perps-controller/src/services/TradingService.ts @@ -619,6 +619,8 @@ export class TradingService { // Calculate fee discount at execution time (fresh, secure) const feeResolution = await this.#calculateFeeDiscountWithMeasurement( this.#resolveOrderNotionalUsd(params), + provider, + context, ); this.#deps.debugLogger.log('TradingService: Fee resolution calculated', { @@ -1461,11 +1463,15 @@ export class TradingService { * priced. This is what lets the subscription source resolve to a blended * rate: omitting it resolves every bounded allowance as a full waiver, * charging 0 bips on an order the preview quoted a blend for. + * @param provider - The actual provider receiving this submission. + * @param context - The submission context, including its network. * @returns The resolved fee, or undefined when controller dependencies are * unavailable. */ async #calculateFeeDiscountWithMeasurement( - orderNotionalUsd?: number, + orderNotionalUsd: number | undefined, + provider: PerpsProvider, + context: ServiceContext, ): Promise { // Check if controller dependencies are available if (!this.#controllerDeps) { @@ -1482,8 +1488,13 @@ export class TradingService { // The notional is what lets the subscription source resolve to a blended // rate. Submitting without it would resolve every bounded allowance as a // full waiver, charging 0 bips on an order the preview quoted a blend for. - const resolution = - await rewardsIntegrationService.resolveFee(orderNotionalUsd); + const resolution = await rewardsIntegrationService.resolveFee( + orderNotionalUsd, + { + providerId: provider.protocolId, + isTestnet: context.tracingContext.isTestnet, + }, + ); const orderExecutionFeeDiscountDuration = this.#deps.performance.now() - orderExecutionFeeDiscountStartTime; @@ -1552,6 +1563,8 @@ export class TradingService { // Calculate fee discount only if required dependencies are available const feeResolution = await this.#calculateFeeDiscountWithMeasurement( this.#resolveOrderNotionalUsd(params.newOrder), + provider, + context, ); // Execute order edit with fee discount management @@ -2088,6 +2101,8 @@ export class TradingService { this.#resolveOrderNotionalUsd({ usdAmount: position?.positionValue, }), + provider, + context, ); // Execute position close with fee discount management @@ -2245,6 +2260,8 @@ export class TradingService { // sum of the positions it will close, not any single one of them. const feeResolution = await this.#calculateFeeDiscountWithMeasurement( await this.#resolveBatchCloseNotionalUsd({ params, provider }), + provider, + context, ); operationResult = await this.#withFeeDiscount({ @@ -2499,8 +2516,11 @@ export class TradingService { pricedTriggerNotionalsUsd.length === triggerNotionalsUsd.length ? Math.max(...pricedTriggerNotionalsUsd) : undefined; - const feeResolution = - await this.#calculateFeeDiscountWithMeasurement(tpslNotionalUsd); + const feeResolution = await this.#calculateFeeDiscountWithMeasurement( + tpslNotionalUsd, + provider, + context, + ); // Execute with fee discount management result = await this.#withFeeDiscount({ @@ -2816,6 +2836,8 @@ export class TradingService { }); const feeResolution = await this.#calculateFeeDiscountWithMeasurement( flipNotionalUsd === undefined ? undefined : flipNotionalUsd * 2, + provider, + context, ); // Place flip order (HyperLiquid handles margin transfer automatically) const result = await this.#withFeeDiscount({ diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index fe44a7770ed..01bb19a14dd 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -1782,7 +1782,6 @@ export type FeeCalculationResult = { // Total fees (protocol + MetaMask) feeRate?: number; // Total fee rate as decimal (e.g., 0.00145 for 0.145%), undefined when unavailable feeAmount?: number; // Total fee amount in USD (when amount is provided) - feeResolution?: PerpsFeeResolution; // Protocol-specific base fees protocolFeeRate?: number; // Protocol fee rate (e.g., 0.00045 for HyperLiquid taker), undefined when unavailable protocolFeeAmount?: number; // Protocol fee amount in USD @@ -1803,12 +1802,18 @@ export type FeeCalculationResult = { chargesMetamaskBuilderFee?: boolean; /** - * The resolution used to price this preview's MetaMask builder fee. - * Includes the winning source and targeted rewards participation when known. - * Omitted when no resolution applies, the builder fee is unavailable, or the - * placement does not charge a builder fee. Subscription eligibility remains - * available separately even when no resolution is applied. + * Fee source whose rate the MetaMask fee above was priced from: the winner + * of the unified fee resolution this quote was computed with. + * + * Absent when the quote was not re-priced from a resolution, for example a + * placement that carries no MetaMask builder fee. `rewards` can win a tie + * with the default, so compare the returned rates before presenting it as a + * reduction. + * + * Describes this preview only. The submit path resolves the fee again, so + * the order uses whichever source wins at submission. */ + feeSource?: PerpsFeeSource; // Optional detailed breakdown for transparency breakdown?: { @@ -1905,15 +1910,23 @@ export type PerpsSubscriptionFeeWaiverStatus = { /** * Fee source that won the unified resolver. * - * `rewards` covers the account-scoped VIP and season discount. `grant` is the - * independent Hyperliquid trading-fee grant supplied by RewardsController. + * `rewards` covers the account-scoped VIP and season discount. `grant` is an + * independent route-scoped trading-fee grant supplied by RewardsController. */ export type PerpsFeeSource = 'default' | 'rewards' | 'grant' | 'subscription'; /** - * Independent RewardsController fee candidate for production Hyperliquid. + * Provider route for resolving route-specific fee candidates. + */ +export type PerpsFeeResolverScope = { + providerId: string; + isTestnet: boolean; +}; + +/** + * Independent RewardsController fee candidate for one exact provider route. */ -export type PerpsTradingFeeGrant = { +export type PerpsTradingFeeGrant = PerpsFeeResolverScope & { /** Absolute MetaMask builder fee, in basis points. */ feeBips: number; @@ -2943,16 +2956,20 @@ export type PerpsPlatformDependencies = { ): Promise; /** - * Get the independent production Hyperliquid trading fee grant. + * Get the independent trading fee grant for an explicit provider route. * * The client owns grant retrieval, authentication, payload validation, and - * venue scoping. Core validates the numeric fee and expiry, calls this on - * every resolution, and compares it with the other fee sources. + * deciding which routes it supports. Core calls this only when the resolver + * has an explicit scope, validates the returned fee, expiry, and exact scope + * match after concurrent candidate work settles, and compares valid + * candidates with the other fee sources. * * Optional so clients predating grant support retain their existing * behavior. */ - getPerpsTradingFeeGrant?(): Promise; + getPerpsTradingFeeGrant?( + scope: PerpsFeeResolverScope, + ): Promise; }; // === Subscription (DI — benefits endpoint is owned by the Subscription team) === diff --git a/packages/perps-controller/src/utils/subscriptionFeeWaiver.ts b/packages/perps-controller/src/utils/subscriptionFeeWaiver.ts index a48b4fcd2bb..27e17e6dfef 100644 --- a/packages/perps-controller/src/utils/subscriptionFeeWaiver.ts +++ b/packages/perps-controller/src/utils/subscriptionFeeWaiver.ts @@ -348,8 +348,9 @@ export function markSubscriptionCloid(params: { */ export function quantizeBuilderFeeTenthsBps(discountBips: number): number { return Math.floor( - BUILDER_FEE_CONFIG.MaxFeeTenthsBps * - (1 - discountBips / BASIS_POINTS_DIVISOR), + (BUILDER_FEE_CONFIG.MaxFeeTenthsBps * + (BASIS_POINTS_DIVISOR - discountBips)) / + BASIS_POINTS_DIVISOR, ); } @@ -373,7 +374,8 @@ export function quantizeBuilderFeeTenthsBps(discountBips: number): number { * placement carries a MetaMask builder fee at all, or undefined when it does not * report one. Distinguishes a genuine zero (a TWAP, for instance) from the zero * a concurrent fully-waived submit leaves in provider state. - * @returns The quote with its MetaMask component and totals re-priced. + * @returns The quote with its MetaMask component and totals re-priced, tagged + * with the source from the same resolution. */ export function applyFeeResolution(params: { fees: FeeCalculationResult; @@ -383,7 +385,11 @@ export function applyFeeResolution(params: { }): FeeCalculationResult { const { fees, resolution, amount, chargesBuilderFee } = params; - if (resolution === undefined || fees.metamaskFeeRate === undefined) { + if ( + resolution === undefined || + fees.metamaskFeeRate === undefined || + chargesBuilderFee === false + ) { return fees; } @@ -430,6 +436,7 @@ export function applyFeeResolution(params: { ...fees, metamaskFeeRate, feeRate, + feeSource: resolution.source, ...(notional !== undefined && { metamaskFeeAmount: notional * metamaskFeeRate, feeAmount: notional * feeRate, diff --git a/packages/perps-controller/tests/src/PerpsController.operations.test.ts b/packages/perps-controller/tests/src/PerpsController.operations.test.ts index 48acf9dfaf5..2a96bf329c2 100644 --- a/packages/perps-controller/tests/src/PerpsController.operations.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.operations.test.ts @@ -1727,7 +1727,7 @@ describe('PerpsController', () => { refresh.mockRestore(); }); - it('passes one rewards resolution to the fee preview and returns it', async () => { + it('passes one scoped rewards resolution to the fee preview and returns its source', async () => { const params = { orderType: 'market' as const, symbol: 'BTC', @@ -1751,14 +1751,17 @@ describe('PerpsController', () => { 'refreshSubscriptionBenefits', ) .mockResolvedValue(undefined); - const fees = { metamaskFeeRate: 0.00035, feeResolution: resolution }; + const fees = { metamaskFeeRate: 0.00035, feeSource: 'rewards' as const }; mockMarketDataServiceInstance.calculateFees.mockResolvedValue(fees); markControllerAsInitialized(); controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); expect(await controller.calculateFees(params)).toBe(fees); expect(resolveFee).toHaveBeenCalledTimes(1); - expect(resolveFee).toHaveBeenCalledWith(1000); + expect(resolveFee).toHaveBeenCalledWith(1000, { + providerId: 'hyperliquid', + isTestnet: false, + }); expect(mockMarketDataServiceInstance.calculateFees).toHaveBeenCalledWith( expect.objectContaining({ context: expect.objectContaining({ @@ -1809,7 +1812,10 @@ describe('PerpsController', () => { // assertion lives in TradingService.test.ts ('charges a partial blend at // submit when the allowance is bounded'), which is what makes the two // paths verifiably agree. - expect(resolveFee).toHaveBeenCalledWith(1000); + expect(resolveFee).toHaveBeenCalledWith(1000, { + providerId: 'hyperliquid', + isTestnet: false, + }); const { context } = ( mockMarketDataServiceInstance.calculateFees as jest.Mock ).mock.calls.at(-1)[0]; @@ -1843,7 +1849,10 @@ describe('PerpsController', () => { symbol: 'BTC', }); - expect(resolveFee).toHaveBeenCalledWith(undefined); + expect(resolveFee).toHaveBeenCalledWith(undefined, { + providerId: 'hyperliquid', + isTestnet: false, + }); jest.restoreAllMocks(); }); diff --git a/packages/perps-controller/tests/src/services/MarketDataService.test.ts b/packages/perps-controller/tests/src/services/MarketDataService.test.ts index a289499360a..b4db08df835 100644 --- a/packages/perps-controller/tests/src/services/MarketDataService.test.ts +++ b/packages/perps-controller/tests/src/services/MarketDataService.test.ts @@ -20,6 +20,7 @@ import type { } from '../../../src/types/index.js'; import type { CandleData } from '../../../src/types/perps-types.js'; import { resetPerpsRestCacheForTests } from '../../../src/utils/coalescePerpsRestRequest.js'; +import { quantizeBuilderFeeTenthsBps } from '../../../src/utils/subscriptionFeeWaiver.js'; /* eslint-disable */ import { createMockHyperLiquidProvider, @@ -867,7 +868,7 @@ describe('MarketDataService', () => { expect(result).toEqual(mockFees); }); - it('returns the pricing resolution through rewards and other source previews', async () => { + it('returns only the winning source from the operation that reprices each preview', async () => { const resolutions: PerpsFeeResolution[] = [ { source: 'rewards', @@ -876,9 +877,9 @@ describe('MarketDataService', () => { subscription: { eligible: false, reason: 'no-source' }, }, { - source: 'rewards', - feeBips: 3.5, - discountBips: 6500, + source: 'grant', + feeBips: 2, + discountBips: 8000, subscription: { eligible: false, reason: 'no-source' }, }, { @@ -909,13 +910,14 @@ describe('MarketDataService', () => { }, context: { ...mockContext, feeResolution: resolution }, }); - expect(result.feeResolution).toBe(resolution); - expect(result.metamaskFeeRate).toBeCloseTo(resolution.feeBips / 10000); - expect(result.feeRate).toBeCloseTo( - resolution.feeBips / 10000 + 0.00045, - ); + expect(result.feeSource).toBe(resolution.source); + expect(result).not.toHaveProperty('feeResolution'); + const expectedMetamaskFeeRate = + quantizeBuilderFeeTenthsBps(resolution.discountBips ?? 0) / 100000; + expect(result.metamaskFeeRate).toBe(expectedMetamaskFeeRate); + expect(result.feeRate).toBeCloseTo(expectedMetamaskFeeRate + 0.00045); expect(result.feeAmount).toBeCloseTo( - (resolution.feeBips / 10000 + 0.00045) * 1000, + (expectedMetamaskFeeRate + 0.00045) * 1000, ); } expect(mockProvider.calculateFees).toHaveBeenCalledTimes( @@ -924,13 +926,14 @@ describe('MarketDataService', () => { }); it.each([ + { rate: undefined, policy: undefined, applies: false }, { rate: undefined, policy: true, applies: false }, { rate: 0, policy: undefined, applies: false }, { rate: 0, policy: false, applies: false }, { rate: 0, policy: true, applies: true }, { rate: 0.001, policy: false, applies: false }, ])( - 'only attributes applicable builder fee previews ($rate, $policy)', + 'only reprices and attributes applicable builder fee previews ($rate, $policy)', async ({ rate, policy, applies }) => { const resolution: PerpsFeeResolution = { source: 'rewards', @@ -953,11 +956,13 @@ describe('MarketDataService', () => { context: { ...mockContext, feeResolution: resolution }, }); if (applies) { - expect(result.feeResolution).toBe(resolution); + expect(result.feeSource).toBe('rewards'); expect(result.metamaskFeeRate).toBeCloseTo(0.00035); } else { - expect(result).not.toHaveProperty('feeResolution'); + expect(result).not.toHaveProperty('feeSource'); + expect(result.metamaskFeeRate).toBe(rate); } + expect(result).not.toHaveProperty('feeResolution'); }, ); diff --git a/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts b/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts index eb30cfb524a..7e668f39902 100644 --- a/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts +++ b/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts @@ -1,5 +1,8 @@ import { RewardsIntegrationService } from '../../../src/services/RewardsIntegrationService.js'; -import type { PerpsPlatformDependencies } from '../../../src/types/index.js'; +import type { + PerpsPlatformDependencies, + PerpsTradingFeeGrant, +} from '../../../src/types/index.js'; import { quantizeBuilderFeeTenthsBps } from '../../../src/utils/subscriptionFeeWaiver.js'; /* eslint-disable */ import { @@ -251,6 +254,19 @@ describe('RewardsIntegrationService', () => { const FRESH_MS = 60_000; const MAX_STALE_MS = 10 * 60 * 1000; const NOW = 1_700_000_000_000; + const HYPERLIQUID_MAINNET_SCOPE = { + providerId: 'hyperliquid', + isTestnet: false, + } as const; + + const createGrant = ( + overrides: Partial = {}, + ): PerpsTradingFeeGrant => ({ + ...HYPERLIQUID_MAINNET_SCOPE, + feeBips: 2, + expiresAt: NOW + 60_000, + ...overrides, + }); /** * Build a benefits payload that passes the eligibility gate by default. @@ -402,23 +418,114 @@ describe('RewardsIntegrationService', () => { }); }); - it('uses a valid grant when VIP and season are unavailable', async () => { + it('passes the requested scope and uses a matching grant', async () => { ( mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock ).mockResolvedValue(null); const getGrant = wireGrant( - jest.fn().mockResolvedValue({ - feeBips: 3, - expiresAt: NOW + 60_000, - }), + jest.fn().mockResolvedValue(createGrant({ feeBips: 3 })), ); - await expect(service.resolveFee()).resolves.toMatchObject({ + await expect( + service.resolveFee(undefined, HYPERLIQUID_MAINNET_SCOPE), + ).resolves.toMatchObject({ source: 'grant', feeBips: 3, discountBips: 7000, }); - expect(getGrant).toHaveBeenCalledWith(); + expect(getGrant).toHaveBeenCalledWith(HYPERLIQUID_MAINNET_SCOPE); + }); + + it('supports matching candidates for provider routes other than Hyperliquid', async () => { + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(null); + const lighterMainnetScope = { + providerId: 'lighter', + isTestnet: false, + } as const; + const getGrant = wireGrant( + jest.fn().mockResolvedValue( + createGrant({ + ...lighterMainnetScope, + feeBips: 2, + }), + ), + ); + + await expect( + service.resolveFee(undefined, lighterMainnetScope), + ).resolves.toMatchObject({ + source: 'grant', + feeBips: 2, + }); + expect(getGrant).toHaveBeenCalledWith(lighterMainnetScope); + }); + + it('fails closed without calling the grant dependency when scope is omitted', async () => { + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(null); + const getGrant = wireGrant( + jest.fn().mockResolvedValue(createGrant({ feeBips: 2 })), + ); + + await expect(service.resolveFee()).resolves.toMatchObject({ + source: 'default', + feeBips: DEFAULT_FEE_BIPS, + }); + expect(getGrant).not.toHaveBeenCalled(); + }); + + it.each([ + { + candidateScope: { providerId: 'lighter', isTestnet: false }, + label: 'provider', + }, + { + candidateScope: { providerId: 'hyperliquid', isTestnet: true }, + label: 'network', + }, + ])( + 'rejects a candidate with a mismatched $label scope', + async ({ candidateScope }) => { + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(null); + const getGrant = wireGrant( + jest.fn().mockResolvedValue( + createGrant({ + ...candidateScope, + feeBips: 2, + }), + ), + ); + + await expect( + service.resolveFee(undefined, HYPERLIQUID_MAINNET_SCOPE), + ).resolves.toMatchObject({ + source: 'default', + feeBips: DEFAULT_FEE_BIPS, + }); + expect(getGrant).toHaveBeenCalledWith(HYPERLIQUID_MAINNET_SCOPE); + }, + ); + + it('keeps calculateUserFeeDiscount grant-free unless scope is explicit', async () => { + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(null); + const getGrant = wireGrant( + jest.fn().mockResolvedValue(createGrant({ feeBips: 2 })), + ); + + await expect(service.calculateUserFeeDiscount()).resolves.toBeUndefined(); + expect(getGrant).not.toHaveBeenCalled(); + + await expect( + service.calculateUserFeeDiscount(undefined, HYPERLIQUID_MAINNET_SCOPE), + ).resolves.toBe(8000); + expect(getGrant).toHaveBeenCalledTimes(1); }); it('preserves VIP-only behavior when the optional grant method is absent', async () => { @@ -431,7 +538,9 @@ describe('RewardsIntegrationService', () => { } ).getPerpsTradingFeeGrant; - await expect(service.resolveFee()).resolves.toMatchObject({ + await expect( + service.resolveFee(undefined, HYPERLIQUID_MAINNET_SCOPE), + ).resolves.toMatchObject({ source: 'rewards', feeBips: 3.5, discountBips: 6500, @@ -453,13 +562,12 @@ describe('RewardsIntegrationService', () => { mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock ).mockResolvedValue(vipDiscountBips); wireGrant( - jest.fn().mockResolvedValue({ - feeBips: grantFeeBips, - expiresAt: NOW + 60_000, - }), + jest.fn().mockResolvedValue(createGrant({ feeBips: grantFeeBips })), ); - await expect(service.resolveFee()).resolves.toMatchObject({ + await expect( + service.resolveFee(undefined, HYPERLIQUID_MAINNET_SCOPE), + ).resolves.toMatchObject({ source, feeBips, }); @@ -470,14 +578,12 @@ describe('RewardsIntegrationService', () => { ( mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock ).mockResolvedValue(6460); - wireGrant( - jest.fn().mockResolvedValue({ - feeBips: 3.51, - expiresAt: NOW + 60_000, - }), - ); + wireGrant(jest.fn().mockResolvedValue(createGrant({ feeBips: 3.51 }))); - const resolution = await service.resolveFee(); + const resolution = await service.resolveFee( + undefined, + HYPERLIQUID_MAINNET_SCOPE, + ); expect(resolution.source).toBe('rewards'); expect(quantizeBuilderFeeTenthsBps(resolution.discountBips ?? 0)).toBe( @@ -491,7 +597,9 @@ describe('RewardsIntegrationService', () => { ).mockResolvedValue(null); wireGrant(jest.fn().mockResolvedValue(null)); - await expect(service.resolveFee()).resolves.toMatchObject({ + await expect( + service.resolveFee(undefined, HYPERLIQUID_MAINNET_SCOPE), + ).resolves.toMatchObject({ source: 'default', feeBips: DEFAULT_FEE_BIPS, discountBips: undefined, @@ -502,11 +610,11 @@ describe('RewardsIntegrationService', () => { ( mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock ).mockResolvedValue(6500); - wireGrant( - jest.fn().mockResolvedValue({ feeBips: 0, expiresAt: NOW + 60_000 }), - ); + wireGrant(jest.fn().mockResolvedValue(createGrant({ feeBips: 0 }))); - await expect(service.resolveFee()).resolves.toMatchObject({ + await expect( + service.resolveFee(undefined, HYPERLIQUID_MAINNET_SCOPE), + ).resolves.toMatchObject({ source: 'grant', feeBips: 0, discountBips: 10000, @@ -517,20 +625,26 @@ describe('RewardsIntegrationService', () => { ( mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock ).mockResolvedValue(null); - wireGrant( - jest.fn().mockResolvedValue({ feeBips: 11, expiresAt: NOW + 60_000 }), - ); + wireGrant(jest.fn().mockResolvedValue(createGrant({ feeBips: 11 }))); - expect((await service.resolveFee()).source).toBe('default'); + expect( + (await service.resolveFee(undefined, HYPERLIQUID_MAINNET_SCOPE)).source, + ).toBe('default'); }); it('ignores expired grants', async () => { ( mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock ).mockResolvedValue(null); - wireGrant(jest.fn().mockResolvedValue({ feeBips: 0, expiresAt: NOW })); + wireGrant( + jest + .fn() + .mockResolvedValue(createGrant({ feeBips: 0, expiresAt: NOW })), + ); - expect((await service.resolveFee()).source).toBe('default'); + expect( + (await service.resolveFee(undefined, HYPERLIQUID_MAINNET_SCOPE)).source, + ).toBe('default'); }); it.each([ @@ -543,9 +657,11 @@ describe('RewardsIntegrationService', () => { ( mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock ).mockResolvedValue(null); - wireGrant(jest.fn().mockResolvedValue(grant)); + wireGrant(jest.fn().mockResolvedValue(createGrant(grant))); - expect((await service.resolveFee()).source).toBe('default'); + expect( + (await service.resolveFee(undefined, HYPERLIQUID_MAINNET_SCOPE)).source, + ).toBe('default'); }); it('keeps VIP when grant retrieval fails', async () => { @@ -554,7 +670,9 @@ describe('RewardsIntegrationService', () => { ).mockResolvedValue(6500); wireGrant(jest.fn().mockRejectedValue(new Error('Grant unavailable'))); - await expect(service.resolveFee()).resolves.toMatchObject({ + await expect( + service.resolveFee(undefined, HYPERLIQUID_MAINNET_SCOPE), + ).resolves.toMatchObject({ source: 'rewards', feeBips: 3.5, }); @@ -564,11 +682,11 @@ describe('RewardsIntegrationService', () => { ( mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock ).mockRejectedValue(new Error('VIP unavailable')); - wireGrant( - jest.fn().mockResolvedValue({ feeBips: 2, expiresAt: NOW + 60_000 }), - ); + wireGrant(jest.fn().mockResolvedValue(createGrant({ feeBips: 2 }))); - await expect(service.resolveFee()).resolves.toMatchObject({ + await expect( + service.resolveFee(undefined, HYPERLIQUID_MAINNET_SCOPE), + ).resolves.toMatchObject({ source: 'grant', feeBips: 2, }); @@ -581,14 +699,22 @@ describe('RewardsIntegrationService', () => { const getGrant = wireGrant( jest .fn() - .mockResolvedValueOnce({ feeBips: 2, expiresAt: NOW + 60_000 }) + .mockResolvedValueOnce(createGrant({ feeBips: 2 })) .mockResolvedValueOnce(null) - .mockResolvedValueOnce({ feeBips: 1, expiresAt: NOW + 60_000 }), + .mockResolvedValueOnce(createGrant({ feeBips: 1 })), ); - expect((await service.resolveFee()).feeBips).toBe(2); - expect((await service.resolveFee()).source).toBe('default'); - expect((await service.resolveFee()).feeBips).toBe(1); + expect( + (await service.resolveFee(undefined, HYPERLIQUID_MAINNET_SCOPE)) + .feeBips, + ).toBe(2); + expect( + (await service.resolveFee(undefined, HYPERLIQUID_MAINNET_SCOPE)).source, + ).toBe('default'); + expect( + (await service.resolveFee(undefined, HYPERLIQUID_MAINNET_SCOPE)) + .feeBips, + ).toBe(1); expect(getGrant).toHaveBeenCalledTimes(3); }); @@ -603,13 +729,17 @@ describe('RewardsIntegrationService', () => { }), ); const getGrant = wireGrant( - jest.fn().mockResolvedValue({ - feeBips: 0, - expiresAt: NOW + 1_000, - }), + jest + .fn() + .mockResolvedValue( + createGrant({ feeBips: 0, expiresAt: NOW + 1_000 }), + ), ); - const resolution = service.resolveFee(); + const resolution = service.resolveFee( + undefined, + HYPERLIQUID_MAINNET_SCOPE, + ); await Promise.resolve(); expect(getGrant).toHaveBeenCalledTimes(1); jest.setSystemTime(NOW + 1_000); @@ -629,17 +759,16 @@ describe('RewardsIntegrationService', () => { }, }); const getGrant = wireGrant( - jest.fn().mockResolvedValue({ - feeBips: 2, - expiresAt: NOW + 60_000, - }), + jest.fn().mockResolvedValue(createGrant({ feeBips: 2 })), ); - await expect(service.resolveFee()).resolves.toMatchObject({ + await expect( + service.resolveFee(undefined, HYPERLIQUID_MAINNET_SCOPE), + ).resolves.toMatchObject({ source: 'grant', feeBips: 2, }); - expect(getGrant).toHaveBeenCalledWith(); + expect(getGrant).toHaveBeenCalledWith(HYPERLIQUID_MAINNET_SCOPE); }); it.each([ @@ -669,29 +798,22 @@ describe('RewardsIntegrationService', () => { }, ])( 'resolves a $grantFeeBips-bip grant against a subscription covering $remainingNotionalUsd USD to $source', - async ({ - grantFeeBips, - remainingNotionalUsd, - source, - feeBips, - }) => { + async ({ grantFeeBips, remainingNotionalUsd, source, feeBips }) => { ( mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock ).mockResolvedValue(null); wireGrant( - jest.fn().mockResolvedValue({ - feeBips: grantFeeBips, - expiresAt: NOW + 60_000, - }), + jest.fn().mockResolvedValue(createGrant({ feeBips: grantFeeBips })), ); wireSubscription( - jest - .fn() - .mockResolvedValue(createBenefits({ remainingNotionalUsd })), + jest.fn().mockResolvedValue(createBenefits({ remainingNotionalUsd })), ); await service.refreshSubscriptionBenefits(); - const resolution = await service.resolveFee(1000); + const resolution = await service.resolveFee( + 1000, + HYPERLIQUID_MAINNET_SCOPE, + ); expect(resolution.source).toBe(source); expect(resolution.feeBips).toBeCloseTo(feeBips); diff --git a/packages/perps-controller/tests/src/services/TradingService.test.ts b/packages/perps-controller/tests/src/services/TradingService.test.ts index 45d94b4eb38..d89effa1b49 100644 --- a/packages/perps-controller/tests/src/services/TradingService.test.ts +++ b/packages/perps-controller/tests/src/services/TradingService.test.ts @@ -50,6 +50,10 @@ describe('TradingService', () => { source: 'default', subscription: { eligible: false, reason: 'no-source' }, }; + const hyperliquidMainnetScope = { + providerId: 'hyperliquid', + isTestnet: false, + } as const; const createContextWithRewards = (): ServiceContext => createMockServiceContext({ @@ -105,11 +109,11 @@ describe('TradingService', () => { }); describe('placeOrder', () => { - it('passes the rewards resolution to the provider, then clears it', async () => { + it('resolves a grant for its own provider scope, passes it, then clears it', async () => { const resolution: PerpsFeeResolution = { - feeBips: 3.5, - discountBips: 6500, - source: 'rewards', + feeBips: 2, + discountBips: 8000, + source: 'grant', subscription: { eligible: false, reason: 'no-source' }, }; const params: OrderParams = { @@ -136,6 +140,10 @@ describe('TradingService', () => { }); expect(mockRewardsIntegrationService.resolveFee).toHaveBeenCalledTimes(1); + expect(mockRewardsIntegrationService.resolveFee).toHaveBeenCalledWith( + undefined, + { providerId: 'hyperliquid', isTestnet: false }, + ); expect(mockProvider.placeOrder).toHaveBeenCalledWith(params); expect(mockProvider.setUserFeeResolution).toHaveBeenLastCalledWith( undefined, @@ -202,6 +210,7 @@ describe('TradingService', () => { // "no notional to blend against" branch and charge a full waiver. expect(mockRewardsIntegrationService.resolveFee).toHaveBeenCalledWith( 1000, + hyperliquidMainnetScope, ); }); @@ -226,6 +235,7 @@ describe('TradingService', () => { expect(mockRewardsIntegrationService.resolveFee).toHaveBeenCalledWith( 900, + hyperliquidMainnetScope, ); }); @@ -308,6 +318,7 @@ describe('TradingService', () => { expect(mockRewardsIntegrationService.resolveFee).toHaveBeenCalledWith( 1000, + hyperliquidMainnetScope, ); }); @@ -334,6 +345,7 @@ describe('TradingService', () => { // 0.02 BTC at the 50000 midpoint of the ladder. expect(mockRewardsIntegrationService.resolveFee).toHaveBeenCalledWith( 1000, + hyperliquidMainnetScope, ); }); @@ -384,6 +396,7 @@ describe('TradingService', () => { expect(mockRewardsIntegrationService.resolveFee).toHaveBeenCalledWith( 2500, + hyperliquidMainnetScope, ); }); @@ -406,6 +419,7 @@ describe('TradingService', () => { // the resolver's pre-existing "no notional" behavior. expect(mockRewardsIntegrationService.resolveFee).toHaveBeenCalledWith( undefined, + hyperliquidMainnetScope, ); }); @@ -2055,6 +2069,7 @@ describe('TradingService', () => { expect(mockRewardsIntegrationService.resolveFee).toHaveBeenCalledWith( 25000, + hyperliquidMainnetScope, ); }); @@ -2073,6 +2088,7 @@ describe('TradingService', () => { expect(mockRewardsIntegrationService.resolveFee).toHaveBeenCalledWith( 5000, + hyperliquidMainnetScope, ); }); @@ -2098,6 +2114,7 @@ describe('TradingService', () => { expect(mockRewardsIntegrationService.resolveFee).toHaveBeenCalledWith( 25000, + hyperliquidMainnetScope, ); }); @@ -2114,6 +2131,7 @@ describe('TradingService', () => { expect(mockRewardsIntegrationService.resolveFee).toHaveBeenCalledWith( 4800, + hyperliquidMainnetScope, ); }); @@ -2398,6 +2416,7 @@ describe('TradingService', () => { // 25000 only — the lighter position is not reachable by this write. expect(mockRewardsIntegrationService.resolveFee).toHaveBeenCalledWith( 25000, + hyperliquidMainnetScope, ); }); @@ -2438,6 +2457,7 @@ describe('TradingService', () => { expect(mockRewardsIntegrationService.resolveFee).toHaveBeenCalledWith( 10000, + hyperliquidMainnetScope, ); }); @@ -2475,6 +2495,7 @@ describe('TradingService', () => { expect(mockRewardsIntegrationService.resolveFee).toHaveBeenCalledWith( 1000, + hyperliquidMainnetScope, ); }); @@ -2826,6 +2847,7 @@ describe('TradingService', () => { // 0.05 BTC at the 55000 trigger, not the position's 25000. expect(mockRewardsIntegrationService.resolveFee).toHaveBeenCalledWith( 2750, + hyperliquidMainnetScope, ); }); @@ -2851,6 +2873,7 @@ describe('TradingService', () => { // 0.1 BTC at the 45000 stop-loss trigger. expect(mockRewardsIntegrationService.resolveFee).toHaveBeenCalledWith( 4500, + hyperliquidMainnetScope, ); }); @@ -2875,6 +2898,7 @@ describe('TradingService', () => { expect(mockRewardsIntegrationService.resolveFee).toHaveBeenCalledWith( 22500, + hyperliquidMainnetScope, ); }); @@ -2895,6 +2919,7 @@ describe('TradingService', () => { expect(mockRewardsIntegrationService.resolveFee).toHaveBeenCalledWith( 27500, + hyperliquidMainnetScope, ); }); @@ -2917,6 +2942,7 @@ describe('TradingService', () => { expect(mockRewardsIntegrationService.resolveFee).toHaveBeenCalledWith( 30000, + hyperliquidMainnetScope, ); expect(mockGetPositions).toHaveBeenCalledTimes(1); }, @@ -2939,6 +2965,7 @@ describe('TradingService', () => { expect(mockRewardsIntegrationService.resolveFee).toHaveBeenCalledWith( undefined, + hyperliquidMainnetScope, ); }); diff --git a/packages/perps-controller/tests/src/utils/subscriptionFeeWaiver.test.ts b/packages/perps-controller/tests/src/utils/subscriptionFeeWaiver.test.ts index 7aaf3df0a64..af24071cea6 100644 --- a/packages/perps-controller/tests/src/utils/subscriptionFeeWaiver.test.ts +++ b/packages/perps-controller/tests/src/utils/subscriptionFeeWaiver.test.ts @@ -404,6 +404,39 @@ describe('applyFeeResolution', () => { expect(priced.feeRate).toBeCloseTo(0.0012, 10); expect(priced.metamaskFeeAmount).toBeCloseTo(0.75, 10); expect(priced.feeAmount).toBeCloseTo(1.2, 10); + expect(priced.feeSource).toBe('subscription'); + }); + + it('reports the rewards source from the resolution that repriced the quote', () => { + const priced = applyFeeResolution({ + fees, + resolution: { + feeBips: 3.5, + discountBips: 6500, + source: 'rewards', + subscription: createStatus({ eligible: false, reason: 'no-source' }), + }, + amount: '1000', + }); + + expect(priced.metamaskFeeRate).toBeCloseTo(0.00035, 10); + expect(priced.feeSource).toBe('rewards'); + }); + + it('reports the grant source from the resolution that repriced the quote', () => { + const priced = applyFeeResolution({ + fees, + resolution: { + feeBips: 2, + discountBips: 8000, + source: 'grant', + subscription: createStatus({ eligible: false, reason: 'no-source' }), + }, + amount: '1000', + }); + + expect(priced.metamaskFeeRate).toBeCloseTo(0.0002, 10); + expect(priced.feeSource).toBe('grant'); }); it('zeroes the MetaMask component on a full waiver', () => { @@ -422,6 +455,7 @@ describe('applyFeeResolution', () => { expect(priced.metamaskFeeRate).toBe(0); expect(priced.feeRate).toBeCloseTo(0.00045, 10); expect(priced.metamaskFeeAmount).toBe(0); + expect(priced.feeSource).toBe('subscription'); }); it('quotes the venue-quantized rate the submit path charges', () => { @@ -467,6 +501,7 @@ describe('applyFeeResolution', () => { // The full 10-bip builder fee, not the 5-bip rate the provider carried. expect(priced.metamaskFeeRate).toBeCloseTo(0.001, 10); expect(priced.feeRate).toBeCloseTo(0.00145, 10); + expect(priced.feeSource).toBe('default'); }); it.each(['-1000', '0'])( @@ -499,7 +534,11 @@ describe('applyFeeResolution', () => { }); it('leaves a placement that carries no builder fee untouched', () => { - const twapFees = { ...fees, metamaskFeeRate: 0, metamaskFeeAmount: 0 }; + const twapFees = { + ...fees, + metamaskFeeRate: 0.001, + metamaskFeeAmount: 1, + }; expect( applyFeeResolution({ @@ -559,6 +598,7 @@ describe('applyFeeResolution', () => { expect(priced.metamaskFeeRate).toBe(0.001); expect(priced.metamaskFeeAmount).toBe(1); + expect(priced.feeSource).toBe('default'); }); it('re-prices rates without amounts when no notional was supplied', () => { @@ -574,6 +614,7 @@ describe('applyFeeResolution', () => { }); expect(priced.metamaskFeeRate).toBe(0); + expect(priced.feeSource).toBe('subscription'); // The previously quoted amounts are left as the provider reported them. expect(priced.metamaskFeeAmount).toBe(fees.metamaskFeeAmount); });