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

- Add observational hard timeout for order submission: tag the `Perps Order Submission` trace and emit a breadcrumb when a provider round-trip exceeds `PlaceOrderTimeoutMs` (60s), without cancelling the in-flight order ([#21217](https://github.com/MetaMask/core/pull/21217))
- Add `HYPERLIQUID_ASSET_NAMES` (a curated `symbol → human-readable name` map, e.g. `BTC → 'Bitcoin'`, `xyz:AAPL → 'Apple'`, `xyz:GOLD → 'Gold'`) and the `getHyperLiquidAssetName(symbol, names?)` helper, both exported from `@metamask/perps-controller/constants`, so clients can match and display markets by full name ([#9082](https://github.com/MetaMask/core/pull/9082))
- HyperLiquid does not expose a per-asset human-readable name; this map is maintained client-side and keyed like `HIP3_ASSET_MARKET_TYPES` (bare `SYMBOL` for crypto, `dex:SYMBOL` for HIP-3). Unmapped assets fall back to their ticker.
- Add `rankMarketsByQuery(markets, query)` and `getMarketMatchRank(market, query)` helpers (and the `MarketMatchRank` enum) for relevance-ranked market search by ticker symbol or human-readable name (exact > prefix > substring, stable within a rank) ([#9082](https://github.com/MetaMask/core/pull/9082))
- Complements the existing unranked `filterMarketsByQuery`; same match semantics (case-insensitive substring on `symbol` and `name`), but ordered by relevance. No fuzzy/phonetic matching.

### Changed

- Deliver HyperLiquid positions, orders, and account/spot balance via per-DEX `clearinghouseState` and `openOrders` subscriptions on all paths, removing the dependency on the deprecated `webData2` snapshot channel ([#9078](https://github.com/MetaMask/core/pull/9078))
- The non-HIP-3 (main-DEX-only) user data path previously used `webData2`, which HyperLiquid is throttling to a 15s push interval and deprecating. It now uses the same sub-second per-DEX subscriptions as the HIP-3 path, with `webData3` retained only for open-interest caps (not latency-sensitive).
- Surface late order completions via trace `reason: 'late_success' | 'late_error'` ([#21217](https://github.com/MetaMask/core/pull/21217))
- `PerpsMarketData.name` returned by `getMarketDataWithPrices()` is now the human-readable market name (resolved via `HYPERLIQUID_ASSET_NAMES`) instead of a copy of the ticker symbol; unmapped assets are unchanged (still equal the symbol) ([#9082](https://github.com/MetaMask/core/pull/9082))
- `transformMarketData` gains an optional `assetNames` parameter (defaults to the bundled map) to override the name source.
- Bump `@metamask/controller-utils` from `^12.1.0` to `^12.2.0` ([#9058](https://github.com/MetaMask/core/pull/9058), [#9083](https://github.com/MetaMask/core/pull/9083))

### Removed
Expand Down
176 changes: 176 additions & 0 deletions packages/perps-controller/src/constants/hyperLiquidConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,182 @@ export const HIP3_ASSET_MARKET_TYPES: Record<string, MarketType> = {
'xyz:DXY': MarketCategory.Forex,
};

/**
* Human-readable market names keyed by HyperLiquid asset symbol.
*
* HyperLiquid does NOT expose a human-readable name per market: the `meta`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HyperLiquid’s meta and perpDexs responses do not provide per asset human readable names, which matches the PR rationale. However, the online docs also list perpAnnotation / perpConciseAnnotations with optional displayName and keywords.

Did we choose the curated map because those annotations are incomplete, optional, too expensive to fetch, or not stable enough?

* universe only returns the ticker (`BTC`, `xyz:TSLA`), and `perpDexs` only
* exposes a `fullName` for the DEX/venue, not the individual asset. This map is
* therefore maintained client-side so that clients (mobile, extension) can:
* - match markets by full name in search ("Bitcoin", "Apple", "Gold"), and
* - display the full name alongside / instead of the ticker.
*
* Keys follow the same convention as {@link HIP3_ASSET_MARKET_TYPES}: bare
* `SYMBOL` for main-DEX crypto and `dex:SYMBOL` for HIP-3 markets. Use
* {@link getHyperLiquidAssetName} to resolve a name with a safe fallback to the
* ticker for unmapped assets.
*
* This list is intentionally curated (not exhaustive): unmapped assets simply
* fall back to their ticker, which matches prior behavior. Add entries as needed.
*/
export const HYPERLIQUID_ASSET_NAMES: Record<string, string> = {
// Main DEX - Crypto majors
BTC: 'Bitcoin',
ETH: 'Ethereum',
SOL: 'Solana',
XRP: 'XRP',
BNB: 'BNB',
DOGE: 'Dogecoin',
ADA: 'Cardano',
AVAX: 'Avalanche',
LINK: 'Chainlink',
LTC: 'Litecoin',
DOT: 'Polkadot',
BCH: 'Bitcoin Cash',
TRX: 'TRON',
MATIC: 'Polygon',
ARB: 'Arbitrum',
OP: 'Optimism',
SUI: 'Sui',
APT: 'Aptos',
ATOM: 'Cosmos',
NEAR: 'NEAR Protocol',
INJ: 'Injective',
TIA: 'Celestia',
SEI: 'Sei',
UNI: 'Uniswap',
AAVE: 'Aave',
MKR: 'Maker',
CRV: 'Curve DAO',
LDO: 'Lido DAO',
PEPE: 'Pepe',
WIF: 'dogwifhat',
BONK: 'Bonk',
SHIB: 'Shiba Inu',
ETC: 'Ethereum Classic',
FIL: 'Filecoin',
HBAR: 'Hedera',
ICP: 'Internet Computer',
STX: 'Stacks',
RUNE: 'THORChain',
TON: 'Toncoin',
KAS: 'Kaspa',
FET: 'Fetch.ai',
ENA: 'Ethena',
JUP: 'Jupiter',
PYTH: 'Pyth Network',
JTO: 'Jito',
STRK: 'Starknet',
BLUR: 'Blur',
GMX: 'GMX',
DYDX: 'dYdX',
HYPE: 'Hyperliquid',

// xyz DEX - Stocks (US)
'xyz:TSLA': 'Tesla',
'xyz:NVDA': 'NVIDIA',
'xyz:INTC': 'Intel',
'xyz:MU': 'Micron Technology',
'xyz:CRCL': 'Circle',
'xyz:HOOD': 'Robinhood',
'xyz:SNDK': 'SanDisk',
'xyz:GOOGL': 'Alphabet (Google)',
'xyz:COIN': 'Coinbase',
'xyz:ORCL': 'Oracle',
'xyz:AMZN': 'Amazon',
'xyz:PLTR': 'Palantir',
'xyz:AAPL': 'Apple',
'xyz:META': 'Meta Platforms',
'xyz:AMD': 'AMD',
'xyz:MSFT': 'Microsoft',
'xyz:BABA': 'Alibaba',
'xyz:RIVN': 'Rivian',
'xyz:NFLX': 'Netflix',
'xyz:COST': 'Costco',
'xyz:LLY': 'Eli Lilly',
'xyz:TSM': 'Taiwan Semiconductor',
'xyz:MSTR': 'Strategy (MicroStrategy)',
'xyz:CRWV': 'CoreWeave',
'xyz:GME': 'GameStop',
'xyz:HIMS': 'Hims & Hers',
'xyz:USAR': 'USA Rare Earth',
'xyz:DKNG': 'DraftKings',
'xyz:RKLB': 'Rocket Lab',
'xyz:MRVL': 'Marvell',
'xyz:ZM': 'Zoom',
'xyz:EBAY': 'eBay',
'xyz:ARM': 'Arm Holdings',
'xyz:BX': 'Blackstone',
'xyz:LITE': 'Lumentum',

// xyz DEX - Stocks (Korea)
'xyz:SKHX': 'SK Hynix',
'xyz:SMSN': 'Samsung Electronics',
'xyz:HYUNDAI': 'Hyundai Motor',

// xyz DEX - Stocks (Japan)
'xyz:SOFTBANK': 'SoftBank Group',
'xyz:KIOXIA': 'Kioxia',

// xyz DEX - Pre-IPO
'xyz:SPCX': 'SpaceX',
'xyz:CBRS': 'Cerebras',
'xyz:IPOP': 'Quantinuum',

// xyz DEX - Indices
'xyz:SP500': 'S&P 500',
'xyz:JP225': 'Nikkei 225',
'xyz:KR200': 'KOSPI 200',
'xyz:VIX': 'CBOE Volatility Index',

// xyz DEX - ETFs
'xyz:EWY': 'iShares MSCI South Korea ETF',
'xyz:EWJ': 'iShares MSCI Japan ETF',
'xyz:EWT': 'iShares MSCI Taiwan ETF',
'xyz:EWZ': 'iShares MSCI Brazil ETF',
'xyz:URNM': 'Sprott Uranium Miners ETF',
'xyz:XLE': 'Energy Select Sector SPDR Fund',

// xyz DEX - Commodities
'xyz:GOLD': 'Gold',
'xyz:SILVER': 'Silver',
'xyz:CL': 'Crude Oil',
'xyz:WTIOIL': 'WTI Crude Oil',
'xyz:COPPER': 'Copper',
'xyz:ALUMINIUM': 'Aluminium',
'xyz:URANIUM': 'Uranium',
'xyz:NATGAS': 'Natural Gas',
'xyz:PLATINUM': 'Platinum',
'xyz:PALLADIUM': 'Palladium',
'xyz:BRENTOIL': 'Brent Crude Oil',

// xyz DEX - Forex
'xyz:EUR': 'Euro',
'xyz:JPY': 'Japanese Yen',
'xyz:GBP': 'British Pound',
'xyz:DXY': 'US Dollar Index',
};

/**
* Resolve the human-readable name for a HyperLiquid market.
*
* Falls back to the ticker symbol when the asset is not present in
* {@link HYPERLIQUID_ASSET_NAMES}, so callers always receive a displayable
* string and unmapped assets keep their prior behavior.
*
* @param symbol - HyperLiquid asset symbol (bare `SYMBOL` for main-DEX crypto,
* `dex:SYMBOL` for HIP-3 markets).
* @param names - Name map to look up against (defaults to the bundled
* {@link HYPERLIQUID_ASSET_NAMES}); injectable for testing/overrides.
* @returns The human-readable name, or the symbol itself when unmapped.
*/
export function getHyperLiquidAssetName(
symbol: string,
names: Record<string, string> = HYPERLIQUID_ASSET_NAMES,
): string {
return names[symbol] ?? symbol;
}

/**
* Testnet-specific HIP-3 DEX configuration
*
Expand Down
3 changes: 3 additions & 0 deletions packages/perps-controller/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -495,7 +495,10 @@ export {
getMarketTypeFilter,
applyMarketFilters,
isHip3Market,
rankMarketsByQuery,
getMarketMatchRank,
} from './utils';
export { MarketMatchRank } from './utils';
export type { MarketPatternMatcher, CompiledMarketPattern } from './utils';
export type {
OrderCalculationsDebugLogger,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
HIP3_ASSET_MARKET_TYPES,
HIP3_FEE_CONFIG,
HIP3_MARGIN_CONFIG,
HYPERLIQUID_ASSET_NAMES,
HYPERLIQUID_WITHDRAWAL_MINUTES,
REFERRAL_CONFIG,
SPOT_ASSET_ID_OFFSET,
Expand Down Expand Up @@ -6816,6 +6817,7 @@ export class HyperLiquidProvider implements PerpsProvider {
},
this.#deps.marketDataFormatters,
HIP3_ASSET_MARKET_TYPES,
HYPERLIQUID_ASSET_NAMES,
);

return this.#cacheFreshMarketDataSnapshot(
Expand Down
1 change: 1 addition & 0 deletions packages/perps-controller/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export * from './hyperLiquidOrderBookProcessor';
export * from './hyperLiquidValidation';
export * from './idUtils';
export * from './marketDataTransform';
export * from './marketSearch';
export * from './marketUtils';
export * from './orderCalculations';
export * from './perpsDiskPersistence';
Expand Down
11 changes: 9 additions & 2 deletions packages/perps-controller/src/utils/marketDataTransform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@
*/
import { hasProperty } from '@metamask/utils';

import { HYPERLIQUID_CONFIG } from '../constants/hyperLiquidConfig';
import {
HYPERLIQUID_CONFIG,
getHyperLiquidAssetName,
} from '../constants/hyperLiquidConfig';
import { PERPS_CONSTANTS } from '../constants/perpsConfig';
import type {
PerpsMarketData,
Expand Down Expand Up @@ -174,12 +177,16 @@ function extractFundingData(params: ExtractFundingDataParams): FundingData {
* @param hyperLiquidData - Raw data from HyperLiquid API
* @param formatters - Injectable formatters for platform-agnostic formatting
* @param assetMarketTypes - Optional mapping of asset symbols to market types
* @param assetNames - Optional mapping of asset symbols to human-readable names.
* Defaults to the bundled HYPERLIQUID_ASSET_NAMES; unmapped assets fall back to
* their ticker symbol.
* @returns Transformed market data ready for UI consumption
*/
export function transformMarketData(
hyperLiquidData: HyperLiquidMarketData,
formatters: MarketDataFormatters,
assetMarketTypes?: Record<string, MarketType>,
assetNames?: Record<string, string>,
): PerpsMarketData[] {
const { universe, assetCtxs, allMids, predictedFundings } = hyperLiquidData;

Expand Down Expand Up @@ -261,7 +268,7 @@ export function transformMarketData(

return {
symbol,
name: symbol,
name: getHyperLiquidAssetName(symbol, assetNames),
maxLeverage: `${asset.maxLeverage}x`,
price: isNaN(currentPrice)
? PERPS_CONSTANTS.FallbackPriceDisplay
Expand Down
111 changes: 111 additions & 0 deletions packages/perps-controller/src/utils/marketSearch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/**
* Market search ranking (TAT-2413).
*
* Provisional, standalone helper layered on the same match semantics as
* `filterMarketsByQuery` (case-insensitive substring on a market's ticker symbol
* and human-readable name). It adds the one thing `filterMarketsByQuery` does
* not: relevance ranking — exact matches first, then prefix, then substring;
* ties keep their input order (stable). No fuzzy/phonetic matching (out of scope
* for v1).
*
* Kept in its own file so it can be promoted or relocated later without touching
* the shared `marketUtils`. A market matches here (rank !== null) iff
* `filterMarketsByQuery` would include it, so the two stay behaviorally aligned.
*
* Portable: no platform-specific imports.
*/
import type { PerpsMarketData } from '../types';

/**
* Relevance tier for a market/query match. Lower values sort first.
*/
export enum MarketMatchRank {
Exact = 0,
Prefix = 1,
Substring = 2,
}

/**
* Rank a single field value against a normalized query.
*
* @param value - Field value (e.g. symbol or name); may be undefined.
* @param query - Already trimmed, lower-cased, non-empty query.
* @returns The match tier, or null when the field does not match.
*/
function fieldRank(
value: string | undefined,
query: string,
): MarketMatchRank | null {
if (!value) {
return null;
}
const normalized = value.toLowerCase();
if (normalized === query) {
return MarketMatchRank.Exact;
}
if (normalized.startsWith(query)) {
return MarketMatchRank.Prefix;
}
if (normalized.includes(query)) {
return MarketMatchRank.Substring;
}
return null;
}

/**
* Compute the best (lowest) relevance rank for a market against a search query,
* considering both its ticker symbol and human-readable name.
*
* @param market - Market to score (uses `symbol` and `name`).
* @param searchQuery - User search text (trimmed/cased internally).
* @returns The match rank, or null when the market does not match (or the query
* is empty/whitespace).
*/
export function getMarketMatchRank(
market: Pick<PerpsMarketData, 'symbol' | 'name'>,
searchQuery: string,
): MarketMatchRank | null {
if (!searchQuery?.trim()) {
return null;
}
const query = searchQuery.toLowerCase().trim();
const ranks = [
fieldRank(market.symbol, query),
fieldRank(market.name, query),
].filter((rank): rank is MarketMatchRank => rank !== null);

return ranks.length > 0 ? Math.min(...ranks) : null;
}

/**
* Filter and rank markets by a search query, matching the human-readable name or
* ticker symbol. Exact matches sort first, then prefix, then substring; markets
* sharing a rank keep their input order (stable). An empty/whitespace query
* returns the markets unchanged (no filtering), matching `filterMarketsByQuery`.
*
* @param markets - Markets to search.
* @param searchQuery - User search text.
* @returns Matching markets ordered by relevance.
*/
export function rankMarketsByQuery(
markets: PerpsMarketData[],
searchQuery: string,
): PerpsMarketData[] {
if (!searchQuery?.trim()) {
return markets;
}
const query = searchQuery.toLowerCase().trim();

const matches: { market: PerpsMarketData; rank: MarketMatchRank }[] = [];
markets.forEach((market) => {
const rank = getMarketMatchRank(market, query);
if (rank !== null) {
matches.push({ market, rank });
}
});

// Stable sort by rank only; Array.prototype.sort is stable in modern engines,
// so equal-rank markets retain their original relative order.
matches.sort((a, b) => a.rank - b.rank);
return matches.map((match) => match.market);
}
Loading
Loading