diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index f8e7df6027..3b7b0408a9 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- 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 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. + +### 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 diff --git a/packages/perps-controller/package.json b/packages/perps-controller/package.json index 84b70d47f2..fb93149f8e 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.1.0", "@metamask/utils": "^11.11.0", "@nktkas/hyperliquid": "^0.32.2", "bignumber.js": "^9.1.2", diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index 8e57403634..3ba898fa34 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 { @@ -126,6 +127,9 @@ 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, @@ -950,7 +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.#marketDataService = new MarketDataService(infrastructure); + 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); @@ -1905,6 +1916,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) @@ -2917,14 +2972,16 @@ 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 isMarketAllowed = this.#buildMarketAllowedFilter(); 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'), + isMarketAllowed, + }); } const provider = this.getActiveProvider(); @@ -2932,6 +2989,7 @@ export class PerpsController extends BaseController< provider, params, context: this.#createServiceContext('getMarkets'), + isMarketAllowed, }); } @@ -2954,8 +3012,6 @@ export class PerpsController extends BaseController< params?: GetMarketDataWithPricesParams, ): Promise { 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({ diff --git a/packages/perps-controller/src/constants/perpsConfig.ts b/packages/perps-controller/src/constants/perpsConfig.ts index 73e85b3be9..340230e9bc 100644 --- a/packages/perps-controller/src/constants/perpsConfig.ts +++ b/packages/perps-controller/src/constants/perpsConfig.ts @@ -328,6 +328,16 @@ export const DATA_LAKE_API_CONFIG = { OrdersEndpoint: 'https://perps.api.cx.metamask.io/api/v1/orders', } as const; +/** + * Terminal API configuration. + * 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 = { + 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/index.ts b/packages/perps-controller/src/index.ts index c82cc7d4e1..f3949fee1d 100644 --- a/packages/perps-controller/src/index.ts +++ b/packages/perps-controller/src/index.ts @@ -244,6 +244,8 @@ export type { PerpsInternalAccount, PerpsRemoteFeatureFlagState, PerpsPlatformDependencies, + PerpsTerminalMarketService, + TerminalAssetMetadata, PerpsCacheType, InvalidateCacheParams, PerpsCacheInvalidator, diff --git a/packages/perps-controller/src/services/MarketDataService.ts b/packages/perps-controller/src/services/MarketDataService.ts index 6b0e728698..a88afb6ba4 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'; @@ -711,20 +712,28 @@ 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.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; @@ -740,12 +749,65 @@ 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.#deps.terminalMarketService) { + try { + const { markets: terminalMarkets } = + await this.#deps.terminalMarketService.fetchMarkets(); + if (terminalMarkets.length > 0) { + 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) => { + state.lastError = null; + state.lastUpdateTimestamp = Date.now(); + }); + } + traceData = { success: true }; + return filtered; + } + } catch (terminalError) { + this.#deps.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 +841,6 @@ export class MarketDataService { }, ); - // Update error state (if stateManager is provided) if (context.stateManager) { context.stateManager.update((state) => { state.lastError = errorMessage; @@ -805,6 +866,8 @@ 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. @@ -818,6 +881,7 @@ export class MarketDataService { context: ServiceContext; }): Promise { const { provider, params, context } = options; + const useTerminalApi = params?.useTerminalApi; const traceId = uuidv4(); let traceData: { success: boolean; error?: string } | undefined; @@ -832,11 +896,38 @@ export class MarketDataService { ...(params?.categories && { categoryCount: String(params.categories.length), }), + ...(useTerminalApi !== undefined && { + useTerminalApi: String(useTerminalApi), + }), }, }); + // 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; + if (useTerminalApi && this.#deps.terminalMarketService) { + try { + const result = await this.#deps.terminalMarketService.fetchMarkets(); + if (result.metadata.size > 0) { + terminalMetadata = result.metadata; + } + } catch (terminalError) { + this.#deps.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 +1343,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..080bb283f4 --- /dev/null +++ b/packages/perps-controller/src/services/TerminalMarketService.ts @@ -0,0 +1,260 @@ +import type { Infer } from '@metamask/superstruct'; +import { + array, + boolean, + is, + nullable, + number, + optional, + string, + type, +} from '@metamask/superstruct'; + +import { PERPS_CONSTANTS, TERMINAL_API_CONFIG } from '../constants/perpsConfig'; +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)); + +/** + * Runtime validation schema for a single market item returned by + * `GET {terminalApiUrl}`. + * + * Uses `type()` (loose object matching) so that extra fields the API sends + * (e.g. `price`, `iconUrl`, `trend`) are silently accepted. + * Each item is individually validated; items that fail validation are + * filtered out and logged rather than rejecting the entire response. + */ +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[]; + metadata: Map; + timestamp: number; +}; + +/** + * TerminalMarketService + * + * 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. + */ +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, + }; + } + + if (!this.#deps.terminalApiUrl) { + throw new Error( + 'Terminal API URL not configured (terminalApiUrl is required)', + ); + } + + const url = this.#deps.terminalApiUrl; + 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 = this.#validateItems(body); + 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; + } + + /** + * 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_CONSTANTS.FeatureName, + source: 'terminal-api', + }, + context: { + name: 'TerminalMarketService.validateItems', + data: { + symbol: + typeof item === 'object' && + item !== null && + Object.prototype.hasOwnProperty.call(item, 'symbol') + ? (item as Record).symbol + : undefined, + }, + }, + }, + ); + } + } + return valid; + } + + /** + * 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' && + VALID_MARKET_TYPES.has(item.marketType) + ) { + entry.marketType = + item.marketType as TerminalAssetMetadata['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_CONSTANTS.FeatureName, source: 'terminal-api' }, + context: { + name: `TerminalMarketService.${method}`, + data: { url: this.#deps.terminalApiUrl }, + }, + }, + ); + } +} diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index 24f8cbbdd4..af4c7d1775 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' @@ -476,6 +488,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 = { @@ -829,6 +853,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. }; /** @@ -843,6 +868,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 = { @@ -1604,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. * @@ -1654,6 +1697,26 @@ export type PerpsPlatformDependencies = { removeItem(key: string): Promise; }; + // === Terminal API (market metadata source) === + /** + * 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. + */ + 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/src/utils/marketDataTransform.ts b/packages/perps-controller/src/utils/marketDataTransform.ts index 58163d0d6e..94fe15277f 100644 --- a/packages/perps-controller/src/utils/marketDataTransform.ts +++ b/packages/perps-controller/src/utils/marketDataTransform.ts @@ -255,16 +255,11 @@ 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]; - 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 - // New markets are always HIP-3 (non-crypto) that haven't been assigned a category yet - const isNewMarket = isHip3 && !explicitMarketType; + const isNewMarket = isHip3 && !marketType; return { symbol, 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..d24aab9486 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 === + terminalApiUrl: 'https://terminal.test-api.cx.metamask.io/v1/perpetuals', + // === 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..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 28cfc5f9c2..53b3756b52 100644 --- a/packages/perps-controller/tests/src/services/MarketDataService.test.ts +++ b/packages/perps-controller/tests/src/services/MarketDataService.test.ts @@ -13,6 +13,9 @@ import type { FeeCalculationParams, AssetRoute, PerpsPlatformDependencies, + PerpsMarketData, + PerpsTerminalMarketService, + TerminalAssetMetadata, } 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(), + }; + + serviceWithTerminal = new MarketDataService({ + ...mockDeps, + terminalMarketService: 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, + params: { useTerminalApi: true }, + context: mockContext, + }); + + 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, + params: { useTerminalApi: true }, + context: mockContext, + }); + + 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, + params: { useTerminalApi: true }, + context: mockContext, + }); + + 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, + params: { useTerminalApi: false }, + context: mockContext, + }); + + 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, + params: { useTerminalApi: true }, + context: mockContext, + }); + + 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, + params: { useTerminalApi: true }, + context: mockContext, + }); + + 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, + params: { useTerminalApi: false }, + context: mockContext, + }); + + 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..b8a9ddd6eb --- /dev/null +++ b/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts @@ -0,0 +1,385 @@ +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/v1/perpetuals', + expect.objectContaining({ method: 'GET' }), + ); + + expect(markets).toHaveLength(3); + expect(markets[0]).toStrictEqual({ + name: 'BTC', + szDecimals: 5, + maxLeverage: 50, + marginTableId: 0, + }); + expect(markets[2]).toStrictEqual({ + name: 'xyz:TSLA', + szDecimals: 2, + maxLeverage: 5, + marginTableId: 2, + onlyIsolated: true, + }); + + expect(metadata.size).toBe(3); + expect(metadata.get('BTC')).toStrictEqual({ + name: 'Bitcoin', + keywords: ['crypto', 'layer-1'], + tags: ['top-10'], + categories: ['crypto'], + marketType: 'crypto', + }); + expect(metadata.get('ETH')).toStrictEqual({ + name: 'Ethereum', + keywords: ['defi', 'layer-1'], + }); + expect(metadata.get('xyz:TSLA')).toStrictEqual({ + name: 'Tesla', + marketType: 'stock', + tags: ['us-equities'], + categories: ['stock'], + }); + }); + + 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, + status: 200, + statusText: 'OK', + json: () => Promise.resolve([]), + } as Response); + + await service.fetchMarkets(); + + expect(globalThis.fetch).toHaveBeenCalledWith( + 'https://terminal.api.cx.metamask.io/v1/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' }, + { 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('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('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]).toStrictEqual({ + name: 'BTC', + szDecimals: 5, + maxLeverage: 50, + marginTableId: 0, + }); + expect(metadata.get('BTC')?.name).toBe('Bitcoin'); + expect(mockDeps.logger.error).not.toHaveBeenCalled(); + }); + + it('accepts only known MarketCategory values as marketType', async () => { + jest.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + 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: '' }, + ]), + } as Response); + + const { metadata } = await service.fetchMarkets(); + + expect(metadata.get('BTC')?.marketType).toBe('crypto'); + expect(metadata.get('TSLA')?.marketType).toBe('stock'); + expect(metadata.get('MEME')?.marketType).toBeUndefined(); + expect(metadata.get('FOO')?.marketType).toBeUndefined(); + }); + + 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]).toStrictEqual({ + 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) + jest.spyOn(Date, 'now').mockReturnValue(Date.now() + 6 * 60 * 1000); + + await service.fetchMarkets(); + + 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/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 962e455477..f401a1d854 100644 --- a/packages/perps-controller/tests/src/utils/marketDataTransform.test.ts +++ b/packages/perps-controller/tests/src/utils/marketDataTransform.test.ts @@ -134,3 +134,6 @@ describe('transformMarketData - human-readable names', () => { expect(result[0].volume).toBe('$1000000'); }); }); + +// Terminal metadata enrichment is handled by MarketDataService.#enrichWithTerminalMetadata +// and tested in MarketDataService.test.ts — not by transformMarketData. diff --git a/packages/perps-controller/tests/src/utils/marketSearch.test.ts b/packages/perps-controller/tests/src/utils/marketSearch.test.ts index a85d8e6498..bddbd76d14 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( + (market) => market.symbol, + ); + expect(result).toContain('BTC'); + expect(result).toContain('xyz:GOLD'); + expect(result).not.toContain('ETH'); + }); +}); diff --git a/yarn.lock b/yarn.lock index cbcb4ad276..8c4e0decd5 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.1.0" "@metamask/transaction-controller": "npm:^68.1.0" "@metamask/utils": "npm:^11.11.0" "@myx-trade/sdk": "npm:^0.1.265"