From 7f3fe1c66028692303b78eb048d721dab40f50a2 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Fri, 5 Jun 2026 00:27:09 +0800 Subject: [PATCH 1/6] feat(perps-controller): centralize market category classification Add getMarketTypeFilter, isEquityAsset, and STOCK_LIKE_MARKET_TYPES so consumers share a single mapping from the MarketCategory data model to the UI MarketTypeFilter pills, instead of re-deriving the logic per client and drifting as new categories are added. --- packages/perps-controller/src/index.ts | 3 + .../perps-controller/src/utils/marketUtils.ts | 65 ++++++++- .../tests/src/utils/marketUtils.test.ts | 137 ++++++++++++++++++ 3 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 packages/perps-controller/tests/src/utils/marketUtils.test.ts diff --git a/packages/perps-controller/src/index.ts b/packages/perps-controller/src/index.ts index 6a4382638f..cf563bd8cf 100644 --- a/packages/perps-controller/src/index.ts +++ b/packages/perps-controller/src/index.ts @@ -491,6 +491,9 @@ export { calculateFundingCountdown, calculate24hHighLow, filterMarketsByQuery, + isEquityAsset, + getMarketTypeFilter, + STOCK_LIKE_MARKET_TYPES, } from './utils'; export type { MarketPatternMatcher, CompiledMarketPattern } from './utils'; export type { diff --git a/packages/perps-controller/src/utils/marketUtils.ts b/packages/perps-controller/src/utils/marketUtils.ts index 794e268b29..488f494337 100644 --- a/packages/perps-controller/src/utils/marketUtils.ts +++ b/packages/perps-controller/src/utils/marketUtils.ts @@ -1,6 +1,69 @@ -import type { PerpsMarketData } from '../types'; +import { MarketCategory } from '../types'; +import type { MarketType, MarketTypeFilter, PerpsMarketData } from '../types'; import type { CandleData, CandleStick } from '../types/perps-types'; +/** + * Stock-like market categories that share the 'stocks' UI filter and follow + * traditional market hours. These replaced the former 'equity' `MarketType`. + */ +export const STOCK_LIKE_MARKET_TYPES: ReadonlySet = new Set([ + MarketCategory.Stock, + MarketCategory.PreIpo, + MarketCategory.Index, + MarketCategory.Etf, +]); + +/** + * Check whether a market type is a stock-like asset (stock, pre-ipo, index, + * etf). Stock-like assets share the 'stocks' category filter and follow + * traditional market hours. + * + * @param marketType - The market type from {@link PerpsMarketData}. + * @returns True if the asset is a stock, pre-ipo, index, or etf. + */ +export const isEquityAsset = (marketType?: string): boolean => + marketType !== undefined && + STOCK_LIKE_MARKET_TYPES.has(marketType as MarketType); + +/** + * Resolve the category filter pill to pre-select for a given market. + * + * Maps the {@link MarketCategory} data model onto the UI {@link MarketTypeFilter} + * pills. Stock-like categories (stock, pre-ipo, index, etf) collapse to the + * single 'stocks' pill via {@link isEquityAsset}. Any HIP-3 signal — `isHip3`, + * `isNewMarket`, or a `marketSource` DEX id — on an otherwise-uncategorized + * market resolves to 'all' rather than 'crypto', because the crypto pill only + * contains main-DEX (non-HIP-3) markets. Only true main-DEX markets resolve to + * 'crypto'. + * + * Centralised here 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 - Market data (marketType, isNewMarket, isHip3, marketSource). + * @returns The market type filter to apply. + */ +export const getMarketTypeFilter = ( + market: Pick< + PerpsMarketData, + 'marketType' | 'isNewMarket' | 'isHip3' | 'marketSource' + >, +): MarketTypeFilter => { + if (isEquityAsset(market.marketType)) { + return 'stocks'; + } + if (market.marketType === MarketCategory.Commodity) { + return 'commodities'; + } + if (market.marketType === MarketCategory.Forex) { + return 'forex'; + } + if (market.isNewMarket || market.isHip3 || market.marketSource) { + return 'all'; + } + return 'crypto'; +}; + /** * Maximum length for market filter patterns (prevents DoS attacks) */ 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 0000000000..bd92b200b8 --- /dev/null +++ b/packages/perps-controller/tests/src/utils/marketUtils.test.ts @@ -0,0 +1,137 @@ +import type { MarketType, PerpsMarketData } from '../../../src/types'; +import { + STOCK_LIKE_MARKET_TYPES, + getMarketTypeFilter, + isEquityAsset, +} 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 helpers', () => { + describe('STOCK_LIKE_MARKET_TYPES', () => { + it('contains the four stock-like categories', () => { + expect([...STOCK_LIKE_MARKET_TYPES].sort()).toStrictEqual([ + 'etf', + 'index', + 'pre-ipo', + 'stock', + ]); + }); + }); + + describe('isEquityAsset', () => { + it.each(['stock', 'pre-ipo', 'index', 'etf'] as const)( + 'returns true for stock-like %s', + (marketType) => { + expect(isEquityAsset(marketType)).toBe(true); + }, + ); + + it.each(['crypto', 'commodity', 'forex'] as const)( + 'returns false for non-equity %s', + (marketType) => { + expect(isEquityAsset(marketType)).toBe(false); + }, + ); + + it('returns false for undefined marketType', () => { + expect(isEquityAsset(undefined)).toBe(false); + }); + + it('returns false for an unknown marketType', () => { + expect(isEquityAsset('bond')).toBe(false); + }); + }); + + describe('getMarketTypeFilter', () => { + it('returns stocks for stock marketType', () => { + expect(getMarketTypeFilter(market({ marketType: 'stock' }))).toBe( + 'stocks', + ); + }); + + it.each(['pre-ipo', 'index', 'etf'] as const)( + 'returns stocks for stock-like %s marketType', + (marketType) => { + expect(getMarketTypeFilter(market({ marketType }))).toBe('stocks'); + }, + ); + + it('returns commodities for commodity marketType', () => { + expect(getMarketTypeFilter(market({ marketType: 'commodity' }))).toBe( + 'commodities', + ); + }); + + it('returns forex for forex marketType', () => { + expect(getMarketTypeFilter(market({ marketType: 'forex' }))).toBe('forex'); + }); + + it('returns crypto for main-DEX markets without a marketType', () => { + expect( + getMarketTypeFilter( + market({ marketType: undefined, isHip3: false, isNewMarket: false }), + ), + ).toBe('crypto'); + }); + + it('returns crypto for explicit crypto marketType', () => { + expect(getMarketTypeFilter(market({ marketType: 'crypto' }))).toBe( + 'crypto', + ); + }); + + it('returns all for new markets without a marketType', () => { + expect( + getMarketTypeFilter(market({ marketType: undefined, isNewMarket: true })), + ).toBe('all'); + }); + + it('returns all for uncategorized HIP-3 markets (not in the crypto pill)', () => { + expect( + getMarketTypeFilter( + market({ marketType: undefined, isHip3: true, isNewMarket: false }), + ), + ).toBe('all'); + }); + + it('returns all when only marketSource marks a HIP-3 market', () => { + expect( + getMarketTypeFilter( + market({ + marketType: undefined, + isHip3: undefined, + isNewMarket: false, + marketSource: 'xyz', + }), + ), + ).toBe('all'); + }); + + it('prioritizes marketType over the HIP-3 / new fallbacks', () => { + expect( + getMarketTypeFilter( + market({ marketType: 'stock', isHip3: true, isNewMarket: true }), + ), + ).toBe('stocks'); + }); + + it('treats an unknown HIP-3 category as all, never crypto', () => { + expect( + getMarketTypeFilter( + market({ marketType: 'bond' as MarketType, isHip3: true }), + ), + ).toBe('all'); + }); + }); +}); From f9cb90395a46b3f7faafe665fcdae8d0689cea51 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Fri, 5 Jun 2026 00:38:06 +0800 Subject: [PATCH 2/6] docs(perps-controller): add changelog entry for category helpers --- packages/perps-controller/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 8af2f9c01a..95b2ed0952 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `getMarketTypeFilter`, `isEquityAsset`, and `STOCK_LIKE_MARKET_TYPES` to centralise market category classification, mapping the `MarketCategory` data model onto the UI `MarketTypeFilter` pills so consumers share one mapping instead of re-deriving it per client ([#9009](https://github.com/MetaMask/core/pull/9009)) + ## [7.0.0] ### Added From 8bb1afd5273aff43b49c1ad80dc9a7970f592bd9 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Fri, 5 Jun 2026 00:43:04 +0800 Subject: [PATCH 3/6] test(perps-controller): apply oxfmt formatting --- .../perps-controller/tests/src/utils/marketUtils.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/perps-controller/tests/src/utils/marketUtils.test.ts b/packages/perps-controller/tests/src/utils/marketUtils.test.ts index bd92b200b8..f1bff21786 100644 --- a/packages/perps-controller/tests/src/utils/marketUtils.test.ts +++ b/packages/perps-controller/tests/src/utils/marketUtils.test.ts @@ -74,7 +74,9 @@ describe('marketUtils category helpers', () => { }); it('returns forex for forex marketType', () => { - expect(getMarketTypeFilter(market({ marketType: 'forex' }))).toBe('forex'); + expect(getMarketTypeFilter(market({ marketType: 'forex' }))).toBe( + 'forex', + ); }); it('returns crypto for main-DEX markets without a marketType', () => { @@ -93,7 +95,9 @@ describe('marketUtils category helpers', () => { it('returns all for new markets without a marketType', () => { expect( - getMarketTypeFilter(market({ marketType: undefined, isNewMarket: true })), + getMarketTypeFilter( + market({ marketType: undefined, isNewMarket: true }), + ), ).toBe('all'); }); From 50d428858be77428cc07479c1f2c49f37c0e4dae Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Fri, 5 Jun 2026 06:49:53 +0800 Subject: [PATCH 4/6] refactor(perps-controller): derive getMarketTypeFilter from matchesCategory Reuse the existing matchesCategory category model instead of a parallel stock-like grouping. Move matchesCategory and applyMarketFilters from MarketDataService into marketUtils (pure helpers), add getMarketTypeFilter as the inverse of matchesCategory, and export all three. Drops the duplicate STOCK_LIKE_MARKET_TYPES / isEquityAsset added earlier. --- packages/perps-controller/CHANGELOG.md | 2 +- packages/perps-controller/src/index.ts | 4 +- .../src/services/MarketDataService.ts | 94 +---------- .../perps-controller/src/utils/marketUtils.ts | 148 +++++++++++------ .../tests/src/utils/marketUtils.test.ts | 156 ++++++++---------- 5 files changed, 174 insertions(+), 230 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 95b2ed0952..e63c485b31 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `getMarketTypeFilter`, `isEquityAsset`, and `STOCK_LIKE_MARKET_TYPES` to centralise market category classification, mapping the `MarketCategory` data model onto the UI `MarketTypeFilter` pills so consumers share one mapping instead of re-deriving it per client ([#9009](https://github.com/MetaMask/core/pull/9009)) +- Export `matchesCategory`, `getMarketTypeFilter`, and `applyMarketFilters` so consumers share one market-category classification (the `MarketTypeFilter` model) instead of re-deriving it per client; `getMarketTypeFilter` is the inverse of `matchesCategory` ([#9009](https://github.com/MetaMask/core/pull/9009)) ## [7.0.0] diff --git a/packages/perps-controller/src/index.ts b/packages/perps-controller/src/index.ts index cf563bd8cf..05aaadd7aa 100644 --- a/packages/perps-controller/src/index.ts +++ b/packages/perps-controller/src/index.ts @@ -491,9 +491,9 @@ export { calculateFundingCountdown, calculate24hHighLow, filterMarketsByQuery, - isEquityAsset, + matchesCategory, getMarketTypeFilter, - STOCK_LIKE_MARKET_TYPES, + applyMarketFilters, } 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 15919a2450..6b0e728698 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/utils/marketUtils.ts b/packages/perps-controller/src/utils/marketUtils.ts index 488f494337..a9c155c110 100644 --- a/packages/perps-controller/src/utils/marketUtils.ts +++ b/packages/perps-controller/src/utils/marketUtils.ts @@ -1,68 +1,118 @@ -import { MarketCategory } from '../types'; -import type { MarketType, MarketTypeFilter, PerpsMarketData } from '../types'; +import { MARKET_CATEGORIES, MarketCategory } from '../types'; +import type { + GetMarketDataWithPricesParams, + MarketTypeFilter, + PerpsMarketData, +} from '../types'; import type { CandleData, CandleStick } from '../types/perps-types'; +import { sortMarkets } from './sortMarkets'; -/** - * Stock-like market categories that share the 'stocks' UI filter and follow - * traditional market hours. These replaced the former 'equity' `MarketType`. - */ -export const STOCK_LIKE_MARKET_TYPES: ReadonlySet = new Set([ - MarketCategory.Stock, - MarketCategory.PreIpo, - MarketCategory.Index, - MarketCategory.Etf, -]); +// ============================================================================ +// Market category classification (pure functions) +// No service dependencies — pure data transformations that can be tested and +// reused independently. `matchesCategory` is the single source of truth for the +// UI category model; `getMarketTypeFilter` is its inverse. +// ============================================================================ /** - * Check whether a market type is a stock-like asset (stock, pre-ipo, index, - * etf). Stock-like assets share the 'stocks' category filter and follow - * traditional market hours. + * Returns true when a market matches the given UI filter category. * - * @param marketType - The market type from {@link PerpsMarketData}. - * @returns True if the asset is a stock, pre-ipo, index, or etf. + * @param market - The market data to test. + * @param category - The filter category to test against. + * @returns Whether the market matches the category. */ -export const isEquityAsset = (marketType?: string): boolean => - marketType !== undefined && - STOCK_LIKE_MARKET_TYPES.has(marketType as MarketType); +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; + } +} /** - * Resolve the category filter pill to pre-select for a given market. - * - * Maps the {@link MarketCategory} data model onto the UI {@link MarketTypeFilter} - * pills. Stock-like categories (stock, pre-ipo, index, etf) collapse to the - * single 'stocks' pill via {@link isEquityAsset}. Any HIP-3 signal — `isHip3`, - * `isNewMarket`, or a `marketSource` DEX id — on an otherwise-uncategorized - * market resolves to 'all' rather than 'crypto', because the crypto pill only - * contains main-DEX (non-HIP-3) markets. Only true main-DEX markets resolve to - * 'crypto'. + * Resolve the category filter pill for a given market — the inverse of + * {@link matchesCategory}. Returns the granular data-model filter (e.g. 'etfs', + * 'indices', 'pre-ipo', 'stocks', 'commodities', 'forex', 'crypto'); markets + * with no matching category (uncategorized HIP-3 / new markets) fall back to + * 'all' so the opened tab always contains the market. * - * Centralised here 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. + * 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 - Market data (marketType, isNewMarket, isHip3, marketSource). + * @param market - The market data to classify. * @returns The market type filter to apply. */ -export const getMarketTypeFilter = ( - market: Pick< - PerpsMarketData, - 'marketType' | 'isNewMarket' | 'isHip3' | 'marketSource' - >, -): MarketTypeFilter => { - if (isEquityAsset(market.marketType)) { - return 'stocks'; +export function getMarketTypeFilter(market: PerpsMarketData): MarketTypeFilter { + return ( + MARKET_CATEGORIES.find((category) => matchesCategory(market, category)) ?? + 'all' + ); +} + +/** + * 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 (market.marketType === MarketCategory.Commodity) { - return 'commodities'; + + if (params?.excludeSymbols?.length) { + const excluded = new Set(params.excludeSymbols); + result = result.filter((market) => !excluded.has(market.symbol)); } - if (market.marketType === MarketCategory.Forex) { - return 'forex'; + + if (params?.sortBy) { + result = sortMarkets({ + markets: result, + sortBy: params.sortBy, + direction: params.direction, + }); } - if (market.isNewMarket || market.isHip3 || market.marketSource) { - return 'all'; + + if (params?.limit !== undefined) { + result = result.slice(0, params.limit); } - return 'crypto'; -}; + + return result; +} /** * Maximum length for market filter patterns (prevents DoS attacks) diff --git a/packages/perps-controller/tests/src/utils/marketUtils.test.ts b/packages/perps-controller/tests/src/utils/marketUtils.test.ts index f1bff21786..4be46ff049 100644 --- a/packages/perps-controller/tests/src/utils/marketUtils.test.ts +++ b/packages/perps-controller/tests/src/utils/marketUtils.test.ts @@ -1,8 +1,7 @@ -import type { MarketType, PerpsMarketData } from '../../../src/types'; +import type { PerpsMarketData } from '../../../src/types'; import { - STOCK_LIKE_MARKET_TYPES, getMarketTypeFilter, - isEquityAsset, + matchesCategory, } from '../../../src/utils/marketUtils'; const market = (overrides: Partial): PerpsMarketData => @@ -17,91 +16,90 @@ const market = (overrides: Partial): PerpsMarketData => ...overrides, }) as PerpsMarketData; -describe('marketUtils category helpers', () => { - describe('STOCK_LIKE_MARKET_TYPES', () => { - it('contains the four stock-like categories', () => { - expect([...STOCK_LIKE_MARKET_TYPES].sort()).toStrictEqual([ - 'etf', - 'index', - 'pre-ipo', - 'stock', - ]); +describe('marketUtils category classification', () => { + describe('matchesCategory', () => { + it("matches every market for 'all'", () => { + expect(matchesCategory(market({ marketType: 'etf' }), 'all')).toBe(true); }); - }); - - describe('isEquityAsset', () => { - it.each(['stock', 'pre-ipo', 'index', 'etf'] as const)( - 'returns true for stock-like %s', - (marketType) => { - expect(isEquityAsset(marketType)).toBe(true); - }, - ); - it.each(['crypto', 'commodity', 'forex'] as const)( - 'returns false for non-equity %s', - (marketType) => { - expect(isEquityAsset(marketType)).toBe(false); - }, - ); - - it('returns false for undefined marketType', () => { - expect(isEquityAsset(undefined)).toBe(false); + it("matches only new markets for 'new'", () => { + expect(matchesCategory(market({ isNewMarket: true }), 'new')).toBe(true); + expect(matchesCategory(market({ isNewMarket: false }), 'new')).toBe( + false, + ); }); - it('returns false for an unknown marketType', () => { - expect(isEquityAsset('bond')).toBe(false); + it("matches non-HIP3 markets for 'crypto'", () => { + expect(matchesCategory(market({ isHip3: false }), 'crypto')).toBe(true); }); - }); - describe('getMarketTypeFilter', () => { - it('returns stocks for stock marketType', () => { - expect(getMarketTypeFilter(market({ marketType: 'stock' }))).toBe( - 'stocks', - ); + it("matches HIP-3 markets explicitly typed crypto for 'crypto'", () => { + expect( + matchesCategory( + market({ isHip3: true, marketType: 'crypto' }), + 'crypto', + ), + ).toBe(true); }); - it.each(['pre-ipo', 'index', 'etf'] as const)( - 'returns stocks for stock-like %s marketType', - (marketType) => { - expect(getMarketTypeFilter(market({ marketType }))).toBe('stocks'); - }, - ); + it("excludes other HIP-3 markets from 'crypto'", () => { + expect( + matchesCategory(market({ isHip3: true, marketType: 'etf' }), 'crypto'), + ).toBe(false); + }); - it('returns commodities for commodity marketType', () => { - expect(getMarketTypeFilter(market({ marketType: 'commodity' }))).toBe( - 'commodities', - ); + it.each([ + ['stock', 'stocks'], + ['pre-ipo', 'pre-ipo'], + ['index', 'indices'], + ['etf', 'etfs'], + ['commodity', 'commodities'], + ['forex', 'forex'], + ] as const)('matches marketType %s for filter %s', (marketType, filter) => { + expect(matchesCategory(market({ marketType }), filter)).toBe(true); }); - it('returns forex for forex marketType', () => { - expect(getMarketTypeFilter(market({ marketType: 'forex' }))).toBe( - 'forex', + it('keeps stock-like categories distinct (stock !== etf filter)', () => { + expect(matchesCategory(market({ marketType: 'stock' }), 'etfs')).toBe( + false, + ); + expect(matchesCategory(market({ marketType: 'etf' }), 'stocks')).toBe( + false, ); }); + }); - it('returns crypto for main-DEX markets without a marketType', () => { - expect( - getMarketTypeFilter( - market({ marketType: undefined, isHip3: false, isNewMarket: false }), - ), - ).toBe('crypto'); - }); + describe('getMarketTypeFilter', () => { + // HIP-3 markets carry a marketType; main-DEX crypto does not. + it.each([ + ['stock', 'stocks'], + ['pre-ipo', 'pre-ipo'], + ['index', 'indices'], + ['etf', 'etfs'], + ['commodity', 'commodities'], + ['forex', 'forex'], + ] as const)( + 'resolves HIP-3 marketType %s to the %s filter', + (marketType, expected) => { + expect(getMarketTypeFilter(market({ marketType, isHip3: true }))).toBe( + expected, + ); + }, + ); - it('returns crypto for explicit crypto marketType', () => { + it('resolves an explicit crypto marketType to crypto', () => { expect(getMarketTypeFilter(market({ marketType: 'crypto' }))).toBe( 'crypto', ); }); - it('returns all for new markets without a marketType', () => { + it('resolves a main-DEX market without a marketType to crypto', () => { expect( - getMarketTypeFilter( - market({ marketType: undefined, isNewMarket: true }), - ), - ).toBe('all'); + getMarketTypeFilter(market({ marketType: undefined, isHip3: false })), + ).toBe('crypto'); }); - it('returns all for uncategorized HIP-3 markets (not in the crypto pill)', () => { + it('falls back to all for uncategorized HIP-3 markets', () => { expect( getMarketTypeFilter( market({ marketType: undefined, isHip3: true, isNewMarket: false }), @@ -109,33 +107,19 @@ describe('marketUtils category helpers', () => { ).toBe('all'); }); - it('returns all when only marketSource marks a HIP-3 market', () => { + it('falls back to all for new markets without a marketType', () => { expect( getMarketTypeFilter( - market({ - marketType: undefined, - isHip3: undefined, - isNewMarket: false, - marketSource: 'xyz', - }), + market({ marketType: undefined, isHip3: true, isNewMarket: true }), ), ).toBe('all'); }); - it('prioritizes marketType over the HIP-3 / new fallbacks', () => { - expect( - getMarketTypeFilter( - market({ marketType: 'stock', isHip3: true, isNewMarket: true }), - ), - ).toBe('stocks'); - }); - - it('treats an unknown HIP-3 category as all, never crypto', () => { - expect( - getMarketTypeFilter( - market({ marketType: 'bond' as MarketType, isHip3: true }), - ), - ).toBe('all'); + it('is the inverse of matchesCategory for the resolved filter', () => { + const etfMarket = market({ marketType: 'etf', isHip3: true }); + const filter = getMarketTypeFilter(etfMarket); + expect(filter).toBe('etfs'); + expect(matchesCategory(etfMarket, filter)).toBe(true); }); }); }); From 1becc7bc5f15730a5ca490d6a113f663712503dc Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Fri, 5 Jun 2026 07:52:41 +0800 Subject: [PATCH 5/6] refactor(perps-controller): clean up market category model - Collapse the stock-like categories into the 'stocks' bucket via a single isEquityAsset / STOCK_LIKE_MARKET_TYPES source. - Drive matchesCategory off an exhaustive MARKET_CATEGORY_TO_FILTER map instead of a per-category switch; keep only the genuine special cases (all, new, crypto's HIP-3 rule). - getMarketTypeFilter now returns the real user-facing bucket (crypto/stocks/commodities/forex/new) and never the 'all' sentinel. - Add MARKET_TYPE_FILTER named constants for the filter values. --- packages/perps-controller/CHANGELOG.md | 5 +- packages/perps-controller/src/index.ts | 3 + packages/perps-controller/src/types/index.ts | 16 +++ .../perps-controller/src/utils/marketUtils.ts | 98 +++++++++++++------ .../tests/src/utils/marketUtils.test.ts | 87 +++++++++++----- 5 files changed, 155 insertions(+), 54 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index e63c485b31..ec49b01e74 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -9,7 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Export `matchesCategory`, `getMarketTypeFilter`, and `applyMarketFilters` so consumers share one market-category classification (the `MarketTypeFilter` model) instead of re-deriving it per client; `getMarketTypeFilter` is the inverse of `matchesCategory` ([#9009](https://github.com/MetaMask/core/pull/9009)) +- 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, collapsing stock-like categories into `stocks`), `isEquityAsset`, and `STOCK_LIKE_MARKET_TYPES`. + - Export the pure `matchesCategory` and `applyMarketFilters` helpers (moved from `MarketDataService`). + - Add `MARKET_TYPE_FILTER` named constants for the `MarketTypeFilter` values. ## [7.0.0] diff --git a/packages/perps-controller/src/index.ts b/packages/perps-controller/src/index.ts index 05aaadd7aa..09733a2274 100644 --- a/packages/perps-controller/src/index.ts +++ b/packages/perps-controller/src/index.ts @@ -137,6 +137,7 @@ export { WebSocketConnectionState, PerpsAnalyticsEvent, MARKET_CATEGORIES, + MARKET_TYPE_FILTER, MarketCategory, } from './types'; export type { @@ -494,6 +495,8 @@ export { matchesCategory, getMarketTypeFilter, applyMarketFilters, + isEquityAsset, + STOCK_LIKE_MARKET_TYPES, } from './utils'; export type { MarketPatternMatcher, CompiledMarketPattern } from './utils'; export type { diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index f9402d5bce..01f1841e6e 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -99,6 +99,22 @@ export type MarketTypeFilter = | 'forex' | 'new'; +/** + * Named constants for the {@link MarketTypeFilter} values, so consumers + * reference `MARKET_TYPE_FILTER.Stocks` instead of bare string literals. + */ +export const MARKET_TYPE_FILTER = { + All: 'all', + Crypto: 'crypto', + Stocks: 'stocks', + PreIpo: 'pre-ipo', + Indices: 'indices', + Etfs: 'etfs', + Commodities: 'commodities', + Forex: 'forex', + New: 'new', +} as const satisfies Record; + /** * Ordered list of the 7 data-model market categories for UI pills. * Does not include the 'all' or 'new' sentinel values — those are applied diff --git a/packages/perps-controller/src/utils/marketUtils.ts b/packages/perps-controller/src/utils/marketUtils.ts index a9c155c110..9a175656ad 100644 --- a/packages/perps-controller/src/utils/marketUtils.ts +++ b/packages/perps-controller/src/utils/marketUtils.ts @@ -1,6 +1,7 @@ -import { MARKET_CATEGORIES, MarketCategory } from '../types'; +import { MARKET_TYPE_FILTER, MarketCategory } from '../types'; import type { GetMarketDataWithPricesParams, + MarketType, MarketTypeFilter, PerpsMarketData, } from '../types'; @@ -14,6 +15,21 @@ import { sortMarkets } from './sortMarkets'; // UI category model; `getMarketTypeFilter` is its inverse. // ============================================================================ +/** + * Maps each data-model {@link MarketCategory} to its UI {@link MarketTypeFilter} + * (e.g. `stock` → `stocks`). Exhaustive: adding a `MarketCategory` is a compile + * error here until it is mapped, so the model can't silently drift. + */ +const MARKET_CATEGORY_TO_FILTER: Record = { + [MarketCategory.CryptoCurrency]: MARKET_TYPE_FILTER.Crypto, + [MarketCategory.Stock]: MARKET_TYPE_FILTER.Stocks, + [MarketCategory.PreIpo]: MARKET_TYPE_FILTER.PreIpo, + [MarketCategory.Index]: MARKET_TYPE_FILTER.Indices, + [MarketCategory.Etf]: MARKET_TYPE_FILTER.Etfs, + [MarketCategory.Commodity]: MARKET_TYPE_FILTER.Commodities, + [MarketCategory.Forex]: MARKET_TYPE_FILTER.Forex, +}; + /** * Returns true when a market matches the given UI filter category. * @@ -26,51 +42,77 @@ export function matchesCategory( category: MarketTypeFilter, ): boolean { switch (category) { - case 'all': + case MARKET_TYPE_FILTER.All: return true; - case 'new': + case MARKET_TYPE_FILTER.New: return market.isNewMarket === true; - case 'crypto': - // Includes non-HIP3 markets AND HIP-3 assets explicitly typed as CryptoCurrency. + case MARKET_TYPE_FILTER.Crypto: + // Non-HIP3 markets, plus 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; + // Every other filter is a 1:1 data-model category match. + return ( + market.marketType !== undefined && + MARKET_CATEGORY_TO_FILTER[market.marketType] === category + ); } } /** - * Resolve the category filter pill for a given market — the inverse of - * {@link matchesCategory}. Returns the granular data-model filter (e.g. 'etfs', - * 'indices', 'pre-ipo', 'stocks', 'commodities', 'forex', 'crypto'); markets - * with no matching category (uncategorized HIP-3 / new markets) fall back to - * 'all' so the opened tab always contains the market. + * Stock-like market categories (stock, pre-ipo, index, etf). Clients collapse + * these into the single 'stocks' filter, and they share traditional market + * hours. Single source of truth for the equity bucket. + */ +export const STOCK_LIKE_MARKET_TYPES: ReadonlySet = new Set([ + MarketCategory.Stock, + MarketCategory.PreIpo, + MarketCategory.Index, + MarketCategory.Etf, +]); + +/** + * Check whether a market type is stock-like (stock, pre-ipo, index, etf). + * + * @param marketType - The market type from {@link PerpsMarketData}. + * @returns True if the asset is stock-like. + */ +export const isEquityAsset = (marketType?: string): boolean => + marketType !== undefined && + STOCK_LIKE_MARKET_TYPES.has(marketType as MarketType); + +/** + * Resolve the user-facing category bucket for a market — one of `crypto`, + * `stocks`, `commodities`, `forex`, or `new`. Stock-like categories (stock, + * pre-ipo, index, etf) collapse into the single `stocks` bucket (see + * {@link isEquityAsset}); the remaining 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 to apply. + * @returns The market type filter bucket. */ export function getMarketTypeFilter(market: PerpsMarketData): MarketTypeFilter { - return ( - MARKET_CATEGORIES.find((category) => matchesCategory(market, category)) ?? - 'all' - ); + const { marketType } = market; + // Stock-like categories (stock, pre-ipo, index, etf) collapse into one pill. + if (isEquityAsset(marketType)) { + return MARKET_TYPE_FILTER.Stocks; + } + // Every other data-model category maps 1:1 (commodity, forex, crypto). + if (marketType) { + return MARKET_CATEGORY_TO_FILTER[marketType]; + } + // No data-model category: a HIP-3 market (isHip3, or a marketSource DEX id) is + // uncategorized → the 'new' bucket; otherwise it's a main-DEX crypto market. + return market.isHip3 || market.marketSource + ? MARKET_TYPE_FILTER.New + : MARKET_TYPE_FILTER.Crypto; } /** diff --git a/packages/perps-controller/tests/src/utils/marketUtils.test.ts b/packages/perps-controller/tests/src/utils/marketUtils.test.ts index 4be46ff049..3e8c895a1f 100644 --- a/packages/perps-controller/tests/src/utils/marketUtils.test.ts +++ b/packages/perps-controller/tests/src/utils/marketUtils.test.ts @@ -1,6 +1,8 @@ import type { PerpsMarketData } from '../../../src/types'; import { + STOCK_LIKE_MARKET_TYPES, getMarketTypeFilter, + isEquityAsset, matchesCategory, } from '../../../src/utils/marketUtils'; @@ -17,6 +19,33 @@ const market = (overrides: Partial): PerpsMarketData => }) as PerpsMarketData; describe('marketUtils category classification', () => { + describe('STOCK_LIKE_MARKET_TYPES', () => { + it('contains the four stock-like categories', () => { + expect([...STOCK_LIKE_MARKET_TYPES].sort()).toStrictEqual([ + 'etf', + 'index', + 'pre-ipo', + 'stock', + ]); + }); + }); + + describe('isEquityAsset', () => { + it.each(['stock', 'pre-ipo', 'index', 'etf'])( + 'returns true for stock-like %s', + (marketType) => { + expect(isEquityAsset(marketType)).toBe(true); + }, + ); + + it.each(['crypto', 'commodity', 'forex', 'bond', undefined])( + 'returns false for non-equity %s', + (marketType) => { + expect(isEquityAsset(marketType)).toBe(false); + }, + ); + }); + describe('matchesCategory', () => { it("matches every market for 'all'", () => { expect(matchesCategory(market({ marketType: 'etf' }), 'all')).toBe(true); @@ -55,14 +84,14 @@ describe('marketUtils category classification', () => { ['etf', 'etfs'], ['commodity', 'commodities'], ['forex', 'forex'], - ] as const)('matches marketType %s for filter %s', (marketType, filter) => { - expect(matchesCategory(market({ marketType }), filter)).toBe(true); - }); + ] as const)( + 'matches marketType %s for the granular filter %s', + (marketType, filter) => { + expect(matchesCategory(market({ marketType }), filter)).toBe(true); + }, + ); - it('keeps stock-like categories distinct (stock !== etf filter)', () => { - expect(matchesCategory(market({ marketType: 'stock' }), 'etfs')).toBe( - false, - ); + it('keeps stock-like categories distinct in the granular model', () => { expect(matchesCategory(market({ marketType: 'etf' }), 'stocks')).toBe( false, ); @@ -70,16 +99,17 @@ describe('marketUtils category classification', () => { }); describe('getMarketTypeFilter', () => { - // HIP-3 markets carry a marketType; main-DEX crypto does not. + // HIP-3 markets carry a marketType; main-DEX crypto does not. Stock-like + // categories collapse into the single 'stocks' filter. it.each([ ['stock', 'stocks'], - ['pre-ipo', 'pre-ipo'], - ['index', 'indices'], - ['etf', 'etfs'], + ['pre-ipo', 'stocks'], + ['index', 'stocks'], + ['etf', 'stocks'], ['commodity', 'commodities'], ['forex', 'forex'], ] as const)( - 'resolves HIP-3 marketType %s to the %s filter', + 'collapses HIP-3 marketType %s to the %s filter', (marketType, expected) => { expect(getMarketTypeFilter(market({ marketType, isHip3: true }))).toBe( expected, @@ -99,27 +129,34 @@ describe('marketUtils category classification', () => { ).toBe('crypto'); }); - it('falls back to all for uncategorized HIP-3 markets', () => { + it('resolves uncategorized HIP-3 markets to the new bucket', () => { expect( - getMarketTypeFilter( - market({ marketType: undefined, isHip3: true, isNewMarket: false }), - ), - ).toBe('all'); + getMarketTypeFilter(market({ marketType: undefined, isHip3: true })), + ).toBe('new'); }); - it('falls back to all for new markets without a marketType', () => { + it('treats a marketSource DEX id as HIP-3 (new, not crypto) when isHip3 is unset', () => { expect( getMarketTypeFilter( - market({ marketType: undefined, isHip3: true, isNewMarket: true }), + market({ + marketType: undefined, + isHip3: undefined, + marketSource: 'xyz', + }), ), - ).toBe('all'); + ).toBe('new'); }); - it('is the inverse of matchesCategory for the resolved filter', () => { - const etfMarket = market({ marketType: 'etf', isHip3: true }); - const filter = getMarketTypeFilter(etfMarket); - expect(filter).toBe('etfs'); - expect(matchesCategory(etfMarket, filter)).toBe(true); + 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'), + ); }); }); }); From 998f88e3f91f4fe3cea952662889b53f3a7a15ee Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Fri, 5 Jun 2026 08:35:29 +0800 Subject: [PATCH 6/6] fix(perps-controller): classify HIP-3 partial markets consistently getMarketTypeFilter treated a marketSource-only market (isHip3 unset, no marketType) as 'new', but matchesCategory still matched it as 'crypto'. Add a shared isHip3Market predicate (isHip3 OR marketSource) used by both, and treat an uncategorized HIP-3 market as 'new' in matchesCategory, so the two classify partial markets the same way. Equivalent for well-formed data. --- packages/perps-controller/CHANGELOG.md | 2 +- packages/perps-controller/src/index.ts | 1 + .../perps-controller/src/utils/marketUtils.ts | 31 ++++++++++--- .../tests/src/utils/marketUtils.test.ts | 43 +++++++++++++++++++ 4 files changed, 70 insertions(+), 7 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index ec49b01e74..78e54ff882 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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, collapsing stock-like categories into `stocks`), `isEquityAsset`, and `STOCK_LIKE_MARKET_TYPES`. + - Export `getMarketTypeFilter` (resolves a market to its UI category filter, collapsing stock-like categories into `stocks`), `isEquityAsset`, `isHip3Market`, and `STOCK_LIKE_MARKET_TYPES`. `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`). - Add `MARKET_TYPE_FILTER` named constants for the `MarketTypeFilter` values. diff --git a/packages/perps-controller/src/index.ts b/packages/perps-controller/src/index.ts index 09733a2274..e5677f13a2 100644 --- a/packages/perps-controller/src/index.ts +++ b/packages/perps-controller/src/index.ts @@ -496,6 +496,7 @@ export { getMarketTypeFilter, applyMarketFilters, isEquityAsset, + isHip3Market, STOCK_LIKE_MARKET_TYPES, } from './utils'; export type { MarketPatternMatcher, CompiledMarketPattern } from './utils'; diff --git a/packages/perps-controller/src/utils/marketUtils.ts b/packages/perps-controller/src/utils/marketUtils.ts index 9a175656ad..1691d018ec 100644 --- a/packages/perps-controller/src/utils/marketUtils.ts +++ b/packages/perps-controller/src/utils/marketUtils.ts @@ -30,6 +30,19 @@ const MARKET_CATEGORY_TO_FILTER: Record = { [MarketCategory.Forex]: MARKET_TYPE_FILTER.Forex, }; +/** + * 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. * @@ -45,11 +58,17 @@ export function matchesCategory( case MARKET_TYPE_FILTER.All: return true; case MARKET_TYPE_FILTER.New: - return market.isNewMarket === true; + // 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 MARKET_TYPE_FILTER.Crypto: - // Non-HIP3 markets, plus HIP-3 assets explicitly typed as CryptoCurrency. + // Main-DEX markets, plus HIP-3 assets explicitly typed as CryptoCurrency. return ( - !market.isHip3 || market.marketType === MarketCategory.CryptoCurrency + !isHip3Market(market) || + market.marketType === MarketCategory.CryptoCurrency ); default: // Every other filter is a 1:1 data-model category match. @@ -108,9 +127,9 @@ export function getMarketTypeFilter(market: PerpsMarketData): MarketTypeFilter { if (marketType) { return MARKET_CATEGORY_TO_FILTER[marketType]; } - // No data-model category: a HIP-3 market (isHip3, or a marketSource DEX id) is - // uncategorized → the 'new' bucket; otherwise it's a main-DEX crypto market. - return market.isHip3 || market.marketSource + // No data-model category: an uncategorized HIP-3 market is the 'new' bucket; + // otherwise it's a main-DEX crypto market. + return isHip3Market(market) ? MARKET_TYPE_FILTER.New : MARKET_TYPE_FILTER.Crypto; } diff --git a/packages/perps-controller/tests/src/utils/marketUtils.test.ts b/packages/perps-controller/tests/src/utils/marketUtils.test.ts index 3e8c895a1f..e05a3dcbf4 100644 --- a/packages/perps-controller/tests/src/utils/marketUtils.test.ts +++ b/packages/perps-controller/tests/src/utils/marketUtils.test.ts @@ -3,6 +3,7 @@ import { STOCK_LIKE_MARKET_TYPES, getMarketTypeFilter, isEquityAsset, + isHip3Market, matchesCategory, } from '../../../src/utils/marketUtils'; @@ -46,6 +47,24 @@ 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); @@ -77,6 +96,17 @@ describe('marketUtils category classification', () => { ).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', 'stocks'], ['pre-ipo', 'pre-ipo'], @@ -158,5 +188,18 @@ describe('marketUtils category classification', () => { expect(getMarketTypeFilter(sample)).not.toBe('all'), ); }); + + // The resolved bucket must agree with matchesCategory (stock-like is the one + // intentional exception: it collapses to 'stocks' while matchesCategory keeps + // stock/pre-ipo/index/etf granular). + it.each([ + 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); + }); }); });