From 334dfb614f5ac3d46bb52dfb7aa66dcae38a14d8 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Thu, 13 Aug 2026 08:27:53 +0800 Subject: [PATCH 1/7] feat(perps): unified fee resolver with cached subscription waiver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve the MetaMask builder fee across every source and return the lowest: default (BUILDER_FEE_CONFIG), rewards (VIP + season, already collapsed by RewardsController), and subscription. The subscription source contributes 0 bips only when the eligibility gate — status active, perpsFeeWaiver entitled, usage available, not exhausted — passes on a cached benefits snapshot. Benefits arrive through a new optional PerpsPlatformDependencies.subscription.getPerpsBenefits() dependency and are cached stale-while-revalidate, mirroring the existing VIP pattern. getSubscriptionFeeWaiverStatus() reads the gate synchronously and only kicks off an opportunistic refresh, so no benefits request is ever awaited on the order-signing path. A missing, hard-stale, or unreachable snapshot fails the gate and falls back to the next-lowest source rather than erroring or over-granting, and backend exhaustion needs no client action because nothing is reserved client-side. calculateFees() surfaces the same cached gate as FeeCalculationResult.subscription (eligible, reason, remainingNotionalUsd) without adjusting the quoted rates, mutating the cap, or issuing a request. No provider change is required: a subscription win resolves to a 10000 bips discount, which the existing builder-fee math already maps to builder.f = 0. A test pins that so it cannot regress. --- packages/perps-controller/CHANGELOG.md | 9 + .../perps-controller/src/PerpsController.ts | 11 +- .../src/constants/perpsConfig.ts | 15 + packages/perps-controller/src/index.ts | 5 + .../src/services/MarketDataService.ts | 11 +- .../src/services/RewardsIntegrationService.ts | 270 ++++++++++++- .../src/services/ServiceContext.ts | 9 + packages/perps-controller/src/types/index.ts | 128 +++++++ .../HyperLiquidProvider.builder-fees.test.ts | 45 +++ .../src/services/MarketDataService.test.ts | 97 +++++ .../RewardsIntegrationService.test.ts | 356 +++++++++++++++++- 11 files changed, 943 insertions(+), 13 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 58e15499ef5..b61badb4236 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -49,9 +49,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add the `StrategyOrderType` and `OrdinaryOrderType` types, plus `STRATEGY_ORDER_TYPES`, `isStrategyOrderType`, `SCALE_ORDER_COUNT`, `computeScalePriceLadder`, `splitScaleSizes`, `computeChaseQuotePrice`, `getPriceTick`, `CHASE_ORDER_CONFIG`, and `HYPERLIQUID_TWAP_LIMITS` ([#9832](https://github.com/MetaMask/core/pull/9832)) - Add an optional schema-v2 Terminal market snapshot path with strict identity, freshness, completeness, unit, and payload validation before falling back to HyperLiquid ([#9815](https://github.com/MetaMask/core/pull/9815)). - Add `PerpsController.getUserDataSnapshot()` to fetch and cache positions, open orders, and account state as one account- and DEX-scoped result ([#9815](https://github.com/MetaMask/core/pull/9815)). +- Add a subscription fee-waiver source to the MetaMask builder fee, wired through the optional `PerpsPlatformDependencies.subscription.getPerpsBenefits()` dependency, along with the `PerpsSubscriptionBenefits`, `PerpsSubscriptionUsage`, `PerpsSubscriptionFeeWaiverStatus`, `PerpsFeeSource`, and `PerpsFeeResolution` types and the `SUBSCRIPTION_BENEFITS_CACHE` constant ([#9847](https://github.com/MetaMask/core/pull/9847)) + - `RewardsIntegrationService.resolveFee()` returns the lowest fee across the default, rewards (VIP and season, already collapsed by `RewardsController`), and subscription sources, together with the winning source and the subscription gate outcome. The subscription source contributes `0` bips only when the eligibility gate — `status=active`, `perpsFeeWaiver` entitled, `usage=available`, not exhausted — passes on the cached benefits snapshot. + - `RewardsIntegrationService.getSubscriptionFeeWaiverStatus()` reads that gate synchronously from the cache and `refreshSubscriptionBenefits()` refreshes it. The cache is stale-while-revalidate: a snapshot older than `SUBSCRIPTION_BENEFITS_CACHE.FreshMs` is still served while a background refresh runs, and past `SUBSCRIPTION_BENEFITS_CACHE.MaxStaleMs` it can no longer grant the waiver. No benefits request is ever awaited on the order-signing path, and a failed or unreachable read falls back to the next-lowest source instead of erroring or over-granting. + - Clients that do not wire `subscription` are unaffected: the resolver keeps returning the rewards or default fee. +- Add `FeeCalculationResult.subscription`, surfacing the subscription waiver's `eligible`, `reason`, and `remainingNotionalUsd` on `PerpsController.calculateFees()` from the same cached benefits snapshot ([#9847](https://github.com/MetaMask/core/pull/9847)) + - The preview is read-only: it does not adjust the quoted fee rates, mutate the notional cap, or issue a network request. The field is omitted entirely when no `subscription` dependency is wired. ### Changed +- `RewardsIntegrationService.calculateUserFeeDiscount()` now returns the unified resolver's winning discount instead of the rewards discount alone, so an eligible subscription waiver resolves to `10000` bips and lands as `builder.f = 0` on the HyperLiquid order payload ([#9847](https://github.com/MetaMask/core/pull/9847)) + - The existing contract is otherwise unchanged: `undefined` still means no source resolved (for example rewards state that has not hydrated), which callers must not read as a definitive "no discount". + - `getTriggerExecution` now reports `'limit'` for `scale` and `chase`, which rest limit orders on the book without carrying an `OrderParams.price`, and `'market'` for `twap`, whose suborders cross it ([#9832](https://github.com/MetaMask/core/pull/9832)) - This is what decides the fee tier and the max order value, so a scale ladder and a chase are no longer quoted at the taker rate or held to the tighter market-order cap. `calculateFees` additionally quotes `chase` at the maker rate regardless of `isMaker`, because a post-only order can only fill as a maker. - `isLimitExecutionOrderType` is unchanged: it answers the narrower question of whether `OrderParams.price` carries a real limit price, which for a strategy placement it does not. diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index 84d0d20ca75..40fc5cad010 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -5154,7 +5154,16 @@ export class PerpsController extends BaseController< params: FeeCalculationParams, ): Promise { const provider = this.getActiveProvider(); - const context = this.#createServiceContext('calculateFees'); + // Cached, synchronous read of the same benefits snapshot the fee resolver + // uses — the preview surfaces eligibility and remaining notional without + // any network call or cap mutation. Clients with no subscription source + // wired get the untouched fee shape. + const waiverStatus = + this.#rewardsIntegrationService.getSubscriptionFeeWaiverStatus(); + const context = this.#createServiceContext('calculateFees', { + subscriptionFeeWaiver: + waiverStatus.reason === 'no-source' ? undefined : waiverStatus, + }); return this.#marketDataService.calculateFees({ provider, params, context }); } diff --git a/packages/perps-controller/src/constants/perpsConfig.ts b/packages/perps-controller/src/constants/perpsConfig.ts index 05807168c0e..81d222ded45 100644 --- a/packages/perps-controller/src/constants/perpsConfig.ts +++ b/packages/perps-controller/src/constants/perpsConfig.ts @@ -384,6 +384,21 @@ export const DATA_LAKE_API_CONFIG = { OrdersEndpoint: 'https://perps.api.cx.metamask.io/api/v1/orders', } as const; +/** + * Subscription benefits cache (stale-while-revalidate). + * + * The unified fee resolver never awaits the benefits read, so these bounds are + * what decide whether the cached snapshot may grant the perps fee waiver: + * - within `FreshMs` the snapshot is served as-is, + * - past `FreshMs` it is still served while a background refresh runs, + * - past `MaxStaleMs` it is no longer trusted to grant the waiver, and the + * resolver falls back to the next-lowest fee source. + */ +export const SUBSCRIPTION_BENEFITS_CACHE = { + FreshMs: 60_000, // 1 minute – no refresh triggered + MaxStaleMs: 10 * 60 * 1000, // 10 minutes – ceiling for granting the waiver +} as const; + /** * Terminal API configuration. * The full endpoint URL is injected at runtime via diff --git a/packages/perps-controller/src/index.ts b/packages/perps-controller/src/index.ts index 635231ec6b0..dea4aec20f2 100644 --- a/packages/perps-controller/src/index.ts +++ b/packages/perps-controller/src/index.ts @@ -243,6 +243,11 @@ export type { MaintenanceMarginParams, FeeCalculationParams, FeeCalculationResult, + PerpsSubscriptionBenefits, + PerpsSubscriptionUsage, + PerpsSubscriptionFeeWaiverStatus, + PerpsFeeSource, + PerpsFeeResolution, UpdatePositionTPSLParams, Order, Funding, diff --git a/packages/perps-controller/src/services/MarketDataService.ts b/packages/perps-controller/src/services/MarketDataService.ts index f5553cd37d8..df8d9758aeb 100644 --- a/packages/perps-controller/src/services/MarketDataService.ts +++ b/packages/perps-controller/src/services/MarketDataService.ts @@ -1286,10 +1286,17 @@ export class MarketDataService { params: FeeCalculationParams; context: ServiceContext; }): Promise { - const { provider, params } = options; + const { provider, params, context } = options; try { - return await provider.calculateFees(params); + const fees = await provider.calculateFees(params); + + // Read-only preview of the same cached benefits snapshot the fee resolver + // reads. The quoted rates are left untouched: surfacing eligibility and + // the remaining notional must not mutate the cap or the cache. + return context.subscriptionFeeWaiver + ? { ...fees, subscription: context.subscriptionFeeWaiver } + : fees; } 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 cdaca4f9d4d..d160d0f23f8 100644 --- a/packages/perps-controller/src/services/RewardsIntegrationService.ts +++ b/packages/perps-controller/src/services/RewardsIntegrationService.ts @@ -2,18 +2,59 @@ import { BASIS_POINTS_DIVISOR, BUILDER_FEE_CONFIG, } from '../constants/hyperLiquidConfig.js'; -import { PERPS_CONSTANTS } from '../constants/perpsConfig.js'; -import type { PerpsPlatformDependencies } from '../types/index.js'; +import { + PERPS_CONSTANTS, + SUBSCRIPTION_BENEFITS_CACHE, +} from '../constants/perpsConfig.js'; +import type { + PerpsFeeResolution, + PerpsFeeSource, + PerpsPlatformDependencies, + PerpsSubscriptionBenefits, + PerpsSubscriptionFeeWaiverStatus, +} from '../types/index.js'; import type { PerpsControllerMessengerBase } from '../types/messenger.js'; import { getSelectedEvmAccountFromMessenger } from '../utils/accountUtils.js'; import { ensureError } from '../utils/errorUtils.js'; import { formatAccountToCaipAccountId } from '../utils/rewardsUtils.js'; +/** + * Default MetaMask builder fee, in basis points. + * This is the fee every user pays when no cheaper source applies. + */ +const DEFAULT_FEE_BIPS = + BUILDER_FEE_CONFIG.MaxFeeDecimal * BASIS_POINTS_DIVISOR; + +/** + * Cached subscription benefits plus the time they were read. + */ +type BenefitsSnapshot = { + benefits: PerpsSubscriptionBenefits | null; + fetchedAt: number; +}; + /** * RewardsIntegrationService * - * Handles rewards-related operations and fee discount calculations. - * Stateless service that coordinates with RewardsController and NetworkController. + * Owns the unified perps fee resolver: it considers every fee source and + * returns the lowest fee, expressed as the discount bips providers consume. + * + * 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. + * - `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`. + * + * The benefits cache is stale-while-revalidate: reads are synchronous against + * the cached snapshot and a refresh is kicked off opportunistically, so the + * order-signing path never awaits a benefits request. Nothing is reserved or + * committed client-side, so backend exhaustion needs no release logic — the + * next refresh simply stops passing the gate. * * Instance-based service with constructor injection of platform dependencies. */ @@ -22,6 +63,12 @@ export class RewardsIntegrationService { readonly #messenger: PerpsControllerMessengerBase; + /** Last successful benefits read, or undefined before the first one. */ + #benefitsSnapshot: BenefitsSnapshot | undefined; + + /** In-flight background refresh, deduped so only one runs at a time. */ + #benefitsRefresh: Promise | undefined; + /** * Create a new RewardsIntegrationService instance * @@ -56,12 +103,185 @@ export class RewardsIntegrationService { } /** - * Calculate user fee discount from rewards + * Calculate user fee discount from the unified fee resolver. * Returns discount in basis points (e.g., 6500 = 65% discount) * - * @returns The fee discount in basis points, or undefined if unavailable. + * @returns The fee discount in basis points, or undefined if no source resolved. */ async calculateUserFeeDiscount(): Promise { + const resolution = await this.resolveFee(); + return resolution.discountBips; + } + + /** + * Resolve the MetaMask builder fee across every source and return the lowest. + * + * Never throws and never awaits the subscription benefits read: a failing or + * unresolved source simply drops out of the comparison, so the worst case is + * the default fee rather than an error or an over-granted waiver. + * + * @returns The winning fee, its source, and the subscription gate outcome. + */ + async resolveFee(): Promise { + // Cached, synchronous, non-blocking — safe on the order-signing path. + const subscription = this.getSubscriptionFeeWaiverStatus(); + const rewardsDiscountBips = await this.#calculateRewardsDiscount(); + + let feeBips = DEFAULT_FEE_BIPS; + let source: PerpsFeeSource = 'default'; + + if (rewardsDiscountBips !== undefined) { + const rewardsFeeBips = + DEFAULT_FEE_BIPS * (1 - rewardsDiscountBips / 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'; + } + } + + // Nothing can undercut a waived fee, so the gate passing always wins. + if (subscription.eligible) { + feeBips = 0; + source = 'subscription'; + } + + const discountBips = + source === 'default' + ? undefined + : Math.round((1 - feeBips / DEFAULT_FEE_BIPS) * BASIS_POINTS_DIVISOR); + + this.#deps.debugLogger.log('RewardsIntegrationService: Fee resolved', { + source, + feeBips, + discountBips, + defaultFeeBips: DEFAULT_FEE_BIPS, + rewardsDiscountBips, + subscriptionEligible: subscription.eligible, + subscriptionReason: subscription.reason, + }); + + return { feeBips, discountBips, source, subscription }; + } + + /** + * Read the subscription fee-waiver gate from the cached benefits snapshot. + * + * Synchronous and side-effect free apart from kicking off an opportunistic + * background refresh when the snapshot is missing or past its freshness + * window; the returned value always comes from what is already cached. + * + * @returns Whether the waiver applies, why, and the remaining notional. + */ + getSubscriptionFeeWaiverStatus(): PerpsSubscriptionFeeWaiverStatus { + if (!this.#deps.subscription) { + return { eligible: false, reason: 'no-source' }; + } + + const snapshot = this.#benefitsSnapshot; + const age = snapshot ? Date.now() - snapshot.fetchedAt : Infinity; + + // Stale-while-revalidate: serve what we have, refresh in the background. + if (age >= SUBSCRIPTION_BENEFITS_CACHE.FreshMs) { + this.refreshSubscriptionBenefits().catch(() => undefined); + } + + if (!snapshot) { + return { eligible: false, reason: 'not-hydrated' }; + } + + if (age > SUBSCRIPTION_BENEFITS_CACHE.MaxStaleMs) { + // Past the ceiling we cannot tell whether the cap is still available, so + // fall back to the next-lowest source rather than over-granting. + return { eligible: false, reason: 'stale' }; + } + + return evaluateFeeWaiverGate(snapshot.benefits); + } + + /** + * Refresh the cached subscription benefits snapshot. + * + * Deduped: concurrent callers share the in-flight request. Rejections are + * logged and swallowed, leaving the previous snapshot in place. Callers on + * the order-signing path must not await this. + * + * @returns A promise that settles when the refresh completes. + */ + async refreshSubscriptionBenefits(): Promise { + const source = this.#deps.subscription; + if (!source) { + return; + } + + if (this.#benefitsRefresh) { + await this.#benefitsRefresh; + return; + } + + const refresh = this.#readSubscriptionBenefits(source); + this.#benefitsRefresh = refresh; + // `finally` always defers, so this never clears the handle we just set. + refresh + .finally(() => { + if (this.#benefitsRefresh === refresh) { + this.#benefitsRefresh = undefined; + } + }) + .catch(() => undefined); + + await refresh; + } + + /** + * Perform one benefits read and store it, keeping the previous snapshot on + * error. Never rejects, so background callers cannot produce an unhandled + * rejection. + * + * @param source - The injected subscription benefits source. + */ + async #readSubscriptionBenefits( + source: NonNullable, + ): Promise { + try { + const benefits = await source.getPerpsBenefits(); + this.#benefitsSnapshot = { benefits, fetchedAt: Date.now() }; + + this.#deps.debugLogger.log( + 'RewardsIntegrationService: Subscription benefits refreshed', + { + status: benefits?.status, + entitled: benefits?.perpsFeeWaiver?.entitled, + usage: benefits?.perpsFeeWaiver?.usage, + exhausted: benefits?.perpsFeeWaiver?.exhausted, + }, + ); + } catch (error) { + // Keep the previous snapshot: an unreachable benefits endpoint must not + // erase a valid cache, and it must never grant the waiver either. + this.#deps.logger.error( + ensureError( + error, + 'RewardsIntegrationService.refreshSubscriptionBenefits', + ), + { + tags: { feature: PERPS_CONSTANTS.FeatureName }, + context: { + name: 'RewardsIntegrationService.refreshSubscriptionBenefits', + data: {}, + }, + }, + ); + } + } + + /** + * Resolve the rewards (VIP + season) discount for the selected account. + * + * @returns The discount in basis points, or undefined when unavailable. + */ + async #calculateRewardsDiscount(): Promise { try { const evmAccount = getSelectedEvmAccountFromMessenger(this.#messenger); @@ -123,7 +343,7 @@ export class RewardsIntegrationService { // bips to convert an absolute VIP fee into a discount fraction. const discountBips = await this.#deps.rewards.getPerpsDiscountForAccount( caipAccountId, - BUILDER_FEE_CONFIG.MaxFeeDecimal * BASIS_POINTS_DIVISOR, + DEFAULT_FEE_BIPS, ); // null = subscription state not hydrated yet; surface as undefined so @@ -165,3 +385,39 @@ export class RewardsIntegrationService { } } } + +/** + * Evaluate the perps fee-waiver eligibility gate against a benefits snapshot. + * + * The gate is `status=active` AND `perpsFeeWaiver` entitled AND + * `usage=available`. A backend `exhausted` flag (or an `exhausted` usage) fails + * the gate on its own; anything short of an affirmative `available` is treated + * as not entitled, because the waiver is only granted on positive evidence. + * + * @param benefits - The cached benefits payload, or null when there is none. + * @returns The gate outcome plus the remaining notional when reported. + */ +function evaluateFeeWaiverGate( + benefits: PerpsSubscriptionBenefits | null, +): PerpsSubscriptionFeeWaiverStatus { + const waiver = benefits?.perpsFeeWaiver; + const { remainingNotionalUsd } = waiver ?? {}; + + if (benefits?.status !== 'active') { + return { eligible: false, reason: 'inactive', remainingNotionalUsd }; + } + + if (waiver?.entitled !== true) { + return { eligible: false, reason: 'not-entitled', remainingNotionalUsd }; + } + + if (waiver.exhausted === true || waiver.usage === 'exhausted') { + return { eligible: false, reason: 'exhausted', remainingNotionalUsd }; + } + + if (waiver.usage !== 'available') { + return { eligible: false, reason: 'not-entitled', remainingNotionalUsd }; + } + + return { eligible: true, reason: 'eligible', remainingNotionalUsd }; +} diff --git a/packages/perps-controller/src/services/ServiceContext.ts b/packages/perps-controller/src/services/ServiceContext.ts index 1b2a833c662..8a19411deaf 100644 --- a/packages/perps-controller/src/services/ServiceContext.ts +++ b/packages/perps-controller/src/services/ServiceContext.ts @@ -2,6 +2,7 @@ import type { PerpsControllerState } from '../PerpsController.js'; import type { Order, PerpsGlobalSnapshotRequest, + PerpsSubscriptionFeeWaiverStatus, Position, } from '../types/index.js'; @@ -74,6 +75,14 @@ export type ServiceContext = { isMarketAllowed: (symbol: string) => boolean; }; + /** + * Cached subscription fee-waiver status for read-only fee previews. + * Read by the controller from `RewardsIntegrationService` — the same cached + * benefits snapshot the fee resolver uses — and omitted entirely when no + * subscription source is wired. + */ + subscriptionFeeWaiver?: PerpsSubscriptionFeeWaiverStatus; + /** * Callback functions for controller-specific operations */ diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index 33f7c113e2a..0d1df3aa428 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -1249,6 +1249,112 @@ export type FeeCalculationResult = { volumeDiscount?: number; stakingDiscount?: number; }; + + /** + * Read-only subscription fee-waiver preview, sourced from the same cached + * benefits snapshot the fee resolver uses. Present only when the controller + * has a subscription source wired; the quoted rates above are not adjusted + * from it, so surfacing this never mutates the cap or the cache. + */ + subscription?: PerpsSubscriptionFeeWaiverStatus; +}; + +/** + * Usage state of the perps fee waiver on a subscription benefits snapshot. + */ +export type PerpsSubscriptionUsage = 'available' | 'exhausted'; + +/** + * Subscription benefits as returned by `GET /v1/profiles/{profileId}/benefits`. + * + * The perps controller never performs this request itself — the client owns the + * Profile JWT and injects the read through + * {@link PerpsPlatformDependencies.subscription}. Only the fields the perps fee + * waiver depends on are modelled here. + */ +export type PerpsSubscriptionBenefits = { + /** Subscription status; only `active` can pass the eligibility gate. */ + status: string; + + /** Perps fee waiver entitlement and its remaining allowance. */ + perpsFeeWaiver?: { + /** Whether the plan entitles this profile to the perps fee waiver. */ + entitled: boolean; + + /** Backend usage state; only `available` can pass the eligibility gate. */ + usage?: PerpsSubscriptionUsage; + + /** + * Set by the backend once the notional cap is crossed. Honored on the next + * cache refresh — there is no client-held reservation to release. + */ + exhausted?: boolean; + + /** Notional (USD) still covered by the waiver, for fee previews. */ + remainingNotionalUsd?: number; + }; +}; + +/** + * Why the subscription fee waiver did or did not apply, plus the remaining + * allowance for fee previews. Derived purely from the cached benefits snapshot. + */ +export type PerpsSubscriptionFeeWaiverStatus = { + /** True only when every condition of the eligibility gate passed. */ + eligible: boolean; + + /** + * Gate outcome: + * - `eligible` — every condition passed + * - `no-source` — no subscription dependency is wired + * - `not-hydrated` — nothing cached yet; a refresh was kicked off + * - `stale` — the cached snapshot is past the hard-stale ceiling + * - `inactive` — subscription status is not `active` + * - `not-entitled` — the plan does not include the perps fee waiver + * - `exhausted` — the backend reported the notional cap as spent + */ + reason: + | 'eligible' + | 'no-source' + | 'not-hydrated' + | 'stale' + | 'inactive' + | 'not-entitled' + | 'exhausted'; + + /** Notional (USD) still covered by the waiver, when the backend reports it. */ + remainingNotionalUsd?: number; +}; + +/** + * 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. + */ +export type PerpsFeeSource = 'default' | 'rewards' | 'subscription'; + +/** + * Outcome of the unified fee resolver. + */ +export type PerpsFeeResolution = { + /** Winning MetaMask builder fee, in basis points (lowest across sources). */ + feeBips: number; + + /** + * Winning fee expressed as a discount off the default builder fee, in basis + * points — the unit providers consume. `undefined` when no source resolved + * (e.g. rewards state has not hydrated and no subscription waiver applies), + * so callers do not treat it as a definitive "no discount" answer. + */ + discountBips: number | undefined; + + /** Source that produced the winning fee. */ + source: PerpsFeeSource; + + /** Subscription gate outcome, always populated for observability. */ + subscription: PerpsSubscriptionFeeWaiverStatus; }; export type UpdatePositionTPSLParams = { @@ -2028,6 +2134,28 @@ export type PerpsPlatformDependencies = { baseFeeBips: number, ): Promise; }; + + // === Subscription (DI — benefits endpoint is owned by the Subscription team) === + /** + * Optional subscription source for the unified fee resolver. + * + * The client owns the Profile JWT, so it performs + * `GET /v1/profiles/{profileId}/benefits` and hands the perps controller the + * parsed body. The controller caches the result stale-while-revalidate and + * never awaits this call on the order-signing path. + * + * Omit it entirely on clients that do not ship the subscription waiver; the + * resolver then falls back to the rewards and default sources. + */ + subscription?: { + /** + * Read the current profile's subscription benefits. + * Resolve `null` when there is no subscription to report (signed out, no + * profile). Rejections are tolerated: the resolver keeps the previous + * snapshot and never grants the waiver from a failed read. + */ + getPerpsBenefits(): Promise; + }; }; /** diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.builder-fees.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.builder-fees.test.ts index 8e64296eb4a..d9d6ff2ba20 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.builder-fees.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.builder-fees.test.ts @@ -689,6 +689,51 @@ describe('HyperLiquidProvider', () => { ); }); + it('sets the builder fee to 0 when the resolved discount is a full subscription waiver', async () => { + // Builder fee already approved: this test is about the fee value on the + // signed payload, not the approval flow. + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + maxBuilderFee: jest.fn().mockResolvedValue(0.001), + }), + ); + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + }; + const exchangeClient = mockClientService.getExchangeClient(); + + // Control: with no source undercutting it, the default builder fee is charged. + const baseline = await provider.placeOrder(orderParams); + + expect(baseline.success).toBe(true); + expect(exchangeClient.order).toHaveBeenCalledWith( + expect.objectContaining({ + builder: { + b: expect.any(String), + f: BUILDER_FEE_CONFIG.MaxFeeTenthsBps, + }, + }), + ); + + // The unified resolver reports a full waiver as a 10000 bips discount, + // which the provider must map to builder.f = 0. + (exchangeClient.order as jest.Mock).mockClear(); + provider.setUserFeeDiscount(10000); + + const waived = await provider.placeOrder(orderParams); + + expect(waived.success).toBe(true); + expect(exchangeClient.order).toHaveBeenCalledWith( + expect.objectContaining({ + builder: { b: expect.any(String), f: 0 }, + }), + ); + }); + it('includes builder fee and referral setup in TP/SL updates', async () => { // Mock builder fee not approved to trigger approval call mockClientService.getInfoClient = jest.fn().mockReturnValue({ diff --git a/packages/perps-controller/tests/src/services/MarketDataService.test.ts b/packages/perps-controller/tests/src/services/MarketDataService.test.ts index 068975d1136..1fab3de302c 100644 --- a/packages/perps-controller/tests/src/services/MarketDataService.test.ts +++ b/packages/perps-controller/tests/src/services/MarketDataService.test.ts @@ -823,6 +823,103 @@ describe('MarketDataService', () => { expect(result).toEqual(mockFees); }); + it('surfaces subscription eligibility and remainingNotionalUsd on the fee preview', async () => { + const params: FeeCalculationParams = { + orderType: 'market', + symbol: 'BTC', + amount: '1000', + isMaker: false, + }; + const mockFees: FeeCalculationResult = { + feeRate: 0.0015, + feeAmount: 1.5, + protocolFeeRate: 0.00045, + metamaskFeeRate: 0.001, + }; + mockProvider.calculateFees.mockResolvedValue(mockFees); + + const result = await marketDataService.calculateFees({ + provider: mockProvider, + params, + context: { + ...mockContext, + subscriptionFeeWaiver: { + eligible: true, + reason: 'eligible', + remainingNotionalUsd: 2500, + }, + }, + }); + + expect(result).toStrictEqual({ + ...mockFees, + subscription: { + eligible: true, + reason: 'eligible', + remainingNotionalUsd: 2500, + }, + }); + }); + + it('reads the fee preview waiver status without any side effects', async () => { + const params: FeeCalculationParams = { + orderType: 'market', + symbol: 'BTC', + amount: '1000', + isMaker: false, + }; + const mockFees: FeeCalculationResult = { + feeRate: 0.0015, + feeAmount: 1.5, + protocolFeeRate: 0.00045, + metamaskFeeRate: 0.001, + }; + mockProvider.calculateFees.mockResolvedValue(mockFees); + const waiver = { + eligible: true, + reason: 'eligible' as const, + remainingNotionalUsd: 2500, + }; + + const result = await marketDataService.calculateFees({ + provider: mockProvider, + params, + context: { ...mockContext, subscriptionFeeWaiver: waiver }, + }); + + // The quoted rates are untouched, the cap is not mutated, and the + // provider is asked exactly once for the same params. + expect(result.feeRate).toBe(mockFees.feeRate); + expect(result.metamaskFeeRate).toBe(mockFees.metamaskFeeRate); + expect(result.protocolFeeRate).toBe(mockFees.protocolFeeRate); + expect(waiver).toStrictEqual({ + eligible: true, + reason: 'eligible', + remainingNotionalUsd: 2500, + }); + expect(mockProvider.calculateFees).toHaveBeenCalledTimes(1); + expect(mockProvider.calculateFees).toHaveBeenCalledWith(params); + }); + + it('omits the subscription preview when no waiver status is provided', async () => { + const params: FeeCalculationParams = { + orderType: 'market', + symbol: 'BTC', + amount: '1000', + isMaker: false, + }; + const mockFees: FeeCalculationResult = { feeRate: 0.0015 }; + mockProvider.calculateFees.mockResolvedValue(mockFees); + + const result = await marketDataService.calculateFees({ + provider: mockProvider, + params, + context: mockContext, + }); + + expect(result).toStrictEqual(mockFees); + }); + it('handles fee calculation errors', async () => { const params: FeeCalculationParams = { orderType: 'limit', diff --git a/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts b/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts index 1b6a05a0e09..da06374edaa 100644 --- a/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts +++ b/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts @@ -244,6 +244,355 @@ describe('RewardsIntegrationService', () => { }); }); + describe('unified fee resolver', () => { + // 10 bips = BUILDER_FEE_CONFIG.MaxFeeDecimal (0.001) * BASIS_POINTS_DIVISOR + const DEFAULT_FEE_BIPS = 10; + const FRESH_MS = 60_000; + const MAX_STALE_MS = 10 * 60 * 1000; + const NOW = 1_700_000_000_000; + + /** + * Build a benefits payload that passes the eligibility gate by default. + * + * @param waiverOverrides - Fields to override on `perpsFeeWaiver`. + * @param overrides - Fields to override on the benefits payload itself. + * @returns A benefits payload. + */ + const createBenefits = ( + waiverOverrides: Record = {}, + overrides: Record = {}, + ) => + ({ + status: 'active', + perpsFeeWaiver: { + entitled: true, + usage: 'available', + remainingNotionalUsd: 5000, + ...waiverOverrides, + }, + ...overrides, + }) as never; + + /** + * Wire a subscription benefits source onto the mocked dependencies. + * + * @param getPerpsBenefits - The mocked benefits reader. + * @returns The same mock, for convenience. + */ + const wireSubscription = (getPerpsBenefits: jest.Mock) => { + (mockDeps as { subscription?: unknown }).subscription = { + getPerpsBenefits, + }; + return getPerpsBenefits; + }; + + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(NOW); + setupMessengerDefaults(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('returns the lowest fee bips across the default, rewards and subscription sources', async () => { + // Rewards unresolved and no subscription source: nothing beats the + // default fee, and the discount stays undefined (not "no discount"). + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(null); + expect(await service.resolveFee()).toMatchObject({ + feeBips: DEFAULT_FEE_BIPS, + discountBips: undefined, + source: 'default', + }); + + // A resolved 0% rewards discount still wins the tie over `default`, so a + // known "no discount" answer stays distinguishable from an unknown one. + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(0); + expect(await service.resolveFee()).toMatchObject({ + feeBips: DEFAULT_FEE_BIPS, + discountBips: 0, + source: 'rewards', + }); + + // A 65% VIP/season discount undercuts the default fee. + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(6500); + expect(await service.resolveFee()).toMatchObject({ + feeBips: 3.5, + discountBips: 6500, + source: 'rewards', + }); + + // Subscription undercuts everything once the cached gate passes. + const getPerpsBenefits = wireSubscription( + jest.fn().mockResolvedValue(createBenefits()), + ); + await service.refreshSubscriptionBenefits(); + expect(getPerpsBenefits).toHaveBeenCalledTimes(1); + expect(await service.resolveFee()).toMatchObject({ + feeBips: 0, + discountBips: 10000, + source: 'subscription', + }); + + // ...including when the rewards source has not hydrated at all. + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(null); + expect(await service.resolveFee()).toMatchObject({ + feeBips: 0, + discountBips: 10000, + source: 'subscription', + }); + }); + + 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' }, + { + benefits: createBenefits({}, { status: 'canceled' }), + eligible: false, + reason: 'inactive', + }, + { + benefits: createBenefits({ entitled: false }), + eligible: false, + reason: 'not-entitled', + }, + { + benefits: createBenefits({ usage: undefined }), + eligible: false, + reason: 'not-entitled', + }, + { + benefits: createBenefits({ usage: 'exhausted' }), + eligible: false, + reason: 'exhausted', + }, + { + benefits: createBenefits({ exhausted: true }), + eligible: false, + reason: 'exhausted', + }, + { benefits: null, eligible: false, reason: 'inactive' }, + ]; + + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(0); + + for (const testCase of cases) { + mockDeps = createMockInfrastructure(); + mockMessenger = createMockMessenger(); + setupMessengerDefaults(); + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(0); + wireSubscription(jest.fn().mockResolvedValue(testCase.benefits)); + service = new RewardsIntegrationService(mockDeps, mockMessenger); + await service.refreshSubscriptionBenefits(); + + const resolution = await service.resolveFee(); + + expect(resolution.subscription).toStrictEqual( + expect.objectContaining({ + eligible: testCase.eligible, + reason: testCase.reason, + }), + ); + expect(resolution.source).toBe( + testCase.eligible ? 'subscription' : 'rewards', + ); + expect(resolution.feeBips).toBe( + testCase.eligible ? 0 : DEFAULT_FEE_BIPS, + ); + } + }); + + it('does not await the benefits network read on the fee resolution path', async () => { + // A benefits read that never settles: if the resolver awaited it, this + // test could not complete. + let releaseBenefits: (value: unknown) => void = () => undefined; + const getPerpsBenefits = wireSubscription( + jest.fn( + async () => + new Promise((resolve) => { + releaseBenefits = resolve; + }), + ), + ); + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(2500); + + const resolution = await service.resolveFee(); + + expect(resolution.source).toBe('rewards'); + expect(resolution.discountBips).toBe(2500); + expect(resolution.subscription).toStrictEqual({ + eligible: false, + reason: 'not-hydrated', + }); + // The refresh was kicked off, just never awaited. + expect(getPerpsBenefits).toHaveBeenCalledTimes(1); + + releaseBenefits(null); + }); + + it('serves a stale snapshot while revalidating it in the background', async () => { + const getPerpsBenefits = wireSubscription( + jest.fn().mockResolvedValue(createBenefits()), + ); + await service.refreshSubscriptionBenefits(); + expect(getPerpsBenefits).toHaveBeenCalledTimes(1); + + // Inside the freshness window: served from cache, no revalidation. + jest.setSystemTime(NOW + FRESH_MS - 1); + expect(service.getSubscriptionFeeWaiverStatus()).toStrictEqual({ + eligible: true, + reason: 'eligible', + remainingNotionalUsd: 5000, + }); + expect(getPerpsBenefits).toHaveBeenCalledTimes(1); + + // Past it: the stale snapshot is still served, and a refresh is started. + jest.setSystemTime(NOW + FRESH_MS + 1); + expect(service.getSubscriptionFeeWaiverStatus()).toStrictEqual({ + eligible: true, + reason: 'eligible', + remainingNotionalUsd: 5000, + }); + expect(getPerpsBenefits).toHaveBeenCalledTimes(2); + }); + + it('falls back to the next-lowest source when the cached benefits snapshot is hard-stale', async () => { + const getPerpsBenefits = wireSubscription( + jest.fn().mockResolvedValue(createBenefits()), + ); + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(6500); + await service.refreshSubscriptionBenefits(); + + // Beyond the ceiling the snapshot can no longer be trusted to grant the + // waiver, even though it says the cap is available. + jest.setSystemTime(NOW + MAX_STALE_MS + 1); + getPerpsBenefits.mockImplementation( + async () => new Promise(() => undefined), + ); + + const resolution = await service.resolveFee(); + + expect(resolution.subscription).toStrictEqual({ + eligible: false, + reason: 'stale', + }); + expect(resolution.source).toBe('rewards'); + expect(resolution.feeBips).toBe(3.5); + }); + + it('falls back to the next-lowest source when the benefits read is unreachable', async () => { + wireSubscription( + jest.fn().mockRejectedValue(new Error('benefits endpoint unreachable')), + ); + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(6500); + + // The refresh swallows the failure rather than rejecting into callers. + await expect( + service.refreshSubscriptionBenefits(), + ).resolves.toBeUndefined(); + + const resolution = await service.resolveFee(); + + expect(resolution.subscription).toStrictEqual({ + eligible: false, + reason: 'not-hydrated', + }); + expect(resolution.source).toBe('rewards'); + expect(resolution.discountBips).toBe(6500); + expect(mockDeps.logger.error).toHaveBeenCalled(); + }); + + it('honors exhausted=true from the backend on the next cache refresh', async () => { + const getPerpsBenefits = wireSubscription( + jest.fn().mockResolvedValue(createBenefits()), + ); + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(0); + await service.refreshSubscriptionBenefits(); + expect(await service.resolveFee()).toMatchObject({ + source: 'subscription', + feeBips: 0, + }); + + // The backend crosses the cap. No client-side release is needed: the + // next refresh simply stops passing the gate. + getPerpsBenefits.mockResolvedValue( + createBenefits({ exhausted: true, remainingNotionalUsd: 0 }), + ); + jest.setSystemTime(NOW + FRESH_MS + 1); + await service.refreshSubscriptionBenefits(); + + const resolution = await service.resolveFee(); + + expect(resolution.subscription).toStrictEqual({ + eligible: false, + reason: 'exhausted', + remainingNotionalUsd: 0, + }); + expect(resolution.source).toBe('rewards'); + expect(resolution.feeBips).toBe(DEFAULT_FEE_BIPS); + expect(resolution.discountBips).toBe(0); + }); + + it('reports no subscription source when the dependency is not wired', async () => { + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(0); + + const resolution = await service.resolveFee(); + + expect(resolution.subscription).toStrictEqual({ + eligible: false, + reason: 'no-source', + }); + expect(resolution.source).toBe('rewards'); + }); + + it('deduplicates concurrent benefits refreshes', async () => { + const getPerpsBenefits = wireSubscription( + jest.fn().mockResolvedValue(createBenefits()), + ); + + await Promise.all([ + service.refreshSubscriptionBenefits(), + service.refreshSubscriptionBenefits(), + service.refreshSubscriptionBenefits(), + ]); + + expect(getPerpsBenefits).toHaveBeenCalledTimes(1); + }); + + it('keeps calculateUserFeeDiscount returning the resolved discount bips', async () => { + wireSubscription(jest.fn().mockResolvedValue(createBenefits())); + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(6500); + await service.refreshSubscriptionBenefits(); + + expect(await service.calculateUserFeeDiscount()).toBe(10000); + }); + }); + describe('instance isolation', () => { it('each instance uses its own deps', async () => { const mockDeps2 = createMockInfrastructure(); @@ -275,9 +624,10 @@ describe('RewardsIntegrationService', () => { ); await service2.calculateUserFeeDiscount(); - // Each instance should use its own logger - expect(mockDeps.debugLogger.log).toHaveBeenCalledTimes(1); - expect(mockDeps2.debugLogger.log).toHaveBeenCalledTimes(1); + // Each instance should use its own logger: one "no account" log plus the + // resolver's outcome log. + expect(mockDeps.debugLogger.log).toHaveBeenCalledTimes(2); + expect(mockDeps2.debugLogger.log).toHaveBeenCalledTimes(2); }); }); }); From 3cee37aafbb926c1aa50b9dd07928689037be463 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Thu, 13 Aug 2026 08:53:11 +0800 Subject: [PATCH 2/7] fix: address self-review feedback (TAT-3618) Throttle the opportunistic benefits refresh on the last read attempt rather than the last success. A failing read never advanced the snapshot timestamp, so during a benefits outage every fee preview started a new request; calculateFees() is a per-input call in a trading UI. Report a null benefits payload as reason 'no-subscription' instead of 'inactive'. The DI contract documents null as "no subscription to report" while the published type documents 'inactive' as "status is not active", so the reason string a client renders was wrong. The gate outcome is unchanged. Re-read the cached waiver status after the awaited rewards round trip so a background refresh landing during that window is picked up. Add invalidateSubscriptionBenefits() so clients can drop the snapshot on sign-out or a profile switch; the snapshot carries no profile identity and would otherwise keep answering for the previous profile until the next successful refresh. Cover the PerpsController -> MarketDataService fee-preview wiring with two controller-level tests, both branches. Deleting the wiring previously left the whole suite green. --- packages/perps-controller/CHANGELOG.md | 2 + .../src/services/RewardsIntegrationService.ts | 59 +++++++++++++-- packages/perps-controller/src/types/index.ts | 3 + .../src/PerpsController.operations.test.ts | 69 ++++++++++++++++++ .../RewardsIntegrationService.test.ts | 71 ++++++++++++++++++- 5 files changed, 199 insertions(+), 5 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index b61badb4236..0ea160314aa 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -52,6 +52,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add a subscription fee-waiver source to the MetaMask builder fee, wired through the optional `PerpsPlatformDependencies.subscription.getPerpsBenefits()` dependency, along with the `PerpsSubscriptionBenefits`, `PerpsSubscriptionUsage`, `PerpsSubscriptionFeeWaiverStatus`, `PerpsFeeSource`, and `PerpsFeeResolution` types and the `SUBSCRIPTION_BENEFITS_CACHE` constant ([#9847](https://github.com/MetaMask/core/pull/9847)) - `RewardsIntegrationService.resolveFee()` returns the lowest fee across the default, rewards (VIP and season, already collapsed by `RewardsController`), and subscription sources, together with the winning source and the subscription gate outcome. The subscription source contributes `0` bips only when the eligibility gate — `status=active`, `perpsFeeWaiver` entitled, `usage=available`, not exhausted — passes on the cached benefits snapshot. - `RewardsIntegrationService.getSubscriptionFeeWaiverStatus()` reads that gate synchronously from the cache and `refreshSubscriptionBenefits()` refreshes it. The cache is stale-while-revalidate: a snapshot older than `SUBSCRIPTION_BENEFITS_CACHE.FreshMs` is still served while a background refresh runs, and past `SUBSCRIPTION_BENEFITS_CACHE.MaxStaleMs` it can no longer grant the waiver. No benefits request is ever awaited on the order-signing path, and a failed or unreachable read falls back to the next-lowest source instead of erroring or over-granting. + - The opportunistic refresh is throttled on the last read _attempt_ rather than the last success, so a benefits outage retries at most once per `FreshMs` window instead of once per caller. + - `RewardsIntegrationService.invalidateSubscriptionBenefits()` drops the cached snapshot. Call it on sign-out or a profile switch: the snapshot carries no profile identity, so without it the previous profile's benefits keep answering until the next successful refresh. - Clients that do not wire `subscription` are unaffected: the resolver keeps returning the rewards or default fee. - Add `FeeCalculationResult.subscription`, surfacing the subscription waiver's `eligible`, `reason`, and `remainingNotionalUsd` on `PerpsController.calculateFees()` from the same cached benefits snapshot ([#9847](https://github.com/MetaMask/core/pull/9847)) - The preview is read-only: it does not adjust the quoted fee rates, mutate the notional cap, or issue a network request. The field is omitted entirely when no `subscription` dependency is wired. diff --git a/packages/perps-controller/src/services/RewardsIntegrationService.ts b/packages/perps-controller/src/services/RewardsIntegrationService.ts index d160d0f23f8..10f214de432 100644 --- a/packages/perps-controller/src/services/RewardsIntegrationService.ts +++ b/packages/perps-controller/src/services/RewardsIntegrationService.ts @@ -66,6 +66,15 @@ export class RewardsIntegrationService { /** Last successful benefits read, or undefined before the first one. */ #benefitsSnapshot: BenefitsSnapshot | undefined; + /** + * When the last benefits read finished, successful or not. + * + * Separate from `#benefitsSnapshot.fetchedAt`, which only advances on + * success: a failing read must still throttle the next opportunistic + * refresh, otherwise an outage turns every fee preview into a new request. + */ + #lastAttemptAt: number | undefined; + /** In-flight background refresh, deduped so only one runs at a time. */ #benefitsRefresh: Promise | undefined; @@ -124,8 +133,13 @@ export class RewardsIntegrationService { */ async resolveFee(): Promise { // Cached, synchronous, non-blocking — safe on the order-signing path. - const subscription = this.getSubscriptionFeeWaiverStatus(); + // Read once up front so any opportunistic refresh starts before the awaited + // rewards round trip rather than after it. + this.getSubscriptionFeeWaiverStatus(); const rewardsDiscountBips = await this.#calculateRewardsDiscount(); + // Re-read: a background refresh may have landed during that await, and the + // freshest cached value costs nothing here. + const subscription = this.getSubscriptionFeeWaiverStatus(); let feeBips = DEFAULT_FEE_BIPS; let source: PerpsFeeSource = 'default'; @@ -179,11 +193,19 @@ export class RewardsIntegrationService { return { eligible: false, reason: 'no-source' }; } + const now = Date.now(); const snapshot = this.#benefitsSnapshot; - const age = snapshot ? Date.now() - snapshot.fetchedAt : Infinity; + const age = snapshot ? now - snapshot.fetchedAt : Infinity; + // Throttled on the last *attempt*, not the last success, so a benefits + // outage retries once per freshness window instead of once per caller. + const sinceAttempt = + this.#lastAttemptAt === undefined ? Infinity : now - this.#lastAttemptAt; // Stale-while-revalidate: serve what we have, refresh in the background. - if (age >= SUBSCRIPTION_BENEFITS_CACHE.FreshMs) { + if ( + age >= SUBSCRIPTION_BENEFITS_CACHE.FreshMs && + sinceAttempt >= SUBSCRIPTION_BENEFITS_CACHE.FreshMs + ) { this.refreshSubscriptionBenefits().catch(() => undefined); } @@ -234,6 +256,24 @@ export class RewardsIntegrationService { await refresh; } + /** + * Drop the cached benefits snapshot. + * + * Call this when the identity behind the benefits changes — sign-out, or a + * profile switch — since the snapshot carries no profile identity of its own + * and would otherwise keep answering for the previous profile until the next + * successful refresh. The next status read reports `not-hydrated` and starts + * a fresh fetch, so the waiver is withheld rather than mis-granted. + */ + invalidateSubscriptionBenefits(): void { + this.#benefitsSnapshot = undefined; + this.#lastAttemptAt = undefined; + + this.#deps.debugLogger.log( + 'RewardsIntegrationService: Subscription benefits cache invalidated', + ); + } + /** * Perform one benefits read and store it, keeping the previous snapshot on * error. Never rejects, so background callers cannot produce an unhandled @@ -273,6 +313,9 @@ export class RewardsIntegrationService { }, }, ); + } finally { + // Recorded on failure too — this is what throttles the retry loop. + this.#lastAttemptAt = Date.now(); } } @@ -393,6 +436,8 @@ export class RewardsIntegrationService { * `usage=available`. A backend `exhausted` flag (or an `exhausted` usage) fails * the gate on its own; anything short of an affirmative `available` is treated * as not entitled, because the waiver is only granted on positive evidence. + * A `null` payload means there is no subscription at all, which is reported + * separately from a subscription that exists but is not active. * * @param benefits - The cached benefits payload, or null when there is none. * @returns The gate outcome plus the remaining notional when reported. @@ -403,7 +448,13 @@ function evaluateFeeWaiverGate( const waiver = benefits?.perpsFeeWaiver; const { remainingNotionalUsd } = waiver ?? {}; - if (benefits?.status !== 'active') { + // `null` is the DI contract's "nothing to report" (signed out, no profile), + // which is distinct from a subscription that exists but is not active. + if (benefits === null) { + return { eligible: false, reason: 'no-subscription' }; + } + + if (benefits.status !== 'active') { return { eligible: false, reason: 'inactive', remainingNotionalUsd }; } diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index 0d1df3aa428..45aae00f8b4 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -1309,6 +1309,8 @@ export type PerpsSubscriptionFeeWaiverStatus = { * - `no-source` — no subscription dependency is wired * - `not-hydrated` — nothing cached yet; a refresh was kicked off * - `stale` — the cached snapshot is past the hard-stale ceiling + * - `no-subscription` — the read succeeded but reported no subscription at + * all (signed out, or no profile) * - `inactive` — subscription status is not `active` * - `not-entitled` — the plan does not include the perps fee waiver * - `exhausted` — the backend reported the notional cap as spent @@ -1318,6 +1320,7 @@ export type PerpsSubscriptionFeeWaiverStatus = { | 'no-source' | 'not-hydrated' | 'stale' + | 'no-subscription' | 'inactive' | 'not-entitled' | 'exhausted'; diff --git a/packages/perps-controller/tests/src/PerpsController.operations.test.ts b/packages/perps-controller/tests/src/PerpsController.operations.test.ts index 2f80d6aee60..46c9171c062 100644 --- a/packages/perps-controller/tests/src/PerpsController.operations.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.operations.test.ts @@ -29,6 +29,7 @@ import { } from '../../src/PerpsController.js'; import type { PerpsControllerState } from '../../src/PerpsController.js'; import { HyperLiquidProvider } from '../../src/providers/HyperLiquidProvider.js'; +import { RewardsIntegrationService } from '../../src/services/RewardsIntegrationService.js'; import type { GetAvailableDexsParams, PerpsProvider, @@ -861,6 +862,74 @@ describe('PerpsController', () => { context: expect.any(Object), }); }); + + it('passes the cached subscription waiver status to the fee preview', async () => { + const feeParams = { + orderType: 'market' as const, + isMaker: false, + amount: '100000', + symbol: 'BTC', + }; + const waiverStatus = { + eligible: true, + reason: 'eligible' as const, + remainingNotionalUsd: 2500, + }; + const getStatus = jest + .spyOn( + RewardsIntegrationService.prototype, + 'getSubscriptionFeeWaiverStatus', + ) + .mockReturnValue(waiverStatus); + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + await controller.calculateFees(feeParams); + + expect(getStatus).toHaveBeenCalled(); + expect(mockMarketDataServiceInstance.calculateFees).toHaveBeenCalledWith( + expect.objectContaining({ + context: expect.objectContaining({ + subscriptionFeeWaiver: waiverStatus, + }), + }), + ); + + getStatus.mockRestore(); + }); + + it('omits the subscription waiver from the fee preview when no source is wired', async () => { + const feeParams = { + orderType: 'market' as const, + isMaker: false, + amount: '100000', + symbol: 'BTC', + }; + // The mocked infrastructure wires no `subscription` dependency, so the + // real service reports `no-source` and the context field must be absent + // rather than carrying a meaningless "not eligible". + const getStatus = jest.spyOn( + RewardsIntegrationService.prototype, + 'getSubscriptionFeeWaiverStatus', + ); + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + await controller.calculateFees(feeParams); + + expect(getStatus).toHaveReturnedWith({ + eligible: false, + reason: 'no-source', + }); + const { context } = ( + mockMarketDataServiceInstance.calculateFees as jest.Mock + ).mock.calls.at(-1)[0]; + expect(context.subscriptionFeeWaiver).toBeUndefined(); + + getStatus.mockRestore(); + }); }); describe('reportOrderToDataLake', () => { diff --git a/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts b/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts index da06374edaa..74df41df647 100644 --- a/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts +++ b/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts @@ -380,7 +380,8 @@ describe('RewardsIntegrationService', () => { eligible: false, reason: 'exhausted', }, - { benefits: null, eligible: false, reason: 'inactive' }, + // `null` is "no subscription to report", not "subscription inactive". + { benefits: null, eligible: false, reason: 'no-subscription' }, ]; ( @@ -582,6 +583,74 @@ describe('RewardsIntegrationService', () => { expect(getPerpsBenefits).toHaveBeenCalledTimes(1); }); + it('throttles the opportunistic refresh while the benefits read keeps failing', async () => { + // A failing read never advances the snapshot timestamp, so without an + // attempt-based throttle every caller would start a new request. + const getPerpsBenefits = wireSubscription( + jest.fn().mockRejectedValue(new Error('benefits endpoint down')), + ); + + await service.refreshSubscriptionBenefits(); + expect(getPerpsBenefits).toHaveBeenCalledTimes(1); + + // Ten fee previews inside the freshness window: still one request. + for (let i = 0; i < 10; i++) { + expect(service.getSubscriptionFeeWaiverStatus()).toStrictEqual({ + eligible: false, + reason: 'not-hydrated', + }); + } + expect(getPerpsBenefits).toHaveBeenCalledTimes(1); + + // Past the window, exactly one retry is allowed through. + jest.setSystemTime(NOW + FRESH_MS + 1); + service.getSubscriptionFeeWaiverStatus(); + service.getSubscriptionFeeWaiverStatus(); + await Promise.resolve(); + + expect(getPerpsBenefits).toHaveBeenCalledTimes(2); + }); + + it('invalidates the cached benefits snapshot on demand', async () => { + const getPerpsBenefits = wireSubscription( + jest.fn().mockResolvedValue(createBenefits()), + ); + await service.refreshSubscriptionBenefits(); + expect(service.getSubscriptionFeeWaiverStatus().eligible).toBe(true); + + // Sign-out / profile switch: the snapshot must stop answering for the + // previous profile immediately, not at the next freshness boundary. + service.invalidateSubscriptionBenefits(); + + expect(service.getSubscriptionFeeWaiverStatus()).toStrictEqual({ + eligible: false, + reason: 'not-hydrated', + }); + // Invalidation clears the retry throttle too, so the refetch is immediate. + expect(getPerpsBenefits).toHaveBeenCalledTimes(2); + }); + + it('uses a background refresh that lands during the rewards round trip', async () => { + const getPerpsBenefits = wireSubscription( + jest.fn().mockResolvedValue(createBenefits()), + ); + // The rewards read resolves only after the benefits refresh has landed, + // which is exactly the window a pre-await snapshot would miss. + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockImplementation(async () => { + await service.refreshSubscriptionBenefits(); + return 6500; + }); + + const resolution = await service.resolveFee(); + + expect(getPerpsBenefits).toHaveBeenCalled(); + expect(resolution.subscription.eligible).toBe(true); + expect(resolution.source).toBe('subscription'); + expect(resolution.feeBips).toBe(0); + }); + it('keeps calculateUserFeeDiscount returning the resolved discount bips', async () => { wireSubscription(jest.fn().mockResolvedValue(createBenefits())); ( From 5e6f40eece810ba342120fc5b14c07a45df02e0b Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Thu, 13 Aug 2026 09:06:17 +0800 Subject: [PATCH 3/7] fix: address self-review feedback (TAT-3618) Expose subscription benefits invalidation to clients. The previous pass added invalidateSubscriptionBenefits() to RewardsIntegrationService, but that service is private to the controller and is not exported from the package, so no consumer could reach it while the changelog instructed them to call it. PerpsController now delegates to it and the method is registered in MESSENGER_EXPOSED_METHODS, making it callable as the PerpsController:invalidateSubscriptionBenefits action; the generated action types are regenerated to match. Fence in-flight benefits reads behind an epoch counter. Invalidation cleared the snapshot but left a running read free to write its result back, so a read issued for the previous profile could repopulate the cache after a sign-out and mark it fresh. The epoch is captured when the read starts and compared before the write; a superseded read is discarded. The same check guards the attempt timestamp, so a discarded read cannot throttle the new identity's first fetch. Reword the changelog to name the controller method and messenger action rather than the unreachable service method. --- packages/perps-controller/CHANGELOG.md | 2 +- .../PerpsController-method-action-types.ts | 16 +++++++++ .../perps-controller/src/PerpsController.ts | 15 ++++++++ .../src/services/RewardsIntegrationService.ts | 34 +++++++++++++++++-- .../src/PerpsController.operations.test.ts | 17 ++++++++++ .../RewardsIntegrationService.test.ts | 27 +++++++++++++++ 6 files changed, 108 insertions(+), 3 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 0ea160314aa..dd618c8668b 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -53,7 +53,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `RewardsIntegrationService.resolveFee()` returns the lowest fee across the default, rewards (VIP and season, already collapsed by `RewardsController`), and subscription sources, together with the winning source and the subscription gate outcome. The subscription source contributes `0` bips only when the eligibility gate — `status=active`, `perpsFeeWaiver` entitled, `usage=available`, not exhausted — passes on the cached benefits snapshot. - `RewardsIntegrationService.getSubscriptionFeeWaiverStatus()` reads that gate synchronously from the cache and `refreshSubscriptionBenefits()` refreshes it. The cache is stale-while-revalidate: a snapshot older than `SUBSCRIPTION_BENEFITS_CACHE.FreshMs` is still served while a background refresh runs, and past `SUBSCRIPTION_BENEFITS_CACHE.MaxStaleMs` it can no longer grant the waiver. No benefits request is ever awaited on the order-signing path, and a failed or unreachable read falls back to the next-lowest source instead of erroring or over-granting. - The opportunistic refresh is throttled on the last read _attempt_ rather than the last success, so a benefits outage retries at most once per `FreshMs` window instead of once per caller. - - `RewardsIntegrationService.invalidateSubscriptionBenefits()` drops the cached snapshot. Call it on sign-out or a profile switch: the snapshot carries no profile identity, so without it the previous profile's benefits keep answering until the next successful refresh. + - `PerpsController.invalidateSubscriptionBenefits()` (also exposed as the `PerpsController:invalidateSubscriptionBenefits` messenger action) drops the cached snapshot. Call it on sign-out or a profile switch: the snapshot carries no profile identity, so without it the previous profile's benefits keep answering until the next successful refresh. A read already in flight when it is called is discarded rather than written back, so it cannot repopulate the cache for the previous identity. - Clients that do not wire `subscription` are unaffected: the resolver keeps returning the rewards or default fee. - Add `FeeCalculationResult.subscription`, surfacing the subscription waiver's `eligible`, `reason`, and `remainingNotionalUsd` on `PerpsController.calculateFees()` from the same cached benefits snapshot ([#9847](https://github.com/MetaMask/core/pull/9847)) - The preview is read-only: it does not adjust the quoted fee rates, mutate the notional cap, or issue a network request. The field is omitted entirely when no `subscription` dependency is wired. diff --git a/packages/perps-controller/src/PerpsController-method-action-types.ts b/packages/perps-controller/src/PerpsController-method-action-types.ts index 7a242c5feb1..c1737428248 100644 --- a/packages/perps-controller/src/PerpsController-method-action-types.ts +++ b/packages/perps-controller/src/PerpsController-method-action-types.ts @@ -778,6 +778,21 @@ export type PerpsControllerCalculateFeesAction = { handler: PerpsController['calculateFees']; }; +/** + * Drop the cached subscription benefits snapshot. + * + * Call this when the identity behind the benefits changes — sign-out, or a + * profile switch. The snapshot carries no profile identity of its own, so + * without this it keeps answering for the previous profile until the next + * successful refresh. The next fee resolution reports the waiver as + * unavailable and refetches, so the waiver is withheld rather than + * mis-granted. + */ +export type PerpsControllerInvalidateSubscriptionBenefitsAction = { + type: `PerpsController:invalidateSubscriptionBenefits`; + handler: PerpsController['invalidateSubscriptionBenefits']; +}; + /** * Disconnect provider and cleanup subscriptions * Call this when navigating away from Perps screens to prevent battery drain @@ -1197,6 +1212,7 @@ export type PerpsControllerMethodActions = | PerpsControllerSubscribeToOICapsAction | PerpsControllerSetLiveDataConfigAction | PerpsControllerCalculateFeesAction + | PerpsControllerInvalidateSubscriptionBenefitsAction | PerpsControllerDisconnectAction | PerpsControllerStartEligibilityMonitoringAction | PerpsControllerStopEligibilityMonitoringAction diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index 40fc5cad010..2f148bf1302 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -905,6 +905,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'getWithdrawalProgress', 'getWithdrawalRoutes', 'init', + 'invalidateSubscriptionBenefits', 'isCurrentlyReinitializing', 'isFirstTimeUserOnCurrentNetwork', 'isWatchlistMarket', @@ -5167,6 +5168,20 @@ export class PerpsController extends BaseController< return this.#marketDataService.calculateFees({ provider, params, context }); } + /** + * Drop the cached subscription benefits snapshot. + * + * Call this when the identity behind the benefits changes — sign-out, or a + * profile switch. The snapshot carries no profile identity of its own, so + * without this it keeps answering for the previous profile until the next + * successful refresh. The next fee resolution reports the waiver as + * unavailable and refetches, so the waiver is withheld rather than + * mis-granted. + */ + invalidateSubscriptionBenefits(): void { + this.#rewardsIntegrationService.invalidateSubscriptionBenefits(); + } + /** * Disconnect provider and cleanup subscriptions * Call this when navigating away from Perps screens to prevent battery drain diff --git a/packages/perps-controller/src/services/RewardsIntegrationService.ts b/packages/perps-controller/src/services/RewardsIntegrationService.ts index 10f214de432..5455ed989d4 100644 --- a/packages/perps-controller/src/services/RewardsIntegrationService.ts +++ b/packages/perps-controller/src/services/RewardsIntegrationService.ts @@ -78,6 +78,16 @@ export class RewardsIntegrationService { /** In-flight background refresh, deduped so only one runs at a time. */ #benefitsRefresh: Promise | undefined; + /** + * Identity generation for the cached benefits. + * + * Bumped by {@link invalidateSubscriptionBenefits}; a read that resolves + * against a superseded epoch is discarded rather than written back, so a + * refresh issued for the previous profile cannot repopulate the cache after + * a sign-out or profile switch. + */ + #benefitsEpoch = 0; + /** * Create a new RewardsIntegrationService instance * @@ -268,6 +278,9 @@ export class RewardsIntegrationService { invalidateSubscriptionBenefits(): void { this.#benefitsSnapshot = undefined; this.#lastAttemptAt = undefined; + // Fence any in-flight read: it was issued for the previous identity, so its + // result must not repopulate the cache after this point. + this.#benefitsEpoch += 1; this.#deps.debugLogger.log( 'RewardsIntegrationService: Subscription benefits cache invalidated', @@ -284,8 +297,20 @@ export class RewardsIntegrationService { async #readSubscriptionBenefits( source: NonNullable, ): Promise { + const epoch = this.#benefitsEpoch; + try { const benefits = await source.getPerpsBenefits(); + + if (epoch !== this.#benefitsEpoch) { + // Invalidated while this read was in flight: it belongs to a previous + // identity, so discarding it is the only safe outcome. + this.#deps.debugLogger.log( + 'RewardsIntegrationService: Discarding benefits read from a previous identity', + ); + return; + } + this.#benefitsSnapshot = { benefits, fetchedAt: Date.now() }; this.#deps.debugLogger.log( @@ -314,8 +339,13 @@ export class RewardsIntegrationService { }, ); } finally { - // Recorded on failure too — this is what throttles the retry loop. - this.#lastAttemptAt = Date.now(); + // Recorded on failure too — this is what throttles the retry loop. Not + // recorded for a fenced read: that attempt belongs to a previous + // identity, and letting it throttle would delay the new identity's first + // fetch by a whole freshness window. + if (epoch === this.#benefitsEpoch) { + this.#lastAttemptAt = Date.now(); + } } } diff --git a/packages/perps-controller/tests/src/PerpsController.operations.test.ts b/packages/perps-controller/tests/src/PerpsController.operations.test.ts index 46c9171c062..e88a59be8b5 100644 --- a/packages/perps-controller/tests/src/PerpsController.operations.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.operations.test.ts @@ -899,6 +899,23 @@ describe('PerpsController', () => { getStatus.mockRestore(); }); + it('exposes subscription benefits invalidation to clients', async () => { + // The service is private to the controller, so a client detecting a + // sign-out or profile switch can only reach it through this method. + const invalidate = jest + .spyOn( + RewardsIntegrationService.prototype, + 'invalidateSubscriptionBenefits', + ) + .mockImplementation(() => undefined); + + controller.invalidateSubscriptionBenefits(); + + expect(invalidate).toHaveBeenCalledTimes(1); + + invalidate.mockRestore(); + }); + it('omits the subscription waiver from the fee preview when no source is wired', async () => { const feeParams = { orderType: 'market' as const, diff --git a/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts b/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts index 74df41df647..0011cf8d375 100644 --- a/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts +++ b/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts @@ -630,6 +630,33 @@ describe('RewardsIntegrationService', () => { expect(getPerpsBenefits).toHaveBeenCalledTimes(2); }); + it('discards an in-flight benefits read that resolves after invalidation', async () => { + // Profile A's read is still in flight when the client signs out. Without + // an epoch fence it would repopulate the cache — and mark it fresh — + // granting profile A's waiver to profile B. + let releaseProfileA: (value: unknown) => void = () => undefined; + const getPerpsBenefits = wireSubscription( + jest.fn( + async () => + new Promise((resolve) => { + releaseProfileA = resolve; + }), + ), + ); + + const inFlight = service.refreshSubscriptionBenefits(); + service.invalidateSubscriptionBenefits(); + releaseProfileA(createBenefits()); + await inFlight; + + expect(service.getSubscriptionFeeWaiverStatus()).toStrictEqual({ + eligible: false, + reason: 'not-hydrated', + }); + // The discarded attempt must not throttle the new identity's first fetch. + expect(getPerpsBenefits).toHaveBeenCalledTimes(2); + }); + it('uses a background refresh that lands during the rewards round trip', async () => { const getPerpsBenefits = wireSubscription( jest.fn().mockResolvedValue(createBenefits()), From f960dd37c14406ee4bdb684c71f96c9f15e67b2b Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Thu, 13 Aug 2026 10:41:47 +0800 Subject: [PATCH 4/7] fix: address CI feedback --- 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 dd618c8668b..21e5f9ad710 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -49,18 +49,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add the `StrategyOrderType` and `OrdinaryOrderType` types, plus `STRATEGY_ORDER_TYPES`, `isStrategyOrderType`, `SCALE_ORDER_COUNT`, `computeScalePriceLadder`, `splitScaleSizes`, `computeChaseQuotePrice`, `getPriceTick`, `CHASE_ORDER_CONFIG`, and `HYPERLIQUID_TWAP_LIMITS` ([#9832](https://github.com/MetaMask/core/pull/9832)) - Add an optional schema-v2 Terminal market snapshot path with strict identity, freshness, completeness, unit, and payload validation before falling back to HyperLiquid ([#9815](https://github.com/MetaMask/core/pull/9815)). - Add `PerpsController.getUserDataSnapshot()` to fetch and cache positions, open orders, and account state as one account- and DEX-scoped result ([#9815](https://github.com/MetaMask/core/pull/9815)). -- Add a subscription fee-waiver source to the MetaMask builder fee, wired through the optional `PerpsPlatformDependencies.subscription.getPerpsBenefits()` dependency, along with the `PerpsSubscriptionBenefits`, `PerpsSubscriptionUsage`, `PerpsSubscriptionFeeWaiverStatus`, `PerpsFeeSource`, and `PerpsFeeResolution` types and the `SUBSCRIPTION_BENEFITS_CACHE` constant ([#9847](https://github.com/MetaMask/core/pull/9847)) +- Add a subscription fee-waiver source to the MetaMask builder fee, wired through the optional `PerpsPlatformDependencies.subscription.getPerpsBenefits()` dependency, along with the `PerpsSubscriptionBenefits`, `PerpsSubscriptionUsage`, `PerpsSubscriptionFeeWaiverStatus`, `PerpsFeeSource`, and `PerpsFeeResolution` types and the `SUBSCRIPTION_BENEFITS_CACHE` constant ([#9857](https://github.com/MetaMask/core/pull/9857)) - `RewardsIntegrationService.resolveFee()` returns the lowest fee across the default, rewards (VIP and season, already collapsed by `RewardsController`), and subscription sources, together with the winning source and the subscription gate outcome. The subscription source contributes `0` bips only when the eligibility gate — `status=active`, `perpsFeeWaiver` entitled, `usage=available`, not exhausted — passes on the cached benefits snapshot. - `RewardsIntegrationService.getSubscriptionFeeWaiverStatus()` reads that gate synchronously from the cache and `refreshSubscriptionBenefits()` refreshes it. The cache is stale-while-revalidate: a snapshot older than `SUBSCRIPTION_BENEFITS_CACHE.FreshMs` is still served while a background refresh runs, and past `SUBSCRIPTION_BENEFITS_CACHE.MaxStaleMs` it can no longer grant the waiver. No benefits request is ever awaited on the order-signing path, and a failed or unreachable read falls back to the next-lowest source instead of erroring or over-granting. - The opportunistic refresh is throttled on the last read _attempt_ rather than the last success, so a benefits outage retries at most once per `FreshMs` window instead of once per caller. - `PerpsController.invalidateSubscriptionBenefits()` (also exposed as the `PerpsController:invalidateSubscriptionBenefits` messenger action) drops the cached snapshot. Call it on sign-out or a profile switch: the snapshot carries no profile identity, so without it the previous profile's benefits keep answering until the next successful refresh. A read already in flight when it is called is discarded rather than written back, so it cannot repopulate the cache for the previous identity. - Clients that do not wire `subscription` are unaffected: the resolver keeps returning the rewards or default fee. -- Add `FeeCalculationResult.subscription`, surfacing the subscription waiver's `eligible`, `reason`, and `remainingNotionalUsd` on `PerpsController.calculateFees()` from the same cached benefits snapshot ([#9847](https://github.com/MetaMask/core/pull/9847)) +- Add `FeeCalculationResult.subscription`, surfacing the subscription waiver's `eligible`, `reason`, and `remainingNotionalUsd` on `PerpsController.calculateFees()` from the same cached benefits snapshot ([#9857](https://github.com/MetaMask/core/pull/9857)) - The preview is read-only: it does not adjust the quoted fee rates, mutate the notional cap, or issue a network request. The field is omitted entirely when no `subscription` dependency is wired. ### Changed -- `RewardsIntegrationService.calculateUserFeeDiscount()` now returns the unified resolver's winning discount instead of the rewards discount alone, so an eligible subscription waiver resolves to `10000` bips and lands as `builder.f = 0` on the HyperLiquid order payload ([#9847](https://github.com/MetaMask/core/pull/9847)) +- `RewardsIntegrationService.calculateUserFeeDiscount()` now returns the unified resolver's winning discount instead of the rewards discount alone, so an eligible subscription waiver resolves to `10000` bips and lands as `builder.f = 0` on the HyperLiquid order payload ([#9857](https://github.com/MetaMask/core/pull/9857)) - The existing contract is otherwise unchanged: `undefined` still means no source resolved (for example rewards state that has not hydrated), which callers must not read as a definitive "no discount". - `getTriggerExecution` now reports `'limit'` for `scale` and `chase`, which rest limit orders on the book without carrying an `OrderParams.price`, and `'market'` for `twap`, whose suborders cross it ([#9832](https://github.com/MetaMask/core/pull/9832)) From 8cc36fce93ed2d5fc4d4ccaf95e74ea2536a936b Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Thu, 13 Aug 2026 11:03:43 +0800 Subject: [PATCH 5/7] fix(perps): address PR feedback - Clear the benefits dedupe handle in invalidateSubscriptionBenefits so the next refresh starts a fresh read for the new identity instead of awaiting the fenced in-flight one. - Export PerpsControllerInvalidateSubscriptionBenefitsAction from the package root, matching every sibling PerpsController*Action. --- packages/perps-controller/src/index.ts | 1 + .../src/services/RewardsIntegrationService.ts | 5 +++ .../RewardsIntegrationService.test.ts | 33 +++++++++++++++++++ 3 files changed, 39 insertions(+) diff --git a/packages/perps-controller/src/index.ts b/packages/perps-controller/src/index.ts index dea4aec20f2..fd7bc7a1135 100644 --- a/packages/perps-controller/src/index.ts +++ b/packages/perps-controller/src/index.ts @@ -98,6 +98,7 @@ export type { PerpsControllerGetWithdrawalProgressAction, PerpsControllerGetWithdrawalRoutesAction, PerpsControllerInitAction, + PerpsControllerInvalidateSubscriptionBenefitsAction, PerpsControllerIsCurrentlyReinitializingAction, PerpsControllerIsFirstTimeUserOnCurrentNetworkAction, PerpsControllerIsWatchlistMarketAction, diff --git a/packages/perps-controller/src/services/RewardsIntegrationService.ts b/packages/perps-controller/src/services/RewardsIntegrationService.ts index 5455ed989d4..5910b404247 100644 --- a/packages/perps-controller/src/services/RewardsIntegrationService.ts +++ b/packages/perps-controller/src/services/RewardsIntegrationService.ts @@ -281,6 +281,11 @@ export class RewardsIntegrationService { // Fence any in-flight read: it was issued for the previous identity, so its // result must not repopulate the cache after this point. this.#benefitsEpoch += 1; + // Drop the dedupe handle too. The fenced read can only be discarded, so + // leaving it in place would make the next refresh await it instead of + // fetching for the new identity. Its `finally` guard compares against the + // current handle, so it will not clear whatever replaces it here. + this.#benefitsRefresh = undefined; this.#deps.debugLogger.log( 'RewardsIntegrationService: Subscription benefits cache invalidated', diff --git a/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts b/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts index 0011cf8d375..ea3b56ee27d 100644 --- a/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts +++ b/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts @@ -657,6 +657,39 @@ describe('RewardsIntegrationService', () => { expect(getPerpsBenefits).toHaveBeenCalledTimes(2); }); + it('starts a fresh read when the next caller arrives while a fenced read is still in flight', async () => { + // Profile A's read is still in flight at sign-out, so the epoch fence can + // only discard it. Deduping profile B onto it would leave the cache + // unhydrated instead of fetching for the new identity. + const releases: ((value: unknown) => void)[] = []; + const getPerpsBenefits = wireSubscription( + jest.fn( + async () => + new Promise((resolve) => { + releases.push(resolve); + }), + ), + ); + + const profileARead = service.refreshSubscriptionBenefits(); + service.invalidateSubscriptionBenefits(); + + // The documented contract: the next status read starts a fresh fetch. + expect(service.getSubscriptionFeeWaiverStatus()).toStrictEqual({ + eligible: false, + reason: 'not-hydrated', + }); + expect(getPerpsBenefits).toHaveBeenCalledTimes(2); + + // Dedupes onto profile B's read, which is what gives us a handle on it. + const profileBRead = service.refreshSubscriptionBenefits(); + releases.forEach((release) => release(createBenefits())); + await Promise.all([profileARead, profileBRead]); + + expect(getPerpsBenefits).toHaveBeenCalledTimes(2); + expect(service.getSubscriptionFeeWaiverStatus().eligible).toBe(true); + }); + it('uses a background refresh that lands during the rewards round trip', async () => { const getPerpsBenefits = wireSubscription( jest.fn().mockResolvedValue(createBenefits()), From a40cd12d38c4b8c371a7e7fca7c0d54c78ee4d8a Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Thu, 13 Aug 2026 13:55:41 +0800 Subject: [PATCH 6/7] fix(perps): preserve subscription fee attribution --- packages/perps-controller/CHANGELOG.md | 9 +- .../PerpsController-method-action-types.ts | 16 +- .../perps-controller/src/PerpsController.ts | 38 ++- packages/perps-controller/src/index.ts | 1 + .../src/providers/AggregatedPerpsProvider.ts | 19 ++ .../src/providers/HyperLiquidProvider.ts | 294 ++++++++++++++---- .../src/services/RewardsIntegrationService.ts | 72 ++--- .../src/services/TradingService.ts | 88 ++++-- packages/perps-controller/src/types/index.ts | 8 + .../src/PerpsController.operations.test.ts | 27 +- .../providers/AggregatedPerpsProvider.test.ts | 32 ++ .../HyperLiquidProvider.builder-fees.test.ts | 175 ++++++++++- .../RewardsIntegrationService.test.ts | 45 ++- .../TradingService.placeOrder.timeout.test.ts | 19 +- .../tests/src/services/TradingService.test.ts | 160 +++++++++- 15 files changed, 815 insertions(+), 188 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 21e5f9ad710..609aa030fe5 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -51,17 +51,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `PerpsController.getUserDataSnapshot()` to fetch and cache positions, open orders, and account state as one account- and DEX-scoped result ([#9815](https://github.com/MetaMask/core/pull/9815)). - Add a subscription fee-waiver source to the MetaMask builder fee, wired through the optional `PerpsPlatformDependencies.subscription.getPerpsBenefits()` dependency, along with the `PerpsSubscriptionBenefits`, `PerpsSubscriptionUsage`, `PerpsSubscriptionFeeWaiverStatus`, `PerpsFeeSource`, and `PerpsFeeResolution` types and the `SUBSCRIPTION_BENEFITS_CACHE` constant ([#9857](https://github.com/MetaMask/core/pull/9857)) - `RewardsIntegrationService.resolveFee()` returns the lowest fee across the default, rewards (VIP and season, already collapsed by `RewardsController`), and subscription sources, together with the winning source and the subscription gate outcome. The subscription source contributes `0` bips only when the eligibility gate — `status=active`, `perpsFeeWaiver` entitled, `usage=available`, not exhausted — passes on the cached benefits snapshot. - - `RewardsIntegrationService.getSubscriptionFeeWaiverStatus()` reads that gate synchronously from the cache and `refreshSubscriptionBenefits()` refreshes it. The cache is stale-while-revalidate: a snapshot older than `SUBSCRIPTION_BENEFITS_CACHE.FreshMs` is still served while a background refresh runs, and past `SUBSCRIPTION_BENEFITS_CACHE.MaxStaleMs` it can no longer grant the waiver. No benefits request is ever awaited on the order-signing path, and a failed or unreachable read falls back to the next-lowest source instead of erroring or over-granting. - - The opportunistic refresh is throttled on the last read _attempt_ rather than the last success, so a benefits outage retries at most once per `FreshMs` window instead of once per caller. + - `RewardsIntegrationService.resolveFee()` and `getSubscriptionFeeWaiverStatus()` are pure cache consumers and never start a subscription request on the order-signing path. `PerpsController.calculateFees()` owns preview hydration through `refreshSubscriptionBenefits()`. A snapshot older than `SUBSCRIPTION_BENEFITS_CACHE.MaxStaleMs` can no longer grant the waiver, and a failed or unreachable refresh falls back to the next-lowest source instead of erroring or over-granting. + - Refreshes are throttled on the last read _attempt_ rather than the last success, so a benefits outage retries at most once per `FreshMs` window instead of once per preview. - `PerpsController.invalidateSubscriptionBenefits()` (also exposed as the `PerpsController:invalidateSubscriptionBenefits` messenger action) drops the cached snapshot. Call it on sign-out or a profile switch: the snapshot carries no profile identity, so without it the previous profile's benefits keep answering until the next successful refresh. A read already in flight when it is called is discarded rather than written back, so it cannot repopulate the cache for the previous identity. - Clients that do not wire `subscription` are unaffected: the resolver keeps returning the rewards or default fee. - Add `FeeCalculationResult.subscription`, surfacing the subscription waiver's `eligible`, `reason`, and `remainingNotionalUsd` on `PerpsController.calculateFees()` from the same cached benefits snapshot ([#9857](https://github.com/MetaMask/core/pull/9857)) - - The preview is read-only: it does not adjust the quoted fee rates, mutate the notional cap, or issue a network request. The field is omitted entirely when no `subscription` dependency is wired. + - The preview refreshes the benefits cache when needed, but does not adjust the quoted fee rates or mutate the notional cap. The field is omitted entirely when no `subscription` dependency is wired. ### Changed -- `RewardsIntegrationService.calculateUserFeeDiscount()` now returns the unified resolver's winning discount instead of the rewards discount alone, so an eligible subscription waiver resolves to `10000` bips and lands as `builder.f = 0` on the HyperLiquid order payload ([#9857](https://github.com/MetaMask/core/pull/9857)) - - The existing contract is otherwise unchanged: `undefined` still means no source resolved (for example rewards state that has not hydrated), which callers must not read as a definitive "no discount". +- `RewardsIntegrationService.calculateUserFeeDiscount()` now returns the unified resolver's winning discount instead of the rewards discount alone, while preserving `undefined` when no source has resolved. TradingService passes the full `PerpsFeeResolution` to providers, isolates it across concurrent operations, and applies it to flip orders. HyperLiquid uses the configured subscription builder only after account-scoped approval through `PerpsController.approveSubscriptionBuilderFee()`; otherwise it uses the ordinary builder at the standard fee ([#9857](https://github.com/MetaMask/core/pull/9857)) - `getTriggerExecution` now reports `'limit'` for `scale` and `chase`, which rest limit orders on the book without carrying an `OrderParams.price`, and `'market'` for `twap`, whose suborders cross it ([#9832](https://github.com/MetaMask/core/pull/9832)) - This is what decides the fee tier and the max order value, so a scale ladder and a chase are no longer quoted at the taker rate or held to the tighter market-order cap. `calculateFees` additionally quotes `chase` at the maker rate regardless of `isMaker`, because a post-only order can only fill as a maker. diff --git a/packages/perps-controller/src/PerpsController-method-action-types.ts b/packages/perps-controller/src/PerpsController-method-action-types.ts index c1737428248..dcba771ad52 100644 --- a/packages/perps-controller/src/PerpsController-method-action-types.ts +++ b/packages/perps-controller/src/PerpsController-method-action-types.ts @@ -778,6 +778,18 @@ export type PerpsControllerCalculateFeesAction = { handler: PerpsController['calculateFees']; }; +/** + * Approve the dedicated subscription builder outside order submission. + * Until this succeeds, subscription waivers fall back to the ordinary + * builder at the standard fee. + * + * @returns Whether the subscription builder is approved. + */ +export type PerpsControllerApproveSubscriptionBuilderFeeAction = { + type: `PerpsController:approveSubscriptionBuilderFee`; + handler: PerpsController['approveSubscriptionBuilderFee']; +}; + /** * Drop the cached subscription benefits snapshot. * @@ -785,8 +797,7 @@ export type PerpsControllerCalculateFeesAction = { * profile switch. The snapshot carries no profile identity of its own, so * without this it keeps answering for the previous profile until the next * successful refresh. The next fee resolution reports the waiver as - * unavailable and refetches, so the waiver is withheld rather than - * mis-granted. + * unavailable, so it is withheld until preview or lifecycle hydration. */ export type PerpsControllerInvalidateSubscriptionBenefitsAction = { type: `PerpsController:invalidateSubscriptionBenefits`; @@ -1212,6 +1223,7 @@ export type PerpsControllerMethodActions = | PerpsControllerSubscribeToOICapsAction | PerpsControllerSetLiveDataConfigAction | PerpsControllerCalculateFeesAction + | PerpsControllerApproveSubscriptionBuilderFeeAction | PerpsControllerInvalidateSubscriptionBenefitsAction | PerpsControllerDisconnectAction | PerpsControllerStartEligibilityMonitoringAction diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index 2f148bf1302..2310086b086 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -856,6 +856,7 @@ type UserSnapshotContext = { }; const MESSENGER_EXPOSED_METHODS = [ + 'approveSubscriptionBuilderFee', 'calculateFees', 'calculateLiquidationPrice', 'calculateMaintenanceMargin', @@ -1722,6 +1723,12 @@ export class PerpsController extends BaseController< builderAddressMainnet: this.#options.clientConfig?.providerCredentials?.hyperliquid ?.builderAddressMainnet, + subscriptionBuilderAddressTestnet: + this.#options.clientConfig?.providerCredentials?.hyperliquid + ?.subscriptionBuilderAddressTestnet, + subscriptionBuilderAddressMainnet: + this.#options.clientConfig?.providerCredentials?.hyperliquid + ?.subscriptionBuilderAddressMainnet, }); this.#standaloneProviderIsTestnet = currentIsTestnet; this.#standaloneProviderHip3Version = currentHip3Version; @@ -2204,6 +2211,12 @@ export class PerpsController extends BaseController< builderAddressMainnet: this.#options.clientConfig?.providerCredentials?.hyperliquid ?.builderAddressMainnet, + subscriptionBuilderAddressTestnet: + this.#options.clientConfig?.providerCredentials?.hyperliquid + ?.subscriptionBuilderAddressTestnet, + subscriptionBuilderAddressMainnet: + this.#options.clientConfig?.providerCredentials?.hyperliquid + ?.subscriptionBuilderAddressMainnet, }); this.providers.set('hyperliquid', hyperLiquidProvider); @@ -5155,10 +5168,10 @@ export class PerpsController extends BaseController< params: FeeCalculationParams, ): Promise { const provider = this.getActiveProvider(); - // Cached, synchronous read of the same benefits snapshot the fee resolver - // uses — the preview surfaces eligibility and remaining notional without - // any network call or cap mutation. Clients with no subscription source - // wired get the untouched fee shape. + // Preview owns subscription hydration. The submit resolver remains a pure + // cache read and can therefore never start a benefits request while an + // order is being signed. + await this.#rewardsIntegrationService.refreshSubscriptionBenefits(); const waiverStatus = this.#rewardsIntegrationService.getSubscriptionFeeWaiverStatus(); const context = this.#createServiceContext('calculateFees', { @@ -5168,6 +5181,20 @@ export class PerpsController extends BaseController< return this.#marketDataService.calculateFees({ provider, params, context }); } + /** + * Approve the dedicated subscription builder outside order submission. + * Until this succeeds, subscription waivers fall back to the ordinary + * builder at the standard fee. + * + * @returns Whether the subscription builder is approved. + */ + async approveSubscriptionBuilderFee(): Promise { + const provider = this.getActiveProvider(); + return provider.approveSubscriptionBuilderFee + ? provider.approveSubscriptionBuilderFee() + : false; + } + /** * Drop the cached subscription benefits snapshot. * @@ -5175,8 +5202,7 @@ export class PerpsController extends BaseController< * profile switch. The snapshot carries no profile identity of its own, so * without this it keeps answering for the previous profile until the next * successful refresh. The next fee resolution reports the waiver as - * unavailable and refetches, so the waiver is withheld rather than - * mis-granted. + * unavailable, so it is withheld until preview or lifecycle hydration. */ invalidateSubscriptionBenefits(): void { this.#rewardsIntegrationService.invalidateSubscriptionBenefits(); diff --git a/packages/perps-controller/src/index.ts b/packages/perps-controller/src/index.ts index fd7bc7a1135..2436eac8203 100644 --- a/packages/perps-controller/src/index.ts +++ b/packages/perps-controller/src/index.ts @@ -49,6 +49,7 @@ export type { ProPositionsSortField, } from './PerpsController.js'; export type { + PerpsControllerApproveSubscriptionBuilderFeeAction, PerpsControllerCalculateFeesAction, PerpsControllerCalculateLiquidationPriceAction, PerpsControllerCalculateMaintenanceMarginAction, diff --git a/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts b/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts index 3add88a3cec..22a84842ee8 100644 --- a/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts +++ b/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts @@ -80,6 +80,7 @@ import type { WithdrawResult, RawLedgerUpdate, PerpsReadOptions, + PerpsFeeResolution, } from '../types/index.js'; /** @@ -656,6 +657,24 @@ export class AggregatedPerpsProvider implements PerpsProvider { }); } + setUserFeeResolution(resolution: PerpsFeeResolution | undefined): void { + this.#providers.forEach((provider) => { + if (provider.setUserFeeResolution) { + provider.setUserFeeResolution(resolution); + } else if (provider.setUserFeeDiscount) { + provider.setUserFeeDiscount(resolution?.discountBips); + } + }); + } + + async approveSubscriptionBuilderFee(): Promise { + const provider = + this.#providers.get('hyperliquid') ?? this.#getDefaultProvider(); + return provider.approveSubscriptionBuilderFee + ? provider.approveSubscriptionBuilderFee() + : false; + } + // ============================================================================ // Lifecycle (Delegate to default provider) // ============================================================================ diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 08be87d6e63..54300be8f1e 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -128,6 +128,7 @@ import type { RawLedgerUpdate, PerpsReadOptions, PerpsUserDataSnapshot, + PerpsFeeResolution, } from '../types/index.js'; import type { PerpsControllerMessengerBase } from '../types/messenger.js'; import type { OrderType, StrategyOrderType } from '../types/perps-types.js'; @@ -539,6 +540,8 @@ type ChaseSession = { * would otherwise be re-quoted at the undiscounted maximum. */ builderFee: number; + /** Builder address captured with the fee for attribution across re-prices. */ + builderAddress: string; /** Absolute deadline, as a `Date.now()` stamp. */ deadline: number; maxRepricings: number; @@ -759,6 +762,9 @@ export class HyperLiquidProvider implements PerpsProvider { readonly #pendingBuilderFeeApprovals = new Map>(); + /** Builder approvals keyed by network, account, and builder address. */ + readonly #approvedBuilderAddresses = new Set(); + // Pre-compiled patterns for fast filtering readonly #compiledAllowlistPatterns: CompiledMarketPattern[] = []; @@ -767,6 +773,8 @@ export class HyperLiquidProvider implements PerpsProvider { // Fee discount context for MetaMask reward discounts (in basis points) #userFeeDiscountBips?: number; + #userFeeResolution?: PerpsFeeResolution; + // Feature flag configuration for HIP-3 market filtering readonly #hip3Enabled: boolean; @@ -842,6 +850,10 @@ export class HyperLiquidProvider implements PerpsProvider { readonly #builderAddressMainnet?: string; + readonly #subscriptionBuilderAddressTestnet?: string; + + readonly #subscriptionBuilderAddressMainnet?: string; + readonly #priceDeviationLimit: number; constructor(options: { @@ -856,11 +868,17 @@ export class HyperLiquidProvider implements PerpsProvider { initialAssetMapping?: [string, number][]; builderAddressTestnet?: string; builderAddressMainnet?: string; + subscriptionBuilderAddressTestnet?: string; + subscriptionBuilderAddressMainnet?: string; }) { this.#deps = options.platformDependencies; this.#messenger = options.messenger; this.#builderAddressTestnet = options.builderAddressTestnet; this.#builderAddressMainnet = options.builderAddressMainnet; + this.#subscriptionBuilderAddressTestnet = + options.subscriptionBuilderAddressTestnet; + this.#subscriptionBuilderAddressMainnet = + options.subscriptionBuilderAddressMainnet; this.#priceDeviationLimit = options.priceDeviationLimit ?? HYPERLIQUID_CONFIG.OraclePriceDeviationLimit; @@ -2346,6 +2364,14 @@ export class HyperLiquidProvider implements PerpsProvider { return `${network}:${userAddress.toLowerCase()}`; } + #getApprovedBuilderKey( + network: string, + userAddress: string, + builderAddress: string, + ): string { + return `${this.#getCacheKey(network, userAddress)}:${builderAddress.toLowerCase()}`; + } + /** * Fetch markets for a specific DEX with optional filtering * Uses session-based caching via getCachedMeta() - no TTL, cleared on disconnect @@ -2674,6 +2700,7 @@ export class HyperLiquidProvider implements PerpsProvider { * @param discountBips - The discount in basis points (e.g., 550 = 5.5%) */ setUserFeeDiscount(discountBips: number | undefined): void { + this.#userFeeResolution = undefined; this.#userFeeDiscountBips = discountBips; this.#deps.debugLogger.log('HyperLiquid: Fee discount context updated', { @@ -2683,6 +2710,22 @@ export class HyperLiquidProvider implements PerpsProvider { }); } + /** + * Set the resolved fee and its attribution source for the next operation. + * + * @param resolution - Unified fee resolution, or undefined to clear it. + */ + setUserFeeResolution(resolution: PerpsFeeResolution | undefined): void { + this.#userFeeResolution = resolution; + this.#userFeeDiscountBips = resolution?.discountBips; + + this.#deps.debugLogger.log('HyperLiquid: Fee resolution context updated', { + source: resolution?.source, + discountBips: resolution?.discountBips, + isActive: resolution !== undefined, + }); + } + /** * Query user data across all enabled DEXs in parallel * @@ -2879,14 +2922,15 @@ export class HyperLiquidProvider implements PerpsProvider { /** * Check current builder fee approval for the user * + * @param builder - Builder address to query. + * @param userAddress - Account whose approval should be queried. * @returns Current max fee rate or null if not approved */ - async #checkBuilderFeeApproval(): Promise { + async #checkBuilderFeeApproval( + builder: string, + userAddress: string, + ): Promise { const infoClient = this.#clientService.getInfoClient(); - const userAddress = await this.#walletService.getUserAddressWithDefault(); - const builder = this.#getBuilderAddress( - this.#clientService.isTestnetMode(), - ); return infoClient.maxBuilderFee({ user: userAddress, @@ -2921,6 +2965,9 @@ export class HyperLiquidProvider implements PerpsProvider { { network, success: globalCached.success }, ); this.#builderFeeCheckCache.set(cacheKey, true); + this.#approvedBuilderAddresses.add( + this.#getApprovedBuilderKey(network, userAddress, builderAddress), + ); return; } @@ -2961,8 +3008,10 @@ export class HyperLiquidProvider implements PerpsProvider { return; } - const { isApproved, requiredDecimal } = - await this.#checkBuilderFeeStatus(); + const { isApproved, requiredDecimal } = await this.#checkBuilderFeeStatus( + builderAddress, + userAddress, + ); if (isApproved) { // User already has approval on-chain @@ -2971,6 +3020,9 @@ export class HyperLiquidProvider implements PerpsProvider { success: true, }); this.#builderFeeCheckCache.set(cacheKey, true); + this.#approvedBuilderAddresses.add( + this.#getApprovedBuilderKey(network, userAddress, builderAddress), + ); this.#deps.debugLogger.log( '[ensureBuilderFeeApproval] Already approved on-chain', @@ -2991,7 +3043,10 @@ export class HyperLiquidProvider implements PerpsProvider { }); // Verify approval was successful before caching - const afterApprovalDecimal = await this.#checkBuilderFeeApproval(); + const afterApprovalDecimal = await this.#checkBuilderFeeApproval( + builderAddress, + userAddress, + ); if ( afterApprovalDecimal === null || @@ -3008,6 +3063,9 @@ export class HyperLiquidProvider implements PerpsProvider { success: true, }); this.#builderFeeCheckCache.set(cacheKey, true); + this.#approvedBuilderAddresses.add( + this.#getApprovedBuilderKey(network, userAddress, builderAddress), + ); this.#deps.debugLogger.log( '[ensureBuilderFeeApproval] Approval successful', @@ -3051,17 +3109,112 @@ export class HyperLiquidProvider implements PerpsProvider { } } + /** + * Approve the dedicated subscription builder outside order submission. + * Failure is non-blocking: order construction will use the ordinary builder + * at the standard fee until a later approval succeeds. + * + * @returns Whether the builder is approved for the current account. + */ + async approveSubscriptionBuilderFee(): Promise { + await this.#ensureClientsInitialized(); + const isTestnet = this.#clientService.isTestnetMode(); + const network = isTestnet ? 'testnet' : 'mainnet'; + const builderAddress = this.#getSubscriptionBuilderAddress(isTestnet); + if (!builderAddress) { + return false; + } + const userAddress = await this.#walletService.getUserAddressWithDefault(); + const key = this.#getApprovedBuilderKey( + network, + userAddress, + builderAddress, + ); + if (this.#approvedBuilderAddresses.has(key)) { + return true; + } + + const pending = this.#pendingBuilderFeeApprovals.get(key); + if (pending) { + try { + await pending; + return this.#approvedBuilderAddresses.has(key); + } catch (error) { + this.#deps.debugLogger.log( + 'HyperLiquidProvider: Subscription builder approval unavailable', + error, + ); + return false; + } + } + + const approval = (async (): Promise => { + const currentApproval = await this.#checkBuilderFeeApproval( + builderAddress, + userAddress, + ); + if ( + currentApproval !== null && + currentApproval >= BUILDER_FEE_CONFIG.MaxFeeDecimal + ) { + this.#approvedBuilderAddresses.add(key); + return; + } + + const exchangeClient = this.#clientService.getExchangeClient(); + await exchangeClient.approveBuilderFee({ + builder: builderAddress, + maxFeeRate: BUILDER_FEE_CONFIG.MaxFeeRate, + }); + const afterApproval = await this.#checkBuilderFeeApproval( + builderAddress, + userAddress, + ); + if ( + afterApproval === null || + afterApproval < BUILDER_FEE_CONFIG.MaxFeeDecimal + ) { + throw new Error( + '[HyperLiquidProvider] Subscription builder approval verification failed', + ); + } + this.#approvedBuilderAddresses.add(key); + })(); + this.#pendingBuilderFeeApprovals.set(key, approval); + + try { + await approval; + return true; + } catch (error) { + this.#deps.debugLogger.log( + 'HyperLiquidProvider: Subscription builder approval unavailable', + error, + ); + return false; + } finally { + this.#pendingBuilderFeeApprovals.delete(key); + } + } + /** * Check if builder fee is approved for the current user * + * @param builderAddress - Builder address to query. + * @param userAddress - Account whose approval should be queried. * @returns Object with approval status and current rate */ - async #checkBuilderFeeStatus(): Promise<{ + async #checkBuilderFeeStatus( + builderAddress: string, + userAddress: string, + ): Promise<{ isApproved: boolean; currentRate: number | null; requiredDecimal: number; }> { - const currentApproval = await this.#checkBuilderFeeApproval(); + const currentApproval = await this.#checkBuilderFeeApproval( + builderAddress, + userAddress, + ); const requiredDecimal = BUILDER_FEE_CONFIG.MaxFeeDecimal; return { @@ -3804,18 +3957,7 @@ export class HyperLiquidProvider implements PerpsProvider { const exchangeClient = this.#clientService.getExchangeClient(); - // Calculate discounted builder fee - let builderFee = BUILDER_FEE_CONFIG.MaxFeeTenthsBps; - if (this.#userFeeDiscountBips !== undefined) { - builderFee = Math.floor( - builderFee * (1 - this.#userFeeDiscountBips / BASIS_POINTS_DIVISOR), - ); - this.#deps.debugLogger.log('Applying builder fee discount', { - originalFee: BUILDER_FEE_CONFIG.MaxFeeTenthsBps, - discountBips: this.#userFeeDiscountBips, - discountedFee: builderFee, - }); - } + const builder = await this.#getBuilderOrderContext(); this.#deps.debugLogger.log('Submitting order via asset ID routing', { symbol, @@ -3830,10 +3972,7 @@ export class HyperLiquidProvider implements PerpsProvider { const result = await exchangeClient.order({ orders, grouping, - builder: { - b: this.#getBuilderAddress(this.#clientService.isTestnetMode()), - f: builderFee, - }, + builder, }); if (result.status !== 'ok') { @@ -4560,10 +4699,7 @@ export class HyperLiquidProvider implements PerpsProvider { const result = await exchangeClient.order({ orders, grouping: 'na', - builder: { - b: this.#getBuilderAddress(this.#clientService.isTestnetMode()), - f: this.#getDiscountedBuilderFee(), - }, + builder: await this.#getBuilderOrderContext(), }); if (result.status !== 'ok') { @@ -4670,8 +4806,8 @@ export class HyperLiquidProvider implements PerpsProvider { throw new Error(PERPS_ERROR_CODES.ORDER_CHASE_ABANDONED); } - // Read once, while the caller's discount context is still set. - const builderFee = this.#getDiscountedBuilderFee(); + // Read once, while the caller's fee-source context is still set. + const builder = await this.#getBuilderOrderContext(); // Held onto rather than looked up again after the submission returns. // `disconnect` drops the service's client reference synchronously, so a @@ -4686,7 +4822,8 @@ export class HyperLiquidProvider implements PerpsProvider { price: quotePrice, size: formattedSize, reduceOnly: params.reduceOnly ?? false, - builderFee, + builderFee: builder.f, + builderAddress: builder.b, exchangeClient: placingClient, }); @@ -4704,7 +4841,8 @@ export class HyperLiquidProvider implements PerpsProvider { pendingReplacement: null, restingPrice: quotePrice, intervalMs, - builderFee, + builderFee: builder.f, + builderAddress: builder.b, deadline: Date.now() + (params.chaseMaxDurationMs ?? CHASE_ORDER_CONFIG.DefaultMaxDurationMs), @@ -4835,6 +4973,7 @@ export class HyperLiquidProvider implements PerpsProvider { * @param params.reduceOnly - Whether the order may only reduce a position. * @param params.builderFee - Builder fee, in tenths of a basis point, captured * when the session started so replacements keep the rate they were quoted at. + * @param params.builderAddress - Builder address captured with the fee. * @param params.exchangeClient - Client to submit through. Passed in rather * than looked up here so a first placement can keep the instance it signed * with, which is the only one that can take the order back once `disconnect` @@ -4848,6 +4987,7 @@ export class HyperLiquidProvider implements PerpsProvider { size: string; reduceOnly: boolean; builderFee: number; + builderAddress: string; exchangeClient: ExchangeClient; }): Promise { const result = await params.exchangeClient.order({ @@ -4865,7 +5005,7 @@ export class HyperLiquidProvider implements PerpsProvider { ], grouping: 'na', builder: { - b: this.#getBuilderAddress(this.#clientService.isTestnetMode()), + b: params.builderAddress, f: params.builderFee, }, }); @@ -5092,6 +5232,7 @@ export class HyperLiquidProvider implements PerpsProvider { size: remaining, reduceOnly: session.reduceOnly, builderFee: session.builderFee, + builderAddress: session.builderAddress, // A running session is on a live provider, so the current client is // the right one; only the first placement has a teardown to survive. exchangeClient: this.#clientService.getExchangeClient(), @@ -5577,6 +5718,46 @@ export class HyperLiquidProvider implements PerpsProvider { ); } + /** + * Resolve the builder payload for the current operation. + * + * Subscription waivers use their dedicated builder only after approval is + * cached for this provider/account session. Until then, the ordinary builder + * and standard fee keep the trade attributable and non-blocking. + * + * @returns HyperLiquid builder address and fee payload. + */ + async #getBuilderOrderContext(): Promise<{ b: string; f: number }> { + const isTestnet = this.#clientService.isTestnetMode(); + const network = isTestnet ? 'testnet' : 'mainnet'; + const defaultBuilder = this.#getBuilderAddress(isTestnet); + + if (this.#userFeeResolution?.source === 'subscription') { + const subscriptionBuilder = + this.#getSubscriptionBuilderAddress(isTestnet); + const userAddress = await this.#walletService.getUserAddressWithDefault(); + if ( + subscriptionBuilder && + this.#approvedBuilderAddresses.has( + this.#getApprovedBuilderKey( + network, + userAddress, + subscriptionBuilder, + ), + ) + ) { + return { b: subscriptionBuilder, f: 0 }; + } + + return { + b: defaultBuilder, + f: BUILDER_FEE_CONFIG.MaxFeeTenthsBps, + }; + } + + return { b: defaultBuilder, f: this.#getDiscountedBuilderFee() }; + } + /** * Read the account's currently resting orders. * @@ -6258,22 +6439,11 @@ export class HyperLiquidProvider implements PerpsProvider { }; } - // Calculate discounted builder fee if reward discount is active - let builderFee = BUILDER_FEE_CONFIG.MaxFeeTenthsBps; - if (this.#userFeeDiscountBips !== undefined) { - builderFee = Math.floor( - builderFee * (1 - this.#userFeeDiscountBips / BASIS_POINTS_DIVISOR), - ); - } - // Single batch API call const result = await exchangeClient.order({ orders, grouping: 'na', - builder: { - b: this.#getBuilderAddress(this.#clientService.isTestnetMode()), - f: builderFee, - }, + builder: await this.#getBuilderOrderContext(), }); // Parse response statuses (one per order) @@ -6747,32 +6917,13 @@ export class HyperLiquidProvider implements PerpsProvider { }; } - // Calculate discounted builder fee if reward discount is active - let builderFee = BUILDER_FEE_CONFIG.MaxFeeTenthsBps; - if (this.#userFeeDiscountBips !== undefined) { - builderFee = Math.floor( - builderFee * (1 - this.#userFeeDiscountBips / BASIS_POINTS_DIVISOR), - ); - this.#deps.debugLogger.log( - 'HyperLiquid: Applying builder fee discount to TP/SL', - { - originalFee: BUILDER_FEE_CONFIG.MaxFeeTenthsBps, - discountBips: this.#userFeeDiscountBips, - discountedFee: builderFee, - }, - ); - } - // Submit via SDK exchange client. Position-bound TP/SL uses 'positionTpsl'; // partial TP/SL must be standalone reduce-only triggers ('na'), since a // position-bound TP/SL always closes the whole position. const result = await exchangeClient.order({ orders, grouping: isPartialTpsl ? 'na' : 'positionTpsl', - builder: { - b: this.#getBuilderAddress(this.#clientService.isTestnetMode()), - f: builderFee, - }, + builder: await this.#getBuilderOrderContext(), }); if (result.status !== 'ok') { @@ -10797,6 +10948,9 @@ export class HyperLiquidProvider implements PerpsProvider { // Clear session caches (ensures fresh state on reconnect/account switch) this.#referralCheckCache.clear(); this.#builderFeeCheckCache.clear(); + this.#approvedBuilderAddresses.clear(); + this.#userFeeResolution = undefined; + this.#userFeeDiscountBips = undefined; // NOTE: UnifiedAccountCache is global and NOT cleared on disconnect // to prevent repeated signing requests across reconnections this.#cachedMetaByDex.clear(); @@ -11032,6 +11186,12 @@ export class HyperLiquidProvider implements PerpsProvider { return this.#builderAddressMainnet || BUILDER_FEE_CONFIG.MainnetBuilder; } + #getSubscriptionBuilderAddress(isTestnet: boolean): string | undefined { + return isTestnet + ? this.#subscriptionBuilderAddressTestnet + : this.#subscriptionBuilderAddressMainnet; + } + #getReferralCode(isTestnet: boolean): string { return isTestnet ? REFERRAL_CONFIG.TestnetCode diff --git a/packages/perps-controller/src/services/RewardsIntegrationService.ts b/packages/perps-controller/src/services/RewardsIntegrationService.ts index 5910b404247..228e70bbc8c 100644 --- a/packages/perps-controller/src/services/RewardsIntegrationService.ts +++ b/packages/perps-controller/src/services/RewardsIntegrationService.ts @@ -50,11 +50,11 @@ type BenefitsSnapshot = { * On a tie the cheaper-to-explain source wins, in the order * `subscription` > `rewards` > `default`. * - * The benefits cache is stale-while-revalidate: reads are synchronous against - * the cached snapshot and a refresh is kicked off opportunistically, so the - * order-signing path never awaits a benefits request. Nothing is reserved or - * committed client-side, so backend exhaustion needs no release logic — the - * next refresh simply stops passing the gate. + * The benefits cache is stale-while-revalidate: fee resolution is a pure read + * of the cached snapshot, while preview and lifecycle callers refresh it + * explicitly. Nothing is reserved or committed client-side, so backend + * exhaustion needs no release logic — the next refresh simply stops passing + * the gate. * * Instance-based service with constructor injection of platform dependencies. */ @@ -70,12 +70,12 @@ export class RewardsIntegrationService { * When the last benefits read finished, successful or not. * * Separate from `#benefitsSnapshot.fetchedAt`, which only advances on - * success: a failing read must still throttle the next opportunistic - * refresh, otherwise an outage turns every fee preview into a new request. + * success: a failing read must still throttle the next preview refresh, + * otherwise an outage turns every fee preview into a new request. */ #lastAttemptAt: number | undefined; - /** In-flight background refresh, deduped so only one runs at a time. */ + /** In-flight refresh, deduped so only one runs at a time. */ #benefitsRefresh: Promise | undefined; /** @@ -135,20 +135,16 @@ export class RewardsIntegrationService { /** * Resolve the MetaMask builder fee across every source and return the lowest. * - * Never throws and never awaits the subscription benefits read: a failing or - * unresolved source simply drops out of the comparison, so the worst case is - * the default fee rather than an error or an over-granted waiver. + * Never throws and never starts a subscription benefits read: a failing or + * unresolved cached source simply drops out of the comparison, so the worst + * case is the default fee rather than an error or an over-granted waiver. * * @returns The winning fee, its source, and the subscription gate outcome. */ async resolveFee(): Promise { - // Cached, synchronous, non-blocking — safe on the order-signing path. - // Read once up front so any opportunistic refresh starts before the awaited - // rewards round trip rather than after it. - this.getSubscriptionFeeWaiverStatus(); const rewardsDiscountBips = await this.#calculateRewardsDiscount(); - // Re-read: a background refresh may have landed during that await, and the - // freshest cached value costs nothing here. + // 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; @@ -192,9 +188,8 @@ export class RewardsIntegrationService { /** * Read the subscription fee-waiver gate from the cached benefits snapshot. * - * Synchronous and side-effect free apart from kicking off an opportunistic - * background refresh when the snapshot is missing or past its freshness - * window; the returned value always comes from what is already cached. + * Synchronous and side-effect free. The returned value always comes from + * what is already cached; preview and lifecycle callers own hydration. * * @returns Whether the waiver applies, why, and the remaining notional. */ @@ -206,19 +201,6 @@ export class RewardsIntegrationService { const now = Date.now(); const snapshot = this.#benefitsSnapshot; const age = snapshot ? now - snapshot.fetchedAt : Infinity; - // Throttled on the last *attempt*, not the last success, so a benefits - // outage retries once per freshness window instead of once per caller. - const sinceAttempt = - this.#lastAttemptAt === undefined ? Infinity : now - this.#lastAttemptAt; - - // Stale-while-revalidate: serve what we have, refresh in the background. - if ( - age >= SUBSCRIPTION_BENEFITS_CACHE.FreshMs && - sinceAttempt >= SUBSCRIPTION_BENEFITS_CACHE.FreshMs - ) { - this.refreshSubscriptionBenefits().catch(() => undefined); - } - if (!snapshot) { return { eligible: false, reason: 'not-hydrated' }; } @@ -236,8 +218,8 @@ export class RewardsIntegrationService { * Refresh the cached subscription benefits snapshot. * * Deduped: concurrent callers share the in-flight request. Rejections are - * logged and swallowed, leaving the previous snapshot in place. Callers on - * the order-signing path must not await this. + * logged and swallowed, leaving the previous snapshot in place. Preview and + * lifecycle callers invoke this outside order submission. * * @returns A promise that settles when the refresh completes. */ @@ -252,6 +234,19 @@ export class RewardsIntegrationService { return; } + const now = Date.now(); + const snapshotAge = this.#benefitsSnapshot + ? now - this.#benefitsSnapshot.fetchedAt + : Infinity; + const sinceAttempt = + this.#lastAttemptAt === undefined ? Infinity : now - this.#lastAttemptAt; + if ( + snapshotAge < SUBSCRIPTION_BENEFITS_CACHE.FreshMs || + sinceAttempt < SUBSCRIPTION_BENEFITS_CACHE.FreshMs + ) { + return; + } + const refresh = this.#readSubscriptionBenefits(source); this.#benefitsRefresh = refresh; // `finally` always defers, so this never clears the handle we just set. @@ -272,8 +267,8 @@ export class RewardsIntegrationService { * Call this when the identity behind the benefits changes — sign-out, or a * profile switch — since the snapshot carries no profile identity of its own * and would otherwise keep answering for the previous profile until the next - * successful refresh. The next status read reports `not-hydrated` and starts - * a fresh fetch, so the waiver is withheld rather than mis-granted. + * successful refresh. The next status read reports `not-hydrated`, so the + * waiver is withheld until a preview or lifecycle caller hydrates it. */ invalidateSubscriptionBenefits(): void { this.#benefitsSnapshot = undefined; @@ -294,8 +289,7 @@ export class RewardsIntegrationService { /** * Perform one benefits read and store it, keeping the previous snapshot on - * error. Never rejects, so background callers cannot produce an unhandled - * rejection. + * error. Never rejects, so callers cannot produce an unhandled rejection. * * @param source - The injected subscription benefits source. */ diff --git a/packages/perps-controller/src/services/TradingService.ts b/packages/perps-controller/src/services/TradingService.ts index b2cb7a2cef0..f0bee6b9087 100644 --- a/packages/perps-controller/src/services/TradingService.ts +++ b/packages/perps-controller/src/services/TradingService.ts @@ -30,6 +30,7 @@ import type { UpdatePositionTPSLParams, PerpsAnalyticsProperties, PerpsPlatformDependencies, + PerpsFeeResolution, } from '../types/index.js'; import { ensureError } from '../utils/errorUtils.js'; import { isLimitExecutionOrderType } from '../utils/orderTypes.js'; @@ -76,6 +77,9 @@ export class TradingService { */ #controllerDeps: TradingServiceControllerDeps | null = null; + /** Serializes provider fee context so concurrent orders cannot share it. */ + #feeContextTail: Promise = Promise.resolve(); + /** * Create a new TradingService instance * @@ -429,25 +433,35 @@ export class TradingService { * * @param options - The configuration options. * @param options.provider - The perps provider instance. - * @param options.feeDiscountBips - The fee discount bips value. + * @param options.feeResolution - The resolved fee and attribution source. * @param options.operation - The operation value. * @returns The result of the operation. */ async #withFeeDiscount(options: { provider: PerpsProvider; - feeDiscountBips?: number; + feeResolution?: PerpsFeeResolution; operation: () => Promise; }): Promise { - const { provider, feeDiscountBips, operation } = options; + const { provider, feeResolution, operation } = options; + const previous = this.#feeContextTail; + let release: () => void = () => undefined; + this.#feeContextTail = new Promise((resolve) => { + release = resolve; + }); + await previous; try { - // Set discount context in provider for this operation - if (feeDiscountBips !== undefined && provider.setUserFeeDiscount) { - provider.setUserFeeDiscount(feeDiscountBips); + if (provider.setUserFeeResolution) { + provider.setUserFeeResolution(feeResolution); + } else if (provider.setUserFeeDiscount) { + provider.setUserFeeDiscount(feeResolution?.discountBips); + } + if (feeResolution) { this.#deps.debugLogger.log( - 'TradingService: Fee discount set in provider', + 'TradingService: Fee resolution set in provider', { - feeDiscountBips, + feeDiscountBips: feeResolution.discountBips, + feeSource: feeResolution.source, }, ); } @@ -456,12 +470,15 @@ export class TradingService { return await operation(); } finally { // Always clear discount context, even on exception - if (provider.setUserFeeDiscount) { + if (provider.setUserFeeResolution) { + provider.setUserFeeResolution(undefined); + } else if (provider.setUserFeeDiscount) { provider.setUserFeeDiscount(undefined); - this.#deps.debugLogger.log( - 'TradingService: Fee discount cleared from provider', - ); } + this.#deps.debugLogger.log( + 'TradingService: Fee resolution cleared from provider', + ); + release(); } } @@ -541,11 +558,12 @@ export class TradingService { }); // Calculate fee discount at execution time (fresh, secure) - const feeDiscountBips = await this.#calculateFeeDiscountWithMeasurement(); + const feeResolution = await this.#calculateFeeDiscountWithMeasurement(); - this.#deps.debugLogger.log('TradingService: Fee discount calculated', { - feeDiscountBips, - hasDiscount: feeDiscountBips !== undefined, + this.#deps.debugLogger.log('TradingService: Fee resolution calculated', { + feeDiscountBips: feeResolution?.discountBips, + feeSource: feeResolution?.source, + hasDiscount: feeResolution?.discountBips !== undefined, }); this.#deps.debugLogger.log( @@ -606,7 +624,7 @@ export class TradingService { }, PERPS_CONSTANTS.PlaceOrderTimeoutMs); const result = await this.#withFeeDiscount({ provider, - feeDiscountBips, + feeResolution, operation: () => provider.placeOrder(params), }); if (orderSubmissionThresholdTimeoutId !== undefined) { @@ -1110,7 +1128,9 @@ export class TradingService { * * @returns The result of the operation. */ - async #calculateFeeDiscountWithMeasurement(): Promise { + async #calculateFeeDiscountWithMeasurement(): Promise< + PerpsFeeResolution | undefined + > { // Check if controller dependencies are available if (!this.#controllerDeps) { this.#deps.debugLogger.log( @@ -1124,8 +1144,7 @@ export class TradingService { const orderExecutionFeeDiscountStartTime = this.#deps.performance.now(); // Calculate fee discount using messenger pattern (service handles controller access internally) - const discountBips = - await rewardsIntegrationService.calculateUserFeeDiscount(); + const resolution = await rewardsIntegrationService.resolveFee(); const orderExecutionFeeDiscountDuration = this.#deps.performance.now() - orderExecutionFeeDiscountStartTime; @@ -1140,12 +1159,13 @@ export class TradingService { this.#deps.debugLogger.log( 'TradingService: Fee discount API call completed', { - discountBips, + discountBips: resolution.discountBips, + source: resolution.source, duration: `${orderExecutionFeeDiscountDuration.toFixed(0)}ms`, }, ); - return discountBips; + return resolution; } /** @@ -1189,12 +1209,12 @@ export class TradingService { }); // Calculate fee discount only if required dependencies are available - const feeDiscountBips = await this.#calculateFeeDiscountWithMeasurement(); + const feeResolution = await this.#calculateFeeDiscountWithMeasurement(); // Execute order edit with fee discount management const result = await this.#withFeeDiscount({ provider, - feeDiscountBips, + feeResolution, operation: () => provider.editOrder(params), }); @@ -1706,12 +1726,12 @@ export class TradingService { }); // Calculate fee discount with measurement - const feeDiscountBips = await this.#calculateFeeDiscountWithMeasurement(); + const feeResolution = await this.#calculateFeeDiscountWithMeasurement(); // Execute position close with fee discount management result = await this.#withFeeDiscount({ provider, - feeDiscountBips, + feeResolution, operation: () => provider.closePosition(params), }); @@ -1857,12 +1877,11 @@ export class TradingService { // Use batch close if provider supports it (provider handles filtering) if (provider.closePositions) { - const feeDiscountBips = - await this.#calculateFeeDiscountWithMeasurement(); + const feeResolution = await this.#calculateFeeDiscountWithMeasurement(); operationResult = await this.#withFeeDiscount({ provider, - feeDiscountBips, + feeResolution, operation: async () => { if (!provider.closePositions) { throw new Error('closePositions method not available'); @@ -2067,12 +2086,12 @@ export class TradingService { }); // Get fee discount from rewards - const feeDiscountBips = await this.#calculateFeeDiscountWithMeasurement(); + const feeResolution = await this.#calculateFeeDiscountWithMeasurement(); // Execute with fee discount management result = await this.#withFeeDiscount({ provider, - feeDiscountBips, + feeResolution, operation: () => provider.updatePositionTPSL(params), }); @@ -2372,8 +2391,13 @@ export class TradingService { ...this.#buildAttributionProperties(trackingData), }); + const feeResolution = await this.#calculateFeeDiscountWithMeasurement(); // Place flip order (HyperLiquid handles margin transfer automatically) - const result = await provider.placeOrder(orderParams); + const result = await this.#withFeeDiscount({ + provider, + feeResolution, + operation: () => provider.placeOrder(orderParams), + }); const completionDuration = this.#deps.performance.now() - startTime; diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index 45aae00f8b4..a49ee71eb44 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -903,6 +903,10 @@ export type HyperLiquidCredentials = { builderAddressTestnet?: string; /** Builder fee wallet address for mainnet. Empty/omitted = uses BUILDER_FEE_CONFIG default. */ builderAddressMainnet?: string; + /** Dedicated subscription waiver builder for testnet. */ + subscriptionBuilderAddressTestnet?: string; + /** Dedicated subscription waiver builder for mainnet. */ + subscriptionBuilderAddressMainnet?: string; }; export type MYXCredentials = { @@ -1584,6 +1588,10 @@ export type PerpsProvider = { // Fee discount context (optional - for MetaMask reward discounts) setUserFeeDiscount?(discountBips: number | undefined): void; + // Full fee resolution context, including attribution source. + setUserFeeResolution?(resolution: PerpsFeeResolution | undefined): void; + /** Approve the dedicated subscription builder outside order submission. */ + approveSubscriptionBuilderFee?(): Promise; // HIP-3 (Builder-deployed DEXs) operations - optional for backward compatibility /** diff --git a/packages/perps-controller/tests/src/PerpsController.operations.test.ts b/packages/perps-controller/tests/src/PerpsController.operations.test.ts index e88a59be8b5..aeffd0b9d33 100644 --- a/packages/perps-controller/tests/src/PerpsController.operations.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.operations.test.ts @@ -828,6 +828,21 @@ describe('PerpsController', () => { }); describe('fee calculations', () => { + it('approves the subscription builder outside order submission', async () => { + mockProvider.approveSubscriptionBuilderFee = jest + .fn() + .mockResolvedValue(true); + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + await expect(controller.approveSubscriptionBuilderFee()).resolves.toBe( + true, + ); + expect(mockProvider.approveSubscriptionBuilderFee).toHaveBeenCalledTimes( + 1, + ); + }); + it('calculates fees', async () => { const feeParams = { orderType: 'market' as const, @@ -881,13 +896,22 @@ describe('PerpsController', () => { 'getSubscriptionFeeWaiverStatus', ) .mockReturnValue(waiverStatus); + const refresh = jest + .spyOn( + RewardsIntegrationService.prototype, + 'refreshSubscriptionBenefits', + ) + .mockResolvedValue(undefined); markControllerAsInitialized(); controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); await controller.calculateFees(feeParams); - expect(getStatus).toHaveBeenCalled(); + expect(refresh).toHaveBeenCalledTimes(1); + expect(refresh.mock.invocationCallOrder[0]).toBeLessThan( + getStatus.mock.invocationCallOrder[0], + ); expect(mockMarketDataServiceInstance.calculateFees).toHaveBeenCalledWith( expect.objectContaining({ context: expect.objectContaining({ @@ -897,6 +921,7 @@ describe('PerpsController', () => { ); getStatus.mockRestore(); + refresh.mockRestore(); }); it('exposes subscription benefits invalidation to clients', async () => { diff --git a/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts b/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts index 08625431cd4..8895e86be49 100644 --- a/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts +++ b/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts @@ -99,6 +99,8 @@ const createMockProvider = (providerId: string): jest.Mocked => { // Configuration setLiveDataConfig: jest.fn(), setUserFeeDiscount: jest.fn(), + setUserFeeResolution: jest.fn(), + approveSubscriptionBuilderFee: jest.fn().mockResolvedValue(true), // Lifecycle toggleTestnet: jest @@ -628,6 +630,36 @@ describe('AggregatedPerpsProvider', () => { expect(mockHLProvider.setUserFeeDiscount).toHaveBeenCalledWith(1000); expect(mockMYXProvider.setUserFeeDiscount).toHaveBeenCalledWith(1000); }); + + it('preserves the fee source for providers that support full resolutions', () => { + const resolution = { + feeBips: 0, + discountBips: 10000, + source: 'subscription' as const, + subscription: { eligible: true, reason: 'eligible' as const }, + }; + mockMYXProvider.setUserFeeResolution = undefined; + + aggregatedProvider.setUserFeeResolution(resolution); + + expect(mockHLProvider.setUserFeeResolution).toHaveBeenCalledWith( + resolution, + ); + expect(mockMYXProvider.setUserFeeDiscount).toHaveBeenCalledWith(10000); + }); + + it('delegates subscription builder approval to the default provider', async () => { + await expect( + aggregatedProvider.approveSubscriptionBuilderFee(), + ).resolves.toBe(true); + + expect( + mockHLProvider.approveSubscriptionBuilderFee, + ).toHaveBeenCalledTimes(1); + expect( + mockMYXProvider.approveSubscriptionBuilderFee, + ).not.toHaveBeenCalled(); + }); }); describe('Provider Management', () => { diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.builder-fees.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.builder-fees.test.ts index d9d6ff2ba20..509984d7e49 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.builder-fees.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.builder-fees.test.ts @@ -341,6 +341,8 @@ const createTestProvider = ( blocklistMarkets?: string[]; useUnifiedAccount?: boolean; initialAssetMapping?: [string, number][]; + subscriptionBuilderAddressTestnet?: string; + subscriptionBuilderAddressMainnet?: string; } = {}, ): HyperLiquidProvider => new HyperLiquidProvider({ @@ -689,7 +691,7 @@ describe('HyperLiquidProvider', () => { ); }); - it('sets the builder fee to 0 when the resolved discount is a full subscription waiver', async () => { + it('routes an approved subscription waiver through the dedicated builder', async () => { // Builder fee already approved: this test is about the fee value on the // signed payload, not the approval flow. mockClientService.getInfoClient = jest.fn().mockReturnValue( @@ -719,19 +721,182 @@ describe('HyperLiquidProvider', () => { }), ); - // The unified resolver reports a full waiver as a 10000 bips discount, - // which the provider must map to builder.f = 0. + const subscriptionBuilder = '0x2222222222222222222222222222222222222222'; + provider = createTestProvider({ + subscriptionBuilderAddressMainnet: subscriptionBuilder, + }); + await expect(provider.approveSubscriptionBuilderFee()).resolves.toBe( + true, + ); + (exchangeClient.order as jest.Mock).mockClear(); - provider.setUserFeeDiscount(10000); + provider.setUserFeeResolution({ + feeBips: 0, + discountBips: 10000, + source: 'subscription', + subscription: { eligible: true, reason: 'eligible' }, + }); const waived = await provider.placeOrder(orderParams); expect(waived.success).toBe(true); expect(exchangeClient.order).toHaveBeenCalledWith( expect.objectContaining({ - builder: { b: expect.any(String), f: 0 }, + builder: { b: subscriptionBuilder, f: 0 }, + }), + ); + }); + + it('initializes clients before approving the subscription builder', async () => { + const subscriptionBuilder = '0x2222222222222222222222222222222222222222'; + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + maxBuilderFee: jest.fn().mockResolvedValue(0.001), + }), + ); + provider = createTestProvider({ + subscriptionBuilderAddressMainnet: subscriptionBuilder, + }); + + await expect(provider.approveSubscriptionBuilderFee()).resolves.toBe( + true, + ); + + expect(mockClientService.initialize).toHaveBeenCalledTimes(1); + expect(mockClientService.getInfoClient).toHaveBeenCalled(); + }); + + it('does not reuse subscription builder approval after an account switch', async () => { + const accountA = '0x1234567890123456789012345678901234567890'; + const accountB = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'; + const subscriptionBuilder = '0x2222222222222222222222222222222222222222'; + const exchangeClient = mockClientService.getExchangeClient(); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + maxBuilderFee: jest.fn().mockResolvedValue(0.001), }), ); + mockWalletService.getUserAddressWithDefault.mockResolvedValue(accountA); + provider = createTestProvider({ + subscriptionBuilderAddressMainnet: subscriptionBuilder, + }); + + await expect(provider.approveSubscriptionBuilderFee()).resolves.toBe( + true, + ); + + mockWalletService.getUserAddressWithDefault.mockResolvedValue(accountB); + (exchangeClient.order as jest.Mock).mockClear(); + provider.setUserFeeResolution({ + feeBips: 0, + discountBips: 10000, + source: 'subscription', + subscription: { eligible: true, reason: 'eligible' }, + }); + + const result = await provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + }); + + expect(result.success).toBe(true); + expect(exchangeClient.order).toHaveBeenCalledWith( + expect.objectContaining({ + builder: { + b: BUILDER_FEE_CONFIG.MainnetBuilder, + f: BUILDER_FEE_CONFIG.MaxFeeTenthsBps, + }, + }), + ); + }); + + it('keeps subscription approval reads scoped to the initiating account', async () => { + const accountA = '0x1234567890123456789012345678901234567890'; + const accountB = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'; + const subscriptionBuilder = '0x2222222222222222222222222222222222222222'; + let releaseInitialRead: (value: number) => void = () => undefined; + const initialRead = new Promise((resolve) => { + releaseInitialRead = resolve; + }); + let markInitialReadStarted: () => void = () => undefined; + const initialReadStarted = new Promise((resolve) => { + markInitialReadStarted = resolve; + }); + const maxBuilderFee = jest + .fn() + .mockImplementationOnce(() => { + markInitialReadStarted(); + return initialRead; + }) + .mockResolvedValueOnce(0.001); + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(createMockInfoClient({ maxBuilderFee })); + mockWalletService.getUserAddressWithDefault.mockResolvedValue(accountA); + provider = createTestProvider({ + subscriptionBuilderAddressMainnet: subscriptionBuilder, + }); + + const approval = provider.approveSubscriptionBuilderFee(); + await initialReadStarted; + mockWalletService.getUserAddressWithDefault.mockResolvedValue(accountB); + releaseInitialRead(0); + + await expect(approval).resolves.toBe(true); + expect(maxBuilderFee).toHaveBeenNthCalledWith(1, { + user: accountA, + builder: subscriptionBuilder, + }); + expect(maxBuilderFee).toHaveBeenNthCalledWith(2, { + user: accountA, + builder: subscriptionBuilder, + }); + }); + + it('falls back to the standard fee when the subscription builder is not approved', async () => { + const subscriptionBuilder = '0x2222222222222222222222222222222222222222'; + const defaultBuilder = BUILDER_FEE_CONFIG.MainnetBuilder; + const exchangeClient = mockClientService.getExchangeClient(); + const maxBuilderFee = jest.fn().mockResolvedValue(0.001); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + maxBuilderFee, + }), + ); + provider = createTestProvider({ + subscriptionBuilderAddressMainnet: subscriptionBuilder, + }); + provider.setUserFeeResolution({ + feeBips: 0, + discountBips: 10000, + source: 'subscription', + subscription: { eligible: true, reason: 'eligible' }, + }); + + const result = await provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + }); + + expect(result.success).toBe(true); + expect(exchangeClient.order).toHaveBeenCalledWith( + expect.objectContaining({ + builder: { + b: defaultBuilder, + f: BUILDER_FEE_CONFIG.MaxFeeTenthsBps, + }, + }), + ); + expect(maxBuilderFee).not.toHaveBeenCalledWith( + expect.objectContaining({ builder: subscriptionBuilder }), + ); + expect(exchangeClient.approveBuilderFee).not.toHaveBeenCalled(); }); it('includes builder fee and referral setup in TP/SL updates', async () => { diff --git a/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts b/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts index ea3b56ee27d..3e9ae3b1f17 100644 --- a/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts +++ b/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts @@ -416,17 +416,9 @@ describe('RewardsIntegrationService', () => { } }); - it('does not await the benefits network read on the fee resolution path', async () => { - // A benefits read that never settles: if the resolver awaited it, this - // test could not complete. - let releaseBenefits: (value: unknown) => void = () => undefined; + it('does not start a benefits network read on the fee resolution path', async () => { const getPerpsBenefits = wireSubscription( - jest.fn( - async () => - new Promise((resolve) => { - releaseBenefits = resolve; - }), - ), + jest.fn().mockResolvedValue(createBenefits()), ); ( mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock @@ -440,13 +432,10 @@ describe('RewardsIntegrationService', () => { eligible: false, reason: 'not-hydrated', }); - // The refresh was kicked off, just never awaited. - expect(getPerpsBenefits).toHaveBeenCalledTimes(1); - - releaseBenefits(null); + expect(getPerpsBenefits).not.toHaveBeenCalled(); }); - it('serves a stale snapshot while revalidating it in the background', async () => { + it('serves a stale snapshot without refreshing on the cache-read path', async () => { const getPerpsBenefits = wireSubscription( jest.fn().mockResolvedValue(createBenefits()), ); @@ -462,13 +451,17 @@ describe('RewardsIntegrationService', () => { }); expect(getPerpsBenefits).toHaveBeenCalledTimes(1); - // Past it: the stale snapshot is still served, and a refresh is started. + // Past it: the stale snapshot is still served without a request. jest.setSystemTime(NOW + FRESH_MS + 1); expect(service.getSubscriptionFeeWaiverStatus()).toStrictEqual({ eligible: true, reason: 'eligible', remainingNotionalUsd: 5000, }); + expect(getPerpsBenefits).toHaveBeenCalledTimes(1); + + // Preview/lifecycle hydration owns the refresh explicitly. + await service.refreshSubscriptionBenefits(); expect(getPerpsBenefits).toHaveBeenCalledTimes(2); }); @@ -583,7 +576,7 @@ describe('RewardsIntegrationService', () => { expect(getPerpsBenefits).toHaveBeenCalledTimes(1); }); - it('throttles the opportunistic refresh while the benefits read keeps failing', async () => { + it('keeps pure cache reads off the network after a failed refresh', async () => { // A failing read never advances the snapshot timestamp, so without an // attempt-based throttle every caller would start a new request. const getPerpsBenefits = wireSubscription( @@ -602,12 +595,13 @@ describe('RewardsIntegrationService', () => { } expect(getPerpsBenefits).toHaveBeenCalledTimes(1); - // Past the window, exactly one retry is allowed through. + // Past the window, cache reads still cannot retry on their own. jest.setSystemTime(NOW + FRESH_MS + 1); service.getSubscriptionFeeWaiverStatus(); service.getSubscriptionFeeWaiverStatus(); - await Promise.resolve(); + expect(getPerpsBenefits).toHaveBeenCalledTimes(1); + await service.refreshSubscriptionBenefits(); expect(getPerpsBenefits).toHaveBeenCalledTimes(2); }); @@ -626,7 +620,8 @@ describe('RewardsIntegrationService', () => { eligible: false, reason: 'not-hydrated', }); - // Invalidation clears the retry throttle too, so the refetch is immediate. + expect(getPerpsBenefits).toHaveBeenCalledTimes(1); + await service.refreshSubscriptionBenefits(); expect(getPerpsBenefits).toHaveBeenCalledTimes(2); }); @@ -653,8 +648,7 @@ describe('RewardsIntegrationService', () => { eligible: false, reason: 'not-hydrated', }); - // The discarded attempt must not throttle the new identity's first fetch. - expect(getPerpsBenefits).toHaveBeenCalledTimes(2); + expect(getPerpsBenefits).toHaveBeenCalledTimes(1); }); it('starts a fresh read when the next caller arrives while a fenced read is still in flight', async () => { @@ -674,15 +668,16 @@ describe('RewardsIntegrationService', () => { const profileARead = service.refreshSubscriptionBenefits(); service.invalidateSubscriptionBenefits(); - // The documented contract: the next status read starts a fresh fetch. + // Status remains a pure read after invalidation. expect(service.getSubscriptionFeeWaiverStatus()).toStrictEqual({ eligible: false, reason: 'not-hydrated', }); - expect(getPerpsBenefits).toHaveBeenCalledTimes(2); + expect(getPerpsBenefits).toHaveBeenCalledTimes(1); - // Dedupes onto profile B's read, which is what gives us a handle on it. + // Preview/lifecycle hydration starts profile B's independent read. const profileBRead = service.refreshSubscriptionBenefits(); + expect(getPerpsBenefits).toHaveBeenCalledTimes(2); releases.forEach((release) => release(createBenefits())); await Promise.all([profileARead, profileBRead]); diff --git a/packages/perps-controller/tests/src/services/TradingService.placeOrder.timeout.test.ts b/packages/perps-controller/tests/src/services/TradingService.placeOrder.timeout.test.ts index 4ef4129acd3..c8ba09b555f 100644 --- a/packages/perps-controller/tests/src/services/TradingService.placeOrder.timeout.test.ts +++ b/packages/perps-controller/tests/src/services/TradingService.placeOrder.timeout.test.ts @@ -20,7 +20,10 @@ describe('TradingService.placeOrder — order submission timeout', () => { let tradingService: TradingService; let mockDeps: jest.Mocked; let mockProvider: jest.Mocked; - let mockRewardsService: { calculateUserFeeDiscount: jest.Mock }; + let mockRewardsService: { + calculateUserFeeDiscount: jest.Mock; + resolveFee: jest.Mock; + }; let mockContext: ReturnType; let mockReportOrderToDataLake: jest.Mock; @@ -37,6 +40,12 @@ describe('TradingService.placeOrder — order submission timeout', () => { tradingService = new TradingService(mockDeps); mockRewardsService = { calculateUserFeeDiscount: jest.fn().mockResolvedValue(undefined), + resolveFee: jest.fn().mockResolvedValue({ + feeBips: 10, + discountBips: undefined, + source: 'default', + subscription: { eligible: false, reason: 'no-source' }, + }), }; tradingService.setControllerDependencies({ rewardsIntegrationService: mockRewardsService as never, @@ -99,7 +108,6 @@ describe('TradingService.placeOrder — order submission timeout', () => { context: mockContext, reportOrderToDataLake: mockReportOrderToDataLake, }); - // Advance past the threshold, allowing microtasks (fee discount await) to run first await jest.advanceTimersByTimeAsync( PERPS_CONSTANTS.PlaceOrderTimeoutMs + 1, @@ -152,6 +160,9 @@ describe('TradingService.placeOrder — order submission timeout', () => { context: mockContext, reportOrderToDataLake: mockReportOrderToDataLake, }); + const rejection = expect(placeOrderPromise).rejects.toThrow( + 'Provider connection timed out', + ); await jest.advanceTimersByTimeAsync( PERPS_CONSTANTS.PlaceOrderTimeoutMs + 1, @@ -160,9 +171,7 @@ describe('TradingService.placeOrder — order submission timeout', () => { const originalError = new Error('Provider connection timed out'); rejectOrder(originalError); - await expect(placeOrderPromise).rejects.toThrow( - 'Provider connection timed out', - ); + await rejection; const endTraceArgs = (mockDeps.tracer.endTrace as jest.Mock).mock .calls[0][0]; diff --git a/packages/perps-controller/tests/src/services/TradingService.test.ts b/packages/perps-controller/tests/src/services/TradingService.test.ts index f740265150d..cee0872a447 100644 --- a/packages/perps-controller/tests/src/services/TradingService.test.ts +++ b/packages/perps-controller/tests/src/services/TradingService.test.ts @@ -15,6 +15,7 @@ import type { Order, UpdatePositionTPSLParams, PerpsPlatformDependencies, + PerpsFeeResolution, } from '../../../src/types/index.js'; /* eslint-disable */ import { createMockHyperLiquidProvider } from '../../helpers/providerMocks.js'; @@ -36,7 +37,17 @@ describe('TradingService', () => { let mockGetPositions: jest.Mock; let mockGetOpenOrders: jest.Mock; let mockSaveTradeConfiguration: jest.Mock; - let mockRewardsIntegrationService: { calculateUserFeeDiscount: jest.Mock }; + let mockRewardsIntegrationService: { + calculateUserFeeDiscount: jest.Mock; + resolveFee: jest.Mock; + }; + + const defaultFeeResolution: PerpsFeeResolution = { + feeBips: 10, + discountBips: undefined, + source: 'default', + subscription: { eligible: false, reason: 'no-source' }, + }; const createContextWithRewards = (): ServiceContext => createMockServiceContext({ @@ -52,6 +63,17 @@ describe('TradingService', () => { tradingService = new TradingService(mockDeps); mockRewardsIntegrationService = { calculateUserFeeDiscount: jest.fn().mockResolvedValue(undefined), + resolveFee: jest.fn(async () => { + const discountBips = + await mockRewardsIntegrationService.calculateUserFeeDiscount(); + return discountBips === undefined + ? defaultFeeResolution + : { + ...defaultFeeResolution, + discountBips, + source: 'rewards' as const, + }; + }), }; // Set controller dependencies for fee discount calculation tradingService.setControllerDependencies({ @@ -81,6 +103,117 @@ describe('TradingService', () => { }); describe('placeOrder', () => { + it('preserves the subscription source through order construction', async () => { + mockProvider.setUserFeeResolution = jest.fn(); + const subscriptionResolution: PerpsFeeResolution = { + feeBips: 0, + discountBips: 10000, + source: 'subscription', + subscription: { + eligible: true, + reason: 'eligible', + remainingNotionalUsd: 1500, + }, + }; + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }; + mockRewardsIntegrationService.resolveFee.mockResolvedValue( + subscriptionResolution, + ); + mockProvider.placeOrder.mockResolvedValue({ success: true }); + + await tradingService.placeOrder({ + provider: mockProvider, + params: orderParams, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(mockProvider.setUserFeeResolution).toHaveBeenCalledWith( + subscriptionResolution, + ); + expect(mockProvider.setUserFeeResolution).toHaveBeenLastCalledWith( + undefined, + ); + }); + + it('isolates fee resolutions between concurrent orders', async () => { + const subscriptionResolution: PerpsFeeResolution = { + feeBips: 0, + discountBips: 10000, + source: 'subscription', + subscription: { eligible: true, reason: 'eligible' }, + }; + const rewardsResolution: PerpsFeeResolution = { + feeBips: 5, + discountBips: 5000, + source: 'rewards', + subscription: { eligible: false, reason: 'not-entitled' }, + }; + mockRewardsIntegrationService.resolveFee + .mockResolvedValueOnce(subscriptionResolution) + .mockResolvedValueOnce(rewardsResolution); + + let activeResolution: PerpsFeeResolution | undefined; + mockProvider.setUserFeeResolution = jest.fn((resolution) => { + activeResolution = resolution; + }); + let releaseFirst: () => void = () => undefined; + const firstPending = new Promise((resolve) => { + releaseFirst = resolve; + }); + let markFirstStarted: () => void = () => undefined; + const firstStarted = new Promise((resolve) => { + markFirstStarted = resolve; + }); + const observed: Array = []; + mockProvider.placeOrder.mockImplementation(async (params) => { + observed.push(activeResolution); + if (params.symbol === 'BTC') { + markFirstStarted(); + await firstPending; + } + return { success: true }; + }); + + const first = tradingService.placeOrder({ + provider: mockProvider, + params: { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + const second = tradingService.placeOrder({ + provider: mockProvider, + params: { + symbol: 'ETH', + isBuy: true, + size: '1', + orderType: 'market', + }, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + await firstStarted; + expect(mockProvider.placeOrder).toHaveBeenCalledTimes(1); + releaseFirst(); + await Promise.all([first, second]); + + expect(observed).toStrictEqual([ + subscriptionResolution, + rewardsResolution, + ]); + }); + it('places order successfully without fee discount', async () => { const orderParams: OrderParams = { symbol: 'BTC', @@ -2330,6 +2463,31 @@ describe('TradingService', () => { stopLossCount: 0, }; + it('preserves the subscription source for flip orders', async () => { + const resolution: PerpsFeeResolution = { + feeBips: 0, + discountBips: 10000, + source: 'subscription', + subscription: { eligible: true, reason: 'eligible' }, + }; + mockProvider.setUserFeeResolution = jest.fn(); + mockRewardsIntegrationService.resolveFee.mockResolvedValue(resolution); + mockProvider.placeOrder.mockResolvedValue({ success: true }); + + await tradingService.flipPosition({ + provider: mockProvider, + position: mockPosition, + context: mockContext, + }); + + expect(mockProvider.setUserFeeResolution).toHaveBeenCalledWith( + resolution, + ); + expect(mockProvider.setUserFeeResolution).toHaveBeenLastCalledWith( + undefined, + ); + }); + it('places order with 2x position size to flip position', async () => { const mockResult: OrderResult = { success: true, From 250e5739b8a709e98863e2ae4a32e2c605856939 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Thu, 13 Aug 2026 16:54:29 +0800 Subject: [PATCH 7/7] fix: address bugbot comments --- .../src/providers/HyperLiquidProvider.ts | 22 ++++++- .../HyperLiquidProvider.builder-fees.test.ts | 60 +++++++++++++++++++ 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 54300be8f1e..9d1476834f6 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -762,6 +762,8 @@ export class HyperLiquidProvider implements PerpsProvider { readonly #pendingBuilderFeeApprovals = new Map>(); + #subscriptionBuilderApprovalEpoch = 0; + /** Builder approvals keyed by network, account, and builder address. */ readonly #approvedBuilderAddresses = new Set(); @@ -3117,7 +3119,11 @@ export class HyperLiquidProvider implements PerpsProvider { * @returns Whether the builder is approved for the current account. */ async approveSubscriptionBuilderFee(): Promise { + const approvalEpoch = this.#subscriptionBuilderApprovalEpoch; await this.#ensureClientsInitialized(); + if (approvalEpoch !== this.#subscriptionBuilderApprovalEpoch) { + return false; + } const isTestnet = this.#clientService.isTestnetMode(); const network = isTestnet ? 'testnet' : 'mainnet'; const builderAddress = this.#getSubscriptionBuilderAddress(isTestnet); @@ -3153,6 +3159,9 @@ export class HyperLiquidProvider implements PerpsProvider { builderAddress, userAddress, ); + if (approvalEpoch !== this.#subscriptionBuilderApprovalEpoch) { + return; + } if ( currentApproval !== null && currentApproval >= BUILDER_FEE_CONFIG.MaxFeeDecimal @@ -3166,10 +3175,16 @@ export class HyperLiquidProvider implements PerpsProvider { builder: builderAddress, maxFeeRate: BUILDER_FEE_CONFIG.MaxFeeRate, }); + if (approvalEpoch !== this.#subscriptionBuilderApprovalEpoch) { + return; + } const afterApproval = await this.#checkBuilderFeeApproval( builderAddress, userAddress, ); + if (approvalEpoch !== this.#subscriptionBuilderApprovalEpoch) { + return; + } if ( afterApproval === null || afterApproval < BUILDER_FEE_CONFIG.MaxFeeDecimal @@ -3184,7 +3199,7 @@ export class HyperLiquidProvider implements PerpsProvider { try { await approval; - return true; + return this.#approvedBuilderAddresses.has(key); } catch (error) { this.#deps.debugLogger.log( 'HyperLiquidProvider: Subscription builder approval unavailable', @@ -3192,7 +3207,9 @@ export class HyperLiquidProvider implements PerpsProvider { ); return false; } finally { - this.#pendingBuilderFeeApprovals.delete(key); + if (this.#pendingBuilderFeeApprovals.get(key) === approval) { + this.#pendingBuilderFeeApprovals.delete(key); + } } } @@ -10948,6 +10965,7 @@ export class HyperLiquidProvider implements PerpsProvider { // Clear session caches (ensures fresh state on reconnect/account switch) this.#referralCheckCache.clear(); this.#builderFeeCheckCache.clear(); + this.#subscriptionBuilderApprovalEpoch += 1; this.#approvedBuilderAddresses.clear(); this.#userFeeResolution = undefined; this.#userFeeDiscountBips = undefined; diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.builder-fees.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.builder-fees.test.ts index 509984d7e49..b2894e630a3 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.builder-fees.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.builder-fees.test.ts @@ -856,6 +856,66 @@ describe('HyperLiquidProvider', () => { }); }); + it('fences subscription approval across disconnect and preserves reconnect dedupe', async () => { + const subscriptionBuilder = '0x2222222222222222222222222222222222222222'; + let releaseOldRead: (value: number) => void = () => undefined; + const oldRead = new Promise((resolve) => { + releaseOldRead = resolve; + }); + let markOldReadStarted: () => void = () => undefined; + const oldReadStarted = new Promise((resolve) => { + markOldReadStarted = resolve; + }); + let releaseNewRead: (value: number) => void = () => undefined; + const newRead = new Promise((resolve) => { + releaseNewRead = resolve; + }); + let markNewReadStarted: () => void = () => undefined; + const newReadStarted = new Promise((resolve) => { + markNewReadStarted = resolve; + }); + const maxBuilderFee = jest + .fn() + .mockImplementationOnce(() => { + markOldReadStarted(); + return oldRead; + }) + .mockImplementationOnce(() => { + markNewReadStarted(); + return newRead; + }) + .mockResolvedValue(0.001); + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(createMockInfoClient({ maxBuilderFee })); + provider = createTestProvider({ + subscriptionBuilderAddressMainnet: subscriptionBuilder, + }); + + const oldApproval = provider.approveSubscriptionBuilderFee(); + await oldReadStarted; + await provider.disconnect(); + + const newApproval = provider.approveSubscriptionBuilderFee(); + await newReadStarted; + releaseOldRead(0); + + await expect(oldApproval).resolves.toBe(false); + expect( + mockClientService.getExchangeClient().approveBuilderFee, + ).not.toHaveBeenCalled(); + expect(maxBuilderFee).toHaveBeenCalledTimes(2); + + const dedupedApproval = provider.approveSubscriptionBuilderFee(); + await Promise.resolve(); + expect(maxBuilderFee).toHaveBeenCalledTimes(2); + + releaseNewRead(0.001); + await expect( + Promise.all([newApproval, dedupedApproval]), + ).resolves.toStrictEqual([true, true]); + }); + it('falls back to the standard fee when the subscription builder is not approved', async () => { const subscriptionBuilder = '0x2222222222222222222222222222222222222222'; const defaultBuilder = BUILDER_FEE_CONFIG.MainnetBuilder;