From ec0a27fe5f510352e897b0dc39f71956c4356316 Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Tue, 16 Jun 2026 10:08:22 +0200 Subject: [PATCH 01/11] feat(perps-controller): integrate Terminal API as primary market data source with HyperLiquid fallback Add TerminalMarketService to fetch market data from the MetaMask Terminal API, gated behind the `perpsTerminalApiMarkets` remote feature flag. When enabled, the Terminal API is used as the primary source for market listings and metadata (name, keywords, tags, categories), with a silent fallback to HyperLiquid on failure. Also enhances market search to index against keyword fields. --- packages/perps-controller/CHANGELOG.md | 12 + .../perps-controller/src/PerpsController.ts | 32 +- .../src/constants/perpsConfig.ts | 16 + .../src/services/MarketDataService.ts | 111 ++++++- .../src/services/TerminalMarketService.ts | 196 ++++++++++++ packages/perps-controller/src/types/index.ts | 20 ++ .../src/utils/marketDataTransform.ts | 25 +- .../src/utils/marketSearch.ts | 36 ++- .../tests/helpers/serviceMocks.ts | 3 + .../tests/src/PerpsController.trading.test.ts | 1 + .../src/services/MarketDataService.test.ts | 203 ++++++++++++ .../services/TerminalMarketService.test.ts | 302 ++++++++++++++++++ .../src/utils/marketDataTransform.test.ts | 138 +++++++- .../tests/src/utils/marketSearch.test.ts | 59 +++- 14 files changed, 1132 insertions(+), 22 deletions(-) create mode 100644 packages/perps-controller/src/services/TerminalMarketService.ts create mode 100644 packages/perps-controller/tests/src/services/TerminalMarketService.test.ts diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index f4ed9f0c52..adbf291100 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add Terminal API integration for market data behind `perpsTerminalApiMarkets` feature flag ([#TBD](https://github.com/MetaMask/core/pull/TBD)) + - `TerminalMarketService` fetches structured market metadata from `{terminalApiBaseUrl}/perpetuals` with a 5-minute cache TTL. + - When enabled, `getMarkets()` attempts the Terminal API first; on failure or empty response, falls back silently to HyperLiquid. + - `getMarketDataWithPrices()` enriches provider data with Terminal API metadata (name, keywords, tags, categories). + - `PerpsPlatformDependencies` gains a required `terminalApiBaseUrl: string` field; clients must inject the correct environment URL. + - `PerpsMarketData` gains optional `keywords`, `tags`, and `categories` fields. + - `transformMarketData()` accepts optional `terminalMetadata` parameter to override static name/category maps per symbol. + - Market search (`getMarketMatchRank`, `rankMarketsByQuery`) now indexes the `keywords` field for richer search results. + - `HYPERLIQUID_ASSET_NAMES` and `HIP3_ASSET_MARKET_TYPES` remain intact as fallback for assets absent from the Terminal API. + ### Changed - Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index 8e57403634..35abff19b2 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -38,6 +38,7 @@ import { FeatureFlagConfigurationService } from './services/FeatureFlagConfigura import { MarketDataService } from './services/MarketDataService'; import { RewardsIntegrationService } from './services/RewardsIntegrationService'; import type { ServiceContext } from './services/ServiceContext'; +import { TerminalMarketService } from './services/TerminalMarketService'; import { TradingService } from './services/TradingService'; // PerpsStreamChannelKey removed: using string for channel keys (PerpsStreamManager.pauseChannel takes string) import { @@ -881,6 +882,26 @@ export class PerpsController extends BaseController< } } + /** + * Whether the Terminal API should be used as the primary market data source. + * Reads the `perpsTerminalApiMarkets` remote feature flag on every call + * (per-fetch, not cached at init) so toggling the flag takes effect immediately. + * + * @returns True when the flag is enabled. + */ + #isTerminalApiEnabled(): boolean { + try { + const remoteState = this.messenger.call( + 'RemoteFeatureFlagController:getState', + ); + return ( + remoteState.remoteFeatureFlags?.perpsTerminalApiMarkets === true + ); + } catch { + return false; + } + } + /** * Active provider instance for routing operations. * When activeProvider is 'hyperliquid' or 'myx': points to specific provider directly @@ -911,6 +932,8 @@ export class PerpsController extends BaseController< readonly #marketDataService: MarketDataService; + readonly #terminalMarketService: TerminalMarketService; + readonly #accountService: AccountService; readonly #eligibilityService: EligibilityService; @@ -950,7 +973,11 @@ export class PerpsController extends BaseController< // Instantiate services with platform dependencies // Services that need cross-controller access receive the messenger this.#tradingService = new TradingService(infrastructure); - this.#marketDataService = new MarketDataService(infrastructure); + this.#terminalMarketService = new TerminalMarketService(infrastructure); + this.#marketDataService = new MarketDataService( + infrastructure, + this.#terminalMarketService, + ); this.#accountService = new AccountService(infrastructure, messenger); this.#eligibilityService = new EligibilityService(infrastructure); this.#dataLakeService = new DataLakeService(infrastructure, messenger); @@ -2932,6 +2959,7 @@ export class PerpsController extends BaseController< provider, params, context: this.#createServiceContext('getMarkets'), + useTerminalApi: this.#isTerminalApiEnabled(), }); } @@ -2962,6 +2990,7 @@ export class PerpsController extends BaseController< provider, params, context: this.#createServiceContext('getMarketDataWithPrices'), + useTerminalApi: this.#isTerminalApiEnabled(), }); } @@ -2970,6 +2999,7 @@ export class PerpsController extends BaseController< provider, params, context: this.#createServiceContext('getMarketDataWithPrices'), + useTerminalApi: this.#isTerminalApiEnabled(), }); } diff --git a/packages/perps-controller/src/constants/perpsConfig.ts b/packages/perps-controller/src/constants/perpsConfig.ts index 73e85b3be9..589f31f77a 100644 --- a/packages/perps-controller/src/constants/perpsConfig.ts +++ b/packages/perps-controller/src/constants/perpsConfig.ts @@ -328,6 +328,22 @@ export const DATA_LAKE_API_CONFIG = { OrdersEndpoint: 'https://perps.api.cx.metamask.io/api/v1/orders', } as const; +/** + * Terminal API configuration + * Endpoints for fetching structured market metadata from the MetaMask Terminal backend. + * The active URL at runtime comes from PerpsPlatformDependencies.terminalApiBaseUrl, + * not these constants (they are reference-only for each environment). + */ +export const TERMINAL_API_CONFIG = { + Endpoints: { + dev: 'https://terminal.dev-api.cx.metamask.io', + uat: 'https://terminal.uat-api.cx.metamask.io', + prd: 'https://terminal.api.cx.metamask.io', + }, + PerpetualPath: '/perpetuals', + CacheTtlMs: 5 * 60 * 1000, // 5 minutes +} as const; + /** * Decimal precision configuration * Controls maximum decimal places for price and input validation diff --git a/packages/perps-controller/src/services/MarketDataService.ts b/packages/perps-controller/src/services/MarketDataService.ts index 6b0e728698..3385d7a9fa 100644 --- a/packages/perps-controller/src/services/MarketDataService.ts +++ b/packages/perps-controller/src/services/MarketDataService.ts @@ -38,6 +38,8 @@ import { coalescePerpsRestRequest } from '../utils/coalescePerpsRestRequest'; import { ensureError, isAbortError } from '../utils/errorUtils'; import { applyMarketFilters } from '../utils/marketUtils'; import type { ServiceContext } from './ServiceContext'; +import type { TerminalMarketService } from './TerminalMarketService'; +import type { TerminalAssetMetadata } from './TerminalMarketService'; /** * MarketDataService @@ -51,13 +53,21 @@ import type { ServiceContext } from './ServiceContext'; export class MarketDataService { readonly #deps: PerpsPlatformDependencies; + readonly #terminalMarketService: TerminalMarketService | undefined; + /** * Create a new MarketDataService instance * * @param deps - Platform dependencies for logging, metrics, etc. + * @param terminalMarketService - Optional terminal market service for + * fetching market data from the Terminal API. */ - constructor(deps: PerpsPlatformDependencies) { + constructor( + deps: PerpsPlatformDependencies, + terminalMarketService?: TerminalMarketService, + ) { this.#deps = deps; + this.#terminalMarketService = terminalMarketService; } /** @@ -711,20 +721,24 @@ export class MarketDataService { /** * Get available markets - * Handles full orchestration: tracing, error logging, state management, and provider delegation + * Handles full orchestration: tracing, error logging, state management, and provider delegation. + * When `useTerminalApi` is true, attempts the Terminal API first; on failure or empty + * response, falls back silently to the HyperLiquid provider path. * * @param options - The configuration options. * @param options.provider - The perps provider instance. * @param options.params - The operation parameters. * @param options.context - The service context for dependencies. + * @param options.useTerminalApi - When true, attempt Terminal API before provider. * @returns The result of the operation. */ async getMarkets(options: { provider: PerpsProvider; params?: GetMarketsParams; context: ServiceContext; + useTerminalApi?: boolean; }): Promise { - const { provider, params, context } = options; + const { provider, params, context, useTerminalApi } = options; const traceId = uuidv4(); let traceData: { success: boolean; error?: string } | undefined; @@ -740,12 +754,34 @@ export class MarketDataService { symbolCount: String(params.symbols.length), }), ...(params?.dex !== undefined && { dex: params.dex }), + ...(useTerminalApi !== undefined && { + useTerminalApi: String(useTerminalApi), + }), }, }); + // Terminal API path: attempt first when flag is enabled + if (useTerminalApi && this.#terminalMarketService) { + try { + const { markets: terminalMarkets } = + await this.#terminalMarketService.fetchMarkets(); + if (terminalMarkets.length > 0) { + if (context.stateManager) { + context.stateManager.update((state) => { + state.lastError = null; + state.lastUpdateTimestamp = Date.now(); + }); + } + traceData = { success: true }; + return terminalMarkets; + } + } catch (terminalError) { + this.#terminalMarketService.logError(terminalError, 'getMarkets'); + } + } + const markets = await provider.getMarkets(params); - // Clear any previous errors on successful call (if stateManager is provided) if (context.stateManager) { context.stateManager.update((state) => { state.lastError = null; @@ -779,7 +815,6 @@ export class MarketDataService { }, ); - // Update error state (if stateManager is provided) if (context.stateManager) { context.stateManager.update((state) => { state.lastError = errorMessage; @@ -805,19 +840,23 @@ 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. * * @param options - The configuration options. * @param options.provider - The perps provider instance. * @param options.params - Optional filter/sort/limit params. * @param options.context - The service context for dependencies. + * @param options.useTerminalApi - When true, enrich with Terminal API metadata. * @returns The result of the operation. */ async getMarketDataWithPrices(options: { provider: PerpsProvider; params?: GetMarketDataWithPricesParams; context: ServiceContext; + useTerminalApi?: boolean; }): Promise { - const { provider, params, context } = options; + const { provider, params, context, useTerminalApi } = options; const traceId = uuidv4(); let traceData: { success: boolean; error?: string } | undefined; @@ -832,11 +871,38 @@ export class MarketDataService { ...(params?.categories && { categoryCount: String(params.categories.length), }), + ...(useTerminalApi !== undefined && { + useTerminalApi: String(useTerminalApi), + }), }, }); + // Fetch Terminal API metadata in parallel with provider data when enabled. + // Terminal metadata enriches the provider result (name, keywords, tags, + // categories) but never replaces live pricing / funding data. + let terminalMetadata: Map | undefined; + if (useTerminalApi && this.#terminalMarketService) { + try { + const result = await this.#terminalMarketService.fetchMarkets(); + if (result.metadata.size > 0) { + terminalMetadata = result.metadata; + } + } catch (terminalError) { + this.#terminalMarketService.logError( + terminalError, + 'getMarketDataWithPrices', + ); + } + } + const markets = await provider.getMarketDataWithPrices(); - const filtered = applyMarketFilters(markets, params); + + // Enrich with terminal metadata when available + const enriched = terminalMetadata + ? this.#enrichWithTerminalMetadata(markets, terminalMetadata) + : markets; + + const filtered = applyMarketFilters(enriched, params); traceData = { success: true }; return filtered; @@ -1252,4 +1318,35 @@ export class MarketDataService { const { provider, address } = options; return provider.getBlockExplorerUrl(address); } + + /** + * Merge Terminal API metadata into provider-sourced PerpsMarketData. + * For each market, if the terminal metadata map contains an entry for its + * symbol, override name/marketType and attach keywords/tags/categories. + * Unmatched markets keep their provider-sourced values. + * + * @param markets - Markets from the provider. + * @param metadata - Per-symbol metadata from the Terminal API. + * @returns Enriched market data array. + */ + #enrichWithTerminalMetadata( + markets: PerpsMarketData[], + metadata: Map, + ): PerpsMarketData[] { + return markets.map((market) => { + const meta = metadata.get(market.symbol); + if (!meta) { + return market; + } + + return { + ...market, + name: meta.name, + ...(meta.marketType !== undefined && { marketType: meta.marketType }), + ...(meta.keywords !== undefined && { keywords: meta.keywords }), + ...(meta.tags !== undefined && { tags: meta.tags }), + ...(meta.categories !== undefined && { categories: meta.categories }), + }; + }); + } } diff --git a/packages/perps-controller/src/services/TerminalMarketService.ts b/packages/perps-controller/src/services/TerminalMarketService.ts new file mode 100644 index 0000000000..9bc20e9575 --- /dev/null +++ b/packages/perps-controller/src/services/TerminalMarketService.ts @@ -0,0 +1,196 @@ +import { TERMINAL_API_CONFIG } from '../constants/perpsConfig'; +import type { + MarketInfo, + MarketType, + PerpsPlatformDependencies, +} from '../types'; +import { ensureError } from '../utils/errorUtils'; + +/** + * Metadata extracted from Terminal API for a single asset. + * Used downstream by transformMarketData to enrich PerpsMarketData. + */ +export type TerminalAssetMetadata = { + name: string; + keywords?: string[]; + tags?: string[]; + categories?: string[]; + marketType?: MarketType; +}; + +/** + * Shape of a single market item returned by GET {terminalApiBaseUrl}/perpetuals. + * Kept intentionally loose — validated at parse time. + */ +type TerminalPerpetualItem = { + symbol: string; + name?: string; + szDecimals?: number; + maxLeverage?: number; + marginTableId?: number; + onlyIsolated?: boolean; + isDelisted?: boolean; + minimumOrderSize?: number; + keywords?: string[]; + tags?: string[]; + categories?: string[]; + marketType?: string; +}; + +type CacheEntry = { + markets: MarketInfo[]; + metadata: Map; + timestamp: number; +}; + +/** + * TerminalMarketService + * + * Fetches structured market metadata from the MetaMask Terminal API + * (`GET {terminalApiBaseUrl}/perpetuals`). Caches responses for + * {@link TERMINAL_API_CONFIG.CacheTtlMs} to avoid redundant network calls + * across polling cycles. + * + * Instance-based service with constructor injection of platform dependencies. + */ +export class TerminalMarketService { + readonly #deps: PerpsPlatformDependencies; + + #cache: CacheEntry | null = null; + + constructor(deps: PerpsPlatformDependencies) { + this.#deps = deps; + } + + /** + * Fetch markets from the Terminal API. + * Returns cached data when available and within TTL. + * + * @returns Object with mapped MarketInfo array and per-symbol metadata. + */ + async fetchMarkets(): Promise<{ + markets: MarketInfo[]; + metadata: Map; + }> { + if (this.#cache && Date.now() - this.#cache.timestamp < TERMINAL_API_CONFIG.CacheTtlMs) { + return { + markets: this.#cache.markets, + metadata: this.#cache.metadata, + }; + } + + const url = `${this.#deps.terminalApiBaseUrl}${TERMINAL_API_CONFIG.PerpetualPath}`; + const response = await fetch(url, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }); + + if (!response.ok) { + throw new Error( + `Terminal API returned ${String(response.status)}: ${response.statusText}`, + ); + } + + const body: unknown = await response.json(); + + if (!Array.isArray(body)) { + throw new Error( + `Terminal API returned non-array body: ${typeof body}`, + ); + } + + const items = body as TerminalPerpetualItem[]; + const markets = this.#mapToMarketInfo(items); + const metadata = this.#extractMetadata(items); + + this.#cache = { markets, metadata, timestamp: Date.now() }; + return { markets, metadata }; + } + + /** + * Invalidate the internal cache so the next fetch hits the network. + */ + clearCache(): void { + this.#cache = null; + } + + /** + * Map Terminal API items to the protocol-agnostic MarketInfo shape. + * + * @param items - Raw items from the API response. + * @returns Array of MarketInfo objects. + */ + #mapToMarketInfo(items: TerminalPerpetualItem[]): MarketInfo[] { + return items + .filter((item) => typeof item.symbol === 'string' && item.symbol.length > 0) + .map((item) => ({ + name: item.symbol, + szDecimals: item.szDecimals ?? 0, + maxLeverage: item.maxLeverage ?? 1, + marginTableId: item.marginTableId ?? 0, + ...(item.onlyIsolated === true && { onlyIsolated: true as const }), + ...(item.isDelisted === true && { isDelisted: true as const }), + ...(item.minimumOrderSize !== undefined && { + minimumOrderSize: item.minimumOrderSize, + }), + })); + } + + /** + * Extract per-symbol metadata for downstream merge into PerpsMarketData. + * + * @param items - Raw items from the API response. + * @returns Map keyed by symbol with enrichment metadata. + */ + #extractMetadata( + items: TerminalPerpetualItem[], + ): Map { + const map = new Map(); + + for (const item of items) { + if (typeof item.symbol !== 'string' || item.symbol.length === 0) { + continue; + } + + const entry: TerminalAssetMetadata = { + name: item.name ?? item.symbol, + }; + + if (Array.isArray(item.keywords) && item.keywords.length > 0) { + entry.keywords = item.keywords; + } + 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' && item.marketType.length > 0) { + entry.marketType = item.marketType as MarketType; + } + + map.set(item.symbol, entry); + } + + return map; + } + + /** + * Log a Terminal API error to Sentry without surfacing it to the user. + * + * @param error - The caught error. + * @param method - The calling method name for context. + */ + logError(error: unknown, method: string): void { + this.#deps.logger.error( + ensureError(error, `TerminalMarketService.${method}`), + { + tags: { feature: 'perps', source: 'terminal-api' }, + context: { + name: `TerminalMarketService.${method}`, + data: { url: this.#deps.terminalApiBaseUrl }, + }, + }, + ); + } +} diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index 24f8cbbdd4..7c061c0370 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -476,6 +476,18 @@ export type PerpsMarketData = { * Indicates this market snapshot came from the last known good cache after live fetch failure. */ isStale?: boolean; + /** + * Searchable keywords from Terminal API metadata (e.g., ['defi', 'layer-1']) + */ + keywords?: string[]; + /** + * 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[]; }; export type ToggleTestnetResult = { @@ -1654,6 +1666,14 @@ export type PerpsPlatformDependencies = { removeItem(key: string): Promise; }; + // === Terminal API (market metadata source) === + /** + * Base URL for the MetaMask Terminal API. + * Each client build (dev/uat/prd) injects the appropriate environment URL. + * Never hardcoded in controller code — always provided by the platform. + */ + terminalApiBaseUrl: string; + // === Rewards (DI — no RewardsController in Core yet) === rewards: { /** diff --git a/packages/perps-controller/src/utils/marketDataTransform.ts b/packages/perps-controller/src/utils/marketDataTransform.ts index 58163d0d6e..79c5b41a21 100644 --- a/packages/perps-controller/src/utils/marketDataTransform.ts +++ b/packages/perps-controller/src/utils/marketDataTransform.ts @@ -16,6 +16,7 @@ import type { MarketType, MarketDataFormatters, } from '../types'; +import type { TerminalAssetMetadata } from '../services/TerminalMarketService'; import type { AllMidsResponse, PerpsUniverse, @@ -180,6 +181,9 @@ function extractFundingData(params: ExtractFundingDataParams): FundingData { * @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. + * @param terminalMetadata - Optional per-symbol metadata from Terminal API. + * When present, overrides name/marketType from static maps and carries through + * keywords/tags/categories. Static maps remain as fallback for unmatched symbols. * @returns Transformed market data ready for UI consumption */ export function transformMarketData( @@ -187,6 +191,7 @@ export function transformMarketData( formatters: MarketDataFormatters, assetMarketTypes?: Record, assetNames?: Record, + terminalMetadata?: Map, ): PerpsMarketData[] { const { universe, assetCtxs, allMids, predictedFundings } = hyperLiquidData; @@ -255,20 +260,23 @@ export function transformMarketData( // Crypto markets (HIP-2) don't have a prefix (e.g., BTC, ETH) const isHip3 = Boolean(dex); - // Determine market type from explicit mapping only - // Only explicitly mapped HIP-3 markets get a marketType (e.g., 'xyz:GOLD' → 'commodity') - // Unmapped HIP-3 markets (e.g., 'hyna:BTC') have no marketType - they go to "New" tab - // Main DEX crypto also has no marketType - const explicitMarketType = assetMarketTypes?.[symbol]; + // Terminal API metadata takes priority over static maps when available + const terminalMeta = terminalMetadata?.get(symbol); + + // Determine market type: terminal API > explicit static mapping + const explicitMarketType = + terminalMeta?.marketType ?? assetMarketTypes?.[symbol]; const marketType: MarketType | undefined = explicitMarketType; // Mark as "new" if it's a HIP-3 market but not explicitly categorized - // New markets are always HIP-3 (non-crypto) that haven't been assigned a category yet const isNewMarket = isHip3 && !explicitMarketType; + // Resolve name: terminal API > static name map > ticker fallback + const resolvedName = terminalMeta?.name ?? getHyperLiquidAssetName(symbol, assetNames); + return { symbol, - name: getHyperLiquidAssetName(symbol, assetNames), + name: resolvedName, maxLeverage: `${asset.maxLeverage}x`, price: isNaN(currentPrice) ? PERPS_CONSTANTS.FallbackPriceDisplay @@ -294,6 +302,9 @@ export function transformMarketData( marketType, isHip3, isNewMarket, + ...(terminalMeta?.keywords && { keywords: terminalMeta.keywords }), + ...(terminalMeta?.tags && { tags: terminalMeta.tags }), + ...(terminalMeta?.categories && { categories: terminalMeta.categories }), }; }); } diff --git a/packages/perps-controller/src/utils/marketSearch.ts b/packages/perps-controller/src/utils/marketSearch.ts index 977846f715..95956f3728 100644 --- a/packages/perps-controller/src/utils/marketSearch.ts +++ b/packages/perps-controller/src/utils/marketSearch.ts @@ -52,17 +52,46 @@ function fieldRank( return null; } +/** + * Rank an array of keyword strings against a normalized query. + * Returns the best (lowest) rank found across all keywords, or null. + * + * @param keywords - Array of keyword strings; may be undefined. + * @param query - Already trimmed, lower-cased, non-empty query. + * @returns The best match tier across all keywords, or null when none match. + */ +function keywordsRank( + keywords: string[] | undefined, + query: string, +): MarketMatchRank | null { + if (!keywords || keywords.length === 0) { + return null; + } + let best: MarketMatchRank | null = null; + for (const keyword of keywords) { + const rank = fieldRank(keyword, query); + if (rank === MarketMatchRank.Exact) { + return rank; + } + if (rank !== null && (best === null || rank < best)) { + best = rank; + } + } + return best; +} + /** * 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 optional keywords + * from Terminal API metadata. * - * @param market - Market to score (uses `symbol` and `name`). + * @param market - Market to score (uses `symbol`, `name`, and optional `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,6 +101,7 @@ export function getMarketMatchRank( const ranks = [ fieldRank(market.symbol, query), fieldRank(market.name, query), + keywordsRank(market.keywords, query), ].filter((rank): rank is MarketMatchRank => rank !== null); return ranks.length > 0 ? Math.min(...ranks) : null; diff --git a/packages/perps-controller/tests/helpers/serviceMocks.ts b/packages/perps-controller/tests/helpers/serviceMocks.ts index f1b6c7bdf3..af34ef5f25 100644 --- a/packages/perps-controller/tests/helpers/serviceMocks.ts +++ b/packages/perps-controller/tests/helpers/serviceMocks.ts @@ -91,6 +91,9 @@ export const createMockInfrastructure = invalidateAll: jest.fn(), }, + // === Terminal API === + terminalApiBaseUrl: 'https://terminal.test-api.cx.metamask.io', + // === Rewards (DI — no RewardsController in Core yet) === rewards: { getPerpsDiscountForAccount: jest.fn().mockResolvedValue(0), diff --git a/packages/perps-controller/tests/src/PerpsController.trading.test.ts b/packages/perps-controller/tests/src/PerpsController.trading.test.ts index 7e297b2843..486378d2a2 100644 --- a/packages/perps-controller/tests/src/PerpsController.trading.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.trading.test.ts @@ -822,6 +822,7 @@ describe('PerpsController', () => { provider: mockProvider, params: undefined, context: expect.any(Object), + useTerminalApi: expect.any(Boolean), }); }); }); diff --git a/packages/perps-controller/tests/src/services/MarketDataService.test.ts b/packages/perps-controller/tests/src/services/MarketDataService.test.ts index 28cfc5f9c2..40d9cbaf11 100644 --- a/packages/perps-controller/tests/src/services/MarketDataService.test.ts +++ b/packages/perps-controller/tests/src/services/MarketDataService.test.ts @@ -1,6 +1,8 @@ import type { CandlePeriod } from '../../../src/constants/chartConfig'; import { MarketDataService } from '../../../src/services/MarketDataService'; import type { ServiceContext } from '../../../src/services/ServiceContext'; +import type { TerminalMarketService } from '../../../src/services/TerminalMarketService'; +import type { TerminalAssetMetadata } from '../../../src/services/TerminalMarketService'; import type { PerpsProvider, Position, @@ -13,6 +15,7 @@ import type { FeeCalculationParams, AssetRoute, PerpsPlatformDependencies, + PerpsMarketData, } from '../../../src/types'; import type { CandleData } from '../../../src/types/perps-types'; import { resetPerpsRestCacheForTests } from '../../../src/utils/coalescePerpsRestRequest'; @@ -1125,4 +1128,204 @@ describe('MarketDataService', () => { expect(mockDeps.logger.error).toHaveBeenCalled(); }); }); + + describe('Terminal API integration', () => { + let mockTerminalService: jest.Mocked; + let serviceWithTerminal: MarketDataService; + + const terminalMarkets: MarketInfo[] = [ + { name: 'BTC', szDecimals: 5, maxLeverage: 50, marginTableId: 0 }, + { name: 'ETH', szDecimals: 4, maxLeverage: 25, marginTableId: 1 }, + ]; + + const terminalMetadata = new Map([ + [ + 'BTC', + { + name: 'Bitcoin', + keywords: ['crypto', 'layer-1'], + tags: ['top-10'], + categories: ['crypto'], + marketType: 'crypto', + }, + ], + [ + 'ETH', + { + name: 'Ethereum', + keywords: ['defi'], + }, + ], + ]); + + beforeEach(() => { + mockTerminalService = { + fetchMarkets: jest.fn(), + clearCache: jest.fn(), + logError: jest.fn(), + } as unknown as jest.Mocked; + + serviceWithTerminal = new MarketDataService( + mockDeps, + mockTerminalService, + ); + }); + + describe('getMarkets with useTerminalApi', () => { + it('uses terminal API when flag is enabled and returns data', async () => { + mockTerminalService.fetchMarkets.mockResolvedValue({ + markets: terminalMarkets, + metadata: terminalMetadata, + }); + + const result = await serviceWithTerminal.getMarkets({ + provider: mockProvider, + context: mockContext, + useTerminalApi: true, + }); + + expect(result).toEqual(terminalMarkets); + expect(mockTerminalService.fetchMarkets).toHaveBeenCalled(); + expect(mockProvider.getMarkets).not.toHaveBeenCalled(); + }); + + it('falls back to provider when terminal API fails', async () => { + const providerMarkets: MarketInfo[] = [ + { name: 'BTC', szDecimals: 5, maxLeverage: 50, marginTableId: 0 }, + ]; + mockTerminalService.fetchMarkets.mockRejectedValue( + new Error('Terminal API down'), + ); + mockProvider.getMarkets.mockResolvedValue(providerMarkets); + + const result = await serviceWithTerminal.getMarkets({ + provider: mockProvider, + context: mockContext, + useTerminalApi: true, + }); + + expect(result).toEqual(providerMarkets); + expect(mockTerminalService.logError).toHaveBeenCalledWith( + expect.any(Error), + 'getMarkets', + ); + }); + + it('falls back to provider when terminal API returns empty', async () => { + const providerMarkets: MarketInfo[] = [ + { name: 'BTC', szDecimals: 5, maxLeverage: 50, marginTableId: 0 }, + ]; + mockTerminalService.fetchMarkets.mockResolvedValue({ + markets: [], + metadata: new Map(), + }); + mockProvider.getMarkets.mockResolvedValue(providerMarkets); + + const result = await serviceWithTerminal.getMarkets({ + provider: mockProvider, + context: mockContext, + useTerminalApi: true, + }); + + expect(result).toEqual(providerMarkets); + }); + + it('uses provider when useTerminalApi is false', async () => { + const providerMarkets: MarketInfo[] = [ + { name: 'BTC', szDecimals: 5, maxLeverage: 50, marginTableId: 0 }, + ]; + mockProvider.getMarkets.mockResolvedValue(providerMarkets); + + const result = await serviceWithTerminal.getMarkets({ + provider: mockProvider, + context: mockContext, + useTerminalApi: false, + }); + + expect(result).toEqual(providerMarkets); + expect(mockTerminalService.fetchMarkets).not.toHaveBeenCalled(); + }); + }); + + describe('getMarketDataWithPrices with useTerminalApi', () => { + const providerMarketData: PerpsMarketData[] = [ + { + symbol: 'BTC', + name: 'BTC', + maxLeverage: '50x', + price: '$50000.00', + change24h: '+$500.00', + change24hPercent: '+1.00%', + volume: '$1000000', + }, + { + symbol: 'ETH', + name: 'ETH', + maxLeverage: '25x', + price: '$3000.00', + change24h: '+$30.00', + change24hPercent: '+1.00%', + volume: '$500000', + }, + ]; + + it('enriches provider data with terminal metadata when flag is enabled', async () => { + mockTerminalService.fetchMarkets.mockResolvedValue({ + markets: terminalMarkets, + metadata: terminalMetadata, + }); + mockProvider.getMarketDataWithPrices.mockResolvedValue( + providerMarketData, + ); + + const result = await serviceWithTerminal.getMarketDataWithPrices({ + provider: mockProvider, + context: mockContext, + useTerminalApi: true, + }); + + expect(result[0]?.name).toBe('Bitcoin'); + expect(result[0]?.keywords).toEqual(['crypto', 'layer-1']); + expect(result[0]?.tags).toEqual(['top-10']); + expect(result[0]?.categories).toEqual(['crypto']); + expect(result[0]?.marketType).toBe('crypto'); + expect(result[1]?.name).toBe('Ethereum'); + expect(result[1]?.keywords).toEqual(['defi']); + }); + + it('returns provider data unchanged when terminal API fails', async () => { + mockTerminalService.fetchMarkets.mockRejectedValue( + new Error('Network error'), + ); + mockProvider.getMarketDataWithPrices.mockResolvedValue( + providerMarketData, + ); + + const result = await serviceWithTerminal.getMarketDataWithPrices({ + provider: mockProvider, + context: mockContext, + useTerminalApi: true, + }); + + expect(result[0]?.name).toBe('BTC'); + expect(result[0]?.keywords).toBeUndefined(); + expect(mockTerminalService.logError).toHaveBeenCalled(); + }); + + it('returns provider data unchanged when flag is disabled', async () => { + mockProvider.getMarketDataWithPrices.mockResolvedValue( + providerMarketData, + ); + + const result = await serviceWithTerminal.getMarketDataWithPrices({ + provider: mockProvider, + context: mockContext, + useTerminalApi: false, + }); + + expect(result[0]?.name).toBe('BTC'); + expect(mockTerminalService.fetchMarkets).not.toHaveBeenCalled(); + }); + }); + }); }); diff --git a/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts b/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts new file mode 100644 index 0000000000..b8fb22cce4 --- /dev/null +++ b/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts @@ -0,0 +1,302 @@ +import { TerminalMarketService } from '../../../src/services/TerminalMarketService'; +import type { PerpsPlatformDependencies } from '../../../src/types'; +import { createMockInfrastructure } from '../../helpers/serviceMocks'; + +describe('TerminalMarketService', () => { + let mockDeps: jest.Mocked; + let service: TerminalMarketService; + + const mockApiResponse = [ + { + symbol: 'BTC', + name: 'Bitcoin', + szDecimals: 5, + maxLeverage: 50, + marginTableId: 0, + keywords: ['crypto', 'layer-1'], + tags: ['top-10'], + categories: ['crypto'], + marketType: 'crypto', + }, + { + symbol: 'ETH', + name: 'Ethereum', + szDecimals: 4, + maxLeverage: 25, + marginTableId: 1, + keywords: ['defi', 'layer-1'], + }, + { + symbol: 'xyz:TSLA', + name: 'Tesla', + szDecimals: 2, + maxLeverage: 5, + marginTableId: 2, + onlyIsolated: true, + marketType: 'stock', + tags: ['us-equities'], + categories: ['stock'], + }, + ]; + + beforeEach(() => { + mockDeps = createMockInfrastructure(); + service = new TerminalMarketService(mockDeps); + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('fetchMarkets', () => { + it('fetches and maps markets successfully', async () => { + jest.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: () => Promise.resolve(mockApiResponse), + } as Response); + + const { markets, metadata } = await service.fetchMarkets(); + + expect(globalThis.fetch).toHaveBeenCalledWith( + 'https://terminal.test-api.cx.metamask.io/perpetuals', + { method: 'GET', headers: { 'Content-Type': 'application/json' } }, + ); + + expect(markets).toHaveLength(3); + expect(markets[0]).toEqual({ + name: 'BTC', + szDecimals: 5, + maxLeverage: 50, + marginTableId: 0, + }); + expect(markets[2]).toEqual({ + name: 'xyz:TSLA', + szDecimals: 2, + maxLeverage: 5, + marginTableId: 2, + onlyIsolated: true, + }); + + expect(metadata.size).toBe(3); + expect(metadata.get('BTC')).toEqual({ + name: 'Bitcoin', + keywords: ['crypto', 'layer-1'], + tags: ['top-10'], + categories: ['crypto'], + marketType: 'crypto', + }); + expect(metadata.get('ETH')).toEqual({ + name: 'Ethereum', + keywords: ['defi', 'layer-1'], + }); + expect(metadata.get('xyz:TSLA')).toEqual({ + name: 'Tesla', + marketType: 'stock', + tags: ['us-equities'], + categories: ['stock'], + }); + }); + + it('constructs URL from injected terminalApiBaseUrl', async () => { + (mockDeps as Record).terminalApiBaseUrl = + 'https://terminal.api.cx.metamask.io'; + + jest.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: () => Promise.resolve([]), + } as Response); + + await service.fetchMarkets(); + + expect(globalThis.fetch).toHaveBeenCalledWith( + 'https://terminal.api.cx.metamask.io/perpetuals', + expect.any(Object), + ); + }); + + it('throws on non-2xx response', async () => { + jest.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + json: () => Promise.resolve({}), + } as Response); + + await expect(service.fetchMarkets()).rejects.toThrow( + 'Terminal API returned 500: Internal Server Error', + ); + }); + + it('throws on non-array response body', async () => { + jest.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: () => Promise.resolve({ data: [] }), + } as Response); + + await expect(service.fetchMarkets()).rejects.toThrow( + 'Terminal API returned non-array body: object', + ); + }); + + it('throws on network error', async () => { + jest + .spyOn(globalThis, 'fetch') + .mockRejectedValue(new Error('Network request failed')); + + await expect(service.fetchMarkets()).rejects.toThrow( + 'Network request failed', + ); + }); + + it('returns empty arrays for empty API response', async () => { + jest.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: () => Promise.resolve([]), + } as Response); + + const { markets, metadata } = await service.fetchMarkets(); + + expect(markets).toHaveLength(0); + expect(metadata.size).toBe(0); + }); + + it('filters out items with missing or empty symbol', async () => { + jest.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: () => + Promise.resolve([ + { symbol: '', name: 'Empty' }, + { name: 'NoSymbol' }, + { symbol: 'VALID', name: 'Valid' }, + ]), + } as Response); + + const { markets, metadata } = await service.fetchMarkets(); + + expect(markets).toHaveLength(1); + expect(markets[0]?.name).toBe('VALID'); + expect(metadata.size).toBe(1); + expect(metadata.has('VALID')).toBe(true); + }); + + it('uses defaults for missing numeric fields', async () => { + jest.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: () => Promise.resolve([{ symbol: 'FOO' }]), + } as Response); + + const { markets } = await service.fetchMarkets(); + + expect(markets[0]).toEqual({ + name: 'FOO', + szDecimals: 0, + maxLeverage: 1, + marginTableId: 0, + }); + }); + + it('falls back to symbol when name is not provided in metadata', async () => { + jest.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: () => Promise.resolve([{ symbol: 'UNKNOWN' }]), + } as Response); + + const { metadata } = await service.fetchMarkets(); + + expect(metadata.get('UNKNOWN')?.name).toBe('UNKNOWN'); + }); + }); + + describe('cache behavior', () => { + it('returns cached data on second call within TTL', async () => { + const fetchSpy = jest + .spyOn(globalThis, 'fetch') + .mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: () => Promise.resolve(mockApiResponse), + } as Response); + + const first = await service.fetchMarkets(); + const second = await service.fetchMarkets(); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(second.markets).toBe(first.markets); + expect(second.metadata).toBe(first.metadata); + }); + + it('fetches again after cache is cleared', async () => { + const fetchSpy = jest + .spyOn(globalThis, 'fetch') + .mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: () => Promise.resolve(mockApiResponse), + } as Response); + + await service.fetchMarkets(); + service.clearCache(); + await service.fetchMarkets(); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it('fetches again after TTL expires', async () => { + const fetchSpy = jest + .spyOn(globalThis, 'fetch') + .mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: () => Promise.resolve(mockApiResponse), + } as Response); + + await service.fetchMarkets(); + + // Advance time past TTL (5 minutes) + const realDateNow = Date.now; + Date.now = () => realDateNow() + 6 * 60 * 1000; + + await service.fetchMarkets(); + + Date.now = realDateNow; + + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + }); + + describe('logError', () => { + it('logs error to Sentry via deps.logger', () => { + const error = new Error('fetch failed'); + service.logError(error, 'getMarkets'); + + expect(mockDeps.logger.error).toHaveBeenCalledWith( + expect.objectContaining({ message: 'fetch failed' }), + expect.objectContaining({ + tags: { feature: 'perps', source: 'terminal-api' }, + context: { + name: 'TerminalMarketService.getMarkets', + data: { url: 'https://terminal.test-api.cx.metamask.io' }, + }, + }), + ); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/utils/marketDataTransform.test.ts b/packages/perps-controller/tests/src/utils/marketDataTransform.test.ts index 962e455477..0e699c2516 100644 --- a/packages/perps-controller/tests/src/utils/marketDataTransform.test.ts +++ b/packages/perps-controller/tests/src/utils/marketDataTransform.test.ts @@ -2,7 +2,8 @@ import { HYPERLIQUID_ASSET_NAMES, getHyperLiquidAssetName, } from '../../../src/constants/hyperLiquidConfig'; -import type { MarketDataFormatters } from '../../../src/types'; +import type { TerminalAssetMetadata } from '../../../src/services/TerminalMarketService'; +import type { MarketDataFormatters, MarketType } from '../../../src/types'; import type { AllMidsResponse, PerpsAssetCtx, @@ -134,3 +135,138 @@ describe('transformMarketData - human-readable names', () => { expect(result[0].volume).toBe('$1000000'); }); }); + +describe('transformMarketData - terminal metadata', () => { + it('overrides name and marketType from terminal metadata', () => { + const universe: PerpsUniverse[] = [ + makeUniverseEntry('BTC'), + makeUniverseEntry('xyz:TSLA'), + ]; + const allMids: AllMidsResponse = { BTC: '50000', 'xyz:TSLA': '200' }; + const staticMarketTypes: Record = { + 'xyz:TSLA': 'stock', + }; + + const terminalMeta = new Map([ + ['BTC', { name: 'Bitcoin (Terminal)', marketType: 'crypto' }], + ['xyz:TSLA', { name: 'Tesla Inc.', marketType: 'stock' }], + ]); + + const result = transformMarketData( + { universe, assetCtxs: [], allMids }, + mockFormatters, + staticMarketTypes, + undefined, + terminalMeta, + ); + + expect(result[0]).toMatchObject({ + symbol: 'BTC', + name: 'Bitcoin (Terminal)', + marketType: 'crypto', + }); + expect(result[1]).toMatchObject({ + symbol: 'xyz:TSLA', + name: 'Tesla Inc.', + marketType: 'stock', + }); + }); + + it('carries keywords, tags, and categories from terminal metadata', () => { + const universe: PerpsUniverse[] = [makeUniverseEntry('BTC')]; + const allMids: AllMidsResponse = { BTC: '50000' }; + + const terminalMeta = new Map([ + [ + 'BTC', + { + name: 'Bitcoin', + keywords: ['crypto', 'layer-1', 'pow'], + tags: ['top-10', 'blue-chip'], + categories: ['crypto', 'major'], + }, + ], + ]); + + const result = transformMarketData( + { universe, assetCtxs: [], allMids }, + mockFormatters, + undefined, + undefined, + terminalMeta, + ); + + expect(result[0]?.keywords).toEqual(['crypto', 'layer-1', 'pow']); + expect(result[0]?.tags).toEqual(['top-10', 'blue-chip']); + expect(result[0]?.categories).toEqual(['crypto', 'major']); + }); + + it('falls back to static maps when symbol is absent from terminal metadata', () => { + const universe: PerpsUniverse[] = [ + makeUniverseEntry('BTC'), + makeUniverseEntry('UNMAPPED'), + ]; + const allMids: AllMidsResponse = { BTC: '50000', UNMAPPED: '10' }; + + const terminalMeta = new Map([ + ['BTC', { name: 'Bitcoin (Terminal)' }], + ]); + + const result = transformMarketData( + { universe, assetCtxs: [], allMids }, + mockFormatters, + undefined, + { BTC: 'Bitcoin Static' }, + terminalMeta, + ); + + expect(result[0]?.name).toBe('Bitcoin (Terminal)'); + expect(result[1]?.name).toBe('UNMAPPED'); + expect(result[1]?.keywords).toBeUndefined(); + }); + + it('terminal marketType takes priority over static assetMarketTypes', () => { + const universe: PerpsUniverse[] = [makeUniverseEntry('xyz:GOLD')]; + const allMids: AllMidsResponse = { 'xyz:GOLD': '2000' }; + const staticTypes: Record = { + 'xyz:GOLD': 'commodity', + }; + + const terminalMeta = new Map([ + ['xyz:GOLD', { name: 'Gold', marketType: 'commodity' }], + ]); + + const result = transformMarketData( + { universe, assetCtxs: [], allMids }, + mockFormatters, + staticTypes, + undefined, + terminalMeta, + ); + + expect(result[0]?.marketType).toBe('commodity'); + expect(result[0]?.isNewMarket).toBe(false); + }); + + it('does not add keywords/tags/categories when terminal metadata has none', () => { + const universe: PerpsUniverse[] = [makeUniverseEntry('ETH')]; + const allMids: AllMidsResponse = { ETH: '3000' }; + + const terminalMeta = new Map([ + ['ETH', { name: 'Ethereum' }], + ]); + + const result = transformMarketData( + { universe, assetCtxs: [], allMids }, + mockFormatters, + undefined, + undefined, + terminalMeta, + ); + + expect(result[0]?.name).toBe('Ethereum'); + expect(result[0]?.keywords).toBeUndefined(); + expect(result[0]?.tags).toBeUndefined(); + expect(result[0]?.categories).toBeUndefined(); + }); +}); diff --git a/packages/perps-controller/tests/src/utils/marketSearch.test.ts b/packages/perps-controller/tests/src/utils/marketSearch.test.ts index a85d8e6498..a1db745383 100644 --- a/packages/perps-controller/tests/src/utils/marketSearch.test.ts +++ b/packages/perps-controller/tests/src/utils/marketSearch.test.ts @@ -6,14 +6,19 @@ import { } from '../../../src/utils/marketSearch'; /** - * Build a minimal market fixture. Only `symbol` and `name` drive search; the - * remaining fields satisfy the PerpsMarketData type. + * Build a minimal market fixture. Only `symbol`, `name`, and optional + * `keywords` 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. + * @param keywords - Optional keyword array from Terminal API metadata. * @returns A PerpsMarketData fixture. */ -function makeMarket(symbol: string, name: string): PerpsMarketData { +function makeMarket( + symbol: string, + name: string, + keywords?: string[], +): PerpsMarketData { return { symbol, name, @@ -22,6 +27,7 @@ function makeMarket(symbol: string, name: string): PerpsMarketData { change24h: '$0.00', change24hPercent: '0.00%', volume: '$0', + ...(keywords !== undefined && { keywords }), }; } @@ -123,3 +129,50 @@ describe('rankMarketsByQuery', () => { ).toStrictEqual(['xyz:GOLD']); }); }); + +describe('keyword matching (Terminal API metadata)', () => { + it('matches against keywords for exact, prefix, and substring', () => { + const market = makeMarket('BTC', 'Bitcoin', ['layer-1', 'pow', 'defi']); + + expect(getMarketMatchRank(market, 'defi')).toBe(MarketMatchRank.Exact); + expect(getMarketMatchRank(market, 'layer')).toBe(MarketMatchRank.Prefix); + expect(getMarketMatchRank(market, 'ayer')).toBe(MarketMatchRank.Substring); + }); + + it('keyword match does not override a better symbol or name match', () => { + const market = makeMarket('BTC', 'Bitcoin', ['crypto']); + expect(getMarketMatchRank(market, 'btc')).toBe(MarketMatchRank.Exact); + }); + + it('returns keyword rank when symbol and name do not match', () => { + const market = makeMarket('BTC', 'Bitcoin', ['digital-gold']); + expect(getMarketMatchRank(market, 'digital-gold')).toBe( + MarketMatchRank.Exact, + ); + }); + + it('returns null when keywords also do not match', () => { + const market = makeMarket('BTC', 'Bitcoin', ['crypto', 'layer-1']); + expect(getMarketMatchRank(market, 'forex')).toBeNull(); + }); + + it('handles markets without keywords gracefully', () => { + const market = makeMarket('ETH', 'Ethereum'); + expect(getMarketMatchRank(market, 'defi')).toBeNull(); + }); + + it('rankMarketsByQuery includes keyword-matched markets', () => { + const markets = [ + makeMarket('BTC', 'Bitcoin', ['digital-gold']), + makeMarket('xyz:GOLD', 'Gold'), + makeMarket('ETH', 'Ethereum'), + ]; + + const result = rankMarketsByQuery(markets, 'gold').map( + (m) => m.symbol, + ); + expect(result).toContain('BTC'); + expect(result).toContain('xyz:GOLD'); + expect(result).not.toContain('ETH'); + }); +}); From 2e6e684520f637f2f0b19f17c2dd8025a19adc71 Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Tue, 16 Jun 2026 10:12:48 +0200 Subject: [PATCH 02/11] fix(perps-controller): apply params filtering to Terminal API getMarkets path The Terminal API path in getMarkets was returning all markets without respecting params.symbols or params.dex, unlike the provider fallback path which applies those filters. This could cause callers requesting specific symbols to receive the full market list. --- .../src/services/MarketDataService.ts | 50 ++++++++++++++++--- .../src/services/MarketDataService.test.ts | 38 ++++++++++++++ 2 files changed, 81 insertions(+), 7 deletions(-) diff --git a/packages/perps-controller/src/services/MarketDataService.ts b/packages/perps-controller/src/services/MarketDataService.ts index 3385d7a9fa..918c8dd12a 100644 --- a/packages/perps-controller/src/services/MarketDataService.ts +++ b/packages/perps-controller/src/services/MarketDataService.ts @@ -766,14 +766,20 @@ export class MarketDataService { const { markets: terminalMarkets } = await this.#terminalMarketService.fetchMarkets(); if (terminalMarkets.length > 0) { - if (context.stateManager) { - context.stateManager.update((state) => { - state.lastError = null; - state.lastUpdateTimestamp = Date.now(); - }); + const filtered = this.#applyGetMarketsParams( + terminalMarkets, + params, + ); + if (filtered.length > 0) { + if (context.stateManager) { + context.stateManager.update((state) => { + state.lastError = null; + state.lastUpdateTimestamp = Date.now(); + }); + } + traceData = { success: true }; + return filtered; } - traceData = { success: true }; - return terminalMarkets; } } catch (terminalError) { this.#terminalMarketService.logError(terminalError, 'getMarkets'); @@ -1329,6 +1335,36 @@ export class MarketDataService { * @param metadata - Per-symbol metadata from the Terminal API. * @returns Enriched market data array. */ + /** + * Apply GetMarketsParams filtering to Terminal API results. + * Replicates the symbol/dex subset that the provider would apply. + * + * @param markets - Unfiltered Terminal API markets. + * @param params - Caller-supplied filter params. + * @returns Filtered markets. + */ + #applyGetMarketsParams( + markets: MarketInfo[], + params?: GetMarketsParams, + ): MarketInfo[] { + if (!params) { + return markets; + } + let result = markets; + if (params.symbols && params.symbols.length > 0) { + const symbolSet = new Set(params.symbols); + result = result.filter((m) => symbolSet.has(m.name)); + } + if (params.dex !== undefined) { + result = result.filter((m) => { + const colonIdx = m.name.indexOf(':'); + const marketDex = colonIdx === -1 ? '' : m.name.substring(0, colonIdx); + return marketDex === params.dex; + }); + } + return result; + } + #enrichWithTerminalMetadata( markets: PerpsMarketData[], metadata: Map, diff --git a/packages/perps-controller/tests/src/services/MarketDataService.test.ts b/packages/perps-controller/tests/src/services/MarketDataService.test.ts index 40d9cbaf11..256d53ad16 100644 --- a/packages/perps-controller/tests/src/services/MarketDataService.test.ts +++ b/packages/perps-controller/tests/src/services/MarketDataService.test.ts @@ -1245,6 +1245,44 @@ describe('MarketDataService', () => { expect(result).toEqual(providerMarkets); expect(mockTerminalService.fetchMarkets).not.toHaveBeenCalled(); }); + + it('filters terminal markets by params.symbols', async () => { + mockTerminalService.fetchMarkets.mockResolvedValue({ + markets: terminalMarkets, + metadata: terminalMetadata, + }); + + const result = await serviceWithTerminal.getMarkets({ + provider: mockProvider, + context: mockContext, + useTerminalApi: true, + params: { symbols: ['BTC'] }, + }); + + expect(result).toHaveLength(1); + expect(result[0]?.name).toBe('BTC'); + expect(mockProvider.getMarkets).not.toHaveBeenCalled(); + }); + + it('falls back to provider when terminal markets are filtered to empty', async () => { + const providerMarkets: MarketInfo[] = [ + { name: 'SOL', szDecimals: 3, maxLeverage: 20, marginTableId: 5 }, + ]; + mockTerminalService.fetchMarkets.mockResolvedValue({ + markets: terminalMarkets, + metadata: terminalMetadata, + }); + mockProvider.getMarkets.mockResolvedValue(providerMarkets); + + const result = await serviceWithTerminal.getMarkets({ + provider: mockProvider, + context: mockContext, + useTerminalApi: true, + params: { symbols: ['SOL'] }, + }); + + expect(result).toEqual(providerMarkets); + }); }); describe('getMarketDataWithPrices with useTerminalApi', () => { From 3478db9942a00ab8409cd8575811da52701a1f9e Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Tue, 16 Jun 2026 10:13:16 +0200 Subject: [PATCH 03/11] fix(perps-controller): fetch terminal metadata in parallel with provider data The comment stated the two fetches ran in parallel, but the code awaited the terminal metadata before starting the provider fetch. Use Promise.all so both requests fly concurrently, reducing wall-clock latency when the Terminal API flag is enabled. --- .../src/services/MarketDataService.ts | 36 ++++++++++--------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/packages/perps-controller/src/services/MarketDataService.ts b/packages/perps-controller/src/services/MarketDataService.ts index 918c8dd12a..e78fd16983 100644 --- a/packages/perps-controller/src/services/MarketDataService.ts +++ b/packages/perps-controller/src/services/MarketDataService.ts @@ -886,22 +886,26 @@ export class MarketDataService { // Fetch Terminal API metadata in parallel with provider data when enabled. // Terminal metadata enriches the provider result (name, keywords, tags, // categories) but never replaces live pricing / funding data. - let terminalMetadata: Map | undefined; - if (useTerminalApi && this.#terminalMarketService) { - try { - const result = await this.#terminalMarketService.fetchMarkets(); - if (result.metadata.size > 0) { - terminalMetadata = result.metadata; - } - } catch (terminalError) { - this.#terminalMarketService.logError( - terminalError, - 'getMarketDataWithPrices', - ); - } - } - - const markets = await provider.getMarketDataWithPrices(); + const terminalMetadataPromise = + useTerminalApi && this.#terminalMarketService + ? this.#terminalMarketService + .fetchMarkets() + .then((result) => + result.metadata.size > 0 ? result.metadata : undefined, + ) + .catch((terminalError: unknown) => { + this.#terminalMarketService?.logError( + terminalError, + 'getMarketDataWithPrices', + ); + return undefined; + }) + : undefined; + + const [markets, terminalMetadata] = await Promise.all([ + provider.getMarketDataWithPrices(), + terminalMetadataPromise, + ]); // Enrich with terminal metadata when available const enriched = terminalMetadata From 48d5c51bf5773d2967934a7439bcff60e115df69 Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Tue, 16 Jun 2026 10:14:13 +0200 Subject: [PATCH 04/11] fix(perps-controller): make terminalApiBaseUrl optional on PerpsPlatformDependencies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding a required field to PerpsPlatformDependencies is a breaking change for all existing consumers. Since the Terminal API feature is behind a remote flag, the URL should be optional — when omitted the service is not instantiated and the integration is disabled regardless of the flag. --- packages/perps-controller/CHANGELOG.md | 2 +- packages/perps-controller/src/PerpsController.ts | 6 ++++-- packages/perps-controller/src/types/index.ts | 5 +++-- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index adbf291100..1f3f95e18a 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `TerminalMarketService` fetches structured market metadata from `{terminalApiBaseUrl}/perpetuals` with a 5-minute cache TTL. - When enabled, `getMarkets()` attempts the Terminal API first; on failure or empty response, falls back silently to HyperLiquid. - `getMarketDataWithPrices()` enriches provider data with Terminal API metadata (name, keywords, tags, categories). - - `PerpsPlatformDependencies` gains a required `terminalApiBaseUrl: string` field; clients must inject the correct environment URL. + - `PerpsPlatformDependencies` gains an optional `terminalApiBaseUrl?: string` field; when omitted, the Terminal API integration is disabled regardless of the feature flag. - `PerpsMarketData` gains optional `keywords`, `tags`, and `categories` fields. - `transformMarketData()` accepts optional `terminalMetadata` parameter to override static name/category maps per symbol. - Market search (`getMarketMatchRank`, `rankMarketsByQuery`) now indexes the `keywords` field for richer search results. diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index 35abff19b2..f2d484c7da 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -932,7 +932,7 @@ export class PerpsController extends BaseController< readonly #marketDataService: MarketDataService; - readonly #terminalMarketService: TerminalMarketService; + readonly #terminalMarketService: TerminalMarketService | undefined; readonly #accountService: AccountService; @@ -973,7 +973,9 @@ export class PerpsController extends BaseController< // Instantiate services with platform dependencies // Services that need cross-controller access receive the messenger this.#tradingService = new TradingService(infrastructure); - this.#terminalMarketService = new TerminalMarketService(infrastructure); + this.#terminalMarketService = infrastructure.terminalApiBaseUrl + ? new TerminalMarketService(infrastructure) + : undefined; this.#marketDataService = new MarketDataService( infrastructure, this.#terminalMarketService, diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index 7c061c0370..f4caa235a3 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -1670,9 +1670,10 @@ export type PerpsPlatformDependencies = { /** * Base URL for the MetaMask Terminal API. * Each client build (dev/uat/prd) injects the appropriate environment URL. - * Never hardcoded in controller code — always provided by the platform. + * Optional — when omitted, the Terminal API integration is disabled + * regardless of the remote feature flag. */ - terminalApiBaseUrl: string; + terminalApiBaseUrl?: string; // === Rewards (DI — no RewardsController in Core yet) === rewards: { From 122c8ba82d7beaaff8da9486bcd98cf1875bc722 Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Tue, 16 Jun 2026 10:15:01 +0200 Subject: [PATCH 05/11] fix(perps-controller): validate marketType from Terminal API against known values The #extractMetadata method cast item.marketType to MarketType with an unchecked `as` assertion. If the API returned an unrecognised value (e.g. "derivatives"), it would propagate silently and could cause downstream category filtering or UI issues. Now only values present in MARKET_CATEGORIES are accepted; unknown strings are dropped. --- .../src/services/TerminalMarketService.ts | 8 +++++++- .../src/services/TerminalMarketService.test.ts | 16 ++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/perps-controller/src/services/TerminalMarketService.ts b/packages/perps-controller/src/services/TerminalMarketService.ts index 9bc20e9575..673ce4e4e3 100644 --- a/packages/perps-controller/src/services/TerminalMarketService.ts +++ b/packages/perps-controller/src/services/TerminalMarketService.ts @@ -4,8 +4,11 @@ import type { MarketType, PerpsPlatformDependencies, } from '../types'; +import { MARKET_CATEGORIES } from '../types'; import { ensureError } from '../utils/errorUtils'; +const VALID_MARKET_TYPES = new Set(MARKET_CATEGORIES); + /** * Metadata extracted from Terminal API for a single asset. * Used downstream by transformMarketData to enrich PerpsMarketData. @@ -165,7 +168,10 @@ export class TerminalMarketService { if (Array.isArray(item.categories) && item.categories.length > 0) { entry.categories = item.categories; } - if (typeof item.marketType === 'string' && item.marketType.length > 0) { + if ( + typeof item.marketType === 'string' && + VALID_MARKET_TYPES.has(item.marketType) + ) { entry.marketType = item.marketType as MarketType; } diff --git a/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts b/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts index b8fb22cce4..0b83b77fd5 100644 --- a/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts +++ b/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts @@ -220,6 +220,22 @@ describe('TerminalMarketService', () => { expect(metadata.get('UNKNOWN')?.name).toBe('UNKNOWN'); }); + + it('ignores unrecognised marketType values from the API', async () => { + jest.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: () => + Promise.resolve([ + { symbol: 'XYZ', name: 'Unknown Type', marketType: 'derivatives' }, + ]), + } as Response); + + const { metadata } = await service.fetchMarkets(); + + expect(metadata.get('XYZ')?.marketType).toBeUndefined(); + }); }); describe('cache behavior', () => { From ffb4791c9c56e79e9b8faf6e4c0982dd24c546cd Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Tue, 16 Jun 2026 10:54:21 +0200 Subject: [PATCH 06/11] fix(perps-controller): add runtime schema validation for Terminal API responses Replace unsafe `as` type cast with per-item superstruct validation in TerminalMarketService. Items that fail validation are filtered out and logged instead of silently accepted. Also fix API path to /v1/perpetuals and add a temporary test script for manual verification. --- packages/perps-controller/CHANGELOG.md | 2 + packages/perps-controller/package.json | 1 + .../scripts/test-terminal-api.ts | 91 +++++++++++++++++++ .../src/constants/perpsConfig.ts | 2 +- .../src/services/TerminalMarketService.ts | 90 ++++++++++++++---- .../services/TerminalMarketService.test.ts | 38 +++++++- yarn.lock | 1 + 7 files changed, 204 insertions(+), 21 deletions(-) create mode 100644 packages/perps-controller/scripts/test-terminal-api.ts diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 1f3f95e18a..ebec95ac5c 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -21,6 +21,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Replace unsafe `as` type cast with runtime schema validation (`@metamask/superstruct`) in `TerminalMarketService` ([#TBD](https://github.com/MetaMask/core/pull/TBD)) + - Each item in the Terminal API response is now individually validated; items that fail validation are filtered out and logged instead of silently accepted. - 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/package.json b/packages/perps-controller/package.json index 23f8a4843f..bd72aa92e4 100644 --- a/packages/perps-controller/package.json +++ b/packages/perps-controller/package.json @@ -100,6 +100,7 @@ "@metamask/base-controller": "^9.1.0", "@metamask/controller-utils": "^12.2.0", "@metamask/messenger": "^1.2.0", + "@metamask/superstruct": "^3.2.1", "@metamask/utils": "^11.11.0", "@nktkas/hyperliquid": "^0.32.2", "bignumber.js": "^9.1.2", diff --git a/packages/perps-controller/scripts/test-terminal-api.ts b/packages/perps-controller/scripts/test-terminal-api.ts new file mode 100644 index 0000000000..ae9e6455f6 --- /dev/null +++ b/packages/perps-controller/scripts/test-terminal-api.ts @@ -0,0 +1,91 @@ +/* eslint-disable no-console */ +import { TerminalMarketService } from '../src/services/TerminalMarketService'; +import type { PerpsPlatformDependencies } from '../src/types'; + +const ENDPOINTS: Record = { + dev: 'https://terminal.dev-api.cx.metamask.io', + uat: 'https://terminal.uat-api.cx.metamask.io', + prd: 'https://terminal.api.cx.metamask.io', +}; + +const env = process.argv[2] || 'dev'; +const baseUrl = ENDPOINTS[env]; + +if (!baseUrl) { + console.error(`Unknown environment "${env}". Use: dev, uat, or prd`); + process.exit(1); +} + +const noop = (): void => undefined; + +const deps = { + terminalApiBaseUrl: baseUrl, + logger: { + error: (err: Error, meta: unknown) => { + console.warn('[validation error]', err.message, meta); + }, + }, + debugLogger: { log: noop }, + metrics: { trackEvent: noop }, + performance: { now: () => performance.now() }, + tracer: { trace: noop, endTrace: noop, setMeasurement: noop, addBreadcrumb: noop }, +} as unknown as PerpsPlatformDependencies; + +const service = new TerminalMarketService(deps); + +async function main(): Promise { + console.log(`Fetching from ${baseUrl} via TerminalMarketService...\n`); + + const { markets, metadata } = await service.fetchMarkets(); + + console.log(`Total markets: ${markets.length}`); + console.log(`Metadata entries: ${metadata.size}\n`); + + // Response shape from first market + if (markets.length > 0) { + console.log('=== MarketInfo shape (first item) ==='); + console.log(JSON.stringify(markets[0], null, 2)); + console.log(); + } + + // First 5 markets + console.log('=== First 5 MarketInfo items ==='); + for (const m of markets.slice(0, 5)) { + console.log(JSON.stringify(m, null, 2)); + console.log('---'); + } + + // All symbols + console.log('\n=== All symbols ==='); + console.log(markets.map((m) => m.name).join(', ')); + + // Metadata enrichment stats + const withName = [...metadata.values()].filter((m) => m.name); + const withKeywords = [...metadata.values()].filter((m) => m.keywords?.length); + const withTags = [...metadata.values()].filter((m) => m.tags?.length); + const withCategories = [...metadata.values()].filter((m) => m.categories?.length); + const marketTypes = [ + ...new Set([...metadata.values()].map((m) => m.marketType).filter(Boolean)), + ]; + + console.log('\n=== Metadata enrichment stats ==='); + console.log(`Entries with name: ${withName.length} / ${metadata.size}`); + console.log(`Entries with keywords: ${withKeywords.length} / ${metadata.size}`); + console.log(`Entries with tags: ${withTags.length} / ${metadata.size}`); + console.log(`Entries with categories: ${withCategories.length} / ${metadata.size}`); + console.log(`Distinct marketTypes: ${marketTypes.join(', ') || '(none)'}`); + + // Sample fully-enriched metadata entry + const enrichedEntry = [...metadata.entries()].find( + ([, m]) => m.keywords?.length && m.marketType, + ); + if (enrichedEntry) { + console.log(`\n=== Sample enriched metadata (${enrichedEntry[0]}) ===`); + console.log(JSON.stringify(enrichedEntry[1], null, 2)); + } +} + +main().catch((err) => { + console.error('Service error:', (err as Error).message); + process.exit(1); +}); diff --git a/packages/perps-controller/src/constants/perpsConfig.ts b/packages/perps-controller/src/constants/perpsConfig.ts index 589f31f77a..d13e279198 100644 --- a/packages/perps-controller/src/constants/perpsConfig.ts +++ b/packages/perps-controller/src/constants/perpsConfig.ts @@ -340,7 +340,7 @@ export const TERMINAL_API_CONFIG = { uat: 'https://terminal.uat-api.cx.metamask.io', prd: 'https://terminal.api.cx.metamask.io', }, - PerpetualPath: '/perpetuals', + PerpetualPath: '/v1/perpetuals', CacheTtlMs: 5 * 60 * 1000, // 5 minutes } as const; diff --git a/packages/perps-controller/src/services/TerminalMarketService.ts b/packages/perps-controller/src/services/TerminalMarketService.ts index 673ce4e4e3..f185bb805b 100644 --- a/packages/perps-controller/src/services/TerminalMarketService.ts +++ b/packages/perps-controller/src/services/TerminalMarketService.ts @@ -1,3 +1,15 @@ +import type { Infer } from '@metamask/superstruct'; +import { + array, + boolean, + is, + nullable, + number, + optional, + string, + type, +} from '@metamask/superstruct'; + import { TERMINAL_API_CONFIG } from '../constants/perpsConfig'; import type { MarketInfo, @@ -22,23 +34,30 @@ export type TerminalAssetMetadata = { }; /** - * Shape of a single market item returned by GET {terminalApiBaseUrl}/perpetuals. - * Kept intentionally loose — validated at parse time. + * Runtime validation schema for a single market item returned by + * `GET {terminalApiBaseUrl}/perpetuals`. + * + * 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. */ -type TerminalPerpetualItem = { - symbol: string; - name?: string; - szDecimals?: number; - maxLeverage?: number; - marginTableId?: number; - onlyIsolated?: boolean; - isDelisted?: boolean; - minimumOrderSize?: number; - keywords?: string[]; - tags?: string[]; - categories?: string[]; - marketType?: string; -}; +const TerminalPerpetualItemStruct = type({ + symbol: string(), + name: optional(nullable(string())), + szDecimals: optional(number()), + maxLeverage: optional(number()), + marginTableId: optional(number()), + onlyIsolated: optional(boolean()), + isDelisted: optional(boolean()), + minimumOrderSize: optional(number()), + keywords: optional(nullable(array(string()))), + tags: optional(nullable(array(string()))), + categories: optional(nullable(array(string()))), + marketType: optional(nullable(string())), +}); + +type TerminalPerpetualItem = Infer; type CacheEntry = { markets: MarketInfo[]; @@ -102,7 +121,7 @@ export class TerminalMarketService { ); } - const items = body as TerminalPerpetualItem[]; + const items = this.#validateItems(body); const markets = this.#mapToMarketInfo(items); const metadata = this.#extractMetadata(items); @@ -117,6 +136,43 @@ export class TerminalMarketService { this.#cache = null; } + /** + * Validate raw API response items against the expected schema. + * Items that fail validation are filtered out and logged rather than + * rejecting the entire response. + * + * @param raw - The raw array from the API response body. + * @returns Array of validated items. + */ + #validateItems(raw: unknown[]): TerminalPerpetualItem[] { + const valid: TerminalPerpetualItem[] = []; + for (const item of raw) { + if (is(item, TerminalPerpetualItemStruct)) { + valid.push(item); + } else { + this.#deps.logger.error( + ensureError( + new Error('Terminal API item failed schema validation'), + 'TerminalMarketService.validateItems', + ), + { + tags: { feature: 'perps', source: 'terminal-api' }, + context: { + name: 'TerminalMarketService.validateItems', + data: { + symbol: + typeof item === 'object' && item !== null && 'symbol' in item + ? (item as Record).symbol + : undefined, + }, + }, + }, + ); + } + } + return valid; + } + /** * Map Terminal API items to the protocol-agnostic MarketInfo shape. * diff --git a/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts b/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts index 0b83b77fd5..58f253e3bc 100644 --- a/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts +++ b/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts @@ -61,7 +61,7 @@ describe('TerminalMarketService', () => { const { markets, metadata } = await service.fetchMarkets(); expect(globalThis.fetch).toHaveBeenCalledWith( - 'https://terminal.test-api.cx.metamask.io/perpetuals', + 'https://terminal.test-api.cx.metamask.io/v1/perpetuals', { method: 'GET', headers: { 'Content-Type': 'application/json' } }, ); @@ -114,7 +114,7 @@ describe('TerminalMarketService', () => { await service.fetchMarkets(); expect(globalThis.fetch).toHaveBeenCalledWith( - 'https://terminal.api.cx.metamask.io/perpetuals', + 'https://terminal.api.cx.metamask.io/v1/perpetuals', expect.any(Object), ); }); @@ -177,7 +177,6 @@ describe('TerminalMarketService', () => { json: () => Promise.resolve([ { symbol: '', name: 'Empty' }, - { name: 'NoSymbol' }, { symbol: 'VALID', name: 'Valid' }, ]), } as Response); @@ -190,6 +189,39 @@ describe('TerminalMarketService', () => { expect(metadata.has('VALID')).toBe(true); }); + it('filters out items that fail schema validation and logs errors', async () => { + jest.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: () => + Promise.resolve([ + { symbol: 123 }, + { name: 'NoSymbol' }, + 'not-an-object', + { symbol: 'VALID', name: 'Valid' }, + ]), + } as Response); + + const { markets, metadata } = await service.fetchMarkets(); + + expect(markets).toHaveLength(1); + expect(markets[0]?.name).toBe('VALID'); + expect(metadata.size).toBe(1); + expect(mockDeps.logger.error).toHaveBeenCalledTimes(3); + expect(mockDeps.logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Terminal API item failed schema validation', + }), + expect.objectContaining({ + tags: { feature: 'perps', source: 'terminal-api' }, + context: expect.objectContaining({ + name: 'TerminalMarketService.validateItems', + }), + }), + ); + }); + it('uses defaults for missing numeric fields', async () => { jest.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, diff --git a/yarn.lock b/yarn.lock index a22201eb7e..01f4264308 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7855,6 +7855,7 @@ __metadata: "@metamask/network-controller": "npm:^32.0.0" "@metamask/profile-sync-controller": "npm:^28.2.0" "@metamask/remote-feature-flag-controller": "npm:^4.2.2" + "@metamask/superstruct": "npm:^3.2.1" "@metamask/transaction-controller": "npm:^68.0.0" "@metamask/utils": "npm:^11.11.0" "@myx-trade/sdk": "npm:^0.1.265" From deb3e34a8c30f93afe8f85f39637476a2c444fd9 Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Tue, 16 Jun 2026 10:54:44 +0200 Subject: [PATCH 07/11] Revert "fix(perps-controller): apply params filtering to Terminal API getMarkets path" This reverts commit 2e6e684520f637f2f0b19f17c2dd8025a19adc71. --- .../src/services/MarketDataService.ts | 50 +++---------------- .../src/services/MarketDataService.test.ts | 38 -------------- 2 files changed, 7 insertions(+), 81 deletions(-) diff --git a/packages/perps-controller/src/services/MarketDataService.ts b/packages/perps-controller/src/services/MarketDataService.ts index e78fd16983..cd14424239 100644 --- a/packages/perps-controller/src/services/MarketDataService.ts +++ b/packages/perps-controller/src/services/MarketDataService.ts @@ -766,20 +766,14 @@ export class MarketDataService { const { markets: terminalMarkets } = await this.#terminalMarketService.fetchMarkets(); if (terminalMarkets.length > 0) { - const filtered = this.#applyGetMarketsParams( - terminalMarkets, - params, - ); - if (filtered.length > 0) { - if (context.stateManager) { - context.stateManager.update((state) => { - state.lastError = null; - state.lastUpdateTimestamp = Date.now(); - }); - } - traceData = { success: true }; - return filtered; + if (context.stateManager) { + context.stateManager.update((state) => { + state.lastError = null; + state.lastUpdateTimestamp = Date.now(); + }); } + traceData = { success: true }; + return terminalMarkets; } } catch (terminalError) { this.#terminalMarketService.logError(terminalError, 'getMarkets'); @@ -1339,36 +1333,6 @@ export class MarketDataService { * @param metadata - Per-symbol metadata from the Terminal API. * @returns Enriched market data array. */ - /** - * Apply GetMarketsParams filtering to Terminal API results. - * Replicates the symbol/dex subset that the provider would apply. - * - * @param markets - Unfiltered Terminal API markets. - * @param params - Caller-supplied filter params. - * @returns Filtered markets. - */ - #applyGetMarketsParams( - markets: MarketInfo[], - params?: GetMarketsParams, - ): MarketInfo[] { - if (!params) { - return markets; - } - let result = markets; - if (params.symbols && params.symbols.length > 0) { - const symbolSet = new Set(params.symbols); - result = result.filter((m) => symbolSet.has(m.name)); - } - if (params.dex !== undefined) { - result = result.filter((m) => { - const colonIdx = m.name.indexOf(':'); - const marketDex = colonIdx === -1 ? '' : m.name.substring(0, colonIdx); - return marketDex === params.dex; - }); - } - return result; - } - #enrichWithTerminalMetadata( markets: PerpsMarketData[], metadata: Map, diff --git a/packages/perps-controller/tests/src/services/MarketDataService.test.ts b/packages/perps-controller/tests/src/services/MarketDataService.test.ts index 256d53ad16..40d9cbaf11 100644 --- a/packages/perps-controller/tests/src/services/MarketDataService.test.ts +++ b/packages/perps-controller/tests/src/services/MarketDataService.test.ts @@ -1245,44 +1245,6 @@ describe('MarketDataService', () => { expect(result).toEqual(providerMarkets); expect(mockTerminalService.fetchMarkets).not.toHaveBeenCalled(); }); - - it('filters terminal markets by params.symbols', async () => { - mockTerminalService.fetchMarkets.mockResolvedValue({ - markets: terminalMarkets, - metadata: terminalMetadata, - }); - - const result = await serviceWithTerminal.getMarkets({ - provider: mockProvider, - context: mockContext, - useTerminalApi: true, - params: { symbols: ['BTC'] }, - }); - - expect(result).toHaveLength(1); - expect(result[0]?.name).toBe('BTC'); - expect(mockProvider.getMarkets).not.toHaveBeenCalled(); - }); - - it('falls back to provider when terminal markets are filtered to empty', async () => { - const providerMarkets: MarketInfo[] = [ - { name: 'SOL', szDecimals: 3, maxLeverage: 20, marginTableId: 5 }, - ]; - mockTerminalService.fetchMarkets.mockResolvedValue({ - markets: terminalMarkets, - metadata: terminalMetadata, - }); - mockProvider.getMarkets.mockResolvedValue(providerMarkets); - - const result = await serviceWithTerminal.getMarkets({ - provider: mockProvider, - context: mockContext, - useTerminalApi: true, - params: { symbols: ['SOL'] }, - }); - - expect(result).toEqual(providerMarkets); - }); }); describe('getMarketDataWithPrices with useTerminalApi', () => { From a2116df5d637adef7d39e9df61d56daf54ed5547 Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Tue, 16 Jun 2026 10:54:47 +0200 Subject: [PATCH 08/11] Revert "fix(perps-controller): fetch terminal metadata in parallel with provider data" This reverts commit 3478db9942a00ab8409cd8575811da52701a1f9e. --- .../src/services/MarketDataService.ts | 36 +++++++++---------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/packages/perps-controller/src/services/MarketDataService.ts b/packages/perps-controller/src/services/MarketDataService.ts index cd14424239..3385d7a9fa 100644 --- a/packages/perps-controller/src/services/MarketDataService.ts +++ b/packages/perps-controller/src/services/MarketDataService.ts @@ -880,26 +880,22 @@ export class MarketDataService { // Fetch Terminal API metadata in parallel with provider data when enabled. // Terminal metadata enriches the provider result (name, keywords, tags, // categories) but never replaces live pricing / funding data. - const terminalMetadataPromise = - useTerminalApi && this.#terminalMarketService - ? this.#terminalMarketService - .fetchMarkets() - .then((result) => - result.metadata.size > 0 ? result.metadata : undefined, - ) - .catch((terminalError: unknown) => { - this.#terminalMarketService?.logError( - terminalError, - 'getMarketDataWithPrices', - ); - return undefined; - }) - : undefined; - - const [markets, terminalMetadata] = await Promise.all([ - provider.getMarketDataWithPrices(), - terminalMetadataPromise, - ]); + let terminalMetadata: Map | undefined; + if (useTerminalApi && this.#terminalMarketService) { + try { + const result = await this.#terminalMarketService.fetchMarkets(); + if (result.metadata.size > 0) { + terminalMetadata = result.metadata; + } + } catch (terminalError) { + this.#terminalMarketService.logError( + terminalError, + 'getMarketDataWithPrices', + ); + } + } + + const markets = await provider.getMarketDataWithPrices(); // Enrich with terminal metadata when available const enriched = terminalMetadata From e7a60b74096ce2e0ca08aed7f16212112b22c250 Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Tue, 16 Jun 2026 10:54:52 +0200 Subject: [PATCH 09/11] Revert "fix(perps-controller): make terminalApiBaseUrl optional on PerpsPlatformDependencies" This reverts commit 48d5c51bf5773d2967934a7439bcff60e115df69. --- packages/perps-controller/CHANGELOG.md | 2 +- packages/perps-controller/src/PerpsController.ts | 6 ++---- packages/perps-controller/src/types/index.ts | 5 ++--- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index ebec95ac5c..a5de39acce 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `TerminalMarketService` fetches structured market metadata from `{terminalApiBaseUrl}/perpetuals` with a 5-minute cache TTL. - When enabled, `getMarkets()` attempts the Terminal API first; on failure or empty response, falls back silently to HyperLiquid. - `getMarketDataWithPrices()` enriches provider data with Terminal API metadata (name, keywords, tags, categories). - - `PerpsPlatformDependencies` gains an optional `terminalApiBaseUrl?: string` field; when omitted, the Terminal API integration is disabled regardless of the feature flag. + - `PerpsPlatformDependencies` gains a required `terminalApiBaseUrl: string` field; clients must inject the correct environment URL. - `PerpsMarketData` gains optional `keywords`, `tags`, and `categories` fields. - `transformMarketData()` accepts optional `terminalMetadata` parameter to override static name/category maps per symbol. - Market search (`getMarketMatchRank`, `rankMarketsByQuery`) now indexes the `keywords` field for richer search results. diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index f2d484c7da..35abff19b2 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -932,7 +932,7 @@ export class PerpsController extends BaseController< readonly #marketDataService: MarketDataService; - readonly #terminalMarketService: TerminalMarketService | undefined; + readonly #terminalMarketService: TerminalMarketService; readonly #accountService: AccountService; @@ -973,9 +973,7 @@ export class PerpsController extends BaseController< // Instantiate services with platform dependencies // Services that need cross-controller access receive the messenger this.#tradingService = new TradingService(infrastructure); - this.#terminalMarketService = infrastructure.terminalApiBaseUrl - ? new TerminalMarketService(infrastructure) - : undefined; + this.#terminalMarketService = new TerminalMarketService(infrastructure); this.#marketDataService = new MarketDataService( infrastructure, this.#terminalMarketService, diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index f4caa235a3..7c061c0370 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -1670,10 +1670,9 @@ export type PerpsPlatformDependencies = { /** * Base URL for the MetaMask Terminal API. * Each client build (dev/uat/prd) injects the appropriate environment URL. - * Optional — when omitted, the Terminal API integration is disabled - * regardless of the remote feature flag. + * Never hardcoded in controller code — always provided by the platform. */ - terminalApiBaseUrl?: string; + terminalApiBaseUrl: string; // === Rewards (DI — no RewardsController in Core yet) === rewards: { From 1f259508378d0f6123815def53b3b262902cc536 Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Tue, 16 Jun 2026 10:54:55 +0200 Subject: [PATCH 10/11] Revert "fix(perps-controller): validate marketType from Terminal API against known values" This reverts commit 122c8ba82d7beaaff8da9486bcd98cf1875bc722. --- .../src/services/TerminalMarketService.ts | 8 +------- .../src/services/TerminalMarketService.test.ts | 16 ---------------- 2 files changed, 1 insertion(+), 23 deletions(-) diff --git a/packages/perps-controller/src/services/TerminalMarketService.ts b/packages/perps-controller/src/services/TerminalMarketService.ts index f185bb805b..b4c37c69ef 100644 --- a/packages/perps-controller/src/services/TerminalMarketService.ts +++ b/packages/perps-controller/src/services/TerminalMarketService.ts @@ -16,11 +16,8 @@ import type { MarketType, PerpsPlatformDependencies, } from '../types'; -import { MARKET_CATEGORIES } from '../types'; import { ensureError } from '../utils/errorUtils'; -const VALID_MARKET_TYPES = new Set(MARKET_CATEGORIES); - /** * Metadata extracted from Terminal API for a single asset. * Used downstream by transformMarketData to enrich PerpsMarketData. @@ -224,10 +221,7 @@ export class TerminalMarketService { if (Array.isArray(item.categories) && item.categories.length > 0) { entry.categories = item.categories; } - if ( - typeof item.marketType === 'string' && - VALID_MARKET_TYPES.has(item.marketType) - ) { + if (typeof item.marketType === 'string' && item.marketType.length > 0) { entry.marketType = item.marketType as MarketType; } diff --git a/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts b/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts index 58f253e3bc..b3d0db44d8 100644 --- a/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts +++ b/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts @@ -252,22 +252,6 @@ describe('TerminalMarketService', () => { expect(metadata.get('UNKNOWN')?.name).toBe('UNKNOWN'); }); - - it('ignores unrecognised marketType values from the API', async () => { - jest.spyOn(globalThis, 'fetch').mockResolvedValue({ - ok: true, - status: 200, - statusText: 'OK', - json: () => - Promise.resolve([ - { symbol: 'XYZ', name: 'Unknown Type', marketType: 'derivatives' }, - ]), - } as Response); - - const { metadata } = await service.fetchMarkets(); - - expect(metadata.get('XYZ')?.marketType).toBeUndefined(); - }); }); describe('cache behavior', () => { From 8838cb6bfaedfb5c4b806dd8cea242e0a1f125c1 Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Tue, 16 Jun 2026 12:01:05 +0200 Subject: [PATCH 11/11] fix(perps-controller): replace restricted `in` operator with hasOwnProperty check --- .../perps-controller/src/services/TerminalMarketService.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/perps-controller/src/services/TerminalMarketService.ts b/packages/perps-controller/src/services/TerminalMarketService.ts index b4c37c69ef..b4f6027732 100644 --- a/packages/perps-controller/src/services/TerminalMarketService.ts +++ b/packages/perps-controller/src/services/TerminalMarketService.ts @@ -158,7 +158,9 @@ export class TerminalMarketService { name: 'TerminalMarketService.validateItems', data: { symbol: - typeof item === 'object' && item !== null && 'symbol' in item + typeof item === 'object' && + item !== null && + Object.prototype.hasOwnProperty.call(item, 'symbol') ? (item as Record).symbol : undefined, },