diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index f4ed9f0c52..a5de39acce 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -7,8 +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 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 +- 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/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..d13e279198 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: '/v1/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..b4f6027732 --- /dev/null +++ b/packages/perps-controller/src/services/TerminalMarketService.ts @@ -0,0 +1,254 @@ +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, + 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; +}; + +/** + * 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. + */ +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 + * (`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 = 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', 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' && 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..b3d0db44d8 --- /dev/null +++ b/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts @@ -0,0 +1,334 @@ +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', + { 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/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('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'); + }); +}); 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"