Skip to content
Open
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
8 changes: 8 additions & 0 deletions packages/perps-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
13 changes: 13 additions & 0 deletions packages/perps-controller/src/constants/hyperLiquidConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,19 @@ export const HIP3_ASSET_MARKET_TYPES: Record<string, MarketType> = {
*
* 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<string, string> = {
// Main DEX - Crypto majors
Expand Down
5 changes: 5 additions & 0 deletions packages/perps-controller/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
74 changes: 72 additions & 2 deletions packages/perps-controller/src/providers/HyperLiquidProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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<PerpConciseAnnotationEntry[]> {
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
Expand Down Expand Up @@ -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(
{
Expand All @@ -6817,7 +6885,8 @@ export class HyperLiquidProvider implements PerpsProvider {
},
this.#deps.marketDataFormatters,
HIP3_ASSET_MARKET_TYPES,
HYPERLIQUID_ASSET_NAMES,
assetNames,
assetKeywords,
);

return this.#cacheFreshMarketDataSnapshot(
Expand Down Expand Up @@ -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;

Expand Down
7 changes: 7 additions & 0 deletions packages/perps-controller/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
*/
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 @@ -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';
Expand Down
115 changes: 115 additions & 0 deletions packages/perps-controller/src/utils/marketAnnotations.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = HYPERLIQUID_ASSET_NAMES,
): Record<string, string> {
if (!annotations?.length) {
return { ...curatedNames };
}

const merged: Record<string, string> = {};
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<string, string[]> {
const result: Record<string, string[]> = {};
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;
}
11 changes: 10 additions & 1 deletion packages/perps-controller/src/utils/marketDataTransform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,14 +179,20 @@ 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(
hyperLiquidData: HyperLiquidMarketData,
formatters: MarketDataFormatters,
assetMarketTypes?: Record<string, MarketType>,
assetNames?: Record<string, string>,
assetKeywords?: Record<string, string[]>,
): PerpsMarketData[] {
const { universe, assetCtxs, allMids, predictedFundings } = hyperLiquidData;

Expand Down Expand Up @@ -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
Expand Down
Loading
Loading