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,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- `MarketDataService.getMarketDataWithPrices` builds market data directly from Terminal API metadata when it already includes a usable price, instead of always calling the HyperLiquid provider for pricing. `TerminalMarketService` now extracts the `price`, `change24h`, `changePercent24h`, `funding`, `volume24h`, `openInterest`, and hourly `trend` fields already present in the Terminal API response, in addition to the existing taxonomy fields. Falls back to the previous provider + enrich behavior when Terminal has no usable price for a symbol ([#9808](https://github.com/MetaMask/core/pull/9808))

### Fixed

- `TerminalMarketService` now reads the Terminal API's `category` field (a singular string) correctly, instead of the non-existent `categories`/`marketType` field names it validated against before. `PerpsMarketData.marketType` was silently staying `undefined` for Terminal-sourced markets as a result, which broke category filtering and the "new market" badge for HIP-3 assets ([#9808](https://github.com/MetaMask/core/pull/9808))

## [11.0.0]

### Added
Expand Down
121 changes: 108 additions & 13 deletions packages/perps-controller/src/services/MarketDataService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,98 @@ import type {
PerpsPlatformDependencies,
PerpsMarketData,
TerminalAssetMetadata,
MarketDataFormatters,
} from '../types/index.js';
import type { CandleData } from '../types/perps-types.js';
import { coalescePerpsRestRequest } from '../utils/coalescePerpsRestRequest.js';
import { ensureError, isAbortError } from '../utils/errorUtils.js';
import { parseAssetName } from '../utils/hyperLiquidAdapter.js';
import {
calculateOpenInterestUSD,
formatChange,
} from '../utils/marketDataTransform.js';
import { applyMarketFilters } from '../utils/marketUtils.js';
import type { ServiceContext } from './ServiceContext.js';

/**
* Coerce a Terminal API value (string | number | undefined) to a number.
*
* @param value - The raw value to coerce.
* @returns The numeric value, or `NaN` when absent/unparseable.
*/
function toNumber(value: string | number | undefined): number {
return typeof value === 'number' ? value : parseFloat(String(value ?? ''));
}

/**
* Build PerpsMarketData straight from Terminal metadata, skipping the
* HyperLiquid provider call entirely. Symbols with no usable price are
* dropped rather than rendered with a placeholder.
*
* @param metadata - Per-symbol Terminal metadata map.
* @param formatters - Injectable formatters for platform-agnostic formatting.
* @returns Formatted PerpsMarketData array, empty if no symbol has a price
* (caller should fall back to the provider path in that case).
*/
function buildMarketsFromTerminalMetadata(
metadata: Map<string, TerminalAssetMetadata>,
formatters: MarketDataFormatters,
): PerpsMarketData[] {
const result: PerpsMarketData[] = [];

for (const [symbol, meta] of metadata.entries()) {
const currentPrice = toNumber(meta.price);
if (isNaN(currentPrice) || currentPrice <= 0) {
continue;
}

const change24h = toNumber(meta.change24h);
const change24hPercent = toNumber(meta.changePercent24h);
const volume = toNumber(meta.volume24h);
const openInterest = calculateOpenInterestUSD(
meta.openInterest,
currentPrice,
);
const fundingRate = toNumber(meta.funding);

const { dex } = parseAssetName(symbol);
const isHip3 = Boolean(dex);

result.push({
symbol,
name: meta.name ?? symbol,
...(meta.description !== undefined && { description: meta.description }),
maxLeverage: `${meta.maxLeverage ?? 1}x`,
price: formatters.formatPerpsFiat(currentPrice, {
ranges: formatters.priceRangesUniversal,
}),
change24h: isNaN(change24h)
? PERPS_CONSTANTS.ZeroAmountDetailedDisplay
: formatChange(change24h, formatters),
change24hPercent: isNaN(change24hPercent)
? '0.00%'
: formatters.formatPercentage(change24hPercent),
volume: isNaN(volume)
? PERPS_CONSTANTS.FallbackPriceDisplay
: formatters.formatVolume(volume),
openInterest: isNaN(openInterest)
? PERPS_CONSTANTS.FallbackPriceDisplay
: formatters.formatVolume(openInterest),
fundingRate: isNaN(fundingRate) ? undefined : fundingRate,
marketSource: dex ?? undefined,
...(meta.marketType !== undefined && { marketType: meta.marketType }),
isHip3,
isNewMarket: isHip3 && !meta.marketType,
...(meta.keywords !== undefined && { keywords: meta.keywords }),
...(meta.tags !== undefined && { tags: meta.tags }),
...(meta.listedAt !== undefined && { listedAt: meta.listedAt }),
...(meta.trend !== undefined && { trend: meta.trend }),
});
}

return result;
}

/**
* MarketDataService
*
Expand Down Expand Up @@ -876,7 +961,7 @@ export class MarketDataService {
* Get market data with prices (includes price, volume, 24h change).
* Applies optional category filtering, sorting, and limit after fetching.
* When `useTerminalApi` is true, enriches provider data with Terminal API metadata
* (name, keywords, tags, categories). On Terminal API failure, falls back silently.
* (name, keywords, tags, marketType). On Terminal API failure, falls back silently.
*
* @param options - The configuration options.
* @param options.provider - The perps provider instance.
Expand Down Expand Up @@ -911,9 +996,10 @@ export class MarketDataService {
},
});

// Fetch Terminal API metadata before provider data when enabled.
// Terminal metadata enriches the provider result (name, keywords, tags,
// categories) but never replaces live pricing / funding data.
// Fetch Terminal metadata first. If it already has a usable price,
// build markets from it directly and skip the HyperLiquid provider
// call. Otherwise fall back to the provider and just enrich it with
// Terminal's name/keywords/tags.
let terminalMetadata: Map<string, TerminalAssetMetadata> | undefined;
if (useTerminalApi && this.#deps.terminalMarketService) {
try {
Expand All @@ -929,12 +1015,23 @@ export class MarketDataService {
}
}

const markets = await provider.getMarketDataWithPrices();

// Enrich with terminal metadata when available
const enriched = terminalMetadata
? this.#enrichWithTerminalMetadata(markets, terminalMetadata)
: markets;
const terminalPricedMarkets = terminalMetadata
? buildMarketsFromTerminalMetadata(
terminalMetadata,
this.#deps.marketDataFormatters,
)
: [];

let enriched: PerpsMarketData[];
if (terminalPricedMarkets.length > 0) {
enriched = terminalPricedMarkets;
} else {
const markets = await provider.getMarketDataWithPrices();
// Enrich with terminal metadata when available
enriched = terminalMetadata
? this.#enrichWithTerminalMetadata(markets, terminalMetadata)
: markets;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Terminal path skips market filters

High Severity

When Terminal metadata has any usable price, getMarketDataWithPrices returns Terminal-built markets and never calls the provider. That skips the eligibility rules HyperLiquidProvider.getMarketDataWithPrices applies via shouldIncludeMarket and #excludeNonUsdcCollateralResults (HIP-3 feature flag, allowlist/blocklist, USDC-only collateral). getMarkets already re-applies isMarketAllowed on its Terminal path for this reason, so blocklisted or non-USDC HIP-3 markets can surface as tradeable while order placement still rejects them.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1155522. Configure here.


const filtered = applyMarketFilters(enriched, params);

Expand Down Expand Up @@ -1357,8 +1454,7 @@ export class MarketDataService {
* Merge Terminal API metadata into provider-sourced PerpsMarketData.
* For each market, if the terminal metadata map contains an entry for its
* symbol, override name/description/marketType and attach
* keywords/tags/categories. Unmatched markets keep their provider-sourced
* values.
* keywords/tags. Unmatched markets keep their provider-sourced values.
*
* @param markets - Markets from the provider.
* @param metadata - Per-symbol metadata from the Terminal API.
Expand All @@ -1383,7 +1479,6 @@ export class MarketDataService {
...(meta.marketType !== undefined && { marketType: meta.marketType }),
...(meta.keywords !== undefined && { keywords: meta.keywords }),
...(meta.tags !== undefined && { tags: meta.tags }),
...(meta.categories !== undefined && { categories: meta.categories }),
...(meta.listedAt !== undefined && { listedAt: meta.listedAt }),
};
});
Expand Down
58 changes: 45 additions & 13 deletions packages/perps-controller/src/services/TerminalMarketService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
number,
optional,
string,
tuple,
type,
union,
} from '@metamask/superstruct';
Expand All @@ -29,10 +30,10 @@ const VALID_MARKET_TYPES = new Set<string>(Object.values(MarketCategory));
* Runtime validation schema for a single market item returned by
* `GET {terminalApiUrl}`.
*
* Uses `type()` (loose object matching) so that extra fields the API sends
* (e.g. `price`, `iconUrl`, `trend`) are silently accepted.
* Each item is individually validated; items that fail validation are
* filtered out and logged rather than rejecting the entire response.
* Uses `type()` (loose object matching), so extra fields the API sends
* beyond this schema (e.g. `iconUrl`) are silently accepted. Each item is
* validated individually; items that fail are filtered out and logged
* instead of rejecting the whole response.
*/
const TerminalPerpetualItemStruct = type({
symbol: string(),
Expand All @@ -46,9 +47,17 @@ const TerminalPerpetualItemStruct = type({
minimumOrderSize: optional(number()),
keywords: optional(nullable(array(string()))),
tags: optional(nullable(array(string()))),
categories: optional(nullable(array(string()))),
marketType: optional(nullable(string())),
// The API field is singular `category`, not `categories` — mapped to our
// `marketType` below, which is the name used everywhere else.
category: optional(nullable(string())),
listedAt: optional(nullable(union([number(), string()]))),
price: optional(union([string(), number()])),
change24h: optional(union([string(), number()])),
changePercent24h: optional(union([string(), number()])),
funding: optional(union([string(), number()])),
volume24h: optional(union([string(), number()])),
openInterest: optional(union([string(), number()])),
trend: optional(array(tuple([number(), string()]))),
});

type TerminalPerpetualItem = Infer<typeof TerminalPerpetualItemStruct>;
Expand Down Expand Up @@ -246,15 +255,11 @@ export class TerminalMarketService {
if (Array.isArray(item.tags) && item.tags.length > 0) {
entry.tags = item.tags;
}
if (Array.isArray(item.categories) && item.categories.length > 0) {
entry.categories = item.categories;
}
if (
typeof item.marketType === 'string' &&
VALID_MARKET_TYPES.has(item.marketType)
typeof item.category === 'string' &&
VALID_MARKET_TYPES.has(item.category)
) {
entry.marketType =
item.marketType as TerminalAssetMetadata['marketType'];
entry.marketType = item.category as TerminalAssetMetadata['marketType'];
}

if (item.listedAt !== null && item.listedAt !== undefined) {
Expand All @@ -267,6 +272,33 @@ export class TerminalMarketService {
}
}

// Surfacing these lets MarketDataService build markets straight from
// Terminal data and skip the HyperLiquid price fetch entirely.
if (item.price !== undefined) {
entry.price = item.price;
}
if (item.change24h !== undefined) {
entry.change24h = item.change24h;
}
if (item.changePercent24h !== undefined) {
entry.changePercent24h = item.changePercent24h;
}
if (item.funding !== undefined) {
entry.funding = item.funding;
}
if (item.volume24h !== undefined) {
entry.volume24h = item.volume24h;
}
if (item.openInterest !== undefined) {
entry.openInterest = item.openInterest;
}
if (item.maxLeverage !== undefined) {
entry.maxLeverage = item.maxLeverage;
}
if (item.trend && item.trend.length > 0) {
entry.trend = item.trend;
}

map.set(item.symbol, entry);
}

Expand Down
21 changes: 16 additions & 5 deletions packages/perps-controller/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,13 +101,23 @@ export type TerminalAssetMetadata = {
description?: string;
keywords?: string[];
tags?: string[];
categories?: string[];
marketType?: MarketType;
/**
* Epoch ms when this market was listed on the Terminal backend.
* Normalized from the raw API value (number or ISO string).
*/
listedAt?: number;
// Live price fields below, as returned raw (unformatted) by the Terminal
// API.
price?: string | number;
change24h?: string | number;
changePercent24h?: string | number;
funding?: string | number;
volume24h?: string | number;
openInterest?: string | number;
maxLeverage?: number;
/** Hourly price points as `[timestampMs, priceString]` tuples. */
trend?: [number, string][];
};

// Market type filter for UI category badges
Expand Down Expand Up @@ -617,16 +627,17 @@ export type PerpsMarketData = {
* Taxonomy tags from Terminal API metadata (e.g., ['top-100', 'gaming'])
*/
tags?: string[];
/**
* Market categories from Terminal API metadata (e.g., ['crypto', 'meme'])
*/
categories?: string[];
/**
* Epoch ms when this market was listed on the Terminal backend.
* Sourced from the Terminal API `listedAt` field.
* Clients can use this to surface recently added markets (e.g. markets listed within the last 30 days).
*/
listedAt?: number;
/**
* Hourly price points as `[timestampMs, priceString]` tuples. Only set
* when using the Terminal API backend.
*/
trend?: [number, string][];
};

export type ToggleTestnetResult = {
Expand Down
Loading