diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 28b0a919bb..b32b0a599b 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- `MarketDataService.getMarketDataWithPrices` builds market data directly from Terminal API metadata when it already includes a usable price, instead of always calling the HyperLiquid provider for pricing. `TerminalMarketService` now extracts the `price`, `change24h`, `changePercent24h`, `funding`, `volume24h`, `openInterest`, and hourly `trend` fields already present in the Terminal API response, in addition to the existing taxonomy fields. Falls back to the previous provider + enrich behavior when Terminal has no usable price for a symbol ([#9808](https://github.com/MetaMask/core/pull/9808)) + +### Fixed + +- `TerminalMarketService` now reads the Terminal API's `category` field (a singular string) correctly, instead of the non-existent `categories`/`marketType` field names it validated against before. `PerpsMarketData.marketType` was silently staying `undefined` for Terminal-sourced markets as a result, which broke category filtering and the "new market" badge for HIP-3 assets ([#9808](https://github.com/MetaMask/core/pull/9808)) +- `MarketDataService.getMarketDataWithPrices` now applies the same allowlist/blocklist filtering as the HyperLiquid provider path to Terminal-sourced markets, so blocklisted or non-allowlisted HIP-3 markets no longer show up as tradeable just because the provider call was skipped ([#9808](https://github.com/MetaMask/core/pull/9808)) +- `TerminalMarketService` now accepts `null` for the Terminal API's `price`/`change24h`/`changePercent24h`/`funding`/`volume24h`/`openInterest`/`trend` fields instead of failing schema validation and dropping the entire item, which previously made markets with a `null` price disappear from both `getMarkets` and `getMarketDataWithPrices` ([#9808](https://github.com/MetaMask/core/pull/9808)) + ## [11.0.0] ### Added diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index aa3492a67b..9831aa48f6 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -3143,6 +3143,7 @@ export class PerpsController extends BaseController< async getMarketDataWithPrices( params?: GetMarketDataWithPricesParams, ): Promise { + const isMarketAllowed = this.#buildMarketAllowedFilter(); if (params?.standalone) { const provider = this.activeProviderInstance ?? this.#getOrCreateStandaloneProvider(); @@ -3150,6 +3151,7 @@ export class PerpsController extends BaseController< provider, params, context: this.#createServiceContext('getMarketDataWithPrices'), + isMarketAllowed, }); } @@ -3158,6 +3160,7 @@ export class PerpsController extends BaseController< provider, params, context: this.#createServiceContext('getMarketDataWithPrices'), + isMarketAllowed, }); } diff --git a/packages/perps-controller/src/services/MarketDataService.ts b/packages/perps-controller/src/services/MarketDataService.ts index 83721cfcfe..4ef360a624 100644 --- a/packages/perps-controller/src/services/MarketDataService.ts +++ b/packages/perps-controller/src/services/MarketDataService.ts @@ -33,13 +33,89 @@ import type { PerpsPlatformDependencies, PerpsMarketData, TerminalAssetMetadata, + MarketDataFormatters, } from '../types/index.js'; import type { CandleData } from '../types/perps-types.js'; import { coalescePerpsRestRequest } from '../utils/coalescePerpsRestRequest.js'; import { ensureError, isAbortError } from '../utils/errorUtils.js'; +import { + calculateOpenInterestUSD, + deriveHip3MarketFields, + formatMarketPriceFields, +} from '../utils/marketDataTransform.js'; import { applyMarketFilters } from '../utils/marketUtils.js'; import type { ServiceContext } from './ServiceContext.js'; +/** + * Coerce a Terminal API value (string | number | undefined) to a number. + * + * @param value - The raw value to coerce. + * @returns The numeric value, or `NaN` when absent/unparseable. + */ +function toNumber(value: string | number | undefined): number { + return typeof value === 'number' ? value : parseFloat(String(value ?? '')); +} + +/** + * Build PerpsMarketData straight from Terminal metadata, skipping the + * HyperLiquid provider call entirely. Symbols with no usable price are + * dropped rather than rendered with a placeholder. + * + * @param metadata - Per-symbol Terminal metadata map. + * @param formatters - Injectable formatters for platform-agnostic formatting. + * @returns Formatted PerpsMarketData array, empty if no symbol has a price + * (caller should fall back to the provider path in that case). + */ +function buildMarketsFromTerminalMetadata( + metadata: Map, + formatters: MarketDataFormatters, +): PerpsMarketData[] { + const result: PerpsMarketData[] = []; + + for (const [symbol, meta] of metadata.entries()) { + const currentPrice = toNumber(meta.price); + if (isNaN(currentPrice) || currentPrice <= 0) { + continue; + } + + const change24h = toNumber(meta.change24h); + const change24hPercent = toNumber(meta.changePercent24h); + const volume = toNumber(meta.volume24h); + const openInterest = calculateOpenInterestUSD( + meta.openInterest, + currentPrice, + ); + const fundingRate = toNumber(meta.funding); + + const { marketSource, isHip3, isNewMarket } = deriveHip3MarketFields( + symbol, + meta.marketType, + ); + + result.push({ + symbol, + name: meta.name ?? symbol, + ...(meta.description !== undefined && { description: meta.description }), + maxLeverage: `${meta.maxLeverage ?? 1}x`, + ...formatMarketPriceFields( + { currentPrice, change24h, change24hPercent, volume, openInterest }, + formatters, + ), + fundingRate: isNaN(fundingRate) ? undefined : fundingRate, + marketSource, + ...(meta.marketType !== undefined && { marketType: meta.marketType }), + isHip3, + isNewMarket, + ...(meta.keywords !== undefined && { keywords: meta.keywords }), + ...(meta.tags !== undefined && { tags: meta.tags }), + ...(meta.listedAt !== undefined && { listedAt: meta.listedAt }), + ...(meta.trend !== undefined && { trend: meta.trend }), + }); + } + + return result; +} + /** * MarketDataService * @@ -876,20 +952,24 @@ export class MarketDataService { * Get market data with prices (includes price, volume, 24h change). * Applies optional category filtering, sorting, and limit after fetching. * When `useTerminalApi` is true, enriches provider data with Terminal API metadata - * (name, keywords, tags, categories). On Terminal API failure, falls back silently. + * (name, keywords, tags, marketType). On Terminal API failure, falls back silently. * * @param options - The configuration options. * @param options.provider - The perps provider instance. * @param options.params - Optional filter/sort/limit params. * @param options.context - The service context for dependencies. + * @param options.isMarketAllowed - Optional filter callback applied to + * Terminal-priced markets so that allowlist/blocklist rules from the provider + * layer are enforced even when the provider is bypassed. * @returns The result of the operation. */ async getMarketDataWithPrices(options: { provider: PerpsProvider; params?: GetMarketDataWithPricesParams; 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; @@ -911,9 +991,10 @@ export class MarketDataService { }, }); - // Fetch Terminal API metadata before provider data when enabled. - // Terminal metadata enriches the provider result (name, keywords, tags, - // categories) but never replaces live pricing / funding data. + // Fetch Terminal metadata first. If it already has a usable price, + // build markets from it directly and skip the HyperLiquid provider + // call. Otherwise fall back to the provider and just enrich it with + // Terminal's name/keywords/tags. let terminalMetadata: Map | undefined; if (useTerminalApi && this.#deps.terminalMarketService) { try { @@ -929,12 +1010,32 @@ export class MarketDataService { } } - const markets = await provider.getMarketDataWithPrices(); + let terminalPricedMarkets = terminalMetadata + ? buildMarketsFromTerminalMetadata( + terminalMetadata, + this.#deps.marketDataFormatters, + ) + : []; + + // Same allowlist/blocklist rules the provider applies, so HIP-3 + // markets that aren't tradeable don't surface here just because we + // bypassed the provider. + if (isMarketAllowed) { + terminalPricedMarkets = terminalPricedMarkets.filter((market) => + isMarketAllowed(market.symbol), + ); + } - // Enrich with terminal metadata when available - const enriched = terminalMetadata - ? this.#enrichWithTerminalMetadata(markets, terminalMetadata) - : markets; + let enriched: PerpsMarketData[]; + if (terminalPricedMarkets.length > 0) { + enriched = terminalPricedMarkets; + } else { + const markets = await provider.getMarketDataWithPrices(); + // Enrich with terminal metadata when available + enriched = terminalMetadata + ? this.#enrichWithTerminalMetadata(markets, terminalMetadata) + : markets; + } const filtered = applyMarketFilters(enriched, params); @@ -1357,8 +1458,7 @@ export class MarketDataService { * Merge Terminal API metadata into provider-sourced PerpsMarketData. * For each market, if the terminal metadata map contains an entry for its * symbol, override name/description/marketType and attach - * keywords/tags/categories. Unmatched markets keep their provider-sourced - * values. + * keywords/tags. Unmatched markets keep their provider-sourced values. * * @param markets - Markets from the provider. * @param metadata - Per-symbol metadata from the Terminal API. @@ -1383,7 +1483,6 @@ export class MarketDataService { ...(meta.marketType !== undefined && { marketType: meta.marketType }), ...(meta.keywords !== undefined && { keywords: meta.keywords }), ...(meta.tags !== undefined && { tags: meta.tags }), - ...(meta.categories !== undefined && { categories: meta.categories }), ...(meta.listedAt !== undefined && { listedAt: meta.listedAt }), }; }); diff --git a/packages/perps-controller/src/services/TerminalMarketService.ts b/packages/perps-controller/src/services/TerminalMarketService.ts index 1330b8f5e8..f4009eebd3 100644 --- a/packages/perps-controller/src/services/TerminalMarketService.ts +++ b/packages/perps-controller/src/services/TerminalMarketService.ts @@ -7,6 +7,7 @@ import { number, optional, string, + tuple, type, union, } from '@metamask/superstruct'; @@ -29,10 +30,10 @@ 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. + * Uses `type()` (loose object matching), so extra fields the API sends + * beyond this schema (e.g. `iconUrl`) are silently accepted. Each item is + * validated individually; items that fail are filtered out and logged + * instead of rejecting the whole response. */ const TerminalPerpetualItemStruct = type({ symbol: string(), @@ -46,9 +47,17 @@ const TerminalPerpetualItemStruct = type({ minimumOrderSize: optional(number()), keywords: optional(nullable(array(string()))), tags: optional(nullable(array(string()))), - categories: optional(nullable(array(string()))), - marketType: optional(nullable(string())), + // The API field is singular `category`, not `categories` — mapped to our + // `marketType` below, which is the name used everywhere else. + category: optional(nullable(string())), listedAt: optional(nullable(union([number(), string()]))), + price: optional(nullable(union([string(), number()]))), + change24h: optional(nullable(union([string(), number()]))), + changePercent24h: optional(nullable(union([string(), number()]))), + funding: optional(nullable(union([string(), number()]))), + volume24h: optional(nullable(union([string(), number()]))), + openInterest: optional(nullable(union([string(), number()]))), + trend: optional(nullable(array(tuple([number(), string()])))), }); type TerminalPerpetualItem = Infer; @@ -246,15 +255,11 @@ export class TerminalMarketService { if (Array.isArray(item.tags) && item.tags.length > 0) { entry.tags = item.tags; } - if (Array.isArray(item.categories) && item.categories.length > 0) { - entry.categories = item.categories; - } if ( - typeof item.marketType === 'string' && - VALID_MARKET_TYPES.has(item.marketType) + typeof item.category === 'string' && + VALID_MARKET_TYPES.has(item.category) ) { - entry.marketType = - item.marketType as TerminalAssetMetadata['marketType']; + entry.marketType = item.category as TerminalAssetMetadata['marketType']; } if (item.listedAt !== null && item.listedAt !== undefined) { @@ -267,6 +272,36 @@ export class TerminalMarketService { } } + // Surfacing these lets MarketDataService build markets straight from + // Terminal data and skip the HyperLiquid price fetch entirely. + if (item.price !== null && item.price !== undefined) { + entry.price = item.price; + } + if (item.change24h !== null && item.change24h !== undefined) { + entry.change24h = item.change24h; + } + if ( + item.changePercent24h !== null && + item.changePercent24h !== undefined + ) { + entry.changePercent24h = item.changePercent24h; + } + if (item.funding !== null && item.funding !== undefined) { + entry.funding = item.funding; + } + if (item.volume24h !== null && item.volume24h !== undefined) { + entry.volume24h = item.volume24h; + } + if (item.openInterest !== null && item.openInterest !== undefined) { + entry.openInterest = item.openInterest; + } + if (item.maxLeverage !== undefined) { + entry.maxLeverage = item.maxLeverage; + } + if (item.trend && item.trend.length > 0) { + entry.trend = item.trend; + } + map.set(item.symbol, entry); } diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index 2bbc0a766c..d068ec88fe 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -101,13 +101,23 @@ export type TerminalAssetMetadata = { description?: string; keywords?: string[]; tags?: string[]; - categories?: string[]; marketType?: MarketType; /** * Epoch ms when this market was listed on the Terminal backend. * Normalized from the raw API value (number or ISO string). */ listedAt?: number; + // Live price fields below, as returned raw (unformatted) by the Terminal + // API. + price?: string | number; + change24h?: string | number; + changePercent24h?: string | number; + funding?: string | number; + volume24h?: string | number; + openInterest?: string | number; + maxLeverage?: number; + /** Hourly price points as `[timestampMs, priceString]` tuples. */ + trend?: [number, string][]; }; // Market type filter for UI category badges @@ -617,16 +627,17 @@ export type PerpsMarketData = { * Taxonomy tags from Terminal API metadata (e.g., ['top-100', 'gaming']) */ tags?: string[]; - /** - * Market categories from Terminal API metadata (e.g., ['crypto', 'meme']) - */ - categories?: string[]; /** * Epoch ms when this market was listed on the Terminal backend. * Sourced from the Terminal API `listedAt` field. * Clients can use this to surface recently added markets (e.g. markets listed within the last 30 days). */ listedAt?: number; + /** + * Hourly price points as `[timestampMs, priceString]` tuples. Only set + * when using the Terminal API backend. + */ + trend?: [number, string][]; }; export type ToggleTestnetResult = { diff --git a/packages/perps-controller/src/utils/marketDataTransform.ts b/packages/perps-controller/src/utils/marketDataTransform.ts index 47246da23f..d55bbeb0ea 100644 --- a/packages/perps-controller/src/utils/marketDataTransform.ts +++ b/packages/perps-controller/src/utils/marketDataTransform.ts @@ -105,6 +105,92 @@ export function isMarketTradable(params: { return deviation <= deviationLimit; } +/** + * The formatted display fields shared by every PerpsMarketData source + * (HyperLiquid provider, Terminal API). Kept in one place so the two + * `isNaN` → fallback/format ladders can't drift apart. + */ +type FormattedMarketPriceFields = Pick< + PerpsMarketData, + 'price' | 'change24h' | 'change24hPercent' | 'volume' | 'openInterest' +>; + +/** + * Format the numeric price/volume/change fields shared by every + * PerpsMarketData source into their display strings, falling back to the + * platform's placeholder display values when a field is unavailable + * (`NaN`). + * + * @param fields - Raw numeric fields (pass `NaN` for anything unavailable). + * @param fields.currentPrice - Current price. + * @param fields.change24h - Absolute 24h price change. + * @param fields.change24hPercent - 24h price change as a percentage. + * @param fields.volume - 24h volume. + * @param fields.openInterest - Open interest in USD. + * @param formatters - Injectable formatters for platform-agnostic formatting. + * @returns The formatted display fields. + */ +export function formatMarketPriceFields( + fields: { + currentPrice: number; + change24h: number; + change24hPercent: number; + volume: number; + openInterest: number; + }, + formatters: MarketDataFormatters, +): FormattedMarketPriceFields { + const { currentPrice, change24h, change24hPercent, volume, openInterest } = + fields; + + return { + price: isNaN(currentPrice) + ? PERPS_CONSTANTS.FallbackPriceDisplay + : formatters.formatPerpsFiat(currentPrice, { + ranges: formatters.priceRangesUniversal, + }), + change24h: isNaN(change24h) + ? PERPS_CONSTANTS.ZeroAmountDetailedDisplay + : formatChange(change24h, formatters), + change24hPercent: isNaN(change24hPercent) + ? '0.00%' + : formatters.formatPercentage(change24hPercent), + volume: isNaN(volume) + ? PERPS_CONSTANTS.FallbackPriceDisplay + : formatters.formatVolume(volume), + openInterest: isNaN(openInterest) + ? PERPS_CONSTANTS.FallbackPriceDisplay + : formatters.formatVolume(openInterest), + }; +} + +/** + * Derive the HIP-3 display fields from a possibly DEX-prefixed symbol + * (e.g. `xyz:TSLA`). Shared by every PerpsMarketData source so "new market" + * classification can't diverge between them. + * + * @param symbol - Asset symbol, e.g. `'BTC'` or `'xyz:TSLA'`. + * @param marketType - Market type already known for this symbol, if any. + * @returns `marketSource` (the DEX prefix, or `undefined` for HIP-2), plus + * `isHip3` and `isNewMarket` (a HIP-3 market with no explicit category yet). + */ +export function deriveHip3MarketFields( + symbol: string, + marketType: MarketType | undefined, +): { + marketSource: string | undefined; + isHip3: boolean; + isNewMarket: boolean; +} { + const { dex } = parseAssetName(symbol); + const isHip3 = Boolean(dex); + return { + marketSource: dex ?? undefined, + isHip3, + isNewMarket: isHip3 && !marketType, + }; +} + /** * HyperLiquid-specific market data structure */ @@ -298,42 +384,22 @@ export function transformMarketData( fundingRate = fundingData.predictedFundingRate; } - // Extract DEX and base symbol for display - // e.g., "flx:TSLA" → { dex: "flx", symbol: "TSLA" } - const { dex } = parseAssetName(symbol); - const marketSource = dex ?? undefined; - - // HIP-3 markets have a DEX prefix (e.g., xyz:TSLA, flx:GOLD) - // Crypto markets (HIP-2) don't have a prefix (e.g., BTC, ETH) - const isHip3 = Boolean(dex); - // Determine market type from explicit static mapping const marketType: MarketType | undefined = assetMarketTypes?.[symbol]; - // Mark as "new" if it's a HIP-3 market but not explicitly categorized - const isNewMarket = isHip3 && !marketType; + const { marketSource, isHip3, isNewMarket } = deriveHip3MarketFields( + symbol, + marketType, + ); return { symbol, name: getHyperLiquidAssetName(symbol, assetNames), maxLeverage: `${asset.maxLeverage}x`, - price: isNaN(currentPrice) - ? PERPS_CONSTANTS.FallbackPriceDisplay - : formatters.formatPerpsFiat(currentPrice, { - ranges: formatters.priceRangesUniversal, - }), - change24h: isNaN(change24h) - ? PERPS_CONSTANTS.ZeroAmountDetailedDisplay - : formatChange(change24h, formatters), - change24hPercent: isNaN(change24hPercent) - ? '0.00%' - : formatters.formatPercentage(change24hPercent), - volume: isNaN(volume) - ? PERPS_CONSTANTS.FallbackPriceDisplay - : formatters.formatVolume(volume), - openInterest: isNaN(openInterest) - ? PERPS_CONSTANTS.FallbackPriceDisplay - : formatters.formatVolume(openInterest), + ...formatMarketPriceFields( + { currentPrice, change24h, change24hPercent, volume, openInterest }, + formatters, + ), nextFundingTime: fundingData.nextFundingTime, fundingIntervalHours: fundingData.fundingIntervalHours, fundingRate, diff --git a/packages/perps-controller/tests/src/services/MarketDataService.test.ts b/packages/perps-controller/tests/src/services/MarketDataService.test.ts index 9f623f2773..b5cd2181c4 100644 --- a/packages/perps-controller/tests/src/services/MarketDataService.test.ts +++ b/packages/perps-controller/tests/src/services/MarketDataService.test.ts @@ -1,4 +1,5 @@ import type { CandlePeriod } from '../../../src/constants/chartConfig.js'; +import { PERPS_CONSTANTS } from '../../../src/constants/perpsConfig.js'; import { MarketDataService } from '../../../src/services/MarketDataService.js'; import type { ServiceContext } from '../../../src/services/ServiceContext.js'; import type { @@ -1146,7 +1147,6 @@ describe('MarketDataService', () => { description: 'The original cryptocurrency and largest by market cap.', keywords: ['crypto', 'layer-1'], tags: ['top-10'], - categories: ['crypto'], marketType: 'crypto', }, ], @@ -1354,7 +1354,6 @@ describe('MarketDataService', () => { ); 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']); @@ -1460,5 +1459,233 @@ describe('MarketDataService', () => { expect(result[1]?.listedAt).toBeUndefined(); }); }); + + describe('getMarketDataWithPrices — Terminal-sourced pricing', () => { + const terminalMetadataWithPrices = new Map( + [ + [ + 'BTC', + { + name: 'Bitcoin', + description: + 'The original cryptocurrency and largest by market cap.', + keywords: ['crypto', 'layer-1'], + price: '50000.5', + change24h: '500', + changePercent24h: 1.5, + volume24h: '1000000', + openInterest: '10', + funding: '0.0001', + maxLeverage: 50, + trend: [ + [1_700_000_000_000, '49000'], + [1_700_003_600_000, '50000.5'], + ], + }, + ], + [ + 'xyz:TSLA', + { + name: 'Tesla', + marketType: 'stock', + price: '250.25', + }, + ], + [ + // No usable price — should be dropped from the Terminal-sourced + // result rather than partially rendered. + 'NODATA', + { name: 'No Data Market' }, + ], + [ + // A '0' price is treated the same as no price - dropped instead + // of rendered as $0.00. + 'ZERO', + { name: 'Zero Price Market', price: '0' }, + ], + ], + ); + + it('builds market data directly from Terminal metadata and skips the provider entirely when price data is present', async () => { + mockTerminalService.fetchMarkets.mockResolvedValue({ + markets: terminalMarkets, + metadata: terminalMetadataWithPrices, + }); + + const result = await serviceWithTerminal.getMarketDataWithPrices({ + provider: mockProvider, + params: { useTerminalApi: true }, + context: mockContext, + }); + + expect(mockProvider.getMarketDataWithPrices).not.toHaveBeenCalled(); + + const btc = result.find((market) => market.symbol === 'BTC'); + expect(btc).toMatchObject({ + symbol: 'BTC', + name: 'Bitcoin', + description: 'The original cryptocurrency and largest by market cap.', + keywords: ['crypto', 'layer-1'], + maxLeverage: '50x', + price: '$50000.50', + change24hPercent: '1.50%', + fundingRate: 0.0001, + isHip3: false, + trend: [ + [1_700_000_000_000, '49000'], + [1_700_003_600_000, '50000.5'], + ], + }); + }); + + it('derives HIP-3 marketSource/isHip3 from the DEX-prefixed symbol', async () => { + mockTerminalService.fetchMarkets.mockResolvedValue({ + markets: terminalMarkets, + metadata: terminalMetadataWithPrices, + }); + + const result = await serviceWithTerminal.getMarketDataWithPrices({ + provider: mockProvider, + params: { useTerminalApi: true }, + context: mockContext, + }); + + const tsla = result.find((market) => market.symbol === 'xyz:TSLA'); + expect(tsla).toMatchObject({ + marketSource: 'xyz', + isHip3: true, + marketType: 'stock', + price: '$250.25', + }); + }); + + it('applies isMarketAllowed to Terminal-sourced markets like it does for the provider path', async () => { + mockTerminalService.fetchMarkets.mockResolvedValue({ + markets: terminalMarkets, + metadata: terminalMetadataWithPrices, + }); + + const isMarketAllowed = jest + .fn() + .mockImplementation((symbol: string) => symbol !== 'xyz:TSLA'); + + const result = await serviceWithTerminal.getMarketDataWithPrices({ + provider: mockProvider, + params: { useTerminalApi: true }, + context: mockContext, + isMarketAllowed, + }); + + expect(mockProvider.getMarketDataWithPrices).not.toHaveBeenCalled(); + expect( + result.find((market) => market.symbol === 'xyz:TSLA'), + ).toBeUndefined(); + expect(result.find((market) => market.symbol === 'BTC')).toBeDefined(); + }); + + it('falls back to the provider when isMarketAllowed excludes every Terminal-sourced market', async () => { + mockTerminalService.fetchMarkets.mockResolvedValue({ + markets: terminalMarkets, + metadata: terminalMetadataWithPrices, + }); + mockProvider.getMarketDataWithPrices.mockResolvedValue([]); + const isMarketAllowed = jest.fn().mockReturnValue(false); + + await serviceWithTerminal.getMarketDataWithPrices({ + provider: mockProvider, + params: { useTerminalApi: true }, + context: mockContext, + isMarketAllowed, + }); + + expect(mockProvider.getMarketDataWithPrices).toHaveBeenCalled(); + }); + + it('falls back to default display values for fields Terminal did not provide', async () => { + mockTerminalService.fetchMarkets.mockResolvedValue({ + markets: terminalMarkets, + metadata: terminalMetadataWithPrices, + }); + + const result = await serviceWithTerminal.getMarketDataWithPrices({ + provider: mockProvider, + params: { useTerminalApi: true }, + context: mockContext, + }); + + // xyz:TSLA only has a `price` in its Terminal metadata fixture — every + // other numeric field should fall back rather than render as garbage. + const tsla = result.find((market) => market.symbol === 'xyz:TSLA'); + expect(tsla).toMatchObject({ + change24h: PERPS_CONSTANTS.ZeroAmountDetailedDisplay, + change24hPercent: '0.00%', + volume: PERPS_CONSTANTS.FallbackPriceDisplay, + openInterest: PERPS_CONSTANTS.FallbackPriceDisplay, + fundingRate: undefined, + maxLeverage: '1x', + }); + }); + + it('drops symbols with no usable price rather than partially rendering them', async () => { + mockTerminalService.fetchMarkets.mockResolvedValue({ + markets: terminalMarkets, + metadata: terminalMetadataWithPrices, + }); + + const result = await serviceWithTerminal.getMarketDataWithPrices({ + provider: mockProvider, + params: { useTerminalApi: true }, + context: mockContext, + }); + + expect( + result.find((market) => market.symbol === 'NODATA'), + ).toBeUndefined(); + }); + + it('drops symbols with a price of "0" rather than rendering $0.00', async () => { + mockTerminalService.fetchMarkets.mockResolvedValue({ + markets: terminalMarkets, + metadata: terminalMetadataWithPrices, + }); + + const result = await serviceWithTerminal.getMarketDataWithPrices({ + provider: mockProvider, + params: { useTerminalApi: true }, + context: mockContext, + }); + + expect( + result.find((market) => market.symbol === 'ZERO'), + ).toBeUndefined(); + }); + + it('falls back to the provider when Terminal metadata has no usable price for any symbol', async () => { + mockTerminalService.fetchMarkets.mockResolvedValue({ + markets: terminalMarkets, + metadata: terminalMetadata, + }); + mockProvider.getMarketDataWithPrices.mockResolvedValue([ + { + symbol: 'BTC', + name: 'BTC', + maxLeverage: '50x', + price: '$50000.00', + change24h: '+$500.00', + change24hPercent: '+1.00%', + volume: '$1000000', + }, + ]); + + const result = await serviceWithTerminal.getMarketDataWithPrices({ + provider: mockProvider, + params: { useTerminalApi: true }, + context: mockContext, + }); + + expect(mockProvider.getMarketDataWithPrices).toHaveBeenCalled(); + expect(result[0]?.name).toBe('Bitcoin'); + }); + }); }); }); diff --git a/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts b/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts index d54ec455e6..5578be2787 100644 --- a/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts +++ b/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts @@ -17,8 +17,7 @@ describe('TerminalMarketService', () => { marginTableId: 0, keywords: ['crypto', 'layer-1'], tags: ['top-10'], - categories: ['crypto'], - marketType: 'crypto', + category: 'crypto', }, { symbol: 'ETH', @@ -35,9 +34,8 @@ describe('TerminalMarketService', () => { maxLeverage: 5, marginTableId: 2, onlyIsolated: true, - marketType: 'stock', + category: 'stock', tags: ['us-equities'], - categories: ['stock'], }, ]; @@ -91,21 +89,111 @@ describe('TerminalMarketService', () => { description: 'The original cryptocurrency and largest by market cap.', keywords: ['crypto', 'layer-1'], tags: ['top-10'], - categories: ['crypto'], marketType: 'crypto', + maxLeverage: 50, }); expect(metadata.get('ETH')).toStrictEqual({ name: 'Ethereum', keywords: ['defi', 'layer-1'], + maxLeverage: 25, }); expect(metadata.get('xyz:TSLA')).toStrictEqual({ name: 'Tesla', marketType: 'stock', tags: ['us-equities'], - categories: ['stock'], + maxLeverage: 5, }); }); + it('extracts declared price/trend fields from validated items', async () => { + jest.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: () => + Promise.resolve([ + { + symbol: 'BTC', + name: 'Bitcoin', + price: '67000.5', + change24h: '-120.3', + changePercent24h: '-0.18', + funding: '0.0001', + volume24h: 1234567890, + openInterest: '987654321', + trend: [ + [1_700_000_000_000, '66000'], + [1_700_003_600_000, '67000.5'], + ], + }, + ]), + } as Response); + + const { metadata } = await service.fetchMarkets(); + + expect(metadata.get('BTC')).toStrictEqual({ + name: 'Bitcoin', + price: '67000.5', + change24h: '-120.3', + changePercent24h: '-0.18', + funding: '0.0001', + volume24h: 1234567890, + openInterest: '987654321', + trend: [ + [1_700_000_000_000, '66000'], + [1_700_003_600_000, '67000.5'], + ], + }); + }); + + it('accepts null price/trend fields instead of rejecting the whole item', async () => { + jest.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: () => + Promise.resolve([ + { + symbol: 'BTC', + name: 'Bitcoin', + price: null, + change24h: null, + changePercent24h: null, + funding: null, + volume24h: null, + openInterest: null, + trend: null, + }, + ]), + } as Response); + + const { markets, metadata } = await service.fetchMarkets(); + + expect(markets).toHaveLength(1); + expect(metadata.get('BTC')).toStrictEqual({ name: 'Bitcoin' }); + }); + + it('rejects items whose trend is malformed rather than silently ignoring it', async () => { + jest.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: () => + Promise.resolve([ + { symbol: 'BTC', name: 'Bitcoin', trend: 'not-an-array' }, + { symbol: 'ETH', name: 'Ethereum', trend: [[1, 2]] }, + { 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(2); + }); + it('uses the full terminalApiUrl without path concatenation', async () => { (mockDeps as Record).terminalApiUrl = 'https://terminal.api.cx.metamask.io/v1/perpetuals'; @@ -265,11 +353,11 @@ describe('TerminalMarketService', () => { 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, + // Extra properties not in the schema + iconUrl: 'https://example.com/btc.png', + sentiment: 'bullish', sparklineData: [65000, 66000, 67000], }, ]), @@ -295,10 +383,10 @@ describe('TerminalMarketService', () => { 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: '' }, + { symbol: 'BTC', name: 'Bitcoin', category: 'crypto' }, + { symbol: 'TSLA', name: 'Tesla', category: 'stock' }, + { symbol: 'MEME', name: 'MemeCoin', category: 'meme' }, + { symbol: 'FOO', name: 'Foo', category: '' }, ]), } as Response);