From d84fd97744aca1bb9e00281b1236d15651ee8327 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Tue, 9 Jun 2026 23:08:56 +0800 Subject: [PATCH 1/3] feat(perps-controller): add shared human-readable market names HyperLiquid exposes no per-asset human-readable name (meta.universe only returns the ticker; perpDexs.fullName is the venue, not the asset). Add a client-maintained HYPERLIQUID_ASSET_NAMES map and getHyperLiquidAssetName helper, exported from /constants so clients can match/display markets by full name. transformMarketData now resolves PerpsMarketData.name via the map (new optional assetNames param), falling back to the ticker. TAT-2413 --- packages/perps-controller/CHANGELOG.md | 7 + .../src/constants/hyperLiquidConfig.ts | 176 ++++++++++++++++++ .../src/providers/HyperLiquidProvider.ts | 2 + .../src/utils/marketDataTransform.ts | 11 +- .../src/utils/marketDataTransform.test.ts | 136 ++++++++++++++ 5 files changed, 330 insertions(+), 2 deletions(-) create mode 100644 packages/perps-controller/tests/src/utils/marketDataTransform.test.ts diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index d0cb9bfc254..0f4e5dd5239 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -7,8 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- 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 (TAT-2413) + - 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. + ### Changed +- `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) (TAT-2413) + - `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.1.1` ([#9058](https://github.com/MetaMask/core/pull/9058)) ## [8.0.0] diff --git a/packages/perps-controller/src/constants/hyperLiquidConfig.ts b/packages/perps-controller/src/constants/hyperLiquidConfig.ts index 2421e6ee085..facad5e6215 100644 --- a/packages/perps-controller/src/constants/hyperLiquidConfig.ts +++ b/packages/perps-controller/src/constants/hyperLiquidConfig.ts @@ -397,6 +397,182 @@ export const HIP3_ASSET_MARKET_TYPES: Record = { '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` + * 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 = { + // 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 = HYPERLIQUID_ASSET_NAMES, +): string { + return names[symbol] ?? symbol; +} + /** * Testnet-specific HIP-3 DEX configuration * diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 95954e852c3..6f2587a67f2 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -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, @@ -6816,6 +6817,7 @@ export class HyperLiquidProvider implements PerpsProvider { }, this.#deps.marketDataFormatters, HIP3_ASSET_MARKET_TYPES, + HYPERLIQUID_ASSET_NAMES, ); return this.#cacheFreshMarketDataSnapshot( diff --git a/packages/perps-controller/src/utils/marketDataTransform.ts b/packages/perps-controller/src/utils/marketDataTransform.ts index 47bcffadfbc..58163d0d6e5 100644 --- a/packages/perps-controller/src/utils/marketDataTransform.ts +++ b/packages/perps-controller/src/utils/marketDataTransform.ts @@ -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, @@ -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, + assetNames?: Record, ): PerpsMarketData[] { const { universe, assetCtxs, allMids, predictedFundings } = hyperLiquidData; @@ -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 diff --git a/packages/perps-controller/tests/src/utils/marketDataTransform.test.ts b/packages/perps-controller/tests/src/utils/marketDataTransform.test.ts new file mode 100644 index 00000000000..962e4554771 --- /dev/null +++ b/packages/perps-controller/tests/src/utils/marketDataTransform.test.ts @@ -0,0 +1,136 @@ +import { + HYPERLIQUID_ASSET_NAMES, + getHyperLiquidAssetName, +} from '../../../src/constants/hyperLiquidConfig'; +import type { MarketDataFormatters } from '../../../src/types'; +import type { + AllMidsResponse, + PerpsAssetCtx, + PerpsUniverse, +} from '../../../src/types/hyperliquid-types'; +import { transformMarketData } from '../../../src/utils/marketDataTransform'; + +// Mock formatters matching the MarketDataFormatters interface +const mockFormatters: MarketDataFormatters = { + formatVolume: (value: number) => `$${value.toFixed(0)}`, + formatPerpsFiat: (value: number) => `$${value.toFixed(2)}`, + formatPercentage: (percent: number) => `${percent.toFixed(2)}%`, + priceRangesUniversal: [], +}; + +/** + * Build a minimal HyperLiquid universe entry. Only the fields read by + * transformMarketData are meaningful; the rest satisfy the SDK type. + * + * @param name - Asset symbol (bare for crypto, `dex:SYMBOL` for HIP-3). + * @returns A PerpsUniverse fixture. + */ +function makeUniverseEntry(name: string): PerpsUniverse { + return { name, szDecimals: 2, maxLeverage: 10, marginTableId: 1 }; +} + +describe('getHyperLiquidAssetName', () => { + it('returns the human-readable name for a mapped main-DEX crypto symbol', () => { + expect(getHyperLiquidAssetName('BTC')).toBe('Bitcoin'); + expect(getHyperLiquidAssetName('ETH')).toBe('Ethereum'); + }); + + it('returns the human-readable name for a mapped HIP-3 symbol', () => { + expect(getHyperLiquidAssetName('xyz:TSLA')).toBe('Tesla'); + expect(getHyperLiquidAssetName('xyz:GOLD')).toBe('Gold'); + }); + + it('falls back to the ticker symbol for an unmapped asset', () => { + expect(getHyperLiquidAssetName('FOO')).toBe('FOO'); + expect(getHyperLiquidAssetName('unknown:BAR')).toBe('unknown:BAR'); + }); + + it('uses an injected name map when provided', () => { + const names = { BTC: 'Bitcoin Override', NEW: 'Brand New' }; + expect(getHyperLiquidAssetName('BTC', names)).toBe('Bitcoin Override'); + expect(getHyperLiquidAssetName('NEW', names)).toBe('Brand New'); + // Falls back to symbol when missing from the injected map. + expect(getHyperLiquidAssetName('ETH', names)).toBe('ETH'); + }); + + it('maps every bundled symbol to a non-empty name', () => { + for (const [symbol, name] of Object.entries(HYPERLIQUID_ASSET_NAMES)) { + expect(typeof symbol).toBe('string'); + expect(name.length).toBeGreaterThan(0); + } + }); +}); + +describe('transformMarketData - human-readable names', () => { + it('populates name from the bundled map for crypto and HIP-3 markets', () => { + const universe: PerpsUniverse[] = [ + makeUniverseEntry('BTC'), + makeUniverseEntry('xyz:AAPL'), + ]; + const allMids: AllMidsResponse = { BTC: '50000', 'xyz:AAPL': '200' }; + + const result = transformMarketData( + { universe, assetCtxs: [], allMids }, + mockFormatters, + ); + + expect(result[0]).toMatchObject({ symbol: 'BTC', name: 'Bitcoin' }); + expect(result[1]).toMatchObject({ symbol: 'xyz:AAPL', name: 'Apple' }); + }); + + it('falls back to the symbol when an asset is not mapped', () => { + const universe: PerpsUniverse[] = [makeUniverseEntry('zzz:UNKNOWN')]; + const allMids: AllMidsResponse = { 'zzz:UNKNOWN': '1' }; + + const result = transformMarketData( + { universe, assetCtxs: [], allMids }, + mockFormatters, + ); + + expect(result[0]).toMatchObject({ + symbol: 'zzz:UNKNOWN', + name: 'zzz:UNKNOWN', + }); + }); + + it('respects an injected assetNames map over the bundled defaults', () => { + const universe: PerpsUniverse[] = [makeUniverseEntry('BTC')]; + const allMids: AllMidsResponse = { BTC: '50000' }; + + const result = transformMarketData( + { universe, assetCtxs: [], allMids }, + mockFormatters, + undefined, + { BTC: 'Custom Bitcoin' }, + ); + + expect(result[0].name).toBe('Custom Bitcoin'); + }); + + it('still reads asset context data alongside the resolved name', () => { + const universe: PerpsUniverse[] = [makeUniverseEntry('BTC')]; + const allMids: AllMidsResponse = { BTC: '50000' }; + const assetCtxs = [ + { + funding: '0.0001', + openInterest: '1000', + prevDayPx: '49000', + dayNtlVlm: '1000000', + markPx: '50000', + midPx: '50000', + oraclePx: '50000', + premium: '0', + impactPxs: ['49990', '50010'], + dayBaseVlm: '20', + }, + ] as unknown as PerpsAssetCtx[]; + + const result = transformMarketData( + { universe, assetCtxs, allMids }, + mockFormatters, + ); + + expect(result[0].name).toBe('Bitcoin'); + expect(result[0].volume).toBe('$1000000'); + }); +}); From f44f075630bc7e0b9e3bfc5f1bcf64bcc0cb779b Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Wed, 10 Jun 2026 07:58:36 +0800 Subject: [PATCH 2/3] feat(perps-controller): add relevance-ranked market search helper Add rankMarketsByQuery and getMarketMatchRank (plus MarketMatchRank enum) for searching markets by ticker symbol or human-readable name, ordered exact > prefix > substring with stable ties. Complements the existing unranked filterMarketsByQuery without changing it; same match semantics, relevance-ordered output. Standalone util so it can be promoted later. TAT-2413 --- packages/perps-controller/CHANGELOG.md | 2 + packages/perps-controller/src/index.ts | 3 + packages/perps-controller/src/utils/index.ts | 1 + .../src/utils/marketSearch.ts | 111 +++++++++++++++++ .../tests/src/utils/marketSearch.test.ts | 115 ++++++++++++++++++ 5 files changed, 232 insertions(+) create mode 100644 packages/perps-controller/src/utils/marketSearch.ts create mode 100644 packages/perps-controller/tests/src/utils/marketSearch.test.ts diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 0f4e5dd5239..164a23bbb04 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 (TAT-2413) - 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) (TAT-2413) + - Complements the existing unranked `filterMarketsByQuery`; same match semantics (case-insensitive substring on `symbol` and `name`), but ordered by relevance. No fuzzy/phonetic matching. ### Changed diff --git a/packages/perps-controller/src/index.ts b/packages/perps-controller/src/index.ts index 040ff121ddc..c82cc7d4e1e 100644 --- a/packages/perps-controller/src/index.ts +++ b/packages/perps-controller/src/index.ts @@ -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, diff --git a/packages/perps-controller/src/utils/index.ts b/packages/perps-controller/src/utils/index.ts index 99cf1cfda9d..95e5e99cae3 100644 --- a/packages/perps-controller/src/utils/index.ts +++ b/packages/perps-controller/src/utils/index.ts @@ -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'; diff --git a/packages/perps-controller/src/utils/marketSearch.ts b/packages/perps-controller/src/utils/marketSearch.ts new file mode 100644 index 00000000000..977846f7158 --- /dev/null +++ b/packages/perps-controller/src/utils/marketSearch.ts @@ -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, + 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); +} diff --git a/packages/perps-controller/tests/src/utils/marketSearch.test.ts b/packages/perps-controller/tests/src/utils/marketSearch.test.ts new file mode 100644 index 00000000000..6c3f15ac869 --- /dev/null +++ b/packages/perps-controller/tests/src/utils/marketSearch.test.ts @@ -0,0 +1,115 @@ +import type { PerpsMarketData } from '../../../src/types'; +import { + MarketMatchRank, + getMarketMatchRank, + rankMarketsByQuery, +} from '../../../src/utils/marketSearch'; + +/** + * Build a minimal market fixture. Only `symbol` and `name` drive search; the + * remaining fields satisfy the PerpsMarketData type. + * + * @param symbol - Ticker symbol (bare for crypto, `dex:SYMBOL` for HIP-3). + * @param name - Human-readable name. + * @returns A PerpsMarketData fixture. + */ +function makeMarket(symbol: string, name: string): PerpsMarketData { + return { + symbol, + name, + maxLeverage: '10x', + price: '$1.00', + change24h: '$0.00', + change24hPercent: '0.00%', + volume: '$0', + }; +} + +describe('getMarketMatchRank', () => { + const btc = makeMarket('BTC', 'Bitcoin'); + + it('ranks an exact symbol or name match as Exact', () => { + expect(getMarketMatchRank(btc, 'BTC')).toBe(MarketMatchRank.Exact); + expect(getMarketMatchRank(btc, 'Bitcoin')).toBe(MarketMatchRank.Exact); + }); + + it('ranks a leading match as Prefix', () => { + expect(getMarketMatchRank(btc, 'bit')).toBe(MarketMatchRank.Prefix); + expect(getMarketMatchRank(btc, 'bt')).toBe(MarketMatchRank.Prefix); + }); + + it('ranks an interior match as Substring', () => { + expect(getMarketMatchRank(btc, 'itco')).toBe(MarketMatchRank.Substring); + }); + + it('is case-insensitive and trims the query', () => { + expect(getMarketMatchRank(btc, ' BITCOIN ')).toBe(MarketMatchRank.Exact); + }); + + it('returns null when nothing matches', () => { + expect(getMarketMatchRank(btc, 'ethereum')).toBeNull(); + }); + + it('returns null for an empty or whitespace query', () => { + expect(getMarketMatchRank(btc, '')).toBeNull(); + expect(getMarketMatchRank(btc, ' ')).toBeNull(); + }); + + it('matches HIP-3 markets by name and by symbol substring', () => { + const tsla = makeMarket('xyz:TSLA', 'Tesla'); + // Full name -> Exact. + expect(getMarketMatchRank(tsla, 'tesla')).toBe(MarketMatchRank.Exact); + // Leading fragment of the name -> Prefix. + expect(getMarketMatchRank(tsla, 'tes')).toBe(MarketMatchRank.Prefix); + // "tsla" only appears inside the dex-prefixed symbol -> Substring. + expect(getMarketMatchRank(tsla, 'tsla')).toBe(MarketMatchRank.Substring); + }); +}); + +describe('rankMarketsByQuery', () => { + it('returns the markets unchanged for an empty or whitespace query', () => { + const markets = [makeMarket('BTC', 'Bitcoin'), makeMarket('ETH', 'Ethereum')]; + expect(rankMarketsByQuery(markets, '')).toBe(markets); + expect(rankMarketsByQuery(markets, ' ')).toBe(markets); + }); + + it('drops non-matching markets', () => { + const markets = [makeMarket('BTC', 'Bitcoin'), makeMarket('ETH', 'Ethereum')]; + const result = rankMarketsByQuery(markets, 'bitcoin'); + expect(result).toHaveLength(1); + expect(result[0].symbol).toBe('BTC'); + }); + + it('orders results exact, then prefix, then substring', () => { + const markets = [ + makeMarket('WETH', 'Wrapped Ether'), // substring of "weth" + makeMarket('ETHFI', 'Ether.fi'), // prefix of "ethfi" + makeMarket('ETH', 'Ethereum'), // exact symbol + ]; + const result = rankMarketsByQuery(markets, 'eth').map((market) => market.symbol); + expect(result).toStrictEqual(['ETH', 'ETHFI', 'WETH']); + }); + + it('keeps input order for markets sharing the same rank (stable)', () => { + const markets = [ + makeMarket('BTC', 'Bitcoin'), // name prefix "bit" + makeMarket('BCH', 'Bitcoin Cash'), // name prefix "bit" + ]; + const result = rankMarketsByQuery(markets, 'bit').map((market) => market.symbol); + expect(result).toStrictEqual(['BTC', 'BCH']); + }); + + it('finds markets by human-readable name (the TAT-2413 case)', () => { + const markets = [ + makeMarket('BTC', 'Bitcoin'), + makeMarket('xyz:AAPL', 'Apple'), + makeMarket('xyz:GOLD', 'Gold'), + ]; + expect(rankMarketsByQuery(markets, 'apple').map((market) => market.symbol)).toStrictEqual( + ['xyz:AAPL'], + ); + expect(rankMarketsByQuery(markets, 'gold').map((market) => market.symbol)).toStrictEqual( + ['xyz:GOLD'], + ); + }); +}); From dc03f5a5d00cbfb7e02e1a5f820474bd93f6bcaf Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Wed, 10 Jun 2026 23:36:52 +0800 Subject: [PATCH 3/3] fix(perps-controller): satisfy CI lint and changelog checks - Format marketSearch.test.ts per oxfmt (lint:misc:check) - Link changelog entries to PR #9082 (changelog:check requires PR links) --- packages/perps-controller/CHANGELOG.md | 6 ++-- .../tests/src/utils/marketSearch.test.ts | 30 ++++++++++++------- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 164a23bbb04..8bc8442903b 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -9,14 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- 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 (TAT-2413) +- 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) (TAT-2413) +- 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 -- `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) (TAT-2413) +- `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.1.1` ([#9058](https://github.com/MetaMask/core/pull/9058)) diff --git a/packages/perps-controller/tests/src/utils/marketSearch.test.ts b/packages/perps-controller/tests/src/utils/marketSearch.test.ts index 6c3f15ac869..a85d8e64987 100644 --- a/packages/perps-controller/tests/src/utils/marketSearch.test.ts +++ b/packages/perps-controller/tests/src/utils/marketSearch.test.ts @@ -68,13 +68,19 @@ describe('getMarketMatchRank', () => { describe('rankMarketsByQuery', () => { it('returns the markets unchanged for an empty or whitespace query', () => { - const markets = [makeMarket('BTC', 'Bitcoin'), makeMarket('ETH', 'Ethereum')]; + const markets = [ + makeMarket('BTC', 'Bitcoin'), + makeMarket('ETH', 'Ethereum'), + ]; expect(rankMarketsByQuery(markets, '')).toBe(markets); expect(rankMarketsByQuery(markets, ' ')).toBe(markets); }); it('drops non-matching markets', () => { - const markets = [makeMarket('BTC', 'Bitcoin'), makeMarket('ETH', 'Ethereum')]; + const markets = [ + makeMarket('BTC', 'Bitcoin'), + makeMarket('ETH', 'Ethereum'), + ]; const result = rankMarketsByQuery(markets, 'bitcoin'); expect(result).toHaveLength(1); expect(result[0].symbol).toBe('BTC'); @@ -86,7 +92,9 @@ describe('rankMarketsByQuery', () => { makeMarket('ETHFI', 'Ether.fi'), // prefix of "ethfi" makeMarket('ETH', 'Ethereum'), // exact symbol ]; - const result = rankMarketsByQuery(markets, 'eth').map((market) => market.symbol); + const result = rankMarketsByQuery(markets, 'eth').map( + (market) => market.symbol, + ); expect(result).toStrictEqual(['ETH', 'ETHFI', 'WETH']); }); @@ -95,7 +103,9 @@ describe('rankMarketsByQuery', () => { makeMarket('BTC', 'Bitcoin'), // name prefix "bit" makeMarket('BCH', 'Bitcoin Cash'), // name prefix "bit" ]; - const result = rankMarketsByQuery(markets, 'bit').map((market) => market.symbol); + const result = rankMarketsByQuery(markets, 'bit').map( + (market) => market.symbol, + ); expect(result).toStrictEqual(['BTC', 'BCH']); }); @@ -105,11 +115,11 @@ describe('rankMarketsByQuery', () => { makeMarket('xyz:AAPL', 'Apple'), makeMarket('xyz:GOLD', 'Gold'), ]; - expect(rankMarketsByQuery(markets, 'apple').map((market) => market.symbol)).toStrictEqual( - ['xyz:AAPL'], - ); - expect(rankMarketsByQuery(markets, 'gold').map((market) => market.symbol)).toStrictEqual( - ['xyz:GOLD'], - ); + expect( + rankMarketsByQuery(markets, 'apple').map((market) => market.symbol), + ).toStrictEqual(['xyz:AAPL']); + expect( + rankMarketsByQuery(markets, 'gold').map((market) => market.symbol), + ).toStrictEqual(['xyz:GOLD']); }); });