diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 8af2f9c01aa..44153680569 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Centralise market category classification so consumers share one model instead of re-deriving it per client ([#9009](https://github.com/MetaMask/core/pull/9009)) + - Export `getMarketTypeFilter` (resolves a market to its UI category filter with singular values aligned to `MarketCategory`) and `isHip3Market`. `getMarketTypeFilter` and `matchesCategory` treat a `marketSource` DEX id as a HIP-3 signal consistently, so partial (route-param) markets classify the same way in both. + - Export the pure `matchesCategory` and `applyMarketFilters` helpers (moved from `MarketDataService`). + +### Changed + +- **BREAKING:** Align `MarketTypeFilter` and `MARKET_CATEGORIES` values with `MarketCategory` singular values ([#9009](https://github.com/MetaMask/core/pull/9009)) + - Replace `stocks` with `stock`, `indices` with `index`, `etfs` with `etf`, and `commodities` with `commodity`. + ## [7.0.0] ### Added diff --git a/packages/perps-controller/src/index.ts b/packages/perps-controller/src/index.ts index 6a4382638f3..040ff121ddc 100644 --- a/packages/perps-controller/src/index.ts +++ b/packages/perps-controller/src/index.ts @@ -491,6 +491,10 @@ export { calculateFundingCountdown, calculate24hHighLow, filterMarketsByQuery, + matchesCategory, + getMarketTypeFilter, + applyMarketFilters, + isHip3Market, } from './utils'; export type { MarketPatternMatcher, CompiledMarketPattern } from './utils'; export type { diff --git a/packages/perps-controller/src/services/MarketDataService.ts b/packages/perps-controller/src/services/MarketDataService.ts index 15919a2450c..6b0e728698b 100644 --- a/packages/perps-controller/src/services/MarketDataService.ts +++ b/packages/perps-controller/src/services/MarketDataService.ts @@ -4,11 +4,7 @@ import type { CandlePeriod } from '../constants/chartConfig'; import { PerpsMeasurementName } from '../constants/performanceMetrics'; import { PERPS_CONSTANTS } from '../constants/perpsConfig'; import { PERPS_ERROR_CODES } from '../perpsErrorCodes'; -import { - MarketCategory, - PerpsTraceNames, - PerpsTraceOperations, -} from '../types'; +import { PerpsTraceNames, PerpsTraceOperations } from '../types'; import type { PerpsProvider, Position, @@ -36,12 +32,11 @@ import type { AssetRoute, PerpsPlatformDependencies, PerpsMarketData, - MarketTypeFilter, } from '../types'; import type { CandleData } from '../types/perps-types'; import { coalescePerpsRestRequest } from '../utils/coalescePerpsRestRequest'; import { ensureError, isAbortError } from '../utils/errorUtils'; -import { sortMarkets } from '../utils/sortMarkets'; +import { applyMarketFilters } from '../utils/marketUtils'; import type { ServiceContext } from './ServiceContext'; /** @@ -1258,88 +1253,3 @@ export class MarketDataService { return provider.getBlockExplorerUrl(address); } } - -// ============================================================================ -// Market filtering helpers (module-level pure functions) -// These live outside the class because they have no service dependencies — -// they are pure data transformations that can be tested and reused independently. -// ============================================================================ - -/** - * Returns true when a market matches the given UI filter category. - * - * @param market - The market data to test. - * @param category - The filter category to test against. - * @returns Whether the market matches the category. - */ -export function matchesCategory( - market: PerpsMarketData, - category: MarketTypeFilter, -): boolean { - switch (category) { - case 'all': - return true; - case 'new': - return market.isNewMarket === true; - case 'crypto': - // Includes non-HIP3 markets AND HIP-3 assets explicitly typed as CryptoCurrency. - return ( - !market.isHip3 || market.marketType === MarketCategory.CryptoCurrency - ); - case 'stocks': - return market.marketType === MarketCategory.Stock; - case 'pre-ipo': - return market.marketType === MarketCategory.PreIpo; - case 'indices': - return market.marketType === MarketCategory.Index; - case 'etfs': - return market.marketType === MarketCategory.Etf; - case 'commodities': - return market.marketType === MarketCategory.Commodity; - case 'forex': - return market.marketType === MarketCategory.Forex; - default: - return true; - } -} - -/** - * Applies optional category filtering, sorting, and limit to a list of markets. - * - * @param markets - Source market array. - * @param params - Optional filter/sort/limit params. - * @returns Filtered, sorted, and/or sliced market array. - */ -export function applyMarketFilters( - markets: PerpsMarketData[], - params?: GetMarketDataWithPricesParams, -): PerpsMarketData[] { - let result = markets; - - if (params?.categories?.length) { - const { categories } = params; - result = result.filter((market) => - // A market is included if it matches ANY of the requested categories. - categories.some((category) => matchesCategory(market, category)), - ); - } - - if (params?.excludeSymbols?.length) { - const excluded = new Set(params.excludeSymbols); - result = result.filter((market) => !excluded.has(market.symbol)); - } - - if (params?.sortBy) { - result = sortMarkets({ - markets: result, - sortBy: params.sortBy, - direction: params.direction, - }); - } - - if (params?.limit !== undefined) { - result = result.slice(0, params.limit); - } - - return result; -} diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index f9402d5bce1..e20cdda3738 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -87,15 +87,14 @@ export enum MarketCategory { export type MarketType = `${MarketCategory}`; // Market type filter for UI category badges -// Note: 'stocks' maps to 'stock', 'commodities' maps to 'commodity' in the data model export type MarketTypeFilter = | 'all' | 'crypto' - | 'stocks' + | 'stock' | 'pre-ipo' - | 'indices' - | 'etfs' - | 'commodities' + | 'index' + | 'etf' + | 'commodity' | 'forex' | 'new'; @@ -107,11 +106,11 @@ export type MarketTypeFilter = */ export const MARKET_CATEGORIES = [ 'crypto', - 'stocks', + 'stock', 'pre-ipo', - 'indices', - 'etfs', - 'commodities', + 'index', + 'etf', + 'commodity', 'forex', ] as const satisfies MarketTypeFilter[]; diff --git a/packages/perps-controller/src/utils/marketUtils.ts b/packages/perps-controller/src/utils/marketUtils.ts index 794e268b294..1e9d51b1d10 100644 --- a/packages/perps-controller/src/utils/marketUtils.ts +++ b/packages/perps-controller/src/utils/marketUtils.ts @@ -1,5 +1,126 @@ -import type { PerpsMarketData } from '../types'; +import type { + GetMarketDataWithPricesParams, + MarketTypeFilter, + PerpsMarketData, +} from '../types'; import type { CandleData, CandleStick } from '../types/perps-types'; +import { sortMarkets } from './sortMarkets'; + +// ============================================================================ +// Market category classification (pure functions) +// No service dependencies — pure data transformations that can be tested and +// reused independently. `matchesCategory` and `getMarketTypeFilter` share the +// same category model. +// ============================================================================ + +/** + * Whether a market is a HIP-3 (non-main-DEX) market. A `marketSource` DEX id + * marks a HIP-3 market even when the `isHip3` flag is unset (e.g. partial route + * params), so both signals are checked. Used as the single HIP-3 signal so the + * classifiers stay consistent. + * + * @param market - The market data to test. + * @returns True if the market is HIP-3. + */ +export const isHip3Market = ( + market: Pick, +): boolean => Boolean(market.isHip3) || Boolean(market.marketSource); + +/** + * Returns true when a market matches the given UI filter category. + * + * @param market - The market data to test. + * @param category - The filter category to test against. + * @returns Whether the market matches the category. + */ +export function matchesCategory( + market: PerpsMarketData, + category: MarketTypeFilter, +): boolean { + switch (category) { + case 'all': + return true; + case 'new': + // Explicitly flagged, or an uncategorized HIP-3 market (kept in sync with + // getMarketTypeFilter's 'new' bucket). + return ( + market.isNewMarket === true || + (isHip3Market(market) && market.marketType === undefined) + ); + case 'crypto': + // Main-DEX markets, plus HIP-3 assets explicitly typed as CryptoCurrency. + return !isHip3Market(market) || market.marketType === 'crypto'; + default: + // Every other filter is a 1:1 data-model category match. + return market.marketType !== undefined && market.marketType === category; + } +} + +/** + * Resolve the user-facing category bucket for a market — one of `crypto`, + * `stock`, `pre-ipo`, `index`, `etf`, `commodity`, `forex`, or `new`. Data-model + * categories map 1:1. A market with no data-model category is `crypto` when it + * is main-DEX, or `new` when it is an uncategorized HIP-3 market (`isHip3`, or a + * `marketSource` DEX id when `isHip3` is unset, e.g. minimal route params). + * Never returns the `all` sentinel. + * + * Centralised as the single source of truth so consumers (e.g. category + * shortcuts, related markets) share one classification instead of re-deriving + * it per client and drifting as new categories are added. + * + * @param market - The market data to classify. + * @returns The market type filter bucket. + */ +export function getMarketTypeFilter(market: PerpsMarketData): MarketTypeFilter { + const { marketType } = market; + if (marketType) { + return marketType; + } + // No data-model category: an uncategorized HIP-3 market is the 'new' bucket; + // otherwise it's a main-DEX crypto market. + return isHip3Market(market) ? 'new' : 'crypto'; +} + +/** + * Applies optional category filtering, sorting, and limit to a list of markets. + * + * @param markets - Source market array. + * @param params - Optional filter/sort/limit params. + * @returns Filtered, sorted, and/or sliced market array. + */ +export function applyMarketFilters( + markets: PerpsMarketData[], + params?: GetMarketDataWithPricesParams, +): PerpsMarketData[] { + let result = markets; + + if (params?.categories?.length) { + const { categories } = params; + result = result.filter((market) => + // A market is included if it matches ANY of the requested categories. + categories.some((category) => matchesCategory(market, category)), + ); + } + + if (params?.excludeSymbols?.length) { + const excluded = new Set(params.excludeSymbols); + result = result.filter((market) => !excluded.has(market.symbol)); + } + + if (params?.sortBy) { + result = sortMarkets({ + markets: result, + sortBy: params.sortBy, + direction: params.direction, + }); + } + + if (params?.limit !== undefined) { + result = result.slice(0, params.limit); + } + + return result; +} /** * Maximum length for market filter patterns (prevents DoS attacks) diff --git a/packages/perps-controller/tests/src/PerpsController.market-filtering.test.ts b/packages/perps-controller/tests/src/PerpsController.market-filtering.test.ts index c75c4c5899f..3e4f84d9737 100644 --- a/packages/perps-controller/tests/src/PerpsController.market-filtering.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.market-filtering.test.ts @@ -153,11 +153,11 @@ describe('PerpsController — market categories & filtering', () => { it('includes all 7 data categories', () => { const categories = controller.getMarketCategories(); expect(categories).toContain('crypto'); - expect(categories).toContain('stocks'); + expect(categories).toContain('stock'); expect(categories).toContain('pre-ipo'); - expect(categories).toContain('indices'); - expect(categories).toContain('etfs'); - expect(categories).toContain('commodities'); + expect(categories).toContain('index'); + expect(categories).toContain('etf'); + expect(categories).toContain('commodity'); expect(categories).toContain('forex'); }); }); @@ -213,7 +213,7 @@ describe('PerpsController — market categories & filtering', () => { expect(symbols).not.toContain('xyz:TSLA'); }); - it('filters to only stock markets when categories is ["stocks"]', async () => { + it('filters to only stock markets when categories is ["stock"]', async () => { const markets = [ buildMarket({ symbol: 'BTC', isHip3: false }), buildMarket({ @@ -235,7 +235,7 @@ describe('PerpsController — market categories & filtering', () => { mockProvider.getMarketDataWithPrices.mockResolvedValue(markets); const result = await controller.getMarketDataWithPrices({ - categories: ['stocks'], + categories: ['stock'], }); expect(result).toHaveLength(2); @@ -266,7 +266,7 @@ describe('PerpsController — market categories & filtering', () => { mockProvider.getMarketDataWithPrices.mockResolvedValue(markets); const result = await controller.getMarketDataWithPrices({ - categories: ['stocks', 'etfs'], + categories: ['stock', 'etf'], }); expect(result).toHaveLength(2); @@ -483,7 +483,7 @@ describe('PerpsController — market categories & filtering', () => { mockProvider.getMarketDataWithPrices.mockResolvedValue(markets); const result = await controller.getMarketDataWithPrices({ - categories: ['stocks'], + categories: ['stock'], sortBy: 'openInterest', direction: 'desc', limit: 2, diff --git a/packages/perps-controller/tests/src/constants/hyperLiquidConfig.test.ts b/packages/perps-controller/tests/src/constants/hyperLiquidConfig.test.ts index 2d5cf519fc3..da3a8904f9a 100644 --- a/packages/perps-controller/tests/src/constants/hyperLiquidConfig.test.ts +++ b/packages/perps-controller/tests/src/constants/hyperLiquidConfig.test.ts @@ -121,11 +121,11 @@ describe('MARKET_CATEGORIES', () => { it('includes all 7 MarketTypeFilter data categories', () => { const dataCategories: MarketTypeFilter[] = [ 'crypto', - 'stocks', + 'stock', 'pre-ipo', - 'indices', - 'etfs', - 'commodities', + 'index', + 'etf', + 'commodity', 'forex', ]; for (const category of dataCategories) { @@ -138,11 +138,11 @@ describe('MARKET_CATEGORIES', () => { // The runtime check here mirrors that constraint. const validValues: readonly string[] = [ 'crypto', - 'stocks', + 'stock', 'pre-ipo', - 'indices', - 'etfs', - 'commodities', + 'index', + 'etf', + 'commodity', 'forex', ]; for (const entry of MARKET_CATEGORIES) { diff --git a/packages/perps-controller/tests/src/utils/marketUtils.test.ts b/packages/perps-controller/tests/src/utils/marketUtils.test.ts new file mode 100644 index 00000000000..070f07803f2 --- /dev/null +++ b/packages/perps-controller/tests/src/utils/marketUtils.test.ts @@ -0,0 +1,171 @@ +import type { PerpsMarketData } from '../../../src/types'; +import { + getMarketTypeFilter, + isHip3Market, + matchesCategory, +} from '../../../src/utils/marketUtils'; + +const market = (overrides: Partial): PerpsMarketData => + ({ + name: 'BTC', + symbol: 'BTC', + price: '50000', + volume: '$1M', + openInterest: '$1M', + change24hPercent: '+1.00%', + fundingRate: 0, + ...overrides, + }) as PerpsMarketData; + +describe('marketUtils category classification', () => { + describe('isHip3Market', () => { + it('is true when isHip3 is set', () => { + expect(isHip3Market(market({ isHip3: true }))).toBe(true); + }); + + it('is true when only marketSource is set', () => { + expect( + isHip3Market(market({ isHip3: undefined, marketSource: 'xyz' })), + ).toBe(true); + }); + + it('is false for a main-DEX market', () => { + expect( + isHip3Market(market({ isHip3: false, marketSource: undefined })), + ).toBe(false); + }); + }); + + describe('matchesCategory', () => { + it("matches every market for 'all'", () => { + expect(matchesCategory(market({ marketType: 'etf' }), 'all')).toBe(true); + }); + + it("matches only new markets for 'new'", () => { + expect(matchesCategory(market({ isNewMarket: true }), 'new')).toBe(true); + expect(matchesCategory(market({ isNewMarket: false }), 'new')).toBe( + false, + ); + }); + + it("matches non-HIP3 markets for 'crypto'", () => { + expect(matchesCategory(market({ isHip3: false }), 'crypto')).toBe(true); + }); + + it("matches HIP-3 markets explicitly typed crypto for 'crypto'", () => { + expect( + matchesCategory( + market({ isHip3: true, marketType: 'crypto' }), + 'crypto', + ), + ).toBe(true); + }); + + it("excludes other HIP-3 markets from 'crypto'", () => { + expect( + matchesCategory(market({ isHip3: true, marketType: 'etf' }), 'crypto'), + ).toBe(false); + }); + + it("treats a marketSource-only partial market as 'new', not 'crypto'", () => { + const partial = market({ + marketType: undefined, + isHip3: undefined, + isNewMarket: undefined, + marketSource: 'xyz', + }); + expect(matchesCategory(partial, 'crypto')).toBe(false); + expect(matchesCategory(partial, 'new')).toBe(true); + }); + + it.each([ + ['stock', 'stock'], + ['pre-ipo', 'pre-ipo'], + ['index', 'index'], + ['etf', 'etf'], + ['commodity', 'commodity'], + ['forex', 'forex'], + ] as const)( + 'matches marketType %s for the aligned filter %s', + (marketType, filter) => { + expect(matchesCategory(market({ marketType }), filter)).toBe(true); + }, + ); + }); + + describe('getMarketTypeFilter', () => { + // HIP-3 markets carry a marketType; main-DEX crypto does not. + it.each([ + ['stock', 'stock'], + ['pre-ipo', 'pre-ipo'], + ['index', 'index'], + ['etf', 'etf'], + ['commodity', 'commodity'], + ['forex', 'forex'], + ] as const)( + 'resolves HIP-3 marketType %s to the %s filter', + (marketType, expected) => { + expect(getMarketTypeFilter(market({ marketType, isHip3: true }))).toBe( + expected, + ); + }, + ); + + it('resolves an explicit crypto marketType to crypto', () => { + expect(getMarketTypeFilter(market({ marketType: 'crypto' }))).toBe( + 'crypto', + ); + }); + + it('resolves a main-DEX market without a marketType to crypto', () => { + expect( + getMarketTypeFilter(market({ marketType: undefined, isHip3: false })), + ).toBe('crypto'); + }); + + it('resolves uncategorized HIP-3 markets to the new bucket', () => { + expect( + getMarketTypeFilter(market({ marketType: undefined, isHip3: true })), + ).toBe('new'); + }); + + it('treats a marketSource DEX id as HIP-3 (new, not crypto) when isHip3 is unset', () => { + expect( + getMarketTypeFilter( + market({ + marketType: undefined, + isHip3: undefined, + marketSource: 'xyz', + }), + ), + ).toBe('new'); + }); + + it('never returns the all sentinel', () => { + const samples = [ + market({ marketType: 'stock', isHip3: true }), + market({ marketType: 'commodity', isHip3: true }), + market({ marketType: undefined, isHip3: false }), + market({ marketType: undefined, isHip3: true }), + ]; + samples.forEach((sample) => + expect(getMarketTypeFilter(sample)).not.toBe('all'), + ); + }); + + // The resolved bucket must agree with matchesCategory. + it.each([ + market({ marketType: 'stock', isHip3: true }), + market({ marketType: 'pre-ipo', isHip3: true }), + market({ marketType: 'index', isHip3: true }), + market({ marketType: 'etf', isHip3: true }), + market({ marketType: 'commodity', isHip3: true }), + market({ marketType: 'forex', isHip3: true }), + market({ marketType: undefined, isHip3: false }), + market({ marketType: undefined, isHip3: true }), + market({ marketType: undefined, marketSource: 'xyz' }), + ])('is consistent with matchesCategory for %o', (sample) => { + expect(matchesCategory(sample, getMarketTypeFilter(sample))).toBe(true); + }); + }); +});