diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index f4ed9f0c52..fa24806be2 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -7,8 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Resolve human-readable market names and search keywords from HyperLiquid perp annotations (`perpConciseAnnotations`), layered beneath the curated `HYPERLIQUID_ASSET_NAMES` map ([#9086](https://github.com/MetaMask/core/pull/9086)) + - `getMarketDataWithPrices()` now fetches `perpConciseAnnotations` (session-cached) and applies each asset's annotation `displayName` and `keywords` to the returned markets. Name resolution precedence is curated map > annotation `displayName` > raw ticker symbol, so first-party names always win and annotation display names only fill gaps. The fetch is non-critical: a failure (or an unsupported environment) falls back to the curated map with no keywords, leaving market data intact. + - Exposes the `mergeAssetNamesWithAnnotations(annotations, curatedNames?)` and `extractAssetKeywords(annotations)` helpers (with the `PerpConciseAnnotation` and `PerpConciseAnnotationEntry` types) used to build these overlays. + ### Changed +- `rankMarketsByQuery`, `getMarketMatchRank`, and `filterMarketsByQuery` now also match a market's optional annotation `keywords` — the ranked helpers using the same exact/prefix/substring tiers as `symbol` and `name`, and `filterMarketsByQuery` by the same case-insensitive substring match — so the ranked and unranked search stay aligned ([#9086](https://github.com/MetaMask/core/pull/9086)) +- Add an optional `keywords?: string[]` field to `PerpsMarketData` and an optional `assetKeywords` parameter to `transformMarketData`, both additive (existing callers and consumers are unaffected) ([#9086](https://github.com/MetaMask/core/pull/9086)) - Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) ## [8.1.0] diff --git a/packages/perps-controller/src/constants/hyperLiquidConfig.ts b/packages/perps-controller/src/constants/hyperLiquidConfig.ts index facad5e621..de1cb00b52 100644 --- a/packages/perps-controller/src/constants/hyperLiquidConfig.ts +++ b/packages/perps-controller/src/constants/hyperLiquidConfig.ts @@ -414,6 +414,19 @@ export const HIP3_ASSET_MARKET_TYPES: Record = { * * This list is intentionally curated (not exhaustive): unmapped assets simply * fall back to their ticker, which matches prior behavior. Add entries as needed. + * + * REQUIRED — do not delete this in favor of HyperLiquid's `perpConciseAnnotations` + * endpoint. That endpoint is layered *beneath* this map (see + * `mergeAssetNamesWithAnnotations`), but it cannot replace it. A mainnet probe + * (2026-06-11) found: + * - Main-DEX crypto (BTC, ETH, SOL, …) has **no annotation entries at all**, so + * without this map "bitcoin" would not resolve to / find the `BTC` market. + * - For HIP-3 stocks the annotation `displayName` is usually just the ticker + * again (`xyz:TSLA → "TSLA"`), i.e. lower quality than the curated name + * (`"Tesla"`). + * The annotations' real added value is `keywords` (search hints), which we layer + * on top — not display names. Revisit only if HyperLiquid starts populating + * high-quality per-asset `displayName`s (especially for the main DEX). */ export const HYPERLIQUID_ASSET_NAMES: Record = { // Main DEX - Crypto majors diff --git a/packages/perps-controller/src/index.ts b/packages/perps-controller/src/index.ts index c82cc7d4e1..e6ffe4b865 100644 --- a/packages/perps-controller/src/index.ts +++ b/packages/perps-controller/src/index.ts @@ -474,6 +474,11 @@ export { formatChange, } from './utils'; export type { HyperLiquidMarketData } from './utils'; +export { mergeAssetNamesWithAnnotations, extractAssetKeywords } from './utils'; +export type { + PerpConciseAnnotation, + PerpConciseAnnotationEntry, +} from './utils'; export { getPerpsConnectionAttemptContext, withPerpsConnectionAttemptContext, diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 92380b16f4..98a5908d21 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -2,6 +2,7 @@ import { CaipAccountId, hasProperty } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; import type { ExchangeClient, + InfoClient, UserAbstractionResponse, } from '@nktkas/hyperliquid'; import { v4 as uuidv4 } from 'uuid'; @@ -20,7 +21,6 @@ import { HIP3_ASSET_MARKET_TYPES, HIP3_FEE_CONFIG, HIP3_MARGIN_CONFIG, - HYPERLIQUID_ASSET_NAMES, HYPERLIQUID_WITHDRAWAL_MINUTES, REFERRAL_CONFIG, SPOT_ASSET_ID_OFFSET, @@ -157,6 +157,11 @@ import { validateOrderParams, validateWithdrawalParams, } from '../utils/hyperLiquidValidation'; +import { + mergeAssetNamesWithAnnotations, + extractAssetKeywords, +} from '../utils/marketAnnotations'; +import type { PerpConciseAnnotationEntry } from '../utils/marketAnnotations'; import { transformMarketData } from '../utils/marketDataTransform'; import { compileMarketPattern, @@ -329,6 +334,12 @@ export class HyperLiquidProvider implements PerpsProvider { // Pre-fetched in ensureReadyForTrading() to avoid API failures during order placement #cachedSpotMeta: SpotMetaResponse | null = null; + // Session cache for HyperLiquid perp annotations (`perpConciseAnnotations`), + // used to resolve human-readable display names and search keywords. Optional + // enhancement layered beneath the curated HYPERLIQUID_ASSET_NAMES map; never + // blocks market data. `null` = not yet fetched this session. + #cachedPerpAnnotations: PerpConciseAnnotationEntry[] | null = null; + // Unified DEX discovery cache — single source of truth for all perpDexs() derivatives. // Replaces three separate caches to eliminate desync bugs by construction. // All writes go through #dexDiscoveryCache.update(); readers use .state. @@ -1669,6 +1680,55 @@ export class HyperLiquidProvider implements PerpsProvider { return spotMeta; } + /** + * Fetch HyperLiquid perp annotations with session-based caching. + * + * Annotations (`perpConciseAnnotations`) carry optional, deployer-set + * `displayName` and `keywords` per asset. They layer beneath the curated + * `HYPERLIQUID_ASSET_NAMES` map and are a non-critical display enhancement, so + * any failure — including environments that do not support the endpoint — + * degrades to an empty result rather than throwing. Callers then fall back to + * the curated map with no keywords, leaving market data unaffected. + * + * @param infoClient - The HyperLiquid info client to fetch with. + * @returns The concise annotations, or an empty array when unavailable. + */ + async #getCachedPerpAnnotations( + infoClient: InfoClient, + ): Promise { + if (this.#cachedPerpAnnotations) { + return this.#cachedPerpAnnotations; + } + + try { + const annotations = + (await infoClient.perpConciseAnnotations()) as PerpConciseAnnotationEntry[]; + this.#cachedPerpAnnotations = annotations; + this.#deps.debugLogger.log( + '[getCachedPerpAnnotations] Fetched and cached perp annotations', + { + total: annotations.length, + withDisplayName: annotations.filter(([, a]) => a?.displayName).length, + withKeywords: annotations.filter(([, a]) => a?.keywords?.length) + .length, + }, + ); + return annotations; + } catch (error) { + // Non-critical: market data must still load using the curated name map. + this.#deps.debugLogger.log( + '[getCachedPerpAnnotations] Failed to fetch perp annotations; using curated names only', + { + error: ensureError( + error, + 'HyperLiquidProvider.getCachedPerpAnnotations', + ).message, + }, + ); + return []; + } + } + /** * Fetch perpDexs data with TTL-based caching * Returns deployerFeeScale info needed for dynamic fee calculation @@ -6808,6 +6868,14 @@ export class HyperLiquidProvider implements PerpsProvider { ), }); + // Resolve display names and search keywords from HyperLiquid perp + // annotations, layered beneath the curated map (curated > annotation + // displayName > symbol). Non-critical: a fetch failure yields the curated + // map and no keywords, leaving market data intact. + const annotations = await this.#getCachedPerpAnnotations(infoClient); + const assetNames = mergeAssetNamesWithAnnotations(annotations); + const assetKeywords = extractAssetKeywords(annotations); + // Transform to UI-friendly format using standalone utility const transformedMarketData = transformMarketData( { @@ -6817,7 +6885,8 @@ export class HyperLiquidProvider implements PerpsProvider { }, this.#deps.marketDataFormatters, HIP3_ASSET_MARKET_TYPES, - HYPERLIQUID_ASSET_NAMES, + assetNames, + assetKeywords, ); return this.#cacheFreshMarketDataSnapshot( @@ -8241,6 +8310,7 @@ export class HyperLiquidProvider implements PerpsProvider { // to prevent repeated signing requests across reconnections this.#cachedMetaByDex.clear(); this.#cachedSpotMeta = null; + this.#cachedPerpAnnotations = null; this.#dexDiscoveryCache.reset(); this.#dexDiscoveryComplete = false; diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index 24f8cbbdd4..c8f23225f0 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -405,6 +405,13 @@ export type PerpsMarketData = { * Full token name (e.g., 'Bitcoin', 'Ethereum') */ name: string; + /** + * Optional search keywords sourced from HyperLiquid perp annotations + * (`perpConciseAnnotations`). Used as additional match hints in ranked search + * (e.g. `rankMarketsByQuery`). Absent when the asset has no annotation + * keywords. + */ + keywords?: string[]; /** * Maximum leverage available as formatted string (e.g., '40x', '25x') */ diff --git a/packages/perps-controller/src/utils/index.ts b/packages/perps-controller/src/utils/index.ts index 95e5e99cae..d3f84c1edc 100644 --- a/packages/perps-controller/src/utils/index.ts +++ b/packages/perps-controller/src/utils/index.ts @@ -23,6 +23,7 @@ export { export * from './hyperLiquidOrderBookProcessor'; export * from './hyperLiquidValidation'; export * from './idUtils'; +export * from './marketAnnotations'; export * from './marketDataTransform'; export * from './marketSearch'; export * from './marketUtils'; diff --git a/packages/perps-controller/src/utils/marketAnnotations.ts b/packages/perps-controller/src/utils/marketAnnotations.ts new file mode 100644 index 0000000000..d5076380fc --- /dev/null +++ b/packages/perps-controller/src/utils/marketAnnotations.ts @@ -0,0 +1,115 @@ +/** + * HyperLiquid perp-annotation resolution (TAT-3338). + * + * HyperLiquid exposes optional, deployer-set annotations for perpetual assets via + * the `perpConciseAnnotations` info endpoint (one bulk call returning a + * `[coin, { category, displayName?, keywords? }]` tuple per asset). These provide + * a `displayName` (a frontend-friendly name to use instead of the raw L1 ticker) + * and `keywords` (search hints). + * + * Annotations are optional and deployer-controlled, so they do not replace the + * curated, first-party {@link HYPERLIQUID_ASSET_NAMES} map — they layer beneath + * it. Name resolution precedence is: + * + * curated map > annotation `displayName` > raw ticker symbol + * + * The raw-symbol fallback is applied downstream by + * {@link getHyperLiquidAssetName} (which returns the symbol for any key absent + * from the supplied name map), so these helpers only need to merge the curated + * map over the annotation display names. + * + * Portable: no platform- or SDK-specific imports. The input type mirrors the + * `@nktkas/hyperliquid` `PerpConciseAnnotationsResponse` shape so the provider + * can pass the SDK response through directly, while keeping this module + * dependency-free and unit-testable. + */ +import { HYPERLIQUID_ASSET_NAMES } from '../constants/hyperLiquidConfig'; + +/** + * A single concise annotation for a perpetual asset, mirroring the + * `@nktkas/hyperliquid` `perpConciseAnnotations` entry value. + */ +export type PerpConciseAnnotation = { + /** Classification category assigned to the perpetual. */ + category: string; + /** Display name for frontends to use instead of the L1 name (optional). */ + displayName?: string; + /** Keywords used as hints to match against searches (optional). */ + keywords?: string[]; +}; + +/** + * A `[coin, annotation]` tuple as returned by `perpConciseAnnotations`. + */ +export type PerpConciseAnnotationEntry = [ + coin: string, + annotation: PerpConciseAnnotation, +]; + +/** + * Build a `symbol → human-readable name` map from concise annotations, with the + * curated map taking precedence. + * + * Annotation display names fill in only where the curated map has no entry, so + * first-party curated names always win. Symbols present in neither map are + * omitted, leaving the downstream {@link getHyperLiquidAssetName} symbol fallback + * to apply. The result is suitable to pass as the `assetNames` argument of + * `transformMarketData`. + * + * @param annotations - Concise annotations (e.g. the `perpConciseAnnotations` + * response). When undefined/empty, the curated map is returned unchanged. + * @param curatedNames - Curated first-party names that override annotations + * (defaults to the bundled {@link HYPERLIQUID_ASSET_NAMES}). + * @returns A merged name map where curated entries override annotation display + * names. + */ +export function mergeAssetNamesWithAnnotations( + annotations: PerpConciseAnnotationEntry[] | undefined, + curatedNames: Record = HYPERLIQUID_ASSET_NAMES, +): Record { + if (!annotations?.length) { + return { ...curatedNames }; + } + + const merged: Record = {}; + for (const [coin, annotation] of annotations) { + const displayName = annotation?.displayName?.trim(); + if (displayName) { + merged[coin] = displayName; + } + } + + // Curated names override annotation display names (first-party wins). + return { ...merged, ...curatedNames }; +} + +/** + * Build a `symbol → keywords` map from concise annotations. + * + * Only assets with at least one non-empty keyword are included. The result is + * suitable to pass as the `assetKeywords` argument of `transformMarketData`, + * which surfaces them on `PerpsMarketData.keywords` for ranked search. + * + * @param annotations - Concise annotations (e.g. the `perpConciseAnnotations` + * response). + * @returns A map of asset symbol to its trimmed, non-empty keywords. + */ +export function extractAssetKeywords( + annotations: PerpConciseAnnotationEntry[] | undefined, +): Record { + const result: Record = {}; + if (!annotations?.length) { + return result; + } + + for (const [coin, annotation] of annotations) { + const keywords = annotation?.keywords + ?.map((keyword) => keyword.trim()) + .filter((keyword) => keyword.length > 0); + if (keywords?.length) { + result[coin] = keywords; + } + } + + return result; +} diff --git a/packages/perps-controller/src/utils/marketDataTransform.ts b/packages/perps-controller/src/utils/marketDataTransform.ts index 58163d0d6e..75efeeaf08 100644 --- a/packages/perps-controller/src/utils/marketDataTransform.ts +++ b/packages/perps-controller/src/utils/marketDataTransform.ts @@ -179,7 +179,12 @@ function extractFundingData(params: ExtractFundingDataParams): FundingData { * @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. + * their ticker symbol. To layer HyperLiquid perp-annotation display names beneath + * the curated names, build this map with `mergeAssetNamesWithAnnotations`. + * @param assetKeywords - Optional mapping of asset symbols to search keywords + * (e.g. from `extractAssetKeywords` over `perpConciseAnnotations`). Surfaced on + * `PerpsMarketData.keywords` for ranked search. Assets without keywords are + * unaffected. * @returns Transformed market data ready for UI consumption */ export function transformMarketData( @@ -187,6 +192,7 @@ export function transformMarketData( formatters: MarketDataFormatters, assetMarketTypes?: Record, assetNames?: Record, + assetKeywords?: Record, ): PerpsMarketData[] { const { universe, assetCtxs, allMids, predictedFundings } = hyperLiquidData; @@ -266,9 +272,12 @@ export function transformMarketData( // New markets are always HIP-3 (non-crypto) that haven't been assigned a category yet const isNewMarket = isHip3 && !explicitMarketType; + const keywords = assetKeywords?.[symbol]; + return { symbol, name: getHyperLiquidAssetName(symbol, assetNames), + ...(keywords?.length ? { keywords } : {}), maxLeverage: `${asset.maxLeverage}x`, price: isNaN(currentPrice) ? PERPS_CONSTANTS.FallbackPriceDisplay diff --git a/packages/perps-controller/src/utils/marketSearch.ts b/packages/perps-controller/src/utils/marketSearch.ts index 977846f715..fce7b5da2e 100644 --- a/packages/perps-controller/src/utils/marketSearch.ts +++ b/packages/perps-controller/src/utils/marketSearch.ts @@ -3,10 +3,10 @@ * * 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). + * and human-readable name), additionally matching any optional annotation + * `keywords` (TAT-3338). 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 @@ -54,15 +54,18 @@ function fieldRank( /** * Compute the best (lowest) relevance rank for a market against a search query, - * considering both its ticker symbol and human-readable name. + * considering its ticker symbol, human-readable name, and any annotation + * keywords. Keywords are ranked with the same exact/prefix/substring tiers as + * the symbol and name, so a market matched only via a keyword still participates + * in ranking. * - * @param market - Market to score (uses `symbol` and `name`). + * @param market - Market to score (uses `symbol`, `name`, and `keywords`). * @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, + market: Pick, searchQuery: string, ): MarketMatchRank | null { if (!searchQuery?.trim()) { @@ -72,16 +75,18 @@ export function getMarketMatchRank( const ranks = [ fieldRank(market.symbol, query), fieldRank(market.name, query), + ...(market.keywords ?? []).map((keyword) => fieldRank(keyword, 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`. + * Filter and rank markets by a search query, matching the human-readable name, + * ticker symbol, or any annotation keywords. 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. diff --git a/packages/perps-controller/src/utils/marketUtils.ts b/packages/perps-controller/src/utils/marketUtils.ts index 1e9d51b1d1..820161f31a 100644 --- a/packages/perps-controller/src/utils/marketUtils.ts +++ b/packages/perps-controller/src/utils/marketUtils.ts @@ -351,6 +351,9 @@ export const filterMarketsByQuery = ( return markets.filter( (market) => market.symbol?.toLowerCase().includes(lowerQuery) || - market.name?.toLowerCase().includes(lowerQuery), + market.name?.toLowerCase().includes(lowerQuery) || + market.keywords?.some((keyword) => + keyword.toLowerCase().includes(lowerQuery), + ), ); }; diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.data.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.data.test.ts index abc073ebec..382d348a33 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.data.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.data.test.ts @@ -223,6 +223,7 @@ const createMockInfoClient = (overrides: Record = {}) => ({ ], ]), perpDexs: jest.fn().mockResolvedValue([null]), + perpConciseAnnotations: jest.fn().mockResolvedValue([]), allMids: jest.fn().mockResolvedValue({ BTC: '50000', ETH: '3000' }), frontendOpenOrders: jest.fn().mockResolvedValue([]), referral: jest.fn().mockResolvedValue({ @@ -700,6 +701,55 @@ describe('HyperLiquidProvider', () => { expect(Array.isArray(positions)).toBe(true); expect(positions.length).toBe(0); }); + + it('enriches getMarketDataWithPrices with perp annotation keywords while curated names win', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + perpConciseAnnotations: jest.fn().mockResolvedValue([ + [ + 'BTC', + { + category: 'crypto', + displayName: 'Annotation BTC', + keywords: ['digital gold'], + }, + ], + ['ETH', { category: 'crypto', keywords: ['smart contracts'] }], + ]), + }), + ); + + const markets = await provider.getMarketDataWithPrices(); + const btc = markets.find((market) => market.symbol === 'BTC'); + const eth = markets.find((market) => market.symbol === 'ETH'); + + // Curated HYPERLIQUID_ASSET_NAMES wins over the annotation displayName. + expect(btc?.name).toBe('Bitcoin'); + // Keywords are surfaced for ranked search. + expect(btc?.keywords).toStrictEqual(['digital gold']); + expect(eth?.keywords).toStrictEqual(['smart contracts']); + expect( + mockClientService.getInfoClient().perpConciseAnnotations, + ).toHaveBeenCalled(); + }); + + it('still returns market data when perp annotations fail to load', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + perpConciseAnnotations: jest + .fn() + .mockRejectedValue(new Error('annotations unavailable')), + }), + ); + + const markets = await provider.getMarketDataWithPrices(); + const btc = markets.find((market) => market.symbol === 'BTC'); + + expect(markets.length).toBeGreaterThan(0); + // Falls back to curated names, with no keywords. + expect(btc?.name).toBe('Bitcoin'); + expect(btc?.keywords).toBeUndefined(); + }); }); describe('Withdrawal Operations', () => { diff --git a/packages/perps-controller/tests/src/utils/marketAnnotations.test.ts b/packages/perps-controller/tests/src/utils/marketAnnotations.test.ts new file mode 100644 index 0000000000..9c595ed662 --- /dev/null +++ b/packages/perps-controller/tests/src/utils/marketAnnotations.test.ts @@ -0,0 +1,89 @@ +import { HYPERLIQUID_ASSET_NAMES } from '../../../src/constants/hyperLiquidConfig'; +import type { PerpConciseAnnotationEntry } from '../../../src/utils/marketAnnotations'; +import { + mergeAssetNamesWithAnnotations, + extractAssetKeywords, +} from '../../../src/utils/marketAnnotations'; + +describe('mergeAssetNamesWithAnnotations', () => { + it('returns a copy of the curated map when there are no annotations', () => { + expect(mergeAssetNamesWithAnnotations(undefined)).toStrictEqual( + HYPERLIQUID_ASSET_NAMES, + ); + expect(mergeAssetNamesWithAnnotations([])).toStrictEqual( + HYPERLIQUID_ASSET_NAMES, + ); + // Must be a copy, not the shared reference. + expect(mergeAssetNamesWithAnnotations(undefined)).not.toBe( + HYPERLIQUID_ASSET_NAMES, + ); + }); + + it('lets curated names win over annotation display names', () => { + const curated = { BTC: 'Bitcoin' }; + const annotations: PerpConciseAnnotationEntry[] = [ + ['BTC', { category: 'crypto', displayName: 'Bitcoin (annotation)' }], + ]; + expect(mergeAssetNamesWithAnnotations(annotations, curated)).toStrictEqual({ + BTC: 'Bitcoin', + }); + }); + + it('fills gaps from annotation display names where curated has no entry', () => { + const curated = { BTC: 'Bitcoin' }; + const annotations: PerpConciseAnnotationEntry[] = [ + ['flx:DOGE', { category: 'crypto', displayName: 'Dogecoin' }], + ]; + expect(mergeAssetNamesWithAnnotations(annotations, curated)).toStrictEqual({ + BTC: 'Bitcoin', + 'flx:DOGE': 'Dogecoin', + }); + }); + + it('ignores annotations with a missing or blank display name', () => { + const annotations: PerpConciseAnnotationEntry[] = [ + ['NOPE', { category: 'crypto' }], + ['BLANK', { category: 'crypto', displayName: ' ' }], + ['OK', { category: 'crypto', displayName: ' Trimmed ' }], + ]; + expect(mergeAssetNamesWithAnnotations(annotations, {})).toStrictEqual({ + OK: 'Trimmed', + }); + }); + + it('defaults the curated map to the bundled HYPERLIQUID_ASSET_NAMES', () => { + const annotations: PerpConciseAnnotationEntry[] = [ + ['BTC', { category: 'crypto', displayName: 'Should not override' }], + ]; + const result = mergeAssetNamesWithAnnotations(annotations); + expect(result.BTC).toBe(HYPERLIQUID_ASSET_NAMES.BTC); + }); +}); + +describe('extractAssetKeywords', () => { + it('returns an empty map when there are no annotations', () => { + expect(extractAssetKeywords(undefined)).toStrictEqual({}); + expect(extractAssetKeywords([])).toStrictEqual({}); + }); + + it('collects trimmed, non-empty keywords per asset', () => { + const annotations: PerpConciseAnnotationEntry[] = [ + ['BTC', { category: 'crypto', keywords: [' digital gold ', 'btc'] }], + ]; + expect(extractAssetKeywords(annotations)).toStrictEqual({ + BTC: ['digital gold', 'btc'], + }); + }); + + it('omits assets with no keywords or only blank keywords', () => { + const annotations: PerpConciseAnnotationEntry[] = [ + ['NONE', { category: 'crypto' }], + ['EMPTY', { category: 'crypto', keywords: [] }], + ['BLANK', { category: 'crypto', keywords: [' ', ''] }], + ['OK', { category: 'crypto', keywords: ['valid'] }], + ]; + expect(extractAssetKeywords(annotations)).toStrictEqual({ + OK: ['valid'], + }); + }); +}); diff --git a/packages/perps-controller/tests/src/utils/marketDataTransform.test.ts b/packages/perps-controller/tests/src/utils/marketDataTransform.test.ts index 962e455477..4634c9839d 100644 --- a/packages/perps-controller/tests/src/utils/marketDataTransform.test.ts +++ b/packages/perps-controller/tests/src/utils/marketDataTransform.test.ts @@ -107,6 +107,44 @@ describe('transformMarketData - human-readable names', () => { expect(result[0].name).toBe('Custom Bitcoin'); }); + it('surfaces injected assetKeywords on matching markets only', () => { + const universe: PerpsUniverse[] = [ + makeUniverseEntry('BTC'), + makeUniverseEntry('ETH'), + ]; + const allMids: AllMidsResponse = { BTC: '50000', ETH: '3000' }; + + const result = transformMarketData( + { universe, assetCtxs: [], allMids }, + mockFormatters, + undefined, + undefined, + { BTC: ['digital gold', 'store of value'] }, + ); + + expect(result[0]).toMatchObject({ + symbol: 'BTC', + keywords: ['digital gold', 'store of value'], + }); + // ETH has no keywords entry -> field omitted entirely. + expect(result[1]).not.toHaveProperty('keywords'); + }); + + it('omits keywords when the assetKeywords entry is empty', () => { + const universe: PerpsUniverse[] = [makeUniverseEntry('BTC')]; + const allMids: AllMidsResponse = { BTC: '50000' }; + + const result = transformMarketData( + { universe, assetCtxs: [], allMids }, + mockFormatters, + undefined, + undefined, + { BTC: [] }, + ); + + expect(result[0]).not.toHaveProperty('keywords'); + }); + it('still reads asset context data alongside the resolved name', () => { const universe: PerpsUniverse[] = [makeUniverseEntry('BTC')]; const allMids: AllMidsResponse = { BTC: '50000' }; diff --git a/packages/perps-controller/tests/src/utils/marketSearch.test.ts b/packages/perps-controller/tests/src/utils/marketSearch.test.ts index a85d8e6498..37ff240f66 100644 --- a/packages/perps-controller/tests/src/utils/marketSearch.test.ts +++ b/packages/perps-controller/tests/src/utils/marketSearch.test.ts @@ -64,6 +64,38 @@ describe('getMarketMatchRank', () => { // "tsla" only appears inside the dex-prefixed symbol -> Substring. expect(getMarketMatchRank(tsla, 'tsla')).toBe(MarketMatchRank.Substring); }); + + it('matches annotation keywords with the same exact/prefix/substring tiers', () => { + const btcWithKeywords: PerpsMarketData = { + ...makeMarket('BTC', 'Bitcoin'), + keywords: ['digital gold', 'store of value'], + }; + // Neither symbol nor name matches "digital gold" — only a keyword does. + expect(getMarketMatchRank(btcWithKeywords, 'digital gold')).toBe( + MarketMatchRank.Exact, + ); + expect(getMarketMatchRank(btcWithKeywords, 'digital')).toBe( + MarketMatchRank.Prefix, + ); + expect(getMarketMatchRank(btcWithKeywords, 'of value')).toBe( + MarketMatchRank.Substring, + ); + }); + + it('uses the best (lowest) rank across symbol, name, and keywords', () => { + const ethWithKeyword: PerpsMarketData = { + ...makeMarket('ETH', 'Ethereum'), + // "eth" is a name/symbol prefix, but an exact keyword should win. + keywords: ['eth'], + }; + expect(getMarketMatchRank(ethWithKeyword, 'eth')).toBe( + MarketMatchRank.Exact, + ); + }); + + it('handles markets without keywords', () => { + expect(getMarketMatchRank(btc, 'gold')).toBeNull(); + }); }); describe('rankMarketsByQuery', () => { @@ -109,6 +141,20 @@ describe('rankMarketsByQuery', () => { expect(result).toStrictEqual(['BTC', 'BCH']); }); + it('finds markets by annotation keyword (the TAT-3338 case)', () => { + const markets: PerpsMarketData[] = [ + makeMarket('BTC', 'Bitcoin'), + { + ...makeMarket('xyz:GOLD', 'Gold'), + keywords: ['precious metal', 'xau'], + }, + ]; + // "xau" matches neither symbol nor name — only the keyword. + expect( + rankMarketsByQuery(markets, 'xau').map((market) => market.symbol), + ).toStrictEqual(['xyz:GOLD']); + }); + it('finds markets by human-readable name (the TAT-2413 case)', () => { const markets = [ makeMarket('BTC', 'Bitcoin'), diff --git a/packages/perps-controller/tests/src/utils/marketUtils.test.ts b/packages/perps-controller/tests/src/utils/marketUtils.test.ts index 070f07803f..5bef4bc14b 100644 --- a/packages/perps-controller/tests/src/utils/marketUtils.test.ts +++ b/packages/perps-controller/tests/src/utils/marketUtils.test.ts @@ -1,5 +1,6 @@ import type { PerpsMarketData } from '../../../src/types'; import { + filterMarketsByQuery, getMarketTypeFilter, isHip3Market, matchesCategory, @@ -169,3 +170,30 @@ describe('marketUtils category classification', () => { }); }); }); + +describe('filterMarketsByQuery', () => { + const gold = market({ + symbol: 'xyz:GOLD', + name: 'Gold', + keywords: ['precious metal', 'xau'], + }); + const btc = market({ symbol: 'BTC', name: 'Bitcoin' }); + + it('returns all markets for an empty/whitespace query', () => { + expect(filterMarketsByQuery([gold, btc], ' ')).toStrictEqual([gold, btc]); + }); + + it('matches by symbol or name', () => { + expect(filterMarketsByQuery([gold, btc], 'bitcoin')).toStrictEqual([btc]); + expect(filterMarketsByQuery([gold, btc], 'xyz')).toStrictEqual([gold]); + }); + + it('matches by annotation keyword when symbol and name do not (the TAT-3338 case)', () => { + // "xau" matches neither symbol nor name — only the keyword. + expect(filterMarketsByQuery([gold, btc], 'xau')).toStrictEqual([gold]); + }); + + it('does not match markets without keywords on a keyword-only query', () => { + expect(filterMarketsByQuery([btc], 'xau')).toStrictEqual([]); + }); +});