diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 3266081f6f..2c08f18be9 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -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 diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index 3ba898fa34..19fcf28418 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -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 @@ -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( @@ -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: @@ -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: 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 f3949fee1d..c88128742d 100644 --- a/packages/perps-controller/src/index.ts +++ b/packages/perps-controller/src/index.ts @@ -472,6 +472,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/providers/MYXProvider.ts b/packages/perps-controller/src/providers/MYXProvider.ts index cdb2494e0c..47845cac67 100644 --- a/packages/perps-controller/src/providers/MYXProvider.ts +++ b/packages/perps-controller/src/providers/MYXProvider.ts @@ -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', }; }); 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 46fe35cf20..160d05039a 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -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 @@ -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) }; diff --git a/packages/perps-controller/src/utils/marketDataTransform.ts b/packages/perps-controller/src/utils/marketDataTransform.ts index 94fe15277f..8ac968d4d5 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/hyperLiquidOrderBookProcessor.test.ts b/packages/perps-controller/tests/src/utils/hyperLiquidOrderBookProcessor.test.ts index f67496d87a..6aa9243a9b 100644 --- a/packages/perps-controller/tests/src/utils/hyperLiquidOrderBookProcessor.test.ts +++ b/packages/perps-controller/tests/src/utils/hyperLiquidOrderBookProcessor.test.ts @@ -30,6 +30,7 @@ describe('hyperLiquidOrderBookProcessor', () => { symbol, price, timestamp: Date.now(), + isTradable: true, })); mockNotifySubscribers = jest.fn(); }); @@ -325,12 +326,14 @@ describe('hyperLiquidOrderBookProcessor', () => { symbol: 'BTC', price: '50000', timestamp: Date.now() - 1000, + isTradable: true, }; const newPrice: PriceUpdate = { symbol: 'BTC', price: '50000', timestamp: Date.now(), + isTradable: true, }; mockCachedPriceData.set('BTC', existingPrice); diff --git a/packages/perps-controller/tests/src/utils/marketDataTransform.test.ts b/packages/perps-controller/tests/src/utils/marketDataTransform.test.ts index f401a1d854..a2f31c55e0 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');