Skip to content
11 changes: 11 additions & 0 deletions packages/perps-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions packages/perps-controller/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,10 @@ export {
calculateFundingCountdown,
calculate24hHighLow,
filterMarketsByQuery,
matchesCategory,
getMarketTypeFilter,
applyMarketFilters,
isHip3Market,
} from './utils';
export type { MarketPatternMatcher, CompiledMarketPattern } from './utils';
export type {
Expand Down
94 changes: 2 additions & 92 deletions packages/perps-controller/src/services/MarketDataService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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;
}
17 changes: 8 additions & 9 deletions packages/perps-controller/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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[];

Expand Down
123 changes: 122 additions & 1 deletion packages/perps-controller/src/utils/marketUtils.ts
Original file line number Diff line number Diff line change
@@ -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<PerpsMarketData, 'isHip3' | 'marketSource'>,
): 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
Expand Down Expand Up @@ -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({
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down
Loading
Loading