diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 81b930dd8c9..52289d1821a 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -7,8 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- 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`. ([#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 1bf4e25ee94..4763a47fadf 100644 --- a/packages/perps-controller/README.md +++ b/packages/perps-controller/README.md @@ -49,6 +49,52 @@ 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 exposes two independent fee candidates. +`getPerpsDiscountForAccount(account, baseFeeBips)` retains its existing +`Promise` contract for account-scoped VIP and season discounts. +The optional `getPerpsTradingFeeGrant(scope)` receives the routed provider and +network and returns an absolute fee candidate for that exact scope: + +```typescript +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 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 +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 `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 `PERPS_ERROR_CODES` / `PerpsErrorCode` are the structured codes returned to 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 ee272db3633..b7372f01d21 100644 --- a/packages/perps-controller/src/index.ts +++ b/packages/perps-controller/src/index.ts @@ -316,7 +316,9 @@ export type { PerpsSubscriptionUsage, PerpsSubscriptionFeeWaiverStatus, PerpsFeeSource, + PerpsFeeResolverScope, PerpsFeeResolution, + PerpsTradingFeeGrant, UpdatePositionTPSLParams, Order, Funding, diff --git a/packages/perps-controller/src/services/MarketDataService.ts b/packages/perps-controller/src/services/MarketDataService.ts index 55a71018ab4..a3c3e8f56f1 100644 --- a/packages/perps-controller/src/services/MarketDataService.ts +++ b/packages/perps-controller/src/services/MarketDataService.ts @@ -1351,12 +1351,12 @@ 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. - return context.subscriptionFeeWaiver - ? { ...priced, subscription: context.subscriptionFeeWaiver } - : priced; + return { + ...priced, + ...(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 c49e8c96bd2..5c3ee5953ad 100644 --- a/packages/perps-controller/src/services/RewardsIntegrationService.ts +++ b/packages/perps-controller/src/services/RewardsIntegrationService.ts @@ -13,10 +13,12 @@ import { } from '../constants/perpsConfig.js'; import type { PerpsFeeResolution, + PerpsFeeResolverScope, PerpsFeeSource, PerpsPlatformDependencies, PerpsSubscriptionBenefits, PerpsSubscriptionFeeWaiverStatus, + PerpsTradingFeeGrant, } from '../types/index.js'; import type { PerpsControllerMessengerBase } from '../types/messenger.js'; import { getSelectedEvmAccountFromMessenger } from '../utils/accountUtils.js'; @@ -53,14 +55,15 @@ 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 - * `RewardsController` (`rewards.getPerpsDiscountForAccount`), so this service - * does not re-derive the VIP/season split. + * - `rewards` — the account-scoped VIP and season discount returned by + * `rewards.getPerpsDiscountForAccount`. + * - `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. * - * On a tie the cheaper-to-explain source wins, in the order - * `subscription` > `rewards` > `default`. + * 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 @@ -153,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; } @@ -182,10 +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 { - const rewardsDiscountBips = await this.#calculateRewardsDiscount(); + async resolveFee( + orderNotionalUsd?: number, + scope?: PerpsFeeResolverScope, + ): Promise { + const [rewardsDiscount, grantCandidate] = await Promise.all([ + this.#calculateRewardsDiscount(), + 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 = + 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(); @@ -193,9 +216,14 @@ export class RewardsIntegrationService { let feeBips = DEFAULT_FEE_BIPS; let source: PerpsFeeSource = 'default'; - if (rewardsDiscountBips !== 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 - rewardsDiscountBips / 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) { @@ -204,6 +232,14 @@ export class RewardsIntegrationService { } } + // 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, @@ -213,11 +249,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 @@ -225,11 +258,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 @@ -259,7 +287,9 @@ export class RewardsIntegrationService { feeBips, discountBips, defaultFeeBips: DEFAULT_FEE_BIPS, - rewardsDiscountBips, + rewardsDiscountBips: rewardsDiscount, + grantFeeBips: grant?.feeBips, + grantExpiresAt: grant?.expiresAt, orderNotionalUsd, subscriptionEligible: subscription.eligible, subscriptionReason: subscription.reason, @@ -714,9 +744,9 @@ export class RewardsIntegrationService { } /** - * Resolve the rewards (VIP + season) discount for the selected account. + * Resolve the VIP and season discount for the selected account. * - * @returns The discount in basis points, or undefined when unavailable. + * @returns The numeric discount, or undefined when unavailable. */ async #calculateRewardsDiscount(): Promise { try { @@ -821,6 +851,60 @@ export class RewardsIntegrationService { return undefined; } } + + /** + * 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( + scope: PerpsFeeResolverScope, + ): Promise { + if (!this.#deps.rewards.getPerpsTradingFeeGrant) { + return null; + } + + try { + return await this.#deps.rewards.getPerpsTradingFeeGrant(scope); + } 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 scope - Exact provider route requested by the resolver. + * @param now - Selection time as Unix milliseconds. + * @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) && + now < 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 38d1bed141c..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) - // 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 @@ -1802,6 +1801,20 @@ export type FeeCalculationResult = { */ chargesMetamaskBuilderFee?: boolean; + /** + * 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?: { baseFeeRate: number; @@ -1897,11 +1910,29 @@ 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 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'; + +/** + * 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 PerpsFeeSource = 'default' | 'rewards' | 'subscription'; +export type PerpsTradingFeeGrant = PerpsFeeResolverScope & { + /** Absolute MetaMask builder fee, in basis points. */ + feeBips: number; + + /** Expiry as a Unix timestamp in milliseconds. */ + expiresAt: number; +}; /** * Outcome of the unified fee resolver. @@ -2911,10 +2942,10 @@ export type PerpsPlatformDependencies = { // === Rewards (DI — no RewardsController in Core yet) === 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. + * Get the VIP and season fee discount for an account. + * + * 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. @@ -2923,6 +2954,22 @@ export type PerpsPlatformDependencies = { caipAccountId: `${string}:${string}:${string}`, baseFeeBips: number, ): Promise; + + /** + * Get the independent trading fee grant for an explicit provider route. + * + * The client owns grant retrieval, authentication, payload validation, and + * 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?( + 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/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 0abfe6a7ece..2a96bf329c2 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', }; @@ -1727,6 +1727,51 @@ describe('PerpsController', () => { refresh.mockRestore(); }); + it('passes one scoped rewards resolution to the fee preview and returns its source', async () => { + const params = { + orderType: 'market' as const, + symbol: 'BTC', + amount: '1000', + }; + const resolution = { + feeBips: 3.5, + discountBips: 6500, + source: 'rewards' as const, + 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, 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, { + providerId: 'hyperliquid', + isTestnet: false, + }); + 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, @@ -1767,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]; @@ -1801,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 54c32e45359..b4db08df835 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, @@ -19,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, @@ -866,6 +868,104 @@ describe('MarketDataService', () => { expect(result).toEqual(mockFees); }); + it('returns only the winning source from the operation that reprices each preview', async () => { + const resolutions: PerpsFeeResolution[] = [ + { + source: 'rewards', + feeBips: 3.5, + discountBips: 6500, + subscription: { eligible: false, reason: 'no-source' }, + }, + { + source: 'grant', + feeBips: 2, + discountBips: 8000, + 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.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( + (expectedMetamaskFeeRate + 0.00045) * 1000, + ); + } + expect(mockProvider.calculateFees).toHaveBeenCalledTimes( + resolutions.length, + ); + }); + + 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 reprices and attributes applicable builder fee previews ($rate, $policy)', + async ({ rate, policy, applies }) => { + const resolution: PerpsFeeResolution = { + source: 'rewards', + feeBips: 3.5, + discountBips: 6500, + 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.feeSource).toBe('rewards'); + expect(result.metamaskFeeRate).toBeCloseTo(0.00035); + } else { + expect(result).not.toHaveProperty('feeSource'); + expect(result.metamaskFeeRate).toBe(rate); + } + 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..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,9 @@ 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 { createMockEvmAccount, @@ -250,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. @@ -326,6 +343,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); @@ -392,6 +418,411 @@ describe('RewardsIntegrationService', () => { }); }); + 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(createGrant({ feeBips: 3 })), + ); + + await expect( + service.resolveFee(undefined, HYPERLIQUID_MAINNET_SCOPE), + ).resolves.toMatchObject({ + source: 'grant', + feeBips: 3, + discountBips: 7000, + }); + 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 () => { + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(6500); + delete ( + mockDeps.rewards as { + getPerpsTradingFeeGrant?: unknown; + } + ).getPerpsTradingFeeGrant; + + await expect( + service.resolveFee(undefined, HYPERLIQUID_MAINNET_SCOPE), + ).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(createGrant({ feeBips: grantFeeBips })), + ); + + await expect( + service.resolveFee(undefined, HYPERLIQUID_MAINNET_SCOPE), + ).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(createGrant({ feeBips: 3.51 }))); + + const resolution = await service.resolveFee( + undefined, + HYPERLIQUID_MAINNET_SCOPE, + ); + + 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(undefined, HYPERLIQUID_MAINNET_SCOPE), + ).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(createGrant({ feeBips: 0 }))); + + await expect( + service.resolveFee(undefined, HYPERLIQUID_MAINNET_SCOPE), + ).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(createGrant({ feeBips: 11 }))); + + 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(createGrant({ feeBips: 0, expiresAt: NOW })), + ); + + expect( + (await service.resolveFee(undefined, HYPERLIQUID_MAINNET_SCOPE)).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(createGrant(grant))); + + expect( + (await service.resolveFee(undefined, HYPERLIQUID_MAINNET_SCOPE)).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(undefined, HYPERLIQUID_MAINNET_SCOPE), + ).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(createGrant({ feeBips: 2 }))); + + await expect( + service.resolveFee(undefined, HYPERLIQUID_MAINNET_SCOPE), + ).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(createGrant({ feeBips: 2 })) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(createGrant({ feeBips: 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); + }); + + 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( + createGrant({ feeBips: 0, expiresAt: NOW + 1_000 }), + ), + ); + + const resolution = service.resolveFee( + undefined, + HYPERLIQUID_MAINNET_SCOPE, + ); + 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(createGrant({ feeBips: 2 })), + ); + + await expect( + service.resolveFee(undefined, HYPERLIQUID_MAINNET_SCOPE), + ).resolves.toMatchObject({ + source: 'grant', + feeBips: 2, + }); + expect(getGrant).toHaveBeenCalledWith(HYPERLIQUID_MAINNET_SCOPE); + }); + + it.each([ + { + grantFeeBips: 4, + remainingNotionalUsd: 1000, + source: 'subscription', + feeBips: 0, + }, + { + grantFeeBips: 4, + remainingNotionalUsd: 500, + source: 'grant', + feeBips: 4, + }, + { + grantFeeBips: 5, + remainingNotionalUsd: 500, + source: 'grant', + feeBips: 5, + }, + { + grantFeeBips: 5, + remainingNotionalUsd: 510, + source: 'subscription', + feeBips: 4.9, + }, + ])( + '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(null); + wireGrant( + jest.fn().mockResolvedValue(createGrant({ feeBips: grantFeeBips })), + ); + wireSubscription( + jest.fn().mockResolvedValue(createBenefits({ remainingNotionalUsd })), + ); + await service.refreshSubscriptionBenefits(); + + const resolution = await service.resolveFee( + 1000, + HYPERLIQUID_MAINNET_SCOPE, + ); + + expect(resolution.source).toBe(source); + expect(resolution.feeBips).toBeCloseTo(feeBips); + expect(quantizeBuilderFeeTenthsBps(resolution.discountBips ?? 0)).toBe( + feeBips * 10, + ); + }, + ); + 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..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,6 +109,48 @@ describe('TradingService', () => { }); describe('placeOrder', () => { + it('resolves a grant for its own provider scope, passes it, then clears it', async () => { + const resolution: PerpsFeeResolution = { + feeBips: 2, + discountBips: 8000, + source: 'grant', + 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(mockRewardsIntegrationService.resolveFee).toHaveBeenCalledWith( + undefined, + { providerId: 'hyperliquid', isTestnet: false }, + ); + 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 = { @@ -164,6 +210,7 @@ describe('TradingService', () => { // "no notional to blend against" branch and charge a full waiver. expect(mockRewardsIntegrationService.resolveFee).toHaveBeenCalledWith( 1000, + hyperliquidMainnetScope, ); }); @@ -188,6 +235,7 @@ describe('TradingService', () => { expect(mockRewardsIntegrationService.resolveFee).toHaveBeenCalledWith( 900, + hyperliquidMainnetScope, ); }); @@ -270,6 +318,7 @@ describe('TradingService', () => { expect(mockRewardsIntegrationService.resolveFee).toHaveBeenCalledWith( 1000, + hyperliquidMainnetScope, ); }); @@ -296,6 +345,7 @@ describe('TradingService', () => { // 0.02 BTC at the 50000 midpoint of the ladder. expect(mockRewardsIntegrationService.resolveFee).toHaveBeenCalledWith( 1000, + hyperliquidMainnetScope, ); }); @@ -346,6 +396,7 @@ describe('TradingService', () => { expect(mockRewardsIntegrationService.resolveFee).toHaveBeenCalledWith( 2500, + hyperliquidMainnetScope, ); }); @@ -368,6 +419,7 @@ describe('TradingService', () => { // the resolver's pre-existing "no notional" behavior. expect(mockRewardsIntegrationService.resolveFee).toHaveBeenCalledWith( undefined, + hyperliquidMainnetScope, ); }); @@ -2017,6 +2069,7 @@ describe('TradingService', () => { expect(mockRewardsIntegrationService.resolveFee).toHaveBeenCalledWith( 25000, + hyperliquidMainnetScope, ); }); @@ -2035,6 +2088,7 @@ describe('TradingService', () => { expect(mockRewardsIntegrationService.resolveFee).toHaveBeenCalledWith( 5000, + hyperliquidMainnetScope, ); }); @@ -2060,6 +2114,7 @@ describe('TradingService', () => { expect(mockRewardsIntegrationService.resolveFee).toHaveBeenCalledWith( 25000, + hyperliquidMainnetScope, ); }); @@ -2076,6 +2131,7 @@ describe('TradingService', () => { expect(mockRewardsIntegrationService.resolveFee).toHaveBeenCalledWith( 4800, + hyperliquidMainnetScope, ); }); @@ -2360,6 +2416,7 @@ describe('TradingService', () => { // 25000 only — the lighter position is not reachable by this write. expect(mockRewardsIntegrationService.resolveFee).toHaveBeenCalledWith( 25000, + hyperliquidMainnetScope, ); }); @@ -2400,6 +2457,7 @@ describe('TradingService', () => { expect(mockRewardsIntegrationService.resolveFee).toHaveBeenCalledWith( 10000, + hyperliquidMainnetScope, ); }); @@ -2437,6 +2495,7 @@ describe('TradingService', () => { expect(mockRewardsIntegrationService.resolveFee).toHaveBeenCalledWith( 1000, + hyperliquidMainnetScope, ); }); @@ -2788,6 +2847,7 @@ describe('TradingService', () => { // 0.05 BTC at the 55000 trigger, not the position's 25000. expect(mockRewardsIntegrationService.resolveFee).toHaveBeenCalledWith( 2750, + hyperliquidMainnetScope, ); }); @@ -2813,6 +2873,7 @@ describe('TradingService', () => { // 0.1 BTC at the 45000 stop-loss trigger. expect(mockRewardsIntegrationService.resolveFee).toHaveBeenCalledWith( 4500, + hyperliquidMainnetScope, ); }); @@ -2837,6 +2898,7 @@ describe('TradingService', () => { expect(mockRewardsIntegrationService.resolveFee).toHaveBeenCalledWith( 22500, + hyperliquidMainnetScope, ); }); @@ -2857,6 +2919,7 @@ describe('TradingService', () => { expect(mockRewardsIntegrationService.resolveFee).toHaveBeenCalledWith( 27500, + hyperliquidMainnetScope, ); }); @@ -2879,6 +2942,7 @@ describe('TradingService', () => { expect(mockRewardsIntegrationService.resolveFee).toHaveBeenCalledWith( 30000, + hyperliquidMainnetScope, ); expect(mockGetPositions).toHaveBeenCalledTimes(1); }, @@ -2901,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); });