From ec0a27fe5f510352e897b0dc39f71956c4356316 Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Tue, 16 Jun 2026 10:08:22 +0200 Subject: [PATCH 01/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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, }, From 3b647a83c22310a57e07a41bb88720a348bdeee5 Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Wed, 17 Jun 2026 11:57:30 +0200 Subject: [PATCH 12/31] test(perps-controller): verify Terminal API parsing tolerates extra properties --- .../services/TerminalMarketService.test.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts b/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts index b3d0db44d8..75b85db696 100644 --- a/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts +++ b/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts @@ -222,6 +222,42 @@ describe('TerminalMarketService', () => { ); }); + it('accepts items with extra properties returned by the backend', async () => { + jest.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: () => + Promise.resolve([ + { + symbol: 'BTC', + name: 'Bitcoin', + szDecimals: 5, + maxLeverage: 50, + marginTableId: 0, + // Extra properties not in the schema + price: 67000.5, + iconUrl: 'https://example.com/btc.png', + trend: 'bullish', + volume24h: 1234567890, + sparklineData: [65000, 66000, 67000], + }, + ]), + } as Response); + + const { markets, metadata } = await service.fetchMarkets(); + + expect(markets).toHaveLength(1); + expect(markets[0]).toEqual({ + name: 'BTC', + szDecimals: 5, + maxLeverage: 50, + marginTableId: 0, + }); + expect(metadata.get('BTC')?.name).toBe('Bitcoin'); + expect(mockDeps.logger.error).not.toHaveBeenCalled(); + }); + it('uses defaults for missing numeric fields', async () => { jest.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, From 138a209ad891780836a7e0032714e7a4e13ebc63 Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Wed, 17 Jun 2026 13:56:45 +0200 Subject: [PATCH 13/31] test(perps-controller): verify marketType accepts any non-empty string from backend --- .../services/TerminalMarketService.test.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts b/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts index 75b85db696..d660d3848e 100644 --- a/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts +++ b/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts @@ -258,6 +258,26 @@ describe('TerminalMarketService', () => { expect(mockDeps.logger.error).not.toHaveBeenCalled(); }); + it('accepts any non-empty string as marketType', async () => { + jest.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: () => + Promise.resolve([ + { symbol: 'BTC', name: 'Bitcoin', marketType: 'crypto' }, + { symbol: 'MEME', name: 'MemeCoin', marketType: 'meme' }, + { symbol: 'FOO', name: 'Foo', marketType: '' }, + ]), + } as Response); + + const { metadata } = await service.fetchMarkets(); + + expect(metadata.get('BTC')?.marketType).toBe('crypto'); + expect(metadata.get('MEME')?.marketType).toBe('meme'); + expect(metadata.get('FOO')?.marketType).toBeUndefined(); + }); + it('uses defaults for missing numeric fields', async () => { jest.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, From 90d0310365d4ceade7ae613632103a3a8e2c65e7 Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Wed, 17 Jun 2026 14:02:32 +0200 Subject: [PATCH 14/31] fix(perps-controller): align @metamask/superstruct version to ^3.1.0 for monorepo consistency --- packages/perps-controller/package.json | 2 +- yarn.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/perps-controller/package.json b/packages/perps-controller/package.json index bd72aa92e4..deaa85cb0b 100644 --- a/packages/perps-controller/package.json +++ b/packages/perps-controller/package.json @@ -100,7 +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/superstruct": "^3.1.0", "@metamask/utils": "^11.11.0", "@nktkas/hyperliquid": "^0.32.2", "bignumber.js": "^9.1.2", diff --git a/yarn.lock b/yarn.lock index 01f4264308..8489fe591b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7855,7 +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/superstruct": "npm:^3.1.0" "@metamask/transaction-controller": "npm:^68.0.0" "@metamask/utils": "npm:^11.11.0" "@myx-trade/sdk": "npm:^0.1.265" From 1538343225459da3fa3dd1c4ff98a6ff02cd9b09 Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Wed, 17 Jun 2026 14:03:13 +0200 Subject: [PATCH 15/31] docs(perps-controller): update changelog PR links to #9137 --- packages/perps-controller/CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index a5de39acce..53bed8da8b 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add Terminal API integration for market data behind `perpsTerminalApiMarkets` feature flag ([#TBD](https://github.com/MetaMask/core/pull/TBD)) +- Add Terminal API integration for market data behind `perpsTerminalApiMarkets` feature flag ([#9137](https://github.com/MetaMask/core/pull/9137)) - `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). @@ -21,7 +21,7 @@ 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)) +- Replace unsafe `as` type cast with runtime schema validation (`@metamask/superstruct`) in `TerminalMarketService` ([#9137](https://github.com/MetaMask/core/pull/9137)) - 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)) From ac5a9592aa257b71869e9475f51156068255a4a2 Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Wed, 17 Jun 2026 15:18:53 +0200 Subject: [PATCH 16/31] fix(perps-controller): resolve lint violations in tests and remove debug script --- .../scripts/test-terminal-api.ts | 91 ------------------- .../services/TerminalMarketService.test.ts | 19 ++-- .../src/utils/marketDataTransform.test.ts | 6 +- .../tests/src/utils/marketSearch.test.ts | 2 +- 4 files changed, 12 insertions(+), 106 deletions(-) delete mode 100644 packages/perps-controller/scripts/test-terminal-api.ts diff --git a/packages/perps-controller/scripts/test-terminal-api.ts b/packages/perps-controller/scripts/test-terminal-api.ts deleted file mode 100644 index ae9e6455f6..0000000000 --- a/packages/perps-controller/scripts/test-terminal-api.ts +++ /dev/null @@ -1,91 +0,0 @@ -/* 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/tests/src/services/TerminalMarketService.test.ts b/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts index d660d3848e..faa685bb33 100644 --- a/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts +++ b/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts @@ -66,13 +66,13 @@ describe('TerminalMarketService', () => { ); expect(markets).toHaveLength(3); - expect(markets[0]).toEqual({ + expect(markets[0]).toStrictEqual({ name: 'BTC', szDecimals: 5, maxLeverage: 50, marginTableId: 0, }); - expect(markets[2]).toEqual({ + expect(markets[2]).toStrictEqual({ name: 'xyz:TSLA', szDecimals: 2, maxLeverage: 5, @@ -81,18 +81,18 @@ describe('TerminalMarketService', () => { }); expect(metadata.size).toBe(3); - expect(metadata.get('BTC')).toEqual({ + expect(metadata.get('BTC')).toStrictEqual({ name: 'Bitcoin', keywords: ['crypto', 'layer-1'], tags: ['top-10'], categories: ['crypto'], marketType: 'crypto', }); - expect(metadata.get('ETH')).toEqual({ + expect(metadata.get('ETH')).toStrictEqual({ name: 'Ethereum', keywords: ['defi', 'layer-1'], }); - expect(metadata.get('xyz:TSLA')).toEqual({ + expect(metadata.get('xyz:TSLA')).toStrictEqual({ name: 'Tesla', marketType: 'stock', tags: ['us-equities'], @@ -248,7 +248,7 @@ describe('TerminalMarketService', () => { const { markets, metadata } = await service.fetchMarkets(); expect(markets).toHaveLength(1); - expect(markets[0]).toEqual({ + expect(markets[0]).toStrictEqual({ name: 'BTC', szDecimals: 5, maxLeverage: 50, @@ -288,7 +288,7 @@ describe('TerminalMarketService', () => { const { markets } = await service.fetchMarkets(); - expect(markets[0]).toEqual({ + expect(markets[0]).toStrictEqual({ name: 'FOO', szDecimals: 0, maxLeverage: 1, @@ -359,13 +359,10 @@ describe('TerminalMarketService', () => { await service.fetchMarkets(); // Advance time past TTL (5 minutes) - const realDateNow = Date.now; - Date.now = () => realDateNow() + 6 * 60 * 1000; + jest.spyOn(Date, 'now').mockReturnValue(Date.now() + 6 * 60 * 1000); await service.fetchMarkets(); - Date.now = realDateNow; - expect(fetchSpy).toHaveBeenCalledTimes(2); }); }); diff --git a/packages/perps-controller/tests/src/utils/marketDataTransform.test.ts b/packages/perps-controller/tests/src/utils/marketDataTransform.test.ts index 0e699c2516..ea93a8d1cc 100644 --- a/packages/perps-controller/tests/src/utils/marketDataTransform.test.ts +++ b/packages/perps-controller/tests/src/utils/marketDataTransform.test.ts @@ -196,9 +196,9 @@ describe('transformMarketData - terminal metadata', () => { 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']); + expect(result[0]?.keywords).toStrictEqual(['crypto', 'layer-1', 'pow']); + expect(result[0]?.tags).toStrictEqual(['top-10', 'blue-chip']); + expect(result[0]?.categories).toStrictEqual(['crypto', 'major']); }); it('falls back to static maps when symbol is absent from terminal metadata', () => { diff --git a/packages/perps-controller/tests/src/utils/marketSearch.test.ts b/packages/perps-controller/tests/src/utils/marketSearch.test.ts index a1db745383..bddbd76d14 100644 --- a/packages/perps-controller/tests/src/utils/marketSearch.test.ts +++ b/packages/perps-controller/tests/src/utils/marketSearch.test.ts @@ -169,7 +169,7 @@ describe('keyword matching (Terminal API metadata)', () => { ]; const result = rankMarketsByQuery(markets, 'gold').map( - (m) => m.symbol, + (market) => market.symbol, ); expect(result).toContain('BTC'); expect(result).toContain('xyz:GOLD'); From 091b5011a328d3373e95688962b8174f45b4f906 Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Wed, 17 Jun 2026 15:29:39 +0200 Subject: [PATCH 17/31] style(perps-controller): fix prettier formatting --- .../perps-controller/src/PerpsController.ts | 4 +- .../src/services/TerminalMarketService.ts | 13 +++--- .../src/utils/marketDataTransform.ts | 3 +- .../services/TerminalMarketService.test.ts | 42 ++++++++----------- 4 files changed, 29 insertions(+), 33 deletions(-) diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index 35abff19b2..fd83d6c7e1 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -894,9 +894,7 @@ export class PerpsController extends BaseController< const remoteState = this.messenger.call( 'RemoteFeatureFlagController:getState', ); - return ( - remoteState.remoteFeatureFlags?.perpsTerminalApiMarkets === true - ); + return remoteState.remoteFeatureFlags?.perpsTerminalApiMarkets === true; } catch { return false; } diff --git a/packages/perps-controller/src/services/TerminalMarketService.ts b/packages/perps-controller/src/services/TerminalMarketService.ts index b4f6027732..796331cd90 100644 --- a/packages/perps-controller/src/services/TerminalMarketService.ts +++ b/packages/perps-controller/src/services/TerminalMarketService.ts @@ -91,7 +91,10 @@ export class TerminalMarketService { markets: MarketInfo[]; metadata: Map; }> { - if (this.#cache && Date.now() - this.#cache.timestamp < TERMINAL_API_CONFIG.CacheTtlMs) { + if ( + this.#cache && + Date.now() - this.#cache.timestamp < TERMINAL_API_CONFIG.CacheTtlMs + ) { return { markets: this.#cache.markets, metadata: this.#cache.metadata, @@ -113,9 +116,7 @@ export class TerminalMarketService { const body: unknown = await response.json(); if (!Array.isArray(body)) { - throw new Error( - `Terminal API returned non-array body: ${typeof body}`, - ); + throw new Error(`Terminal API returned non-array body: ${typeof body}`); } const items = this.#validateItems(body); @@ -180,7 +181,9 @@ export class TerminalMarketService { */ #mapToMarketInfo(items: TerminalPerpetualItem[]): MarketInfo[] { return items - .filter((item) => typeof item.symbol === 'string' && item.symbol.length > 0) + .filter( + (item) => typeof item.symbol === 'string' && item.symbol.length > 0, + ) .map((item) => ({ name: item.symbol, szDecimals: item.szDecimals ?? 0, diff --git a/packages/perps-controller/src/utils/marketDataTransform.ts b/packages/perps-controller/src/utils/marketDataTransform.ts index 79c5b41a21..37f2232bff 100644 --- a/packages/perps-controller/src/utils/marketDataTransform.ts +++ b/packages/perps-controller/src/utils/marketDataTransform.ts @@ -272,7 +272,8 @@ export function transformMarketData( const isNewMarket = isHip3 && !explicitMarketType; // Resolve name: terminal API > static name map > ticker fallback - const resolvedName = terminalMeta?.name ?? getHyperLiquidAssetName(symbol, assetNames); + const resolvedName = + terminalMeta?.name ?? getHyperLiquidAssetName(symbol, assetNames); return { symbol, diff --git a/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts b/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts index faa685bb33..1733101066 100644 --- a/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts +++ b/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts @@ -312,14 +312,12 @@ describe('TerminalMarketService', () => { 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 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(); @@ -330,14 +328,12 @@ describe('TerminalMarketService', () => { }); 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); + const fetchSpy = jest.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: () => Promise.resolve(mockApiResponse), + } as Response); await service.fetchMarkets(); service.clearCache(); @@ -347,14 +343,12 @@ describe('TerminalMarketService', () => { }); 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); + const fetchSpy = jest.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: () => Promise.resolve(mockApiResponse), + } as Response); await service.fetchMarkets(); From e4b456ca8a3398a30fc08688b14a3e8eee001721 Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Wed, 17 Jun 2026 15:49:10 +0200 Subject: [PATCH 18/31] style(perps-controller): fix oxfmt formatting in marketDataTransform --- packages/perps-controller/src/utils/marketDataTransform.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/perps-controller/src/utils/marketDataTransform.ts b/packages/perps-controller/src/utils/marketDataTransform.ts index 37f2232bff..9e09211adb 100644 --- a/packages/perps-controller/src/utils/marketDataTransform.ts +++ b/packages/perps-controller/src/utils/marketDataTransform.ts @@ -11,12 +11,12 @@ import { getHyperLiquidAssetName, } from '../constants/hyperLiquidConfig'; import { PERPS_CONSTANTS } from '../constants/perpsConfig'; +import type { TerminalAssetMetadata } from '../services/TerminalMarketService'; import type { PerpsMarketData, MarketType, MarketDataFormatters, } from '../types'; -import type { TerminalAssetMetadata } from '../services/TerminalMarketService'; import type { AllMidsResponse, PerpsUniverse, From e5533b36efc35dac00de7318a2d84362c3afe6ec Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Thu, 18 Jun 2026 11:16:45 +0200 Subject: [PATCH 19/31] refactor(perps-controller): replace remote feature flag with explicit useTerminalApi param Move Terminal API toggle from an internal remote feature flag read to a caller-provided `useTerminalApi` parameter on `GetMarketsParams` and `GetMarketDataWithPricesParams`, giving consumers direct control over the data source selection. --- .../perps-controller/src/PerpsController.ts | 26 ++++--------------- packages/perps-controller/src/types/index.ts | 2 ++ 2 files changed, 7 insertions(+), 21 deletions(-) diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index fd83d6c7e1..d72805560e 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -882,24 +882,6 @@ 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 @@ -2957,7 +2939,7 @@ export class PerpsController extends BaseController< provider, params, context: this.#createServiceContext('getMarkets'), - useTerminalApi: this.#isTerminalApiEnabled(), + useTerminalApi: params?.useTerminalApi === true, }); } @@ -2979,6 +2961,8 @@ export class PerpsController extends BaseController< async getMarketDataWithPrices( params?: GetMarketDataWithPricesParams, ): Promise { + const useTerminal = params?.useTerminalApi === true; + if (params?.standalone) { // Use activeProviderInstance if available (respects provider abstraction) // Fallback to cached standalone provider for pre-initialization discovery @@ -2988,7 +2972,7 @@ export class PerpsController extends BaseController< provider, params, context: this.#createServiceContext('getMarketDataWithPrices'), - useTerminalApi: this.#isTerminalApiEnabled(), + useTerminalApi: useTerminal, }); } @@ -2997,7 +2981,7 @@ export class PerpsController extends BaseController< provider, params, context: this.#createServiceContext('getMarketDataWithPrices'), - useTerminalApi: this.#isTerminalApiEnabled(), + useTerminalApi: useTerminal, }); } diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index 7c061c0370..da5040b99a 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -841,6 +841,7 @@ export type GetMarketsParams = { dex?: string; // HyperLiquid HIP-3: DEX name (empty string '' or undefined for main DEX). Other protocols: ignored. skipFilters?: boolean; // Skip market filtering (both allowlist and blocklist, default: false). When true, returns all markets without filtering. standalone?: boolean; // Lightweight mode: skip full initialization, only fetch market metadata (no wallet/WebSocket needed). Only main DEX markets returned. Use for discovery use cases like checking if a perps market exists. + useTerminalApi?: boolean; // When true, force Terminal API as market data source regardless of remote feature flag. }; /** @@ -855,6 +856,7 @@ export type GetMarketDataWithPricesParams = { sortBy?: SortField; // Sort results by this field direction?: SortDirection; // Sort direction (default: desc) limit?: number; // Maximum number of results to return + useTerminalApi?: boolean; // When true, force Terminal API as market data source regardless of remote feature flag. }; export type SubscribePricesParams = { From c928c7a108c642d763484244ad392586c44b2305 Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Thu, 18 Jun 2026 11:34:24 +0200 Subject: [PATCH 20/31] fix(perps-controller): route standalone getMarkets through MarketDataService and apply symbol filters - Standalone getMarkets now delegates to MarketDataService (consistent with getMarketDataWithPrices), so Terminal API is used when requested. - Terminal API path in getMarkets applies symbols filtering before returning results instead of returning the full unfiltered list. --- packages/perps-controller/src/PerpsController.ts | 11 +++++++++-- .../src/services/MarketDataService.ts | 8 +++++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index d72805560e..5106c3b221 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -2926,12 +2926,19 @@ export class PerpsController extends BaseController< async getMarkets(params?: GetMarketsParams): Promise { // For standalone mode, access provider directly without initialization check // This allows discovery use cases (checking if market exists) without full perps setup + const useTerminal = params?.useTerminalApi === true; + if (params?.standalone) { // Use activeProviderInstance if available (respects provider abstraction) // Fallback to cached standalone provider for pre-initialization discovery const provider = this.activeProviderInstance ?? this.#getOrCreateStandaloneProvider(); - return provider.getMarkets(params); + return this.#marketDataService.getMarkets({ + provider, + params, + context: this.#createServiceContext('getMarkets'), + useTerminalApi: useTerminal, + }); } const provider = this.getActiveProvider(); @@ -2939,7 +2946,7 @@ export class PerpsController extends BaseController< provider, params, context: this.#createServiceContext('getMarkets'), - useTerminalApi: params?.useTerminalApi === true, + useTerminalApi: useTerminal, }); } diff --git a/packages/perps-controller/src/services/MarketDataService.ts b/packages/perps-controller/src/services/MarketDataService.ts index 3385d7a9fa..836b8165cc 100644 --- a/packages/perps-controller/src/services/MarketDataService.ts +++ b/packages/perps-controller/src/services/MarketDataService.ts @@ -766,6 +766,12 @@ export class MarketDataService { const { markets: terminalMarkets } = await this.#terminalMarketService.fetchMarkets(); if (terminalMarkets.length > 0) { + const filtered = params?.symbols?.length + ? terminalMarkets.filter((market) => + (params.symbols as string[]).includes(market.name), + ) + : terminalMarkets; + if (context.stateManager) { context.stateManager.update((state) => { state.lastError = null; @@ -773,7 +779,7 @@ export class MarketDataService { }); } traceData = { success: true }; - return terminalMarkets; + return filtered; } } catch (terminalError) { this.#terminalMarketService.logError(terminalError, 'getMarkets'); From 43112196f8b83009c8da9955c64061a101a2fe76 Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Thu, 18 Jun 2026 11:43:11 +0200 Subject: [PATCH 21/31] fix(perps-controller): use case-insensitive symbol matching for Terminal API filter Align Terminal API symbol filtering with HyperLiquidProvider behavior by comparing symbols case-insensitively. --- packages/perps-controller/src/services/MarketDataService.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/perps-controller/src/services/MarketDataService.ts b/packages/perps-controller/src/services/MarketDataService.ts index 836b8165cc..90b37f85b3 100644 --- a/packages/perps-controller/src/services/MarketDataService.ts +++ b/packages/perps-controller/src/services/MarketDataService.ts @@ -768,7 +768,10 @@ export class MarketDataService { if (terminalMarkets.length > 0) { const filtered = params?.symbols?.length ? terminalMarkets.filter((market) => - (params.symbols as string[]).includes(market.name), + (params.symbols as string[]).some( + (sym) => + market.name.toLowerCase() === sym.toLowerCase(), + ), ) : terminalMarkets; From a5f381a0c003188f695e5b15a8a5ccdbf237b768 Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Thu, 18 Jun 2026 11:59:01 +0200 Subject: [PATCH 22/31] style(perps-controller): fix prettier formatting in MarketDataService --- packages/perps-controller/src/services/MarketDataService.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/perps-controller/src/services/MarketDataService.ts b/packages/perps-controller/src/services/MarketDataService.ts index 90b37f85b3..42b9f9a3bb 100644 --- a/packages/perps-controller/src/services/MarketDataService.ts +++ b/packages/perps-controller/src/services/MarketDataService.ts @@ -769,8 +769,7 @@ export class MarketDataService { const filtered = params?.symbols?.length ? terminalMarkets.filter((market) => (params.symbols as string[]).some( - (sym) => - market.name.toLowerCase() === sym.toLowerCase(), + (sym) => market.name.toLowerCase() === sym.toLowerCase(), ), ) : terminalMarkets; From c761cb25351da653e949c49ad76ab6891a88ef1d Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Thu, 18 Jun 2026 14:29:33 +0200 Subject: [PATCH 23/31] fix(perps-controller): address PR review feedback for Terminal API integration - Remove useTerminalApi duplication from MarketDataService method options; read it from params instead of accepting it as a separate option - Reuse deps object in MarketDataService constructor by including terminalMarketService in the deps parameter via intersection type - Replace hardcoded 'perps' magic string with PERPS_CONSTANTS.FeatureName in TerminalMarketService - Make terminalApiBaseUrl optional in PerpsPlatformDependencies so existing client factories are non-breaking on upgrade - Add early guard in TerminalMarketService.fetchMarkets() for missing URL --- .../perps-controller/src/PerpsController.ts | 22 ++++------------- .../src/services/MarketDataService.ts | 19 +++++++-------- .../src/services/TerminalMarketService.ts | 12 +++++++--- packages/perps-controller/src/types/index.ts | 3 ++- .../tests/src/PerpsController.trading.test.ts | 1 - .../src/services/MarketDataService.test.ts | 24 +++++++++---------- 6 files changed, 36 insertions(+), 45 deletions(-) diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index 5106c3b221..144bd0c46a 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -954,10 +954,10 @@ export class PerpsController extends BaseController< // Services that need cross-controller access receive the messenger this.#tradingService = new TradingService(infrastructure); this.#terminalMarketService = new TerminalMarketService(infrastructure); - this.#marketDataService = new MarketDataService( - infrastructure, - this.#terminalMarketService, - ); + this.#marketDataService = new MarketDataService({ + ...infrastructure, + terminalMarketService: this.#terminalMarketService, + }); this.#accountService = new AccountService(infrastructure, messenger); this.#eligibilityService = new EligibilityService(infrastructure); this.#dataLakeService = new DataLakeService(infrastructure, messenger); @@ -2924,20 +2924,13 @@ export class PerpsController extends BaseController< * @returns Array of available markets matching the filter criteria. */ async getMarkets(params?: GetMarketsParams): Promise { - // For standalone mode, access provider directly without initialization check - // This allows discovery use cases (checking if market exists) without full perps setup - const useTerminal = params?.useTerminalApi === true; - if (params?.standalone) { - // Use activeProviderInstance if available (respects provider abstraction) - // Fallback to cached standalone provider for pre-initialization discovery const provider = this.activeProviderInstance ?? this.#getOrCreateStandaloneProvider(); return this.#marketDataService.getMarkets({ provider, params, context: this.#createServiceContext('getMarkets'), - useTerminalApi: useTerminal, }); } @@ -2946,7 +2939,6 @@ export class PerpsController extends BaseController< provider, params, context: this.#createServiceContext('getMarkets'), - useTerminalApi: useTerminal, }); } @@ -2968,18 +2960,13 @@ export class PerpsController extends BaseController< async getMarketDataWithPrices( params?: GetMarketDataWithPricesParams, ): Promise { - const useTerminal = params?.useTerminalApi === true; - if (params?.standalone) { - // Use activeProviderInstance if available (respects provider abstraction) - // Fallback to cached standalone provider for pre-initialization discovery const provider = this.activeProviderInstance ?? this.#getOrCreateStandaloneProvider(); return this.#marketDataService.getMarketDataWithPrices({ provider, params, context: this.#createServiceContext('getMarketDataWithPrices'), - useTerminalApi: useTerminal, }); } @@ -2988,7 +2975,6 @@ export class PerpsController extends BaseController< provider, params, context: this.#createServiceContext('getMarketDataWithPrices'), - useTerminalApi: useTerminal, }); } diff --git a/packages/perps-controller/src/services/MarketDataService.ts b/packages/perps-controller/src/services/MarketDataService.ts index 42b9f9a3bb..eff85d0076 100644 --- a/packages/perps-controller/src/services/MarketDataService.ts +++ b/packages/perps-controller/src/services/MarketDataService.ts @@ -59,15 +59,16 @@ export class MarketDataService { * Create a new MarketDataService instance * * @param deps - Platform dependencies for logging, metrics, etc. - * @param terminalMarketService - Optional terminal market service for + * @param deps.terminalMarketService - Optional terminal market service for * fetching market data from the Terminal API. */ constructor( - deps: PerpsPlatformDependencies, - terminalMarketService?: TerminalMarketService, + deps: PerpsPlatformDependencies & { + terminalMarketService?: TerminalMarketService; + }, ) { this.#deps = deps; - this.#terminalMarketService = terminalMarketService; + this.#terminalMarketService = deps.terminalMarketService; } /** @@ -729,16 +730,15 @@ export class MarketDataService { * @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, useTerminalApi } = options; + const { provider, params, context } = options; + const useTerminalApi = params?.useTerminalApi; const traceId = uuidv4(); let traceData: { success: boolean; error?: string } | undefined; @@ -855,16 +855,15 @@ export class MarketDataService { * @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, useTerminalApi } = options; + const { provider, params, context } = options; + const useTerminalApi = params?.useTerminalApi; const traceId = uuidv4(); let traceData: { success: boolean; error?: string } | undefined; diff --git a/packages/perps-controller/src/services/TerminalMarketService.ts b/packages/perps-controller/src/services/TerminalMarketService.ts index 796331cd90..222a91743a 100644 --- a/packages/perps-controller/src/services/TerminalMarketService.ts +++ b/packages/perps-controller/src/services/TerminalMarketService.ts @@ -10,7 +10,7 @@ import { type, } from '@metamask/superstruct'; -import { TERMINAL_API_CONFIG } from '../constants/perpsConfig'; +import { PERPS_CONSTANTS, TERMINAL_API_CONFIG } from '../constants/perpsConfig'; import type { MarketInfo, MarketType, @@ -101,6 +101,12 @@ export class TerminalMarketService { }; } + if (!this.#deps.terminalApiBaseUrl) { + throw new Error( + 'Terminal API base URL not configured (terminalApiBaseUrl is required)', + ); + } + const url = `${this.#deps.terminalApiBaseUrl}${TERMINAL_API_CONFIG.PerpetualPath}`; const response = await fetch(url, { method: 'GET', @@ -154,7 +160,7 @@ export class TerminalMarketService { 'TerminalMarketService.validateItems', ), { - tags: { feature: 'perps', source: 'terminal-api' }, + tags: { feature: PERPS_CONSTANTS.FeatureName, source: 'terminal-api' }, context: { name: 'TerminalMarketService.validateItems', data: { @@ -246,7 +252,7 @@ export class TerminalMarketService { this.#deps.logger.error( ensureError(error, `TerminalMarketService.${method}`), { - tags: { feature: 'perps', source: 'terminal-api' }, + tags: { feature: PERPS_CONSTANTS.FeatureName, 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 da5040b99a..fc977ea442 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -1673,8 +1673,9 @@ 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: only required when Terminal API features (useTerminalApi) are enabled. */ - terminalApiBaseUrl: string; + terminalApiBaseUrl?: string; // === Rewards (DI — no RewardsController in Core yet) === rewards: { diff --git a/packages/perps-controller/tests/src/PerpsController.trading.test.ts b/packages/perps-controller/tests/src/PerpsController.trading.test.ts index 486378d2a2..7e297b2843 100644 --- a/packages/perps-controller/tests/src/PerpsController.trading.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.trading.test.ts @@ -822,7 +822,6 @@ 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 40d9cbaf11..0d95d756a6 100644 --- a/packages/perps-controller/tests/src/services/MarketDataService.test.ts +++ b/packages/perps-controller/tests/src/services/MarketDataService.test.ts @@ -42,7 +42,7 @@ describe('MarketDataService', () => { mockProvider = createMockHyperLiquidProvider() as unknown as jest.Mocked; mockDeps = createMockInfrastructure(); - marketDataService = new MarketDataService(mockDeps); + marketDataService = new MarketDataService({ ...mockDeps }); mockContext = createMockServiceContext({ errorContext: { controller: 'MarketDataService', method: 'test' }, }); @@ -1165,10 +1165,10 @@ describe('MarketDataService', () => { logError: jest.fn(), } as unknown as jest.Mocked; - serviceWithTerminal = new MarketDataService( - mockDeps, - mockTerminalService, - ); + serviceWithTerminal = new MarketDataService({ + ...mockDeps, + terminalMarketService: mockTerminalService, + }); }); describe('getMarkets with useTerminalApi', () => { @@ -1180,8 +1180,8 @@ describe('MarketDataService', () => { const result = await serviceWithTerminal.getMarkets({ provider: mockProvider, + params: { useTerminalApi: true }, context: mockContext, - useTerminalApi: true, }); expect(result).toEqual(terminalMarkets); @@ -1200,8 +1200,8 @@ describe('MarketDataService', () => { const result = await serviceWithTerminal.getMarkets({ provider: mockProvider, + params: { useTerminalApi: true }, context: mockContext, - useTerminalApi: true, }); expect(result).toEqual(providerMarkets); @@ -1223,8 +1223,8 @@ describe('MarketDataService', () => { const result = await serviceWithTerminal.getMarkets({ provider: mockProvider, + params: { useTerminalApi: true }, context: mockContext, - useTerminalApi: true, }); expect(result).toEqual(providerMarkets); @@ -1238,8 +1238,8 @@ describe('MarketDataService', () => { const result = await serviceWithTerminal.getMarkets({ provider: mockProvider, + params: { useTerminalApi: false }, context: mockContext, - useTerminalApi: false, }); expect(result).toEqual(providerMarkets); @@ -1280,8 +1280,8 @@ describe('MarketDataService', () => { const result = await serviceWithTerminal.getMarketDataWithPrices({ provider: mockProvider, + params: { useTerminalApi: true }, context: mockContext, - useTerminalApi: true, }); expect(result[0]?.name).toBe('Bitcoin'); @@ -1303,8 +1303,8 @@ describe('MarketDataService', () => { const result = await serviceWithTerminal.getMarketDataWithPrices({ provider: mockProvider, + params: { useTerminalApi: true }, context: mockContext, - useTerminalApi: true, }); expect(result[0]?.name).toBe('BTC'); @@ -1319,8 +1319,8 @@ describe('MarketDataService', () => { const result = await serviceWithTerminal.getMarketDataWithPrices({ provider: mockProvider, + params: { useTerminalApi: false }, context: mockContext, - useTerminalApi: false, }); expect(result[0]?.name).toBe('BTC'); From 98b9873db9b3f1567019280393e5c02e526d8a5f Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Thu, 18 Jun 2026 14:34:26 +0200 Subject: [PATCH 24/31] style(perps-controller): fix prettier formatting in TerminalMarketService --- .../perps-controller/src/services/TerminalMarketService.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/perps-controller/src/services/TerminalMarketService.ts b/packages/perps-controller/src/services/TerminalMarketService.ts index 222a91743a..f7cc96ecaf 100644 --- a/packages/perps-controller/src/services/TerminalMarketService.ts +++ b/packages/perps-controller/src/services/TerminalMarketService.ts @@ -160,7 +160,10 @@ export class TerminalMarketService { 'TerminalMarketService.validateItems', ), { - tags: { feature: PERPS_CONSTANTS.FeatureName, source: 'terminal-api' }, + tags: { + feature: PERPS_CONSTANTS.FeatureName, + source: 'terminal-api', + }, context: { name: 'TerminalMarketService.validateItems', data: { From a3242fe34d607c664d68f581cbac1b4b21c07575 Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Thu, 18 Jun 2026 14:43:52 +0200 Subject: [PATCH 25/31] fix(perps-controller): remove hardcoded Terminal API endpoint URLs from config Endpoint URLs should only be passed from the client via PerpsPlatformDependencies.terminalApiBaseUrl, not stored in core. --- .../perps-controller/src/constants/perpsConfig.ts | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/perps-controller/src/constants/perpsConfig.ts b/packages/perps-controller/src/constants/perpsConfig.ts index d13e279198..895dd3f10b 100644 --- a/packages/perps-controller/src/constants/perpsConfig.ts +++ b/packages/perps-controller/src/constants/perpsConfig.ts @@ -329,17 +329,11 @@ export const DATA_LAKE_API_CONFIG = { } 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). + * Terminal API configuration. + * The base URL is injected at runtime via PerpsPlatformDependencies.terminalApiBaseUrl + * from each client build (dev/uat/prd); only path and cache settings live here. */ 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: '/v1/perpetuals', CacheTtlMs: 5 * 60 * 1000, // 5 minutes } as const; From 5cf1fd22785dea943540ab12252595b533bf58fe Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Thu, 18 Jun 2026 16:32:27 +0200 Subject: [PATCH 26/31] =?UTF-8?q?fix(perps-controller):=20address=20PR=20r?= =?UTF-8?q?eview=20=E2=80=94=20Terminal=20path=20filtering,=20changelog=20?= =?UTF-8?q?accuracy,=20dead=20code=20removal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Apply allowlist/blocklist filtering on the Terminal API getMarkets path so blocklisted HIP-3 markets no longer leak through when useTerminalApi is enabled. The controller now passes an isMarketAllowed callback that mirrors the provider's shouldIncludeMarket logic. - Fix changelog: terminalApiBaseUrl is optional (not required) and the feature is gated by useTerminalApi param (not a perpsTerminalApiMarkets remote flag that doesn't exist). - Rename terminalApiBaseUrl → terminalApiUrl; clients inject the full endpoint URL, removing the PerpetualPath constant. - Remove unused terminalMetadata param from transformMarketData (enrichment is handled by MarketDataService.#enrichWithTerminalMetadata). - Move TerminalAssetMetadata to types/index.ts and export it. - Validate marketType against MarketCategory enum instead of accepting any string. - Fix "in parallel" comment that described sequential awaits. - Replace intersection-type constructor with separate param for terminalMarketService. --- packages/perps-controller/CHANGELOG.md | 9 +- .../perps-controller/src/PerpsController.ts | 61 +++++++- .../src/constants/perpsConfig.ts | 6 +- packages/perps-controller/src/index.ts | 1 + .../src/services/MarketDataService.ts | 57 +++++-- .../src/services/TerminalMarketService.ts | 45 +++--- packages/perps-controller/src/types/index.ts | 19 ++- .../src/utils/marketDataTransform.ts | 31 +--- .../tests/helpers/serviceMocks.ts | 2 +- .../tests/src/PerpsController.trading.test.ts | 1 + .../src/services/MarketDataService.test.ts | 12 +- .../services/TerminalMarketService.test.ts | 16 +- .../src/utils/marketDataTransform.test.ts | 139 +----------------- 13 files changed, 166 insertions(+), 233 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 53bed8da8b..f9dc34bbc5 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -9,13 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add Terminal API integration for market data behind `perpsTerminalApiMarkets` feature flag ([#9137](https://github.com/MetaMask/core/pull/9137)) - - `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. +- Add Terminal API integration for market data, controlled via `useTerminalApi` parameter on `GetMarketsParams` / `GetMarketDataWithPricesParams` ([#9137](https://github.com/MetaMask/core/pull/9137)) + - `TerminalMarketService` fetches structured market metadata from the injected `terminalApiUrl` with a 5-minute cache TTL. + - When enabled, `getMarkets()` attempts the Terminal API first; on failure or empty response, falls back silently to HyperLiquid. Terminal results respect the same allowlist/blocklist filtering as the provider path. - `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 `terminalApiUrl?: string` field; clients should inject the full endpoint URL when Terminal API features are enabled. - `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. diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index 144bd0c46a..5f41783e3a 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -127,6 +127,12 @@ import { } from './types/transactionTypes'; import { getSelectedEvmAccountFromMessenger } from './utils/accountUtils'; import { ensureError } from './utils/errorUtils'; +import { parseAssetName } from './utils/hyperLiquidAdapter'; +import { + compileMarketPattern, + shouldIncludeMarket, +} from './utils/marketUtils'; +import type { CompiledMarketPattern } from './utils/marketUtils'; import { hydrateFromDiskSync, persistMarketEntriesToDisk, @@ -954,10 +960,10 @@ export class PerpsController extends BaseController< // Services that need cross-controller access receive the messenger this.#tradingService = new TradingService(infrastructure); this.#terminalMarketService = new TerminalMarketService(infrastructure); - this.#marketDataService = new MarketDataService({ - ...infrastructure, - terminalMarketService: this.#terminalMarketService, - }); + this.#marketDataService = new MarketDataService( + infrastructure, + this.#terminalMarketService, + ); this.#accountService = new AccountService(infrastructure, messenger); this.#eligibilityService = new EligibilityService(infrastructure); this.#dataLakeService = new DataLakeService(infrastructure, messenger); @@ -1912,6 +1918,50 @@ export class PerpsController extends BaseController< return this.state as unknown as PerpsControllerState; } + /** + * Build a filter function that mirrors the provider's allowlist/blocklist + * logic so that Terminal API results are filtered identically. + * + * @returns Filter predicate accepting a market symbol. + */ + #buildMarketAllowedFilter(): (symbol: string) => boolean { + const hip3Enabled = this.#hip3Enabled; + const compiledAllowlist = this.#compilePatternsSafely( + this.#hip3AllowlistMarkets, + ); + const compiledBlocklist = this.#compilePatternsSafely( + this.#hip3BlocklistMarkets, + ); + return (symbol: string) => { + const { dex } = parseAssetName(symbol); + return shouldIncludeMarket( + symbol, + dex, + hip3Enabled, + compiledAllowlist, + compiledBlocklist, + ); + }; + } + + /** + * Compile market patterns safely, skipping any that fail validation. + * + * @param patterns - Raw pattern strings from config. + * @returns Compiled patterns (invalid entries silently skipped). + */ + #compilePatternsSafely(patterns: string[]): CompiledMarketPattern[] { + const compiled: CompiledMarketPattern[] = []; + for (const pattern of patterns) { + try { + compiled.push({ pattern, matcher: compileMarketPattern(pattern) }); + } catch { + // Invalid patterns silently skipped — logged at provider level. + } + } + return compiled; + } + /** * Create a ServiceContext for dependency injection into services * Provides all orchestration dependencies (tracing, analytics, state management) @@ -2924,6 +2974,7 @@ export class PerpsController extends BaseController< * @returns Array of available markets matching the filter criteria. */ async getMarkets(params?: GetMarketsParams): Promise { + const isMarketAllowed = this.#buildMarketAllowedFilter(); if (params?.standalone) { const provider = this.activeProviderInstance ?? this.#getOrCreateStandaloneProvider(); @@ -2931,6 +2982,7 @@ export class PerpsController extends BaseController< provider, params, context: this.#createServiceContext('getMarkets'), + isMarketAllowed, }); } @@ -2939,6 +2991,7 @@ export class PerpsController extends BaseController< provider, params, context: this.#createServiceContext('getMarkets'), + isMarketAllowed, }); } diff --git a/packages/perps-controller/src/constants/perpsConfig.ts b/packages/perps-controller/src/constants/perpsConfig.ts index 895dd3f10b..340230e9bc 100644 --- a/packages/perps-controller/src/constants/perpsConfig.ts +++ b/packages/perps-controller/src/constants/perpsConfig.ts @@ -330,11 +330,11 @@ export const DATA_LAKE_API_CONFIG = { /** * Terminal API configuration. - * The base URL is injected at runtime via PerpsPlatformDependencies.terminalApiBaseUrl - * from each client build (dev/uat/prd); only path and cache settings live here. + * The full endpoint URL is injected at runtime via + * `PerpsPlatformDependencies.terminalApiUrl` from each client build + * (dev/uat/prd); only cache settings live here. */ export const TERMINAL_API_CONFIG = { - PerpetualPath: '/v1/perpetuals', CacheTtlMs: 5 * 60 * 1000, // 5 minutes } as const; diff --git a/packages/perps-controller/src/index.ts b/packages/perps-controller/src/index.ts index c82cc7d4e1..4d738beaac 100644 --- a/packages/perps-controller/src/index.ts +++ b/packages/perps-controller/src/index.ts @@ -244,6 +244,7 @@ export type { PerpsInternalAccount, PerpsRemoteFeatureFlagState, PerpsPlatformDependencies, + TerminalAssetMetadata, PerpsCacheType, InvalidateCacheParams, PerpsCacheInvalidator, diff --git a/packages/perps-controller/src/services/MarketDataService.ts b/packages/perps-controller/src/services/MarketDataService.ts index eff85d0076..caeef65572 100644 --- a/packages/perps-controller/src/services/MarketDataService.ts +++ b/packages/perps-controller/src/services/MarketDataService.ts @@ -32,6 +32,7 @@ import type { AssetRoute, PerpsPlatformDependencies, PerpsMarketData, + TerminalAssetMetadata, } from '../types'; import type { CandleData } from '../types/perps-types'; import { coalescePerpsRestRequest } from '../utils/coalescePerpsRestRequest'; @@ -39,7 +40,6 @@ 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 @@ -59,16 +59,16 @@ export class MarketDataService { * Create a new MarketDataService instance * * @param deps - Platform dependencies for logging, metrics, etc. - * @param deps.terminalMarketService - Optional terminal market service for - * fetching market data from the Terminal API. + * @param terminalMarketService - Optional terminal market service for + * fetching market data from the Terminal API. Passed separately to avoid + * a circular import (types ↔ services) in PerpsPlatformDependencies. */ constructor( - deps: PerpsPlatformDependencies & { - terminalMarketService?: TerminalMarketService; - }, + deps: PerpsPlatformDependencies, + terminalMarketService?: TerminalMarketService, ) { this.#deps = deps; - this.#terminalMarketService = deps.terminalMarketService; + this.#terminalMarketService = terminalMarketService; } /** @@ -730,14 +730,19 @@ export class MarketDataService { * @param options.provider - The perps provider instance. * @param options.params - The operation parameters. * @param options.context - The service context for dependencies. + * @param options.isMarketAllowed - Optional filter callback applied to + * Terminal API results so that allowlist/blocklist rules from the provider + * layer are enforced even when the provider is bypassed. Skipped when + * `params.skipFilters` is true. * @returns The result of the operation. */ async getMarkets(options: { provider: PerpsProvider; params?: GetMarketsParams; context: ServiceContext; + isMarketAllowed?: (symbol: string) => boolean; }): Promise { - const { provider, params, context } = options; + const { provider, params, context, isMarketAllowed } = options; const useTerminalApi = params?.useTerminalApi; const traceId = uuidv4(); let traceData: { success: boolean; error?: string } | undefined; @@ -766,13 +771,33 @@ export class MarketDataService { const { markets: terminalMarkets } = await this.#terminalMarketService.fetchMarkets(); if (terminalMarkets.length > 0) { - const filtered = params?.symbols?.length - ? terminalMarkets.filter((market) => - (params.symbols as string[]).some( - (sym) => market.name.toLowerCase() === sym.toLowerCase(), - ), - ) - : terminalMarkets; + let filtered = terminalMarkets; + + // Apply allowlist/blocklist filtering (same as provider path) + if (!params?.skipFilters && isMarketAllowed) { + filtered = filtered.filter((market) => + isMarketAllowed(market.name), + ); + } + + // Filter by specific DEX when requested + if (params?.dex !== undefined) { + const dexPrefix = params.dex ? `${params.dex}:` : ''; + filtered = filtered.filter((market) => + dexPrefix + ? market.name.startsWith(dexPrefix) + : !market.name.includes(':'), + ); + } + + // Filter by symbols when requested + if (params?.symbols?.length) { + filtered = filtered.filter((market) => + (params.symbols as string[]).some( + (sym) => market.name.toLowerCase() === sym.toLowerCase(), + ), + ); + } if (context.stateManager) { context.stateManager.update((state) => { @@ -884,7 +909,7 @@ export class MarketDataService { }, }); - // Fetch Terminal API metadata in parallel with provider data when enabled. + // 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. let terminalMetadata: Map | undefined; diff --git a/packages/perps-controller/src/services/TerminalMarketService.ts b/packages/perps-controller/src/services/TerminalMarketService.ts index f7cc96ecaf..db619048f5 100644 --- a/packages/perps-controller/src/services/TerminalMarketService.ts +++ b/packages/perps-controller/src/services/TerminalMarketService.ts @@ -11,28 +11,19 @@ import { } from '@metamask/superstruct'; import { PERPS_CONSTANTS, TERMINAL_API_CONFIG } from '../constants/perpsConfig'; -import type { - MarketInfo, - MarketType, - PerpsPlatformDependencies, +import { + MarketCategory, + type MarketInfo, + type PerpsPlatformDependencies, + type TerminalAssetMetadata, } 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; -}; +const VALID_MARKET_TYPES = new Set(Object.values(MarketCategory)); /** * Runtime validation schema for a single market item returned by - * `GET {terminalApiBaseUrl}/perpetuals`. + * `GET {terminalApiUrl}`. * * Uses `type()` (loose object matching) so that extra fields the API sends * (e.g. `price`, `iconUrl`, `trend`) are silently accepted. @@ -65,10 +56,9 @@ type CacheEntry = { /** * 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. + * Fetches structured market metadata from the MetaMask Terminal API. + * 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. */ @@ -101,13 +91,13 @@ export class TerminalMarketService { }; } - if (!this.#deps.terminalApiBaseUrl) { + if (!this.#deps.terminalApiUrl) { throw new Error( - 'Terminal API base URL not configured (terminalApiBaseUrl is required)', + 'Terminal API URL not configured (terminalApiUrl is required)', ); } - const url = `${this.#deps.terminalApiBaseUrl}${TERMINAL_API_CONFIG.PerpetualPath}`; + const url = this.#deps.terminalApiUrl; const response = await fetch(url, { method: 'GET', headers: { 'Content-Type': 'application/json' }, @@ -235,8 +225,11 @@ 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) { - entry.marketType = item.marketType as MarketType; + if ( + typeof item.marketType === 'string' && + VALID_MARKET_TYPES.has(item.marketType) + ) { + entry.marketType = item.marketType as TerminalAssetMetadata['marketType']; } map.set(item.symbol, entry); @@ -258,7 +251,7 @@ export class TerminalMarketService { tags: { feature: PERPS_CONSTANTS.FeatureName, source: 'terminal-api' }, context: { name: `TerminalMarketService.${method}`, - data: { url: this.#deps.terminalApiBaseUrl }, + data: { url: this.#deps.terminalApiUrl }, }, }, ); diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index fc977ea442..e54973a4cb 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -86,6 +86,18 @@ export enum MarketCategory { export type MarketType = `${MarketCategory}`; +/** + * Metadata extracted from Terminal API for a single asset. + * Used downstream to enrich PerpsMarketData with name, keywords, tags, etc. + */ +export type TerminalAssetMetadata = { + name: string; + keywords?: string[]; + tags?: string[]; + categories?: string[]; + marketType?: MarketType; +}; + // Market type filter for UI category badges export type MarketTypeFilter = | 'all' @@ -1670,12 +1682,13 @@ export type PerpsPlatformDependencies = { // === Terminal API (market metadata source) === /** - * Base URL for the MetaMask Terminal API. - * Each client build (dev/uat/prd) injects the appropriate environment URL. + * Full endpoint URL for the MetaMask Terminal API perpetuals endpoint. + * Each client build (dev/uat/prd) injects the correct environment URL + * (e.g. `https://terminal.api.cx.metamask.io/v1/perpetuals`). * Never hardcoded in controller code — always provided by the platform. * Optional: only required when Terminal API features (useTerminalApi) are enabled. */ - terminalApiBaseUrl?: string; + terminalApiUrl?: 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 9e09211adb..38ed354258 100644 --- a/packages/perps-controller/src/utils/marketDataTransform.ts +++ b/packages/perps-controller/src/utils/marketDataTransform.ts @@ -11,12 +11,7 @@ import { getHyperLiquidAssetName, } from '../constants/hyperLiquidConfig'; import { PERPS_CONSTANTS } from '../constants/perpsConfig'; -import type { TerminalAssetMetadata } from '../services/TerminalMarketService'; -import type { - PerpsMarketData, - MarketType, - MarketDataFormatters, -} from '../types'; +import type { PerpsMarketData, MarketType, MarketDataFormatters } from '../types'; import type { AllMidsResponse, PerpsUniverse, @@ -181,9 +176,6 @@ 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( @@ -191,7 +183,6 @@ export function transformMarketData( formatters: MarketDataFormatters, assetMarketTypes?: Record, assetNames?: Record, - terminalMetadata?: Map, ): PerpsMarketData[] { const { universe, assetCtxs, allMids, predictedFundings } = hyperLiquidData; @@ -260,24 +251,15 @@ export function transformMarketData( // Crypto markets (HIP-2) don't have a prefix (e.g., BTC, ETH) const isHip3 = Boolean(dex); - // 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; + // Determine market type from explicit static mapping + const marketType: MarketType | undefined = assetMarketTypes?.[symbol]; // Mark as "new" if it's a HIP-3 market but not explicitly categorized - const isNewMarket = isHip3 && !explicitMarketType; - - // Resolve name: terminal API > static name map > ticker fallback - const resolvedName = - terminalMeta?.name ?? getHyperLiquidAssetName(symbol, assetNames); + const isNewMarket = isHip3 && !marketType; return { symbol, - name: resolvedName, + name: getHyperLiquidAssetName(symbol, assetNames), maxLeverage: `${asset.maxLeverage}x`, price: isNaN(currentPrice) ? PERPS_CONSTANTS.FallbackPriceDisplay @@ -303,9 +285,6 @@ 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/tests/helpers/serviceMocks.ts b/packages/perps-controller/tests/helpers/serviceMocks.ts index af34ef5f25..d24aab9486 100644 --- a/packages/perps-controller/tests/helpers/serviceMocks.ts +++ b/packages/perps-controller/tests/helpers/serviceMocks.ts @@ -92,7 +92,7 @@ export const createMockInfrastructure = }, // === Terminal API === - terminalApiBaseUrl: 'https://terminal.test-api.cx.metamask.io', + terminalApiUrl: 'https://terminal.test-api.cx.metamask.io/v1/perpetuals', // === Rewards (DI — no RewardsController in Core yet) === rewards: { diff --git a/packages/perps-controller/tests/src/PerpsController.trading.test.ts b/packages/perps-controller/tests/src/PerpsController.trading.test.ts index 7e297b2843..916f61dc6a 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), + isMarketAllowed: expect.any(Function), }); }); }); diff --git a/packages/perps-controller/tests/src/services/MarketDataService.test.ts b/packages/perps-controller/tests/src/services/MarketDataService.test.ts index 0d95d756a6..0a814235e8 100644 --- a/packages/perps-controller/tests/src/services/MarketDataService.test.ts +++ b/packages/perps-controller/tests/src/services/MarketDataService.test.ts @@ -2,7 +2,6 @@ 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, @@ -16,6 +15,7 @@ import type { AssetRoute, PerpsPlatformDependencies, PerpsMarketData, + TerminalAssetMetadata, } from '../../../src/types'; import type { CandleData } from '../../../src/types/perps-types'; import { resetPerpsRestCacheForTests } from '../../../src/utils/coalescePerpsRestRequest'; @@ -42,7 +42,7 @@ describe('MarketDataService', () => { mockProvider = createMockHyperLiquidProvider() as unknown as jest.Mocked; mockDeps = createMockInfrastructure(); - marketDataService = new MarketDataService({ ...mockDeps }); + marketDataService = new MarketDataService(mockDeps); mockContext = createMockServiceContext({ errorContext: { controller: 'MarketDataService', method: 'test' }, }); @@ -1165,10 +1165,10 @@ describe('MarketDataService', () => { logError: jest.fn(), } as unknown as jest.Mocked; - serviceWithTerminal = new MarketDataService({ - ...mockDeps, - terminalMarketService: mockTerminalService, - }); + serviceWithTerminal = new MarketDataService( + mockDeps, + mockTerminalService, + ); }); describe('getMarkets with useTerminalApi', () => { diff --git a/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts b/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts index 1733101066..4621a397ac 100644 --- a/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts +++ b/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts @@ -62,7 +62,7 @@ describe('TerminalMarketService', () => { expect(globalThis.fetch).toHaveBeenCalledWith( 'https://terminal.test-api.cx.metamask.io/v1/perpetuals', - { method: 'GET', headers: { 'Content-Type': 'application/json' } }, + expect.objectContaining({ method: 'GET' }), ); expect(markets).toHaveLength(3); @@ -100,9 +100,9 @@ describe('TerminalMarketService', () => { }); }); - it('constructs URL from injected terminalApiBaseUrl', async () => { - (mockDeps as Record).terminalApiBaseUrl = - 'https://terminal.api.cx.metamask.io'; + it('uses the full terminalApiUrl without path concatenation', async () => { + (mockDeps as Record).terminalApiUrl = + 'https://terminal.api.cx.metamask.io/v1/perpetuals'; jest.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, @@ -258,7 +258,7 @@ describe('TerminalMarketService', () => { expect(mockDeps.logger.error).not.toHaveBeenCalled(); }); - it('accepts any non-empty string as marketType', async () => { + it('accepts only known MarketCategory values as marketType', async () => { jest.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, status: 200, @@ -266,6 +266,7 @@ describe('TerminalMarketService', () => { json: () => Promise.resolve([ { symbol: 'BTC', name: 'Bitcoin', marketType: 'crypto' }, + { symbol: 'TSLA', name: 'Tesla', marketType: 'stock' }, { symbol: 'MEME', name: 'MemeCoin', marketType: 'meme' }, { symbol: 'FOO', name: 'Foo', marketType: '' }, ]), @@ -274,7 +275,8 @@ describe('TerminalMarketService', () => { const { metadata } = await service.fetchMarkets(); expect(metadata.get('BTC')?.marketType).toBe('crypto'); - expect(metadata.get('MEME')?.marketType).toBe('meme'); + expect(metadata.get('TSLA')?.marketType).toBe('stock'); + expect(metadata.get('MEME')?.marketType).toBeUndefined(); expect(metadata.get('FOO')?.marketType).toBeUndefined(); }); @@ -372,7 +374,7 @@ describe('TerminalMarketService', () => { tags: { feature: 'perps', source: 'terminal-api' }, context: { name: 'TerminalMarketService.getMarkets', - data: { url: 'https://terminal.test-api.cx.metamask.io' }, + data: { url: 'https://terminal.test-api.cx.metamask.io/v1/perpetuals' }, }, }), ); diff --git a/packages/perps-controller/tests/src/utils/marketDataTransform.test.ts b/packages/perps-controller/tests/src/utils/marketDataTransform.test.ts index ea93a8d1cc..f401a1d854 100644 --- a/packages/perps-controller/tests/src/utils/marketDataTransform.test.ts +++ b/packages/perps-controller/tests/src/utils/marketDataTransform.test.ts @@ -2,8 +2,7 @@ import { HYPERLIQUID_ASSET_NAMES, getHyperLiquidAssetName, } from '../../../src/constants/hyperLiquidConfig'; -import type { TerminalAssetMetadata } from '../../../src/services/TerminalMarketService'; -import type { MarketDataFormatters, MarketType } from '../../../src/types'; +import type { MarketDataFormatters } from '../../../src/types'; import type { AllMidsResponse, PerpsAssetCtx, @@ -136,137 +135,5 @@ describe('transformMarketData - human-readable names', () => { }); }); -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).toStrictEqual(['crypto', 'layer-1', 'pow']); - expect(result[0]?.tags).toStrictEqual(['top-10', 'blue-chip']); - expect(result[0]?.categories).toStrictEqual(['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(); - }); -}); +// Terminal metadata enrichment is handled by MarketDataService.#enrichWithTerminalMetadata +// and tested in MarketDataService.test.ts — not by transformMarketData. From 9aa0d1d463ed2a216a43d8cedb4f6748e1b8c48f Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Thu, 18 Jun 2026 16:40:23 +0200 Subject: [PATCH 27/31] style(perps-controller): fix prettier formatting after merge --- packages/perps-controller/src/PerpsController.ts | 5 +---- .../perps-controller/src/services/TerminalMarketService.ts | 3 ++- packages/perps-controller/src/utils/marketDataTransform.ts | 6 +++++- .../tests/src/services/TerminalMarketService.test.ts | 4 +++- 4 files changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index 5f41783e3a..890a094c94 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -128,10 +128,7 @@ import { import { getSelectedEvmAccountFromMessenger } from './utils/accountUtils'; import { ensureError } from './utils/errorUtils'; import { parseAssetName } from './utils/hyperLiquidAdapter'; -import { - compileMarketPattern, - shouldIncludeMarket, -} from './utils/marketUtils'; +import { compileMarketPattern, shouldIncludeMarket } from './utils/marketUtils'; import type { CompiledMarketPattern } from './utils/marketUtils'; import { hydrateFromDiskSync, diff --git a/packages/perps-controller/src/services/TerminalMarketService.ts b/packages/perps-controller/src/services/TerminalMarketService.ts index db619048f5..c5ad89d052 100644 --- a/packages/perps-controller/src/services/TerminalMarketService.ts +++ b/packages/perps-controller/src/services/TerminalMarketService.ts @@ -229,7 +229,8 @@ export class TerminalMarketService { typeof item.marketType === 'string' && VALID_MARKET_TYPES.has(item.marketType) ) { - entry.marketType = item.marketType as TerminalAssetMetadata['marketType']; + entry.marketType = + item.marketType as TerminalAssetMetadata['marketType']; } map.set(item.symbol, entry); diff --git a/packages/perps-controller/src/utils/marketDataTransform.ts b/packages/perps-controller/src/utils/marketDataTransform.ts index 38ed354258..94fe15277f 100644 --- a/packages/perps-controller/src/utils/marketDataTransform.ts +++ b/packages/perps-controller/src/utils/marketDataTransform.ts @@ -11,7 +11,11 @@ import { getHyperLiquidAssetName, } from '../constants/hyperLiquidConfig'; import { PERPS_CONSTANTS } from '../constants/perpsConfig'; -import type { PerpsMarketData, MarketType, MarketDataFormatters } from '../types'; +import type { + PerpsMarketData, + MarketType, + MarketDataFormatters, +} from '../types'; import type { AllMidsResponse, PerpsUniverse, diff --git a/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts b/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts index 4621a397ac..b8a9ddd6eb 100644 --- a/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts +++ b/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts @@ -374,7 +374,9 @@ describe('TerminalMarketService', () => { tags: { feature: 'perps', source: 'terminal-api' }, context: { name: 'TerminalMarketService.getMarkets', - data: { url: 'https://terminal.test-api.cx.metamask.io/v1/perpetuals' }, + data: { + url: 'https://terminal.test-api.cx.metamask.io/v1/perpetuals', + }, }, }), ); From d903cb0f440fa580a36ce40508dec7b6cad993e5 Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Fri, 19 Jun 2026 10:51:56 +0200 Subject: [PATCH 28/31] fix(perps-controller): use top-level type-only import in TerminalMarketService Split mixed import into separate type-only and value imports to satisfy the import-x/consistent-type-specifier-style lint rule. --- .../src/services/TerminalMarketService.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/perps-controller/src/services/TerminalMarketService.ts b/packages/perps-controller/src/services/TerminalMarketService.ts index c5ad89d052..080bb283f4 100644 --- a/packages/perps-controller/src/services/TerminalMarketService.ts +++ b/packages/perps-controller/src/services/TerminalMarketService.ts @@ -11,12 +11,12 @@ import { } from '@metamask/superstruct'; import { PERPS_CONSTANTS, TERMINAL_API_CONFIG } from '../constants/perpsConfig'; -import { - MarketCategory, - type MarketInfo, - type PerpsPlatformDependencies, - type TerminalAssetMetadata, +import type { + MarketInfo, + PerpsPlatformDependencies, + TerminalAssetMetadata, } from '../types'; +import { MarketCategory } from '../types'; import { ensureError } from '../utils/errorUtils'; const VALID_MARKET_TYPES = new Set(Object.values(MarketCategory)); From 9d28acb29317805e0c6cc203d88a09f735f3c1bc Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Fri, 19 Jun 2026 12:02:58 +0200 Subject: [PATCH 29/31] fix(perps-controller): move PR #9137 changelog entries to Unreleased The Terminal API entries were under the already-released [8.2.0] section, causing the changelog CI check to fail. --- packages/perps-controller/CHANGELOG.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 12cec8044d..1a026ed5a0 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -7,8 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [8.2.0] - ### Added - Add Terminal API integration for market data, controlled via `useTerminalApi` parameter on `GetMarketsParams` / `GetMarketDataWithPricesParams` ([#9137](https://github.com/MetaMask/core/pull/9137)) @@ -19,6 +17,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `PerpsMarketData` gains optional `keywords`, `tags`, and `categories` fields. - 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 + +- Replace unsafe `as` type cast with runtime schema validation (`@metamask/superstruct`) in `TerminalMarketService` ([#9137](https://github.com/MetaMask/core/pull/9137)) + - Each item in the Terminal API response is now individually validated; items that fail validation are filtered out and logged instead of silently accepted. + +## [8.2.0] + +### Added + - Add Perps Discovery analytics constants to `PERPS_EVENT_PROPERTY` and `PERPS_EVENT_VALUE` so mobile can import them from `@metamask/perps-controller` instead of maintaining a local mirror ([#9178](https://github.com/MetaMask/core/pull/9178)) - New `PERPS_EVENT_PROPERTY` keys: `SOURCE_SECTION`, `RESULT_COUNT`, `SECTION_NAME`, `SECTION_INDEX`, `SECTIONS_DISPLAYED`, `WATCHLIST_COUNT`, `WATCHLIST_MARKETS` - New `PERPS_EVENT_VALUE.SOURCE_SECTION` group: values for home sections (`positions`, `orders`, `watchlist`, `whats_happening`, `products`, `top_gainers`, `top_losers`, `crypto`, `commodity`, `stock`, `forex`), explore sections (`perps_movers`, `perps_crypto`, `perps_stocks_commodities`, `perps_markets`), and market-list sections (`all_markets`, `new`, `active_search`) @@ -29,8 +37,6 @@ 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` ([#9137](https://github.com/MetaMask/core/pull/9137)) - - 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] From 731371e6b2a47401df8f59c20c8f5418ee5ce4da Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Fri, 19 Jun 2026 13:25:05 +0200 Subject: [PATCH 30/31] refactor(perps-controller): move TerminalMarketService into PerpsPlatformDependencies Add PerpsTerminalMarketService interface to PerpsPlatformDependencies so callers can inject any implementation. MarketDataService now reads the service from deps instead of a separate constructor argument. The controller auto-creates the service from terminalApiUrl when not explicitly provided, preserving backward compatibility. --- packages/perps-controller/CHANGELOG.md | 2 +- .../perps-controller/src/PerpsController.ts | 15 ++++++----- packages/perps-controller/src/index.ts | 1 + .../src/services/MarketDataService.ts | 24 +++++------------ packages/perps-controller/src/types/index.ts | 27 +++++++++++++++++++ .../src/services/MarketDataService.test.ts | 14 +++++----- 6 files changed, 51 insertions(+), 32 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 1a026ed5a0..3b7b0408a9 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 the injected `terminalApiUrl` with a 5-minute cache TTL. - When enabled, `getMarkets()` attempts the Terminal API first; on failure or empty response, falls back silently to HyperLiquid. Terminal results respect the same allowlist/blocklist filtering as the provider path. - `getMarketDataWithPrices()` enriches provider data with Terminal API metadata (name, keywords, tags, categories). - - `PerpsPlatformDependencies` gains an optional `terminalApiUrl?: string` field; clients should inject the full endpoint URL when Terminal API features are enabled. + - `PerpsPlatformDependencies` gains an optional `terminalApiUrl?: string` field and an optional `terminalMarketService?: PerpsTerminalMarketService` field; clients can inject a pre-built service instance or let the controller create one from the URL. - `PerpsMarketData` gains optional `keywords`, `tags`, and `categories` fields. - 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. diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index 890a094c94..3ba898fa34 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -915,8 +915,6 @@ export class PerpsController extends BaseController< readonly #marketDataService: MarketDataService; - readonly #terminalMarketService: TerminalMarketService; - readonly #accountService: AccountService; readonly #eligibilityService: EligibilityService; @@ -956,11 +954,14 @@ 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.#marketDataService = new MarketDataService( - infrastructure, - this.#terminalMarketService, - ); + this.#marketDataService = new MarketDataService({ + ...infrastructure, + terminalMarketService: + infrastructure.terminalMarketService ?? + (infrastructure.terminalApiUrl + ? new TerminalMarketService(infrastructure) + : undefined), + }); this.#accountService = new AccountService(infrastructure, messenger); this.#eligibilityService = new EligibilityService(infrastructure); this.#dataLakeService = new DataLakeService(infrastructure, messenger); diff --git a/packages/perps-controller/src/index.ts b/packages/perps-controller/src/index.ts index 4d738beaac..f3949fee1d 100644 --- a/packages/perps-controller/src/index.ts +++ b/packages/perps-controller/src/index.ts @@ -244,6 +244,7 @@ export type { PerpsInternalAccount, PerpsRemoteFeatureFlagState, PerpsPlatformDependencies, + PerpsTerminalMarketService, TerminalAssetMetadata, PerpsCacheType, InvalidateCacheParams, diff --git a/packages/perps-controller/src/services/MarketDataService.ts b/packages/perps-controller/src/services/MarketDataService.ts index caeef65572..e22e01e4e2 100644 --- a/packages/perps-controller/src/services/MarketDataService.ts +++ b/packages/perps-controller/src/services/MarketDataService.ts @@ -39,7 +39,6 @@ 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'; /** * MarketDataService @@ -53,22 +52,13 @@ import type { TerminalMarketService } from './TerminalMarketService'; 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. Passed separately to avoid - * a circular import (types ↔ services) in PerpsPlatformDependencies. */ - constructor( - deps: PerpsPlatformDependencies, - terminalMarketService?: TerminalMarketService, - ) { + constructor(deps: PerpsPlatformDependencies) { this.#deps = deps; - this.#terminalMarketService = terminalMarketService; } /** @@ -766,10 +756,10 @@ export class MarketDataService { }); // Terminal API path: attempt first when flag is enabled - if (useTerminalApi && this.#terminalMarketService) { + if (useTerminalApi && this.#deps.terminalMarketService) { try { const { markets: terminalMarkets } = - await this.#terminalMarketService.fetchMarkets(); + await this.#deps.terminalMarketService.fetchMarkets(); if (terminalMarkets.length > 0) { let filtered = terminalMarkets; @@ -809,7 +799,7 @@ export class MarketDataService { return filtered; } } catch (terminalError) { - this.#terminalMarketService.logError(terminalError, 'getMarkets'); + this.#deps.terminalMarketService.logError(terminalError, 'getMarkets'); } } @@ -913,14 +903,14 @@ export class MarketDataService { // 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) { + if (useTerminalApi && this.#deps.terminalMarketService) { try { - const result = await this.#terminalMarketService.fetchMarkets(); + const result = await this.#deps.terminalMarketService.fetchMarkets(); if (result.metadata.size > 0) { terminalMetadata = result.metadata; } } catch (terminalError) { - this.#terminalMarketService.logError( + this.#deps.terminalMarketService.logError( terminalError, 'getMarketDataWithPrices', ); diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index e54973a4cb..af4c7d1775 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -1630,6 +1630,23 @@ export type PerpsRemoteFeatureFlagState = { remoteFeatureFlags: Record; }; +/** + * Injectable interface for the Terminal-market service. + * + * `MarketDataService` programs against this contract so the concrete + * `TerminalMarketService` class (which lives in `services/`) never leaks + * into the types barrel — callers can supply any implementation that + * satisfies the shape (production, stub, mock, etc.). + */ +export type PerpsTerminalMarketService = { + fetchMarkets(): Promise<{ + markets: MarketInfo[]; + metadata: Map; + }>; + clearCache(): void; + logError(error: unknown, method: string): void; +}; + /** * Platform dependencies for PerpsController and services. * @@ -1690,6 +1707,16 @@ export type PerpsPlatformDependencies = { */ terminalApiUrl?: string; + /** + * Optional Terminal-market service instance for fetching structured market + * metadata from the MetaMask Terminal API. + * + * When provided, `MarketDataService` uses this service to attempt the + * Terminal API path before falling back to the provider. + * Clients that do not use the Terminal API can omit this field. + */ + terminalMarketService?: PerpsTerminalMarketService; + // === Rewards (DI — no RewardsController in Core yet) === rewards: { /** diff --git a/packages/perps-controller/tests/src/services/MarketDataService.test.ts b/packages/perps-controller/tests/src/services/MarketDataService.test.ts index 0a814235e8..53b3756b52 100644 --- a/packages/perps-controller/tests/src/services/MarketDataService.test.ts +++ b/packages/perps-controller/tests/src/services/MarketDataService.test.ts @@ -1,7 +1,6 @@ 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 { PerpsProvider, Position, @@ -15,6 +14,7 @@ import type { AssetRoute, PerpsPlatformDependencies, PerpsMarketData, + PerpsTerminalMarketService, TerminalAssetMetadata, } from '../../../src/types'; import type { CandleData } from '../../../src/types/perps-types'; @@ -1130,7 +1130,7 @@ describe('MarketDataService', () => { }); describe('Terminal API integration', () => { - let mockTerminalService: jest.Mocked; + let mockTerminalService: jest.Mocked; let serviceWithTerminal: MarketDataService; const terminalMarkets: MarketInfo[] = [ @@ -1163,12 +1163,12 @@ describe('MarketDataService', () => { fetchMarkets: jest.fn(), clearCache: jest.fn(), logError: jest.fn(), - } as unknown as jest.Mocked; + }; - serviceWithTerminal = new MarketDataService( - mockDeps, - mockTerminalService, - ); + serviceWithTerminal = new MarketDataService({ + ...mockDeps, + terminalMarketService: mockTerminalService, + }); }); describe('getMarkets with useTerminalApi', () => { From 846a8b4df3edb75c49ac329f9ed0dd41efc568f3 Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Fri, 19 Jun 2026 13:31:33 +0200 Subject: [PATCH 31/31] style(perps-controller): fix prettier formatting in MarketDataService --- packages/perps-controller/src/services/MarketDataService.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/perps-controller/src/services/MarketDataService.ts b/packages/perps-controller/src/services/MarketDataService.ts index e22e01e4e2..a88afb6ba4 100644 --- a/packages/perps-controller/src/services/MarketDataService.ts +++ b/packages/perps-controller/src/services/MarketDataService.ts @@ -799,7 +799,10 @@ export class MarketDataService { return filtered; } } catch (terminalError) { - this.#deps.terminalMarketService.logError(terminalError, 'getMarkets'); + this.#deps.terminalMarketService.logError( + terminalError, + 'getMarkets', + ); } }