Skip to content
Draft
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
8 changes: 8 additions & 0 deletions packages/perps-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Add the optional route-aware `PerpsPlatformDependencies.rewards.getPerpsTradingFeeGrant(scope)` integration and exported `PerpsTradingFeeGrant` type for independent, expiring fee candidates. Candidates carry `providerId` and `isTestnet`; Core accepts only an exact match for the routed resolver scope. The corresponding Mobile integration supplies Hyperliquid mainnet candidates, while Core remains provider-agnostic for future routes such as Lighter. ([#10664](https://github.com/MetaMask/core/pull/10664))
- Add optional `FeeCalculationResult.feeSource`, reporting the winning source (`default`, `rewards`, `grant`, or `subscription`) from the same operation that repriced a fee preview. It is absent when no resolution applies or the placement carries no MetaMask builder fee; submission resolves again against its actual provider route. ([#10664](https://github.com/MetaMask/core/pull/10664))

### Changed

- **BREAKING:** Add `'grant'` to `PerpsFeeSource`; exhaustive consumers must handle the new winner. Keep `getPerpsDiscountForAccount` as `Promise<number | null>` for VIP/season and retrieve grants independently through optional `getPerpsTradingFeeGrant`. ([#10664](https://github.com/MetaMask/core/pull/10664))
- Clients that expose grants return the requested `providerId` and `isTestnet` with an absolute `feeBips` and Unix-millisecond `expiresAt`. Core retrieves both rewards candidates concurrently, validates fee, expiry, and exact route scope after they settle, selects the lowest valid fee after venue quantization, and does not cache grants.
- Restrict grant retrieval to explicitly scoped previews and submissions. Calls without provider scope retain VIP/season, subscription, and default resolution without invoking the optional grant dependency; scoped calls are provider-agnostic and reject mismatched candidates. ([#10664](https://github.com/MetaMask/core/pull/10664))
- **BREAKING:** `OrderFill.pnl` is optional when the venue omits realized PnL. Consumers must preserve missing amounts as unknown when aggregating or displaying fills; only a reported `'0'` is zero ([#10605](https://github.com/MetaMask/core/pull/10605))

### Fixed
Expand Down
46 changes: 46 additions & 0 deletions packages/perps-controller/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,52 @@ The package exports the controller's parameter, result, provider, and
messenger types for client integrations. Provider availability and aggregated
routing are controlled by client configuration and feature flags.

## Rewards discounts and fee previews

The client-owned RewardsController exposes two independent fee candidates.
`getPerpsDiscountForAccount(account, baseFeeBips)` retains its existing
`Promise<number | null>` contract for account-scoped VIP and season discounts.
The optional `getPerpsTradingFeeGrant(scope)` receives the routed provider and
network and returns an absolute fee candidate for that exact scope:

```typescript
import type {
PerpsFeeResolverScope,
PerpsTradingFeeGrant,
} from '@metamask/perps-controller';

const scope: PerpsFeeResolverScope = {
providerId: 'hyperliquid',
isTestnet: false,
};
const grant: PerpsTradingFeeGrant = {
...scope,
feeBips: 3,
expiresAt: Date.now() + 60_000,
};
```

Core retrieves VIP/season and grant concurrently and isolates failures between
them whenever the resolver has an explicit scope. Scope omission fails closed
without calling the grant dependency. After all candidate work settles, Core
validates that `feeBips` is finite and non-negative, `expiresAt` is finite and
still in the future, and the candidate's `providerId` and `isTestnet` exactly
match the requested scope. The client owns authentication, payload validation,
and deciding which routes it supports. The corresponding Mobile integration is
intended to supply candidates only for Hyperliquid mainnet; Core remains
provider-agnostic so clients can add Lighter or other route-scoped grants
without a contract redesign.

Core compares both candidates with the default fee and cached subscription
waiver. Rewards preserves its existing tie with default. A grant wins only when
strictly cheaper than the current winner after Hyperliquid's tenths-of-a-basis-
point quantization, so rewards wins a quantized VIP/grant tie and default beats
a non-reducing grant. Subscription retains the same strictly-cheaper
quantized-tie policy. `PerpsController.calculateFees` exposes the result through
its optional `feeSource`, populated by the same operation that reprices the
preview. This is preview-only attribution; every submission resolves again
against its actual provider route and may have a different winner.

## Error codes

`PERPS_ERROR_CODES` / `PerpsErrorCode` are the structured codes returned to
Expand Down
9 changes: 7 additions & 2 deletions packages/perps-controller/src/PerpsController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6005,8 +6005,13 @@ export class PerpsController extends BaseController<
const orderNotionalUsd = params.amount
? Number.parseFloat(params.amount)
: undefined;
const feeResolution =
await this.#rewardsIntegrationService.resolveFee(orderNotionalUsd);
const feeResolution = await this.#rewardsIntegrationService.resolveFee(
orderNotionalUsd,
{
providerId: provider.protocolId,
isTestnet: this.state.isTestnet,
},
);
// Taken from the resolution rather than read separately: a second read can
// observe a different snapshot if the cache is invalidated or the feature
// flag flips between the two, which would surface metadata describing a
Expand Down
2 changes: 2 additions & 0 deletions packages/perps-controller/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,9 @@ export type {
PerpsSubscriptionUsage,
PerpsSubscriptionFeeWaiverStatus,
PerpsFeeSource,
PerpsFeeResolverScope,
PerpsFeeResolution,
PerpsTradingFeeGrant,
UpdatePositionTPSLParams,
Order,
Funding,
Expand Down
12 changes: 6 additions & 6 deletions packages/perps-controller/src/services/MarketDataService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1351,12 +1351,12 @@ export class MarketDataService {
chargesBuilderFee: fees.chargesMetamaskBuilderFee,
});

// Read-only preview of the same cached benefits snapshot the fee resolver
// reads. Surfacing eligibility and the remaining notional must not mutate
// the cap or the cache.
return context.subscriptionFeeWaiver
? { ...priced, subscription: context.subscriptionFeeWaiver }
: priced;
return {
...priced,
...(context.subscriptionFeeWaiver && {
subscription: context.subscriptionFeeWaiver,
}),
};
} catch (error) {
this.#deps.logger.error(
ensureError(error, 'MarketDataService.calculateFees'),
Expand Down
130 changes: 107 additions & 23 deletions packages/perps-controller/src/services/RewardsIntegrationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@ import {
} from '../constants/perpsConfig.js';
import type {
PerpsFeeResolution,
PerpsFeeResolverScope,
PerpsFeeSource,
PerpsPlatformDependencies,
PerpsSubscriptionBenefits,
PerpsSubscriptionFeeWaiverStatus,
PerpsTradingFeeGrant,
} from '../types/index.js';
import type { PerpsControllerMessengerBase } from '../types/messenger.js';
import { getSelectedEvmAccountFromMessenger } from '../utils/accountUtils.js';
Expand Down Expand Up @@ -53,14 +55,15 @@ type BenefitsSnapshot = {
*
* Sources, all in fee basis points (lowest wins):
* - `default` — {@link BUILDER_FEE_CONFIG}, the fee with no reductions.
* - `rewards` — VIP and season, collapsed into one discount by
* `RewardsController` (`rewards.getPerpsDiscountForAccount`), so this service
* does not re-derive the VIP/season split.
* - `rewards` — the account-scoped VIP and season discount returned by
* `rewards.getPerpsDiscountForAccount`.
* - `grant` — an independent, expiring, route-scoped fee returned by
* `rewards.getPerpsTradingFeeGrant`.
* - `subscription` — `0` bips, but only when the eligibility gate passes on a
* cached read of the profile's benefits.
*
* On a tie the cheaper-to-explain source wins, in the order
* `subscription` > `rewards` > `default`.
* Rewards wins ties with default. Grant and subscription must be strictly
* cheaper after venue quantization to win.
*
* The benefits cache is stale-while-revalidate: fee resolution is a pure read
* of the cached snapshot, while preview and lifecycle callers refresh it
Expand Down Expand Up @@ -153,12 +156,15 @@ export class RewardsIntegrationService {
* Returns discount in basis points (e.g., 6500 = 65% discount)
*
* @param orderNotionalUsd - Order notional (USD), when the caller knows it.
* @param scope - Provider route used to request route-scoped fee sources.
* Omission intentionally excludes grants for compatibility callers.
* @returns The fee discount in basis points, or undefined if no source resolved.
*/
async calculateUserFeeDiscount(
orderNotionalUsd?: number,
scope?: PerpsFeeResolverScope,
): Promise<number | undefined> {
const resolution = await this.resolveFee(orderNotionalUsd);
const resolution = await this.resolveFee(orderNotionalUsd, scope);
return resolution.discountBips;
}

Expand All @@ -182,20 +188,42 @@ export class RewardsIntegrationService {
* source instead.
*
* @param orderNotionalUsd - Order notional (USD), when the caller knows it.
* @param scope - Provider route used to request route-scoped fee sources.
* Omission intentionally excludes grants.
* @returns The winning fee, its source, and the subscription gate outcome.
*/
async resolveFee(orderNotionalUsd?: number): Promise<PerpsFeeResolution> {
const rewardsDiscountBips = await this.#calculateRewardsDiscount();
async resolveFee(
orderNotionalUsd?: number,
scope?: PerpsFeeResolverScope,
): Promise<PerpsFeeResolution> {
const [rewardsDiscount, grantCandidate] = await Promise.all([
this.#calculateRewardsDiscount(),
scope
? this.#getPerpsTradingFeeGrant(scope)
: Promise.resolve<PerpsTradingFeeGrant | null>(null),
]);
// Expiry is deliberately checked only after every concurrent candidate has
// settled. A grant that expires while the account-scoped rewards request is
// in flight must not be selected.
const grant =
scope && isValidTradingFeeGrant(grantCandidate, scope, Date.now())
? grantCandidate
: undefined;
// Pure cache read: subscription benefits must never start a network request
// while an order is being prepared for signing.
const subscription = this.getSubscriptionFeeWaiverStatus();

let feeBips = DEFAULT_FEE_BIPS;
let source: PerpsFeeSource = 'default';

if (rewardsDiscountBips !== undefined) {
const toTenthsBps = (bips: number): number =>
quantizeBuilderFeeTenthsBps(
Math.round((1 - bips / DEFAULT_FEE_BIPS) * BASIS_POINTS_DIVISOR),
);

if (rewardsDiscount !== undefined) {
const rewardsFeeBips =
DEFAULT_FEE_BIPS * (1 - rewardsDiscountBips / BASIS_POINTS_DIVISOR);
DEFAULT_FEE_BIPS * (1 - rewardsDiscount / BASIS_POINTS_DIVISOR);
// `<=` so an equal rewards fee still reports the rewards source, keeping
// a resolved 0% discount distinguishable from an unresolved one.
if (rewardsFeeBips <= feeBips) {
Expand All @@ -204,6 +232,14 @@ export class RewardsIntegrationService {
}
}

// A grant only wins when it lowers the fee the venue will actually charge.
// Rewards therefore keeps a quantized tie, and default keeps a grant that
// does not reduce the on-wire rate.
if (grant && toTenthsBps(grant.feeBips) < toTenthsBps(feeBips)) {
feeBips = grant.feeBips;
source = 'grant';
}

const waiver = resolveSubscriptionWaiverRate({
status: subscription,
maxFeeBips: DEFAULT_FEE_BIPS,
Expand All @@ -213,23 +249,15 @@ export class RewardsIntegrationService {
let subscriptionWaiverKind: PerpsFeeResolution['subscriptionWaiverKind'];
let subscriptionCoveredNotionalUsd: number | undefined;

// The waiver competes like any other source. A full waiver still wins on
// `<=`, but a partial blend only wins when it is genuinely cheaper than the
// rewards discount — the ADR's requirement that subscription be able to
// lose.
//
// Full and partial waivers compete with the rewards fee. Subscription
// only wins when it produces a genuinely cheaper fee on the wire.
// Compared *after* venue quantization, because that is the fee the order
// pays. The builder fee is submitted in integer tenths of a basis point, so
// a blend like 9.9999 bips is cheaper than the 10-bip default in arithmetic
// and identical to it on the wire. Selecting subscription on the raw number
// would label such an order `source: 'subscription'` with a 0-bip discount
// while it pays full price, and would disagree with the cloid marker, which
// already gates on the quantized fee.
const toTenthsBps = (bips: number): number =>
quantizeBuilderFeeTenthsBps(
Math.round((1 - bips / DEFAULT_FEE_BIPS) * BASIS_POINTS_DIVISOR),
);

// Subscription must be strictly cheaper on the wire to claim the order. A
// tie buys the user nothing — the same rate is already available from the
// source that won — while claiming it marks the cloid and spends the
Expand Down Expand Up @@ -259,7 +287,9 @@ export class RewardsIntegrationService {
feeBips,
discountBips,
defaultFeeBips: DEFAULT_FEE_BIPS,
rewardsDiscountBips,
rewardsDiscountBips: rewardsDiscount,
grantFeeBips: grant?.feeBips,
grantExpiresAt: grant?.expiresAt,
orderNotionalUsd,
subscriptionEligible: subscription.eligible,
subscriptionReason: subscription.reason,
Expand Down Expand Up @@ -714,9 +744,9 @@ export class RewardsIntegrationService {
}

/**
* Resolve the rewards (VIP + season) discount for the selected account.
* Resolve the VIP and season discount for the selected account.
*
* @returns The discount in basis points, or undefined when unavailable.
* @returns The numeric discount, or undefined when unavailable.
*/
async #calculateRewardsDiscount(): Promise<number | undefined> {
try {
Expand Down Expand Up @@ -821,6 +851,60 @@ export class RewardsIntegrationService {
return undefined;
}
}

/**
* Read the independent trading-fee grant for a provider route.
*
* @param scope - Exact route requested by the resolver.
* @returns The candidate, or null when unsupported or unavailable.
*/
async #getPerpsTradingFeeGrant(
scope: PerpsFeeResolverScope,
): Promise<PerpsTradingFeeGrant | null> {
if (!this.#deps.rewards.getPerpsTradingFeeGrant) {
return null;
}

try {
return await this.#deps.rewards.getPerpsTradingFeeGrant(scope);
} catch (error) {
this.#deps.logger.error(
ensureError(error, 'RewardsIntegrationService.getPerpsTradingFeeGrant'),
{
tags: { feature: PERPS_CONSTANTS.FeatureName },
context: {
name: 'RewardsIntegrationService.getPerpsTradingFeeGrant',
data: {},
},
},
);
return null;
}
}
}

/**
* Validate a trading-fee grant at the time fee selection occurs.
*
* @param grant - Candidate returned by the client-owned RewardsController.
* @param scope - Exact provider route requested by the resolver.
* @param now - Selection time as Unix milliseconds.
* @returns Whether the candidate is valid, unexpired, and matches the route.
*/
function isValidTradingFeeGrant(
grant: PerpsTradingFeeGrant | null,
scope: PerpsFeeResolverScope,
now: number,
): grant is PerpsTradingFeeGrant {
return Boolean(
grant &&
grant.providerId === scope.providerId &&
grant.isTestnet === scope.isTestnet &&
Number.isFinite(grant.feeBips) &&
grant.feeBips >= 0 &&
Number.isFinite(grant.expiresAt) &&
now < grant.expiresAt,
);
}

/**
Expand Down
Loading
Loading