Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
4 changes: 4 additions & 0 deletions packages/perps-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- 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.
- Add `mergeAssetNamesWithAnnotations(annotations, curatedNames?)` and `extractAssetKeywords(annotations)` helpers (with the `PerpConciseAnnotation` and `PerpConciseAnnotationEntry` types), which adapt HyperLiquid `perpConciseAnnotations` into the name/keyword overlays consumed by `transformMarketData` ([#9086](https://github.com/MetaMask/core/pull/9086))
- Name resolution precedence is curated map > annotation `displayName` > raw ticker symbol, so first-party `HYPERLIQUID_ASSET_NAMES` entries always win; annotation display names only fill gaps. The live `perpConciseAnnotations` fetch is not wired into the provider yet (pending a mainnet coverage assessment).

### Changed

- `rankMarketsByQuery` and `getMarketMatchRank` now also match a market's optional annotation `keywords`, using the same exact/prefix/substring tiers as `symbol` and `name` ([#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))
- 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))
Expand Down
8 changes: 8 additions & 0 deletions packages/perps-controller/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,14 @@ 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
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
27 changes: 16 additions & 11 deletions packages/perps-controller/src/utils/marketSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<PerpsMarketData, 'symbol' | 'name'>,
market: Pick<PerpsMarketData, 'symbol' | 'name' | 'keywords'>,
searchQuery: string,
): MarketMatchRank | null {
if (!searchQuery?.trim()) {
Expand All @@ -72,16 +75,18 @@ export function getMarketMatchRank(
const ranks = [
fieldRank(market.symbol, query),
fieldRank(market.name, query),
...(market.keywords ?? []).map((keyword) => fieldRank(keyword, query)),
Comment thread
abretonc7s marked this conversation as resolved.
].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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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'],
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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' };
Expand Down
Loading
Loading