diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 58e15499ef..609aa030fe 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -49,9 +49,19 @@ 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 ([#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.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 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, 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. - `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-method-action-types.ts b/packages/perps-controller/src/PerpsController-method-action-types.ts index 7a242c5feb..dcba771ad5 100644 --- a/packages/perps-controller/src/PerpsController-method-action-types.ts +++ b/packages/perps-controller/src/PerpsController-method-action-types.ts @@ -778,6 +778,32 @@ 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. + * + * 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, so it is withheld until preview or lifecycle hydration. + */ +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 +1223,8 @@ export type PerpsControllerMethodActions = | PerpsControllerSubscribeToOICapsAction | PerpsControllerSetLiveDataConfigAction | PerpsControllerCalculateFeesAction + | PerpsControllerApproveSubscriptionBuilderFeeAction + | PerpsControllerInvalidateSubscriptionBenefitsAction | PerpsControllerDisconnectAction | PerpsControllerStartEligibilityMonitoringAction | PerpsControllerStopEligibilityMonitoringAction diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index 84d0d20ca7..2310086b08 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', @@ -905,6 +906,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'getWithdrawalProgress', 'getWithdrawalRoutes', 'init', + 'invalidateSubscriptionBenefits', 'isCurrentlyReinitializing', 'isFirstTimeUserOnCurrentNetwork', 'isWatchlistMarket', @@ -1721,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; @@ -2203,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); @@ -5154,10 +5168,46 @@ export class PerpsController extends BaseController< params: FeeCalculationParams, ): Promise { const provider = this.getActiveProvider(); - const context = this.#createServiceContext('calculateFees'); + // 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', { + subscriptionFeeWaiver: + waiverStatus.reason === 'no-source' ? undefined : waiverStatus, + }); 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. + * + * 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, so it is withheld until preview or lifecycle hydration. + */ + 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/constants/perpsConfig.ts b/packages/perps-controller/src/constants/perpsConfig.ts index 05807168c0..81d222ded4 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 635231ec6b..2436eac820 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, @@ -98,6 +99,7 @@ export type { PerpsControllerGetWithdrawalProgressAction, PerpsControllerGetWithdrawalRoutesAction, PerpsControllerInitAction, + PerpsControllerInvalidateSubscriptionBenefitsAction, PerpsControllerIsCurrentlyReinitializingAction, PerpsControllerIsFirstTimeUserOnCurrentNetworkAction, PerpsControllerIsWatchlistMarketAction, @@ -243,6 +245,11 @@ export type { MaintenanceMarginParams, FeeCalculationParams, FeeCalculationResult, + PerpsSubscriptionBenefits, + PerpsSubscriptionUsage, + PerpsSubscriptionFeeWaiverStatus, + PerpsFeeSource, + PerpsFeeResolution, UpdatePositionTPSLParams, Order, Funding, diff --git a/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts b/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts index 3add88a3ce..22a84842ee 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 08be87d6e6..9d1476834f 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,11 @@ export class HyperLiquidProvider implements PerpsProvider { readonly #pendingBuilderFeeApprovals = new Map>(); + #subscriptionBuilderApprovalEpoch = 0; + + /** Builder approvals keyed by network, account, and builder address. */ + readonly #approvedBuilderAddresses = new Set(); + // Pre-compiled patterns for fast filtering readonly #compiledAllowlistPatterns: CompiledMarketPattern[] = []; @@ -767,6 +775,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 +852,10 @@ export class HyperLiquidProvider implements PerpsProvider { readonly #builderAddressMainnet?: string; + readonly #subscriptionBuilderAddressTestnet?: string; + + readonly #subscriptionBuilderAddressMainnet?: string; + readonly #priceDeviationLimit: number; constructor(options: { @@ -856,11 +870,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 +2366,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 +2702,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 +2712,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 +2924,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 +2967,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 +3010,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 +3022,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 +3045,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 +3065,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 +3111,127 @@ 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 { + 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); + 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 (approvalEpoch !== this.#subscriptionBuilderApprovalEpoch) { + return; + } + 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, + }); + 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 + ) { + throw new Error( + '[HyperLiquidProvider] Subscription builder approval verification failed', + ); + } + this.#approvedBuilderAddresses.add(key); + })(); + this.#pendingBuilderFeeApprovals.set(key, approval); + + try { + await approval; + return this.#approvedBuilderAddresses.has(key); + } catch (error) { + this.#deps.debugLogger.log( + 'HyperLiquidProvider: Subscription builder approval unavailable', + error, + ); + return false; + } finally { + if (this.#pendingBuilderFeeApprovals.get(key) === approval) { + 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 +3974,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 +3989,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 +4716,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 +4823,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 +4839,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 +4858,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 +4990,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 +5004,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 +5022,7 @@ export class HyperLiquidProvider implements PerpsProvider { ], grouping: 'na', builder: { - b: this.#getBuilderAddress(this.#clientService.isTestnetMode()), + b: params.builderAddress, f: params.builderFee, }, }); @@ -5092,6 +5249,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 +5735,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 +6456,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 +6934,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 +10965,10 @@ 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; // NOTE: UnifiedAccountCache is global and NOT cleared on disconnect // to prevent repeated signing requests across reconnections this.#cachedMetaByDex.clear(); @@ -11032,6 +11204,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/MarketDataService.ts b/packages/perps-controller/src/services/MarketDataService.ts index f5553cd37d..df8d9758ae 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 cdaca4f9d4..228e70bbc8 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: 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. */ @@ -22,6 +63,31 @@ export class RewardsIntegrationService { readonly #messenger: PerpsControllerMessengerBase; + /** 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 preview refresh, + * otherwise an outage turns every fee preview into a new request. + */ + #lastAttemptAt: number | undefined; + + /** In-flight 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 * @@ -56,12 +122,238 @@ 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 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 { + const rewardsDiscountBips = await this.#calculateRewardsDiscount(); + // Pure cache read: subscription benefits must never start a network request + // while an order is being prepared for signing. + const subscription = this.getSubscriptionFeeWaiverStatus(); + + let feeBips = DEFAULT_FEE_BIPS; + let source: PerpsFeeSource = 'default'; + + 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. 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. + */ + getSubscriptionFeeWaiverStatus(): PerpsSubscriptionFeeWaiverStatus { + if (!this.#deps.subscription) { + return { eligible: false, reason: 'no-source' }; + } + + const now = Date.now(); + const snapshot = this.#benefitsSnapshot; + const age = snapshot ? now - snapshot.fetchedAt : Infinity; + 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. Preview and + * lifecycle callers invoke this outside order submission. + * + * @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 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. + refresh + .finally(() => { + if (this.#benefitsRefresh === refresh) { + this.#benefitsRefresh = undefined; + } + }) + .catch(() => undefined); + + 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`, so the + * waiver is withheld until a preview or lifecycle caller hydrates it. + */ + 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; + // 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', + ); + } + + /** + * Perform one benefits read and store it, keeping the previous snapshot on + * error. Never rejects, so callers cannot produce an unhandled rejection. + * + * @param source - The injected subscription benefits source. + */ + 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( + '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: {}, + }, + }, + ); + } finally { + // 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(); + } + } + } + + /** + * 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 +415,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 +457,47 @@ 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. + * 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. + */ +function evaluateFeeWaiverGate( + benefits: PerpsSubscriptionBenefits | null, +): PerpsSubscriptionFeeWaiverStatus { + const waiver = benefits?.perpsFeeWaiver; + const { remainingNotionalUsd } = waiver ?? {}; + + // `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 }; + } + + 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 1b2a833c66..8a19411dea 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/services/TradingService.ts b/packages/perps-controller/src/services/TradingService.ts index b2cb7a2cef..f0bee6b908 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 33f7c113e2..a49ee71eb4 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 = { @@ -1249,6 +1253,115 @@ 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 + * - `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 + */ + reason: + | 'eligible' + | 'no-source' + | 'not-hydrated' + | 'stale' + | 'no-subscription' + | '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 = { @@ -1475,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 /** @@ -2028,6 +2145,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/PerpsController.operations.test.ts b/packages/perps-controller/tests/src/PerpsController.operations.test.ts index 2f80d6aee6..aeffd0b9d3 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, @@ -827,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, @@ -861,6 +877,101 @@ 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); + const refresh = jest + .spyOn( + RewardsIntegrationService.prototype, + 'refreshSubscriptionBenefits', + ) + .mockResolvedValue(undefined); + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + await controller.calculateFees(feeParams); + + expect(refresh).toHaveBeenCalledTimes(1); + expect(refresh.mock.invocationCallOrder[0]).toBeLessThan( + getStatus.mock.invocationCallOrder[0], + ); + expect(mockMarketDataServiceInstance.calculateFees).toHaveBeenCalledWith( + expect.objectContaining({ + context: expect.objectContaining({ + subscriptionFeeWaiver: waiverStatus, + }), + }), + ); + + getStatus.mockRestore(); + refresh.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, + 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/providers/AggregatedPerpsProvider.test.ts b/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts index 08625431cd..8895e86be4 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 8e64296eb4..b2894e630a 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,6 +691,274 @@ describe('HyperLiquidProvider', () => { ); }); + 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( + 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, + }, + }), + ); + + const subscriptionBuilder = '0x2222222222222222222222222222222222222222'; + provider = createTestProvider({ + subscriptionBuilderAddressMainnet: subscriptionBuilder, + }); + await expect(provider.approveSubscriptionBuilderFee()).resolves.toBe( + true, + ); + + (exchangeClient.order as jest.Mock).mockClear(); + 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: 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('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; + 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 () => { // 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 068975d113..1fab3de302 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 1b6a05a0e0..3e9ae3b1f1 100644 --- a/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts +++ b/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts @@ -244,6 +244,479 @@ 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', + }, + // `null` is "no subscription to report", not "subscription inactive". + { benefits: null, eligible: false, reason: 'no-subscription' }, + ]; + + ( + 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 start a benefits network read on the fee resolution path', async () => { + const getPerpsBenefits = wireSubscription( + jest.fn().mockResolvedValue(createBenefits()), + ); + ( + 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', + }); + expect(getPerpsBenefits).not.toHaveBeenCalled(); + }); + + it('serves a stale snapshot without refreshing on the cache-read path', 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 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); + }); + + 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 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( + 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, cache reads still cannot retry on their own. + jest.setSystemTime(NOW + FRESH_MS + 1); + service.getSubscriptionFeeWaiverStatus(); + service.getSubscriptionFeeWaiverStatus(); + expect(getPerpsBenefits).toHaveBeenCalledTimes(1); + + await service.refreshSubscriptionBenefits(); + 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', + }); + expect(getPerpsBenefits).toHaveBeenCalledTimes(1); + await service.refreshSubscriptionBenefits(); + 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', + }); + expect(getPerpsBenefits).toHaveBeenCalledTimes(1); + }); + + 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(); + + // Status remains a pure read after invalidation. + expect(service.getSubscriptionFeeWaiverStatus()).toStrictEqual({ + eligible: false, + reason: 'not-hydrated', + }); + expect(getPerpsBenefits).toHaveBeenCalledTimes(1); + + // 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]); + + 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()), + ); + // 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())); + ( + 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 +748,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); }); }); }); 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 4ef4129acd..c8ba09b555 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 f740265150..cee0872a44 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,