Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/perps-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1197,6 +1223,8 @@ export type PerpsControllerMethodActions =
| PerpsControllerSubscribeToOICapsAction
| PerpsControllerSetLiveDataConfigAction
| PerpsControllerCalculateFeesAction
| PerpsControllerApproveSubscriptionBuilderFeeAction
| PerpsControllerInvalidateSubscriptionBenefitsAction
| PerpsControllerDisconnectAction
| PerpsControllerStartEligibilityMonitoringAction
| PerpsControllerStopEligibilityMonitoringAction
Expand Down
52 changes: 51 additions & 1 deletion packages/perps-controller/src/PerpsController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -856,6 +856,7 @@ type UserSnapshotContext = {
};

const MESSENGER_EXPOSED_METHODS = [
'approveSubscriptionBuilderFee',
'calculateFees',
'calculateLiquidationPrice',
'calculateMaintenanceMargin',
Expand Down Expand Up @@ -905,6 +906,7 @@ const MESSENGER_EXPOSED_METHODS = [
'getWithdrawalProgress',
'getWithdrawalRoutes',
'init',
'invalidateSubscriptionBenefits',
'isCurrentlyReinitializing',
'isFirstTimeUserOnCurrentNetwork',
'isWatchlistMarket',
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -5154,10 +5168,46 @@ export class PerpsController extends BaseController<
params: FeeCalculationParams,
): Promise<FeeCalculationResult> {
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<boolean> {
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
Expand Down
15 changes: 15 additions & 0 deletions packages/perps-controller/src/constants/perpsConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions packages/perps-controller/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export type {
ProPositionsSortField,
} from './PerpsController.js';
export type {
PerpsControllerApproveSubscriptionBuilderFeeAction,
PerpsControllerCalculateFeesAction,
PerpsControllerCalculateLiquidationPriceAction,
PerpsControllerCalculateMaintenanceMarginAction,
Expand Down Expand Up @@ -98,6 +99,7 @@ export type {
PerpsControllerGetWithdrawalProgressAction,
PerpsControllerGetWithdrawalRoutesAction,
PerpsControllerInitAction,
PerpsControllerInvalidateSubscriptionBenefitsAction,
PerpsControllerIsCurrentlyReinitializingAction,
PerpsControllerIsFirstTimeUserOnCurrentNetworkAction,
PerpsControllerIsWatchlistMarketAction,
Expand Down Expand Up @@ -243,6 +245,11 @@ export type {
MaintenanceMarginParams,
FeeCalculationParams,
FeeCalculationResult,
PerpsSubscriptionBenefits,
Comment thread
cursor[bot] marked this conversation as resolved.
PerpsSubscriptionUsage,
PerpsSubscriptionFeeWaiverStatus,
PerpsFeeSource,
PerpsFeeResolution,
UpdatePositionTPSLParams,
Order,
Funding,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ import type {
WithdrawResult,
RawLedgerUpdate,
PerpsReadOptions,
PerpsFeeResolution,
} from '../types/index.js';

/**
Expand Down Expand Up @@ -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<boolean> {
const provider =
this.#providers.get('hyperliquid') ?? this.#getDefaultProvider();
return provider.approveSubscriptionBuilderFee
? provider.approveSubscriptionBuilderFee()
: false;
}

// ============================================================================
// Lifecycle (Delegate to default provider)
// ============================================================================
Expand Down
Loading