diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index f8e7df6027..81079a67b6 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- 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 optional `isTradable` boolean to `PriceUpdate`. 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); `undefined` means tradability is unknown and the market should be treated as tradable. + - 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). + ## [8.2.0] ### Added diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index 8e57403634..577a6b8d28 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -832,6 +832,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 @@ -971,6 +976,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( @@ -1277,6 +1283,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: @@ -1718,6 +1725,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: diff --git a/packages/perps-controller/src/constants/hyperLiquidConfig.ts b/packages/perps-controller/src/constants/hyperLiquidConfig.ts index facad5e621..667598b354 100644 --- a/packages/perps-controller/src/constants/hyperLiquidConfig.ts +++ b/packages/perps-controller/src/constants/hyperLiquidConfig.ts @@ -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; /** diff --git a/packages/perps-controller/src/index.ts b/packages/perps-controller/src/index.ts index c82cc7d4e1..e63190f1c7 100644 --- a/packages/perps-controller/src/index.ts +++ b/packages/perps-controller/src/index.ts @@ -470,6 +470,7 @@ export { } from './utils'; export { calculateOpenInterestUSD, + isMarketTradable, transformMarketData, formatChange, } from './utils'; diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 92380b16f4..6aee702685 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -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, @@ -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; @@ -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) @@ -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 diff --git a/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts b/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts index 97cde840cc..300a3bac07 100644 --- a/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts +++ b/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts @@ -16,6 +16,7 @@ import type { SpotStateWsEvent, } from '@nktkas/hyperliquid'; +import { HYPERLIQUID_CONFIG } from '../constants/hyperLiquidConfig'; import { TP_SL_CONFIG, PERPS_CONSTANTS, @@ -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'; @@ -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 @@ -326,6 +333,7 @@ export class HyperLiquidSubscriptionService { enabledDexs?: string[], allowlistMarkets?: string[], blocklistMarkets?: string[], + priceDeviationLimit?: number, ) { this.#clientService = clientService; this.#walletService = walletService; @@ -335,6 +343,8 @@ export class HyperLiquidSubscriptionService { this.#discoveredDexNames = enabledDexs ?? []; this.#allowlistMarkets = allowlistMarkets ?? []; this.#blocklistMarkets = blocklistMarkets ?? []; + this.#priceDeviationLimit = + priceDeviationLimit ?? HYPERLIQUID_CONFIG.OraclePriceDeviationLimit; } /** @@ -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; diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index 24f8cbbdd4..a4c9fa33a5 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -664,6 +664,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 @@ -710,6 +720,20 @@ 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. + * + * 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. + * + * This is computed per provider/protocol from that protocol's own rules; absence + * (`undefined`) means tradability is unknown and the market should be treated as + * tradable. + */ + isTradable?: boolean; providerId?: PerpsProviderType; // Multi-provider: price source (injected by aggregator) }; diff --git a/packages/perps-controller/src/utils/marketDataTransform.ts b/packages/perps-controller/src/utils/marketDataTransform.ts index 58163d0d6e..a8b7381e45 100644 --- a/packages/perps-controller/src/utils/marketDataTransform.ts +++ b/packages/perps-controller/src/utils/marketDataTransform.ts @@ -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 */ diff --git a/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.market-data.test.ts b/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.market-data.test.ts index d87a1380ce..7d86783bac 100644 --- a/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.market-data.test.ts +++ b/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.market-data.test.ts @@ -12,6 +12,7 @@ import type { HyperLiquidClientService } from '../../../src/services/HyperLiquid import { HyperLiquidSubscriptionService } from '../../../src/services/HyperLiquidSubscriptionService'; import type { HyperLiquidWalletService } from '../../../src/services/HyperLiquidWalletService'; import type { + PriceUpdate, SubscribeOrderBookParams, SubscribeOrderFillsParams, SubscribePositionsParams, @@ -2559,4 +2560,124 @@ describe('HyperLiquidSubscriptionService', () => { expect(result2).toBeNull(); }); }); + + describe('Market tradability (isTradable)', () => { + const getLastBtcUpdate = (mockCallback: jest.Mock) => { + const { calls } = mockCallback.mock; + const lastCall = calls[calls.length - 1][0]; + return lastCall.find((update: PriceUpdate) => update.symbol === 'BTC'); + }; + + it('marks a market tradable when the mid price is close to the oracle price', async () => { + // Default mock: mid (allMids) BTC = 50000, oraclePx = 50100 -> ~0.2% deviation + const mockCallback = jest.fn(); + + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: mockCallback, + includeMarketData: true, + }); + + await jest.runAllTimersAsync(); + + expect(getLastBtcUpdate(mockCallback)).toEqual( + expect.objectContaining({ symbol: 'BTC', isTradable: true }), + ); + + unsubscribe(); + }); + + it('marks a market untradable when the mid price deviates more than 95% from the oracle price', async () => { + // mid (allMids) BTC = 50000, oraclePx = 100 -> deviation far beyond the 95% limit + mockSubscriptionClient.activeAssetCtx = jest.fn( + (params: any, callback: any) => { + setTimeout(() => { + callback({ + coin: params.coin, + ctx: { + prevDayPx: '49000', + funding: '0.01', + openInterest: '1000000', + dayNtlVlm: '50000000', + oraclePx: '100', + midPx: '50000', + }, + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const mockCallback = jest.fn(); + + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: mockCallback, + includeMarketData: true, + }); + + await jest.runAllTimersAsync(); + + expect(getLastBtcUpdate(mockCallback)).toEqual( + expect.objectContaining({ symbol: 'BTC', isTradable: false }), + ); + + unsubscribe(); + }); + + it('honors an injected price deviation limit', async () => { + // mid (allMids) BTC = 50000, oraclePx = 40000 -> 25% deviation: tradable under the + // default 0.95 limit, but untradable under an injected 0.1 (10%) limit. + mockSubscriptionClient.activeAssetCtx = jest.fn( + (params: any, callback: any) => { + setTimeout(() => { + callback({ + coin: params.coin, + ctx: { + prevDayPx: '49000', + funding: '0.01', + openInterest: '1000000', + dayNtlVlm: '50000000', + oraclePx: '40000', + midPx: '50000', + }, + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const customService = new HyperLiquidSubscriptionService( + mockClientService, + mockWalletService, + mockDeps, + true, // hip3Enabled + [], // enabledDexs + [], // allowlistMarkets + [], // blocklistMarkets + 0.1, // priceDeviationLimit (10%) + ); + + const mockCallback = jest.fn(); + + const unsubscribe = await customService.subscribeToPrices({ + symbols: ['BTC'], + callback: mockCallback, + includeMarketData: true, + }); + + await jest.runAllTimersAsync(); + + expect(getLastBtcUpdate(mockCallback)).toEqual( + expect.objectContaining({ symbol: 'BTC', isTradable: false }), + ); + + unsubscribe(); + customService.clearAll(); + }); + }); }); diff --git a/packages/perps-controller/tests/src/utils/marketDataTransform.test.ts b/packages/perps-controller/tests/src/utils/marketDataTransform.test.ts index 962e455477..cf5a8fea6e 100644 --- a/packages/perps-controller/tests/src/utils/marketDataTransform.test.ts +++ b/packages/perps-controller/tests/src/utils/marketDataTransform.test.ts @@ -1,5 +1,6 @@ import { HYPERLIQUID_ASSET_NAMES, + HYPERLIQUID_CONFIG, getHyperLiquidAssetName, } from '../../../src/constants/hyperLiquidConfig'; import type { MarketDataFormatters } from '../../../src/types'; @@ -8,7 +9,10 @@ import type { PerpsAssetCtx, PerpsUniverse, } from '../../../src/types/hyperliquid-types'; -import { transformMarketData } from '../../../src/utils/marketDataTransform'; +import { + isMarketTradable, + transformMarketData, +} from '../../../src/utils/marketDataTransform'; // Mock formatters matching the MarketDataFormatters interface const mockFormatters: MarketDataFormatters = { @@ -29,6 +33,79 @@ function makeUniverseEntry(name: string): PerpsUniverse { return { name, szDecimals: 2, maxLeverage: 10, marginTableId: 1 }; } +describe('marketDataTransform', () => { + describe('isMarketTradable', () => { + it('is tradable when mid and oracle prices are equal', () => { + expect(isMarketTradable({ midPrice: 50000, oraclePrice: 50000 })).toBe( + true, + ); + }); + + it('is tradable for small deviations well within the limit', () => { + // 0.2% deviation + expect(isMarketTradable({ midPrice: 50100, oraclePrice: 50000 })).toBe( + true, + ); + }); + + it('is tradable exactly at the deviation limit (inclusive boundary)', () => { + // 95% above the oracle price -> deviation === limit + expect( + isMarketTradable({ midPrice: 50000 * 1.95, oraclePrice: 50000 }), + ).toBe(true); + }); + + it('is untradable when the mid price is more than 95% above the oracle price', () => { + // 96% above the oracle price -> deviation > limit + expect( + isMarketTradable({ midPrice: 50000 * 1.96, oraclePrice: 50000 }), + ).toBe(false); + }); + + it('is untradable when the mid price is more than 95% below the oracle price', () => { + // Mid price near zero relative to oracle -> ~100% deviation + expect(isMarketTradable({ midPrice: 1, oraclePrice: 50000 })).toBe(false); + }); + + it('respects a custom deviation limit', () => { + // 20% deviation with a 10% limit -> untradable + expect( + isMarketTradable({ + midPrice: 120, + oraclePrice: 100, + deviationLimit: 0.1, + }), + ).toBe(false); + // Same deviation with a 50% limit -> tradable + expect( + isMarketTradable({ + midPrice: 120, + oraclePrice: 100, + deviationLimit: 0.5, + }), + ).toBe(true); + }); + + it('uses the HyperLiquid 0.95 default when no limit is provided', () => { + expect(HYPERLIQUID_CONFIG.OraclePriceDeviationLimit).toBe(0.95); + // Just over 95% -> untradable with the default limit + expect(isMarketTradable({ midPrice: 1.96, oraclePrice: 1 })).toBe(false); + }); + + it.each([ + ['mid price undefined', { midPrice: undefined, oraclePrice: 50000 }], + ['oracle price undefined', { midPrice: 50000, oraclePrice: undefined }], + ['mid price NaN', { midPrice: NaN, oraclePrice: 50000 }], + ['oracle price NaN', { midPrice: 50000, oraclePrice: NaN }], + ['mid price zero', { midPrice: 0, oraclePrice: 50000 }], + ['oracle price zero', { midPrice: 50000, oraclePrice: 0 }], + ['oracle price negative', { midPrice: 50000, oraclePrice: -1 }], + ])('defaults to tradable when %s', (_label, params) => { + expect(isMarketTradable(params)).toBe(true); + }); + }); +}); + describe('getHyperLiquidAssetName', () => { it('returns the human-readable name for a mapped main-DEX crypto symbol', () => { expect(getHyperLiquidAssetName('BTC')).toBe('Bitcoin');