Skip to content
Merged
4 changes: 4 additions & 0 deletions packages/perps-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `PerpsMarketData` gains optional `keywords`, `tags`, and `categories` fields.
- Market search (`getMarketMatchRank`, `rankMarketsByQuery`) now indexes the `keywords` field for richer search results.
- `HYPERLIQUID_ASSET_NAMES` and `HIP3_ASSET_MARKET_TYPES` remain intact as fallback for assets absent from the Terminal API.
- Surface per-market trading availability so clients can warn before placing an order that would be rejected ([#9205](https://github.com/MetaMask/core/pull/9205))
- Add an `isTradable` boolean to `PriceUpdate` that defaults to `true`. It is `false` when a market's mid price has drifted past the protocol's oracle-deviation limit (HyperLiquid rejects orders more than 95% away from the reference price, which most often affects HIP-3 markets); a provider with no such rule, or that cannot yet assess tradability, reports `true`.
- Add an optional, protocol-agnostic `fallbackPriceDeviationLimit` to `PerpsControllerConfig` so clients can tune the deviation threshold; each provider applies its own default when omitted.
- Export the pure `isMarketTradable` helper and add `HYPERLIQUID_CONFIG.OraclePriceDeviationLimit` (`0.95`, the HyperLiquid default).

### Changed

Expand Down
8 changes: 8 additions & 0 deletions packages/perps-controller/src/PerpsController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -836,6 +836,11 @@ export class PerpsController extends BaseController<

#hip3ConfigSource: 'remote' | 'fallback' = 'fallback';

// Optional client override for the max market-vs-oracle price deviation before a
// market is reported untradable (PriceUpdate.isTradable). Protocol-agnostic: passed
// through to each provider, which applies its own default when this is undefined.
readonly #priceDeviationLimit?: number;

/**
* Check if MYX provider is enabled via feature flag
* Uses same pattern as other feature flags in FeatureFlagConfigurationService
Expand Down Expand Up @@ -982,6 +987,7 @@ export class PerpsController extends BaseController<
this.#hip3BlocklistMarkets = [
...(clientConfig.fallbackHip3BlocklistMarkets ?? []),
];
this.#priceDeviationLimit = clientConfig.fallbackPriceDeviationLimit;

// Immediately set the fallback region list since RemoteFeatureFlagController is empty by default and takes a moment to populate.
this.setBlockedRegionList(
Expand Down Expand Up @@ -1288,6 +1294,7 @@ export class PerpsController extends BaseController<
hip3Enabled: this.#hip3Enabled,
allowlistMarkets: this.#hip3AllowlistMarkets,
blocklistMarkets: this.#hip3BlocklistMarkets,
priceDeviationLimit: this.#priceDeviationLimit,
platformDependencies: this.#options.infrastructure,
messenger: this.messenger,
builderAddressTestnet:
Expand Down Expand Up @@ -1729,6 +1736,7 @@ export class PerpsController extends BaseController<
hip3Enabled: this.#hip3Enabled,
allowlistMarkets: this.#hip3AllowlistMarkets,
blocklistMarkets: this.#hip3BlocklistMarkets,
priceDeviationLimit: this.#priceDeviationLimit,
platformDependencies: this.#options.infrastructure,
messenger: this.messenger,
builderAddressTestnet:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,13 @@ export const HYPERLIQUID_CONFIG = {
// Exchange name used in predicted funding data
// HyperLiquid uses 'HlPerp' as their perps exchange identifier
ExchangeName: 'HlPerp',
// Maximum allowed deviation of the market (mid) price from the oracle (reference)
// price before HyperLiquid rejects orders. HyperLiquid enforces "Order price cannot
// be more than 95% away from the reference price", which makes markets — most often
// HIP-3 builder-deployed ones — temporarily untradable when the mid price drifts past
// this limit. Expressed as a decimal fraction (0.95 = 95%).
// Protocol rule, not a UI warning threshold (see VALIDATION_THRESHOLDS.PriceDeviation).
OraclePriceDeviationLimit: 0.95,
} as const;

/**
Expand Down
1 change: 1 addition & 0 deletions packages/perps-controller/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,7 @@ export {
} from './utils';
export {
calculateOpenInterestUSD,
isMarketTradable,
transformMarketData,
formatChange,
} from './utils';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
HIP3_FEE_CONFIG,
HIP3_MARGIN_CONFIG,
HYPERLIQUID_ASSET_NAMES,
HYPERLIQUID_CONFIG,
HYPERLIQUID_WITHDRAWAL_MINUTES,
REFERRAL_CONFIG,
SPOT_ASSET_ID_OFFSET,
Expand Down Expand Up @@ -408,11 +409,14 @@ export class HyperLiquidProvider implements PerpsProvider {

readonly #builderAddressMainnet?: string;

readonly #priceDeviationLimit: number;

constructor(options: {
isTestnet?: boolean;
hip3Enabled?: boolean;
allowlistMarkets?: string[];
blocklistMarkets?: string[];
priceDeviationLimit?: number;
useUnifiedAccount?: boolean;
platformDependencies: PerpsPlatformDependencies;
messenger: PerpsControllerMessengerBase;
Expand All @@ -424,6 +428,9 @@ export class HyperLiquidProvider implements PerpsProvider {
this.#messenger = options.messenger;
this.#builderAddressTestnet = options.builderAddressTestnet;
this.#builderAddressMainnet = options.builderAddressMainnet;
this.#priceDeviationLimit =
options.priceDeviationLimit ??
HYPERLIQUID_CONFIG.OraclePriceDeviationLimit;
const isTestnet = options.isTestnet ?? false;

// Dev-friendly defaults: Enable all markets by default for easier testing (discovery mode)
Expand Down Expand Up @@ -458,6 +465,7 @@ export class HyperLiquidProvider implements PerpsProvider {
[], // enabledDexs - will be populated after DEX discovery in buildAssetMapping
this.#allowlistMarkets,
this.#blocklistMarkets,
this.#priceDeviationLimit,
);

// NOTE: Clients are NOT initialized here - they'll be initialized lazily
Expand Down
2 changes: 2 additions & 0 deletions packages/perps-controller/src/providers/MYXProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,8 @@ export class MYXProvider implements PerpsProvider {
price,
timestamp: Date.now(),
percentChange24h: change24h.toFixed(2),
// MYX has no oracle-deviation tradability rule yet, so always report tradable.
isTradable: true,
providerId: 'myx',
};
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
SpotStateWsEvent,
} from '@nktkas/hyperliquid';

import { HYPERLIQUID_CONFIG } from '../constants/hyperLiquidConfig';
import {
TP_SL_CONFIG,
PERPS_CONSTANTS,
Expand Down Expand Up @@ -59,7 +60,10 @@ import {
parseAssetName,
} from '../utils/hyperLiquidAdapter';
import { processBboData } from '../utils/hyperLiquidOrderBookProcessor';
import { calculateOpenInterestUSD } from '../utils/marketDataTransform';
import {
calculateOpenInterestUSD,
isMarketTradable,
} from '../utils/marketDataTransform';
import type { HyperLiquidClientService } from './HyperLiquidClientService';
import type { HyperLiquidWalletService } from './HyperLiquidWalletService';

Expand All @@ -82,6 +86,9 @@ export class HyperLiquidSubscriptionService {

#blocklistMarkets: string[]; // Market filtering (blocklist)

// Max market-vs-oracle price deviation before a market is reported untradable
readonly #priceDeviationLimit: number;

#discoveredDexNames: string[] = []; // DEX order for mapping webData3 perpDexStates indices

// DEX discovery synchronization - allows subscriptions to wait for HIP-3 DEX discovery
Expand Down Expand Up @@ -326,6 +333,7 @@ export class HyperLiquidSubscriptionService {
enabledDexs?: string[],
allowlistMarkets?: string[],
blocklistMarkets?: string[],
priceDeviationLimit?: number,
) {
this.#clientService = clientService;
this.#walletService = walletService;
Expand All @@ -335,6 +343,8 @@ export class HyperLiquidSubscriptionService {
this.#discoveredDexNames = enabledDexs ?? [];
this.#allowlistMarkets = allowlistMarkets ?? [];
this.#blocklistMarkets = blocklistMarkets ?? [];
this.#priceDeviationLimit =
priceDeviationLimit ?? HYPERLIQUID_CONFIG.OraclePriceDeviationLimit;
}

/**
Expand Down Expand Up @@ -2865,6 +2875,15 @@ export class HyperLiquidSubscriptionService {
? marketData?.openInterest
: undefined,
volume24h: hasMarketDataSubscribers ? marketData?.volume24h : undefined,
// Flag markets that are currently untradable because the mid price has drifted
// too far from the oracle price (HyperLiquid rejects such orders). Lets clients
// warn the user before they attempt an order that would fail. Defaults to tradable
// when the oracle price isn't yet cached.
isTradable: isMarketTradable({
midPrice: currentPrice,
oraclePrice: marketData?.oraclePrice,
deviationLimit: this.#priceDeviationLimit,
}),
};

return priceUpdate;
Expand Down
25 changes: 25 additions & 0 deletions packages/perps-controller/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -688,6 +688,16 @@ export type PerpsControllerConfig = {
*/
fallbackHip3BlocklistMarkets?: string[];

/**
* Override for the maximum allowed deviation of a market's price from its oracle
* (reference) price before it is reported as untradable (`PriceUpdate.isTradable`),
* as a decimal fraction (e.g. `0.95` = 95%). Protocol-agnostic: each provider applies
* its own default when omitted (HyperLiquid uses
* `HYPERLIQUID_CONFIG.OraclePriceDeviationLimit`, `0.95`). Lets a client tune the
* threshold without a package release.
*/
fallbackPriceDeviationLimit?: number;

/**
* Per-provider credentials and configuration.
* Nested by provider name so each provider's settings are self-contained
Expand Down Expand Up @@ -734,6 +744,21 @@ export type PriceUpdate = {
funding?: number; // Current funding rate
openInterest?: number; // Open interest in USD
volume24h?: number; // 24h trading volume in USD
/**
* Whether the market is currently tradable. Defaults to `true`.
*
* Some markets — most often HIP-3 builder-deployed ones — become temporarily
* untradable when their market price drifts too far from the oracle price, in which
* case the protocol rejects orders (HyperLiquid: "Order price cannot be more than 95%
* away from the reference price"). Clients use this to proactively show a "trading
* unavailable" warning instead of letting the order fail on submission.
*
* Computed per provider/protocol from that protocol's own rules. It is `false` only
* when a provider determines the market is currently untradable; a provider that has no
* such rule, or cannot assess tradability yet (e.g. before the oracle price is cached),
* reports `true`. The value is always a concrete boolean — never `undefined`.
*/
isTradable: boolean;
providerId?: PerpsProviderType; // Multi-provider: price source (injected by aggregator)
};

Expand Down
52 changes: 52 additions & 0 deletions packages/perps-controller/src/utils/marketDataTransform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,58 @@ export function calculateOpenInterestUSD(
return openInterestNum * priceNum;
}

/**
* Determine whether a market is currently tradable based on how far its market
* (mid) price has drifted from the oracle (reference) price.
*
* HyperLiquid rejects orders when the order price is more than 95% away from the
* reference price ("Order price cannot be more than 95% away from the reference
* price"). This most often affects HIP-3 builder-deployed markets, which can become
* temporarily untradable when their mid price diverges far from the oracle price.
* Clients use this signal to proactively warn the user (e.g. a "trading unavailable"
* banner) instead of letting the order fail on submission.
*
* Note: the deviation limit is a HyperLiquid protocol rule. Other providers may have
* different rules and should compute tradability accordingly.
*
* @param params - The parameters for the tradability check.
* @param params.midPrice - Current market/mid price.
* @param params.oraclePrice - Current oracle/reference price.
* @param params.deviationLimit - Max allowed deviation as a decimal fraction
* (defaults to HyperLiquid's 0.95). A market is untradable when
* `abs(midPrice - oraclePrice) / oraclePrice > deviationLimit`.
* @returns `true` when the market is tradable (or when prices are unavailable, so the
* absence of data never blocks trading); `false` when the deviation exceeds the limit.
*/
export function isMarketTradable(params: {
midPrice: number | undefined;
oraclePrice: number | undefined;
deviationLimit?: number;
}): boolean {
const {
midPrice,
oraclePrice,
deviationLimit = HYPERLIQUID_CONFIG.OraclePriceDeviationLimit,
} = params;

// Without usable prices we cannot assess deviation — default to tradable so missing
// data never blocks the user. A non-positive price means "no data" (e.g. the transient
// zero price emitted before the first real tick), not an untradable market.
if (
midPrice === undefined ||
oraclePrice === undefined ||
isNaN(midPrice) ||
isNaN(oraclePrice) ||
midPrice <= 0 ||
oraclePrice <= 0
) {
return true;
}

const deviation = Math.abs(midPrice - oraclePrice) / oraclePrice;
return deviation <= deviationLimit;
}

/**
* HyperLiquid-specific market data structure
*/
Expand Down
Loading
Loading