From f5e40e464bea83c59eb38c71dc7d17e7da24bdf0 Mon Sep 17 00:00:00 2001 From: Alejandro Garcia Date: Mon, 25 May 2026 16:02:39 +0200 Subject: [PATCH 01/12] feat(perps): extend market categories --- packages/perps-controller/CHANGELOG.md | 12 + .../PerpsController-method-action-types.ts | 13 + .../perps-controller/src/PerpsController.ts | 43 +- .../src/constants/hyperLiquidConfig.ts | 159 ++++--- packages/perps-controller/src/index.ts | 4 +- .../src/services/MarketDataService.ts | 168 ++++++- packages/perps-controller/src/types/index.ts | 51 ++- .../perps-controller/src/utils/sortMarkets.ts | 11 +- .../src/PerpsController.configuration.test.ts | 7 + .../PerpsController.market-filtering.test.ts | 428 ++++++++++++++++++ .../PerpsController.providers-cache.test.ts | 7 + .../src/constants/hyperLiquidConfig.test.ts | 140 +++++- 12 files changed, 950 insertions(+), 93 deletions(-) create mode 100644 packages/perps-controller/tests/src/PerpsController.market-filtering.test.ts diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 986f1b250c..5d936965aa 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `MarketCategory` enum, `MARKET_CATEGORIES` ordered array (includes `'all'` and `'new'` UI sentinel values), and `getMarketCategories` messenger action ([#TBD](https://github.com/MetaMask/core/pull/TBD)) +- Expand `HIP3_ASSET_MARKET_TYPES` with new stock, ETF, pre-IPO, forex, and commodity markets ([#TBD](https://github.com/MetaMask/core/pull/TBD)) +- Add `categories`, `sortBy`, `direction`, and `limit` optional params to `GetMarketsParams` and `getMarketDataWithPrices()` for post-processing filtering, sorting, and pagination of market data ([#TBD](https://github.com/MetaMask/core/pull/TBD)) +- Export `SortField` and `SortDirection` types from the package root ([#TBD](https://github.com/MetaMask/core/pull/TBD)) + +### Changed + +- **BREAKING:** Replace `'equity'` with granular `MarketType` values: `'stock'`, `'pre-ipo'`, `'index'`, and `'etf'` ([#TBD](https://github.com/MetaMask/core/pull/TBD)) + - Update any code matching `marketType === 'equity'` to use the specific sub-type. + ## [6.3.0] ### Added diff --git a/packages/perps-controller/src/PerpsController-method-action-types.ts b/packages/perps-controller/src/PerpsController-method-action-types.ts index cdc4c733a4..eba605346d 100644 --- a/packages/perps-controller/src/PerpsController-method-action-types.ts +++ b/packages/perps-controller/src/PerpsController-method-action-types.ts @@ -403,6 +403,18 @@ export type PerpsControllerGetMarketsAction = { handler: PerpsController['getMarkets']; }; +/** + * Get the list of unique market categories available in HIP-3 markets. + * Derives the categories from the HIP3_ASSET_MARKET_TYPES config + * so the UI can render category filter tabs without hard-coding the list. + * + * @returns Array of unique MarketType values present in the config. + */ +export type PerpsControllerGetMarketCategoriesAction = { + type: `PerpsController:getMarketCategories`; + handler: PerpsController['getMarketCategories']; +}; + /** * Get market data with prices (includes price, volume, 24h change) * @@ -1034,6 +1046,7 @@ export type PerpsControllerMethodActions = | PerpsControllerGetFundingAction | PerpsControllerGetAccountStateAction | PerpsControllerGetHistoricalPortfolioAction + | PerpsControllerGetMarketCategoriesAction | PerpsControllerGetMarketsAction | PerpsControllerGetMarketDataWithPricesAction | PerpsControllerStartMarketDataPreloadAction diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index bc3fe86762..359ad66b92 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -22,7 +22,6 @@ import { PERPS_CONSTANTS, MARKET_SORTING_CONFIG, PROVIDER_CONFIG, - PERPS_DISK_CACHE_MARKETS, PERPS_DISK_CACHE_USER_DATA, buildProviderCacheKey, MAX_SLIPPAGE_BOUNDS, @@ -47,6 +46,7 @@ import { PerpsTraceNames, PerpsTraceOperations, isVersionGatedFeatureFlag, + MARKET_CATEGORIES, // Platform dependencies interface for core migration (bundles all platform-specific deps) } from './types'; import type { @@ -110,6 +110,7 @@ import type { PerpsRemoteFeatureFlagState, PerpsTransactionParams, PerpsAddTransactionOptions, + MarketTypeFilter, MYXCredentials, } from './types'; import type { @@ -723,6 +724,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'getCurrentNetwork', 'getFunding', 'getHistoricalPortfolio', + 'getMarketCategories', 'getMarketDataWithPrices', 'getMarketFilterPreferences', 'getMarkets', @@ -2933,28 +2935,44 @@ export class PerpsController extends BaseController< } /** - * Get market data with prices (includes price, volume, 24h change) + * Get market data with prices (includes price, volume, 24h change). + * Optionally filter by category, sort, and limit the results. * * For standalone mode, bypasses getActiveProvider() to allow market data queries * without full perps initialization (e.g., for background preloading on app start) * * @param params - The operation parameters. * @param params.standalone - Whether to use standalone mode. + * @param params.categories - Filter to markets matching any of these categories. + * @param params.sortBy - Sort results by this field. + * @param params.direction - Sort direction (default: desc). + * @param params.limit - Maximum number of results to return. * @returns A promise that resolves to the market data. */ - async getMarketDataWithPrices(params?: { - standalone?: boolean; - }): Promise { + async getMarketDataWithPrices( + params?: Pick< + GetMarketsParams, + 'categories' | 'sortBy' | 'direction' | 'limit' | 'standalone' + >, + ): Promise { if (params?.standalone) { // Use activeProviderInstance if available (respects provider abstraction) // Fallback to cached standalone provider for pre-initialization discovery const provider = this.activeProviderInstance ?? this.#getOrCreateStandaloneProvider(); - return provider.getMarketDataWithPrices(); + return this.#marketDataService.getMarketDataWithPrices({ + provider, + params, + context: this.#createServiceContext('getMarketDataWithPrices'), + }); } const provider = this.getActiveProvider(); - return provider.getMarketDataWithPrices(); + return this.#marketDataService.getMarketDataWithPrices({ + provider, + params, + context: this.#createServiceContext('getMarketDataWithPrices'), + }); } // ============================================================================ @@ -3960,6 +3978,17 @@ export class PerpsController extends BaseController< return this.state.isTestnet ? 'testnet' : 'mainnet'; } + /** + * Get the ordered list of all market categories for HIP-3 markets. + * Returns a stable, explicitly ordered array so the UI can render + * category filter tabs without deriving order from config insertion. + * + * @returns Ordered array of {@link MarketTypeFilter} values (includes 'all' and 'new'). + */ + getMarketCategories(): MarketTypeFilter[] { + return MARKET_CATEGORIES; + } + /** * Get the current WebSocket connection state from the active provider. * Used by the UI to monitor connection health and show notifications. diff --git a/packages/perps-controller/src/constants/hyperLiquidConfig.ts b/packages/perps-controller/src/constants/hyperLiquidConfig.ts index 6551915bea..1e186958dc 100644 --- a/packages/perps-controller/src/constants/hyperLiquidConfig.ts +++ b/packages/perps-controller/src/constants/hyperLiquidConfig.ts @@ -1,5 +1,8 @@ import type { CaipAssetId, CaipChainId, Hex } from '@metamask/utils'; +import { MarketCategory } from '../types'; +import type { MarketType } from '../types'; + import type { HyperLiquidNetwork, HyperLiquidEndpoints, @@ -292,79 +295,107 @@ export const SPOT_ASSET_ID_OFFSET = 10000; * Maps asset symbols (e.g., "xyz:TSLA") to their market type for badge display. * * Market type determines the badge shown in the UI: - * - 'equity': STOCK badge (stocks like TSLA, NVDA) - * - 'commodity': COMMODITY badge (commodities like GOLD) - * - 'forex': FOREX badge (forex pairs) - * - undefined: No badge for crypto or unmapped assets + * - 'stock': Individual stocks (TSLA, NVDA, AAPL, etc.) + * - 'pre-ipo': Pre-IPO assets not yet publicly listed + * - 'index': Market indices (SP500, JP225, VIX, etc.) + * - 'etf': Exchange-traded funds (EWY, EWJ, USAR, etc.) + * - 'commodity': Commodities (GOLD, SILVER, CL, etc.) + * - 'forex': Forex pairs (EUR, JPY, DXY) + * - 'crypto': Explicitly categorized crypto assets + * - undefined: No badge for unmapped assets * * Format: 'dex:SYMBOL' → MarketType * This allows flexible per-asset classification. * Assets not listed here will have no market type (undefined). */ -export const HIP3_ASSET_MARKET_TYPES: Record< - string, - 'equity' | 'commodity' | 'forex' | 'crypto' -> = { - // xyz DEX - Equities - 'xyz:TSLA': 'equity', - 'xyz:NVDA': 'equity', - 'xyz:XYZ100': 'equity', - 'xyz:INTC': 'equity', - 'xyz:MU': 'equity', - 'xyz:CRCL': 'equity', - 'xyz:HOOD': 'equity', - 'xyz:SNDK': 'equity', - 'xyz:GOOGL': 'equity', - 'xyz:COIN': 'equity', - 'xyz:ORCL': 'equity', - 'xyz:AMZN': 'equity', - 'xyz:PLTR': 'equity', - 'xyz:AAPL': 'equity', - 'xyz:META': 'equity', - 'xyz:AMD': 'equity', - 'xyz:MSFT': 'equity', - 'xyz:BABA': 'equity', - 'xyz:RIVN': 'equity', - 'xyz:NFLX': 'equity', - 'xyz:COST': 'equity', - 'xyz:LLY': 'equity', - 'xyz:TSM': 'equity', - 'xyz:SKHX': 'equity', - 'xyz:MSTR': 'equity', - 'xyz:CRWV': 'equity', - 'xyz:SMSN': 'equity', - - 'xyz:GME': 'equity', - 'xyz:SOFTBANK': 'equity', - 'xyz:HYUNDAI': 'equity', - 'xyz:KIOXIA': 'equity', - 'xyz:HIMS': 'equity', - 'xyz:EWY': 'equity', - 'xyz:EWJ': 'equity', - 'xyz:SP500': 'equity', - 'xyz:JP225': 'equity', - 'xyz:KR200': 'equity', - 'xyz:VIX': 'equity', - 'xyz:USAR': 'equity', +export const HIP3_ASSET_MARKET_TYPES: Record = { + // xyz DEX - Stocks (US) + 'xyz:TSLA': MarketCategory.Stock, + 'xyz:NVDA': MarketCategory.Stock, + 'xyz:INTC': MarketCategory.Stock, + 'xyz:MU': MarketCategory.Stock, + 'xyz:CRCL': MarketCategory.Stock, + 'xyz:HOOD': MarketCategory.Stock, + 'xyz:SNDK': MarketCategory.Stock, + 'xyz:GOOGL': MarketCategory.Stock, + 'xyz:COIN': MarketCategory.Stock, + 'xyz:ORCL': MarketCategory.Stock, + 'xyz:AMZN': MarketCategory.Stock, + 'xyz:PLTR': MarketCategory.Stock, + 'xyz:AAPL': MarketCategory.Stock, + 'xyz:META': MarketCategory.Stock, + 'xyz:AMD': MarketCategory.Stock, + 'xyz:MSFT': MarketCategory.Stock, + 'xyz:BABA': MarketCategory.Stock, + 'xyz:RIVN': MarketCategory.Stock, + 'xyz:NFLX': MarketCategory.Stock, + 'xyz:COST': MarketCategory.Stock, + 'xyz:LLY': MarketCategory.Stock, + 'xyz:TSM': MarketCategory.Stock, + 'xyz:MSTR': MarketCategory.Stock, + 'xyz:CRWV': MarketCategory.Stock, + 'xyz:GME': MarketCategory.Stock, + 'xyz:HIMS': MarketCategory.Stock, + 'xyz:USAR': MarketCategory.Stock, + 'xyz:DKNG': MarketCategory.Stock, + 'xyz:BIRD': MarketCategory.Stock, + 'xyz:RKLB': MarketCategory.Stock, + 'xyz:MRVL': MarketCategory.Stock, + 'xyz:ZM': MarketCategory.Stock, + 'xyz:EBAY': MarketCategory.Stock, + 'xyz:CBRS': MarketCategory.Stock, + 'xyz:PURRDAT': MarketCategory.Stock, + 'xyz:ARM': MarketCategory.Stock, + 'xyz:BX': MarketCategory.Stock, + 'xyz:LITE': MarketCategory.Stock, + + // xyz DEX - Stocks (Korea) + 'xyz:SKHX': MarketCategory.Stock, + 'xyz:SMSN': MarketCategory.Stock, + 'xyz:HYUNDAI': MarketCategory.Stock, + + // xyz DEX - Stocks (Japan) + 'xyz:SOFTBANK': MarketCategory.Stock, + 'xyz:KIOXIA': MarketCategory.Stock, + + // xyz DEX - Pre-IPO + 'xyz:SPCX': MarketCategory.PreIpo, + + // xyz DEX - Indices + 'xyz:SP500': MarketCategory.Index, + 'xyz:XYZ100': MarketCategory.Index, + 'xyz:JP225': MarketCategory.Index, + 'xyz:KR200': MarketCategory.Index, + 'xyz:VIX': MarketCategory.Index, + + // xyz DEX - ETFs + 'xyz:EWY': MarketCategory.Etf, + 'xyz:EWJ': MarketCategory.Etf, + 'xyz:EWT': MarketCategory.Etf, + 'xyz:EWZ': MarketCategory.Etf, + 'xyz:URNM': MarketCategory.Etf, + 'xyz:DRAM': MarketCategory.Etf, + 'xyz:XLE': MarketCategory.Etf, // xyz DEX - Commodities - 'xyz:GOLD': 'commodity', - 'xyz:SILVER': 'commodity', - 'xyz:CL': 'commodity', - 'xyz:COPPER': 'commodity', - 'xyz:ALUMINIUM': 'commodity', - 'xyz:URANIUM': 'commodity', - 'xyz:URNM': 'commodity', - 'xyz:NATGAS': 'commodity', - 'xyz:PLATINUM': 'commodity', - 'xyz:PALLADIUM': 'commodity', - 'xyz:BRENTOIL': 'commodity', + 'xyz:GOLD': MarketCategory.Commodity, + 'xyz:SILVER': MarketCategory.Commodity, + 'xyz:CL': MarketCategory.Commodity, + 'xyz:WTIOIL': MarketCategory.Commodity, + 'xyz:COPPER': MarketCategory.Commodity, + 'xyz:ALUMINIUM': MarketCategory.Commodity, + 'xyz:URANIUM': MarketCategory.Commodity, + 'xyz:NATGAS': MarketCategory.Commodity, + 'xyz:PLATINUM': MarketCategory.Commodity, + 'xyz:PALLADIUM': MarketCategory.Commodity, + 'xyz:BRENTOIL': MarketCategory.Commodity, // xyz DEX - Forex - 'xyz:EUR': 'forex', - 'xyz:JPY': 'forex', - 'xyz:DXY': 'forex', -} as const; + 'xyz:EUR': MarketCategory.Forex, + 'xyz:JPY': MarketCategory.Forex, + 'xyz:GBP': MarketCategory.Forex, + 'xyz:DXY': MarketCategory.Forex, +}; /** * Testnet-specific HIP-3 DEX configuration diff --git a/packages/perps-controller/src/index.ts b/packages/perps-controller/src/index.ts index 64d331b75b..f5dccaeb27 100644 --- a/packages/perps-controller/src/index.ts +++ b/packages/perps-controller/src/index.ts @@ -72,6 +72,7 @@ export type { PerpsControllerGetHistoricalPortfolioAction, PerpsControllerGetMarketDataWithPricesAction, PerpsControllerGetMarketFilterPreferencesAction, + PerpsControllerGetMarketCategoriesAction, PerpsControllerGetMarketsAction, PerpsControllerGetMaxLeverageAction, PerpsControllerGetOpenOrdersAction, @@ -132,13 +133,14 @@ export type { export { HyperLiquidProvider } from './providers/HyperLiquidProvider'; // Type definitions (explicit named exports) -export { WebSocketConnectionState, PerpsAnalyticsEvent } from './types'; +export { WebSocketConnectionState, PerpsAnalyticsEvent, MARKET_CATEGORIES } from './types'; export type { RawLedgerUpdate, UserHistoryItem, GetUserHistoryParams, TradeConfiguration, OrderType, + MarketCategory, MarketType, MarketTypeFilter, InputMethod, diff --git a/packages/perps-controller/src/services/MarketDataService.ts b/packages/perps-controller/src/services/MarketDataService.ts index 102e0506ff..70dab3ed80 100644 --- a/packages/perps-controller/src/services/MarketDataService.ts +++ b/packages/perps-controller/src/services/MarketDataService.ts @@ -4,7 +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 { PerpsTraceNames, PerpsTraceOperations } from '../types'; +import { MarketCategory, PerpsTraceNames, PerpsTraceOperations } from '../types'; import type { PerpsProvider, Position, @@ -30,10 +30,13 @@ import type { ClosePositionParams, 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 type { ServiceContext } from './ServiceContext'; /** @@ -799,6 +802,86 @@ export class MarketDataService { } } + /** + * Get market data with prices (includes price, volume, 24h change). + * Applies optional category filtering, sorting, and limit after fetching. + * + * @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. + * @returns The result of the operation. + */ + async getMarketDataWithPrices(options: { + provider: PerpsProvider; + params?: Pick< + GetMarketsParams, + 'categories' | 'sortBy' | 'direction' | 'limit' + >; + context: ServiceContext; + }): Promise { + const { provider, params, context } = options; + const traceId = uuidv4(); + let traceData: { success: boolean; error?: string } | undefined; + + try { + this.#deps.tracer.trace({ + name: PerpsTraceNames.GetMarkets, + id: traceId, + op: PerpsTraceOperations.Operation, + tags: { + provider: context.tracingContext.provider, + isTestnet: String(context.tracingContext.isTestnet), + ...(params?.categories && { + categoryCount: String(params.categories.length), + }), + }, + }); + + const markets = await provider.getMarketDataWithPrices(); + const filtered = applyMarketFilters(markets, params); + + traceData = { success: true }; + return filtered; + } catch (error) { + const errorMessage = + error instanceof Error + ? error.message + : PERPS_ERROR_CODES.MARKETS_FAILED; + + this.#deps.logger.error( + ensureError(error, 'MarketDataService.getMarketDataWithPrices'), + { + tags: { + feature: PERPS_CONSTANTS.FeatureName, + provider: context.tracingContext.provider, + network: context.tracingContext.isTestnet ? 'testnet' : 'mainnet', + }, + context: { + name: context.errorContext.controller, + data: { + method: context.errorContext.method, + params, + }, + }, + }, + ); + + traceData = { + success: false, + error: errorMessage, + }; + + throw error; + } finally { + this.#deps.tracer.endTrace({ + name: PerpsTraceNames.GetMarkets, + id: traceId, + data: traceData, + }); + } + } + /** * Get available DEXs (HIP-3 support required) * @@ -1173,3 +1256,86 @@ 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 | 'watchlist', +): boolean { + switch (category) { + case 'all': + return true; + case 'new': + return market.isNewMarket === true; + case 'crypto': + return !market.isHip3; + 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; + case 'watchlist': + // Watchlist state lives in the calling client; always false here. + return false; + 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?: Pick< + GetMarketsParams, + 'categories' | 'sortBy' | 'direction' | 'limit' + >, +): 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?.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 967c8d60d7..fe5343e683 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -74,18 +74,47 @@ export type TradeConfiguration = { }; // Market asset type classification (reusable across components) -export type MarketType = 'crypto' | 'equity' | 'commodity' | 'forex'; +export enum MarketCategory { + CryptoCurrency = 'crypto', + Stock = 'stock', + PreIpo = 'pre-ipo', + Index = 'index', + Etf = 'etf', + Commodity = 'commodity', + Forex = 'forex', +} + +export type MarketType = MarketCategory; // Market type filter for UI category badges -// Note: 'stocks' maps to 'equity' and 'commodities' maps to 'commodity' in the data model +// Note: 'stocks' maps to 'stock', 'commodities' maps to 'commodity' in the data model export type MarketTypeFilter = | 'all' | 'crypto' | 'stocks' + | 'pre-ipo' + | 'indices' + | 'etfs' | 'commodities' | 'forex' | 'new'; +/** + * Ordered list of the 7 data-model market categories for UI pills. + * Does not include the 'all' or 'new' sentinel values — those are applied + * via dedicated UI controls, not the category pills. + * Kept in sync with {@link MarketTypeFilter} via `satisfies`. + */ +export const MARKET_CATEGORIES = [ + 'crypto', + 'stocks', + 'pre-ipo', + 'indices', + 'etfs', + 'commodities', + 'forex', +] as const satisfies MarketTypeFilter[]; + // Input method for amount entry tracking export type InputMethod = | 'default' @@ -422,7 +451,10 @@ export type PerpsMarketData = { /** * Market asset type classification (optional) * - crypto: Cryptocurrency (default for most markets) - * - equity: Stock/equity markets (HIP-3) + * - stock: Individual stocks (HIP-3) + * - pre-ipo: Pre-IPO assets (HIP-3) + * - index: Market indices (HIP-3) + * - etf: Exchange-traded funds (HIP-3) * - commodity: Commodity markets (HIP-3) * - forex: Foreign exchange pairs (HIP-3) */ @@ -783,11 +815,22 @@ export type GetSupportedPathsParams = { /** Placeholder for future filter/pagination params (e.g., validated, chain). Empty today so the API signature is stable. */ export type GetAvailableDexsParams = Record; +/** Field to sort markets by. */ +export type SortField = 'volume' | 'priceChange' | 'fundingRate' | 'openInterest'; + +/** Direction for market sorting. */ +export type SortDirection = 'asc' | 'desc'; + export type GetMarketsParams = { symbols?: string[]; // Optional symbol filter (e.g., ['BTC', 'xyz:XYZ100']) dex?: string; // HyperLiquid HIP-3: DEX name (empty string '' or undefined for main DEX). Other protocols: ignored. skipFilters?: boolean; // Skip market filtering (both allowlist and blocklist, default: false). When true, returns all markets without filtering. standalone?: boolean; // Lightweight mode: skip full initialization, only fetch market metadata (no wallet/WebSocket needed). Only main DEX markets returned. Use for discovery use cases like checking if a perps market exists. + // Category / sort / pagination (applied as post-processing in PerpsController): + categories?: (MarketTypeFilter | 'watchlist')[]; // Filter to markets in any of these categories; omit for all markets + sortBy?: SortField; // Sort results by this field + direction?: SortDirection; // Sort direction (default: desc) + limit?: number; // Maximum number of results to return }; export type SubscribePricesParams = { @@ -998,7 +1041,7 @@ export type PerpsProvider = { getPositions(params?: GetPositionsParams): Promise; getAccountState(params?: GetAccountStateParams): Promise; getMarkets(params?: GetMarketsParams): Promise; - getMarketDataWithPrices(): Promise; + getMarketDataWithPrices(params?: Pick): Promise; withdraw(params: WithdrawParams): Promise; // API operation - stays in provider // Note: deposit() is handled by PerpsController routing (blockchain operation) validateDeposit( diff --git a/packages/perps-controller/src/utils/sortMarkets.ts b/packages/perps-controller/src/utils/sortMarkets.ts index 902e1f29c1..c3dff7cd3d 100644 --- a/packages/perps-controller/src/utils/sortMarkets.ts +++ b/packages/perps-controller/src/utils/sortMarkets.ts @@ -2,14 +2,9 @@ import { MARKET_SORTING_CONFIG, PERPS_CONSTANTS, } from '../constants/perpsConfig'; -import type { PerpsMarketData } from '../types'; - -export type SortField = - | 'volume' - | 'priceChange' - | 'fundingRate' - | 'openInterest'; -export type SortDirection = 'asc' | 'desc'; +import type { PerpsMarketData, SortDirection, SortField } from '../types'; + +export type { SortField, SortDirection }; export type SortMarketsParams = { markets: PerpsMarketData[]; diff --git a/packages/perps-controller/tests/src/PerpsController.configuration.test.ts b/packages/perps-controller/tests/src/PerpsController.configuration.test.ts index bacb51ff5d..384eb9ba8c 100644 --- a/packages/perps-controller/tests/src/PerpsController.configuration.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.configuration.test.ts @@ -176,6 +176,9 @@ const mockMarketDataServiceInstance = { getPositions: jest.fn(), getAccountState: jest.fn(), getMarkets: jest.fn(), + getMarketDataWithPrices: jest.fn().mockImplementation(({ provider }: { provider: { getMarketDataWithPrices: () => Promise } }) => + provider.getMarketDataWithPrices(), + ), getWithdrawalRoutes: jest.fn().mockReturnValue([]), validateClosePosition: jest.fn().mockResolvedValue({ isValid: true }), validateOrder: jest.fn(), @@ -483,6 +486,10 @@ describe('PerpsController', () => { returnOnEquity: '0', }); mockMarketDataServiceInstance.getMarkets.mockResolvedValue([]); + mockMarketDataServiceInstance.getMarketDataWithPrices.mockImplementation( + ({ provider }: { provider: { getMarketDataWithPrices: () => Promise } }) => + provider.getMarketDataWithPrices(), + ); mockMarketDataServiceInstance.getWithdrawalRoutes.mockReturnValue([]); mockMarketDataServiceInstance.validateClosePosition.mockResolvedValue({ isValid: true, diff --git a/packages/perps-controller/tests/src/PerpsController.market-filtering.test.ts b/packages/perps-controller/tests/src/PerpsController.market-filtering.test.ts new file mode 100644 index 0000000000..a6e7cd2e43 --- /dev/null +++ b/packages/perps-controller/tests/src/PerpsController.market-filtering.test.ts @@ -0,0 +1,428 @@ +/** + * Tests for PerpsController market filtering, sorting, and pagination: + * - getMarketCategories() + * - getMarketDataWithPrices({ categories, sortBy, direction, limit }) + */ + +/* eslint-disable */ + +import { createMockHyperLiquidProvider } from '../helpers/providerMocks'; +import { + createMockInfrastructure, + createMockMessenger, +} from '../helpers/serviceMocks'; +import { + PerpsController, + getDefaultPerpsControllerState, + InitializationState, +} from '../../src/PerpsController'; +import { HyperLiquidProvider } from '../../src/providers/HyperLiquidProvider'; +import type { + PerpsProvider, + PerpsProviderType, + PerpsPlatformDependencies, + PerpsMarketData, +} from '../../src/types'; +import { MARKET_CATEGORIES, MarketCategory } from '../../src/types'; + +jest.mock('@nktkas/hyperliquid', () => ({})); +jest.mock('@myx-trade/sdk', () => ({ + MyxClient: jest.fn(), + OrderStatusEnum: { Successful: 9 }, +})); + +jest.mock( + '../../../core/Engine', + () => ({ + __esModule: true, + default: { + context: { + RewardsController: { getPerpsDiscountForAccount: jest.fn() }, + NetworkController: { + getNetworkClientById: jest.fn().mockReturnValue({ + configuration: { chainId: '0x1' }, + }), + }, + AccountTreeController: { + getAccountsFromSelectedAccountGroup: jest.fn().mockReturnValue([]), + }, + TransactionController: { + estimateGasFee: jest.fn(), + estimateGas: jest.fn(), + }, + AccountTrackerController: { + state: { accountsByChainId: {} }, + }, + }, + }, + }), + { virtual: true }, +); + +jest.mock('../../src/providers/HyperLiquidProvider'); +jest.mock('../../src/providers/MYXProvider'); + +jest.mock('../../src/utils/wait', () => ({ + wait: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock('../../src/services/EligibilityService', () => ({ + EligibilityService: jest.fn().mockImplementation(() => ({ + checkAndUpdateEligibility: jest + .fn() + .mockResolvedValue({ isEligible: true }), + isCurrentlyEligible: jest.fn().mockReturnValue(true), + subscribeToEligibilityChanges: jest.fn().mockReturnValue(() => undefined), + })), +})); + +/** Expose protected methods for testing. */ +class TestablePerpsController extends PerpsController { + public testMarkInitialized() { + this.isInitialized = true; + this.update((state) => { + state.initializationState = InitializationState.Initialized; + }); + } + + public testSetProviders(providers: Map) { + this.providers = providers; + const firstProvider = providers.values().next().value; + if (firstProvider) { + this.activeProviderInstance = firstProvider; + } + } +} + +/** Build a minimal PerpsMarketData object. */ +function buildMarket( + overrides: Partial = {}, +): PerpsMarketData { + return { + symbol: 'TEST', + name: 'Test Market', + maxLeverage: '10x', + price: '$100.00', + change24h: '$0.00', + change24hPercent: '0%', + volume: '$1M', + ...overrides, + }; +} + +describe('PerpsController — market categories & filtering', () => { + let controller: TestablePerpsController; + let mockProvider: jest.Mocked; + let mockInfrastructure: jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + + mockProvider = createMockHyperLiquidProvider(); + mockInfrastructure = createMockInfrastructure(); + + const mockCall = jest.fn().mockReturnValue(undefined); + + controller = new TestablePerpsController({ + messenger: createMockMessenger({ call: mockCall }), + state: getDefaultPerpsControllerState(), + infrastructure: mockInfrastructure, + }); + + controller.testSetProviders( + new Map([['hyperliquid', mockProvider as unknown as PerpsProvider]]), + ); + controller.testMarkInitialized(); + }); + + // ============================================================================ + // getMarketCategories + // ============================================================================ + + describe('getMarketCategories', () => { + it('returns the MARKET_CATEGORIES constant', () => { + expect(controller.getMarketCategories()).toStrictEqual(MARKET_CATEGORIES); + }); + + it('does not include the all or new sentinel values', () => { + const categories = controller.getMarketCategories(); + expect(categories).not.toContain('all'); + expect(categories).not.toContain('new'); + }); + + it('includes all 7 data categories', () => { + const categories = controller.getMarketCategories(); + expect(categories).toContain('crypto'); + expect(categories).toContain('stocks'); + expect(categories).toContain('pre-ipo'); + expect(categories).toContain('indices'); + expect(categories).toContain('etfs'); + expect(categories).toContain('commodities'); + expect(categories).toContain('forex'); + }); + }); + + // ============================================================================ + // getMarketDataWithPrices — category filtering + // ============================================================================ + + describe('getMarketDataWithPrices — category filtering', () => { + it('returns unfiltered results when no params are provided', async () => { + const markets = [ + buildMarket({ symbol: 'BTC', isHip3: false }), + buildMarket({ + symbol: 'xyz:TSLA', + isHip3: true, + marketType: MarketCategory.Stock, + }), + ]; + mockProvider.getMarketDataWithPrices.mockResolvedValue(markets); + + const result = await controller.getMarketDataWithPrices(); + + expect(result).toHaveLength(2); + }); + + it('filters to only crypto (non-HIP3) markets when categories is ["crypto"]', async () => { + const markets = [ + buildMarket({ symbol: 'BTC', isHip3: false }), + buildMarket({ symbol: 'ETH', isHip3: false }), + buildMarket({ + symbol: 'xyz:TSLA', + isHip3: true, + marketType: MarketCategory.Stock, + }), + ]; + mockProvider.getMarketDataWithPrices.mockResolvedValue(markets); + + const result = await controller.getMarketDataWithPrices({ + categories: ['crypto'], + }); + + expect(result).toHaveLength(2); + expect(result.every((m) => !m.isHip3)).toBe(true); + }); + + it('filters to only stock markets when categories is ["stocks"]', async () => { + const markets = [ + buildMarket({ symbol: 'BTC', isHip3: false }), + buildMarket({ + symbol: 'xyz:TSLA', + isHip3: true, + marketType: MarketCategory.Stock, + }), + buildMarket({ + symbol: 'xyz:NVDA', + isHip3: true, + marketType: MarketCategory.Stock, + }), + buildMarket({ + symbol: 'xyz:EUR', + isHip3: true, + marketType: MarketCategory.Forex, + }), + ]; + mockProvider.getMarketDataWithPrices.mockResolvedValue(markets); + + const result = await controller.getMarketDataWithPrices({ + categories: ['stocks'], + }); + + expect(result).toHaveLength(2); + expect(result.every((m) => m.marketType === MarketCategory.Stock)).toBe( + true, + ); + }); + + it('returns the union of matched markets when multiple categories are given', async () => { + const markets = [ + buildMarket({ symbol: 'BTC', isHip3: false }), + buildMarket({ + symbol: 'xyz:TSLA', + isHip3: true, + marketType: MarketCategory.Stock, + }), + buildMarket({ + symbol: 'xyz:SPY', + isHip3: true, + marketType: MarketCategory.Etf, + }), + buildMarket({ + symbol: 'xyz:EUR', + isHip3: true, + marketType: MarketCategory.Forex, + }), + ]; + mockProvider.getMarketDataWithPrices.mockResolvedValue(markets); + + const result = await controller.getMarketDataWithPrices({ + categories: ['stocks', 'etfs'], + }); + + expect(result).toHaveLength(2); + const symbols = result.map((m) => m.symbol); + expect(symbols).toContain('xyz:TSLA'); + expect(symbols).toContain('xyz:SPY'); + }); + + it('returns all markets when categories contains "all"', async () => { + const markets = [ + buildMarket({ symbol: 'BTC', isHip3: false }), + buildMarket({ + symbol: 'xyz:TSLA', + isHip3: true, + marketType: MarketCategory.Stock, + }), + ]; + mockProvider.getMarketDataWithPrices.mockResolvedValue(markets); + + const result = await controller.getMarketDataWithPrices({ + categories: ['all'], + }); + + expect(result).toHaveLength(2); + }); + + it('filters to isNewMarket markets when categories is ["new"]', async () => { + const markets = [ + buildMarket({ symbol: 'BTC', isNewMarket: false }), + buildMarket({ + symbol: 'xyz:NEWTOKEN', + isHip3: true, + isNewMarket: true, + }), + ]; + mockProvider.getMarketDataWithPrices.mockResolvedValue(markets); + + const result = await controller.getMarketDataWithPrices({ + categories: ['new'], + }); + + expect(result).toHaveLength(1); + expect(result[0].symbol).toBe('xyz:NEWTOKEN'); + }); + + it('returns empty array when no markets match the given categories', async () => { + const markets = [buildMarket({ symbol: 'BTC', isHip3: false })]; + mockProvider.getMarketDataWithPrices.mockResolvedValue(markets); + + const result = await controller.getMarketDataWithPrices({ + categories: ['forex'], + }); + + expect(result).toHaveLength(0); + }); + }); + + // ============================================================================ + // getMarketDataWithPrices — sorting + // ============================================================================ + + describe('getMarketDataWithPrices — sorting', () => { + it('sorts by openInterest descending when sortBy is "openInterest"', async () => { + const markets = [ + buildMarket({ symbol: 'LOW', openInterest: '$1M' }), + buildMarket({ symbol: 'HIGH', openInterest: '$5M' }), + buildMarket({ symbol: 'MID', openInterest: '$3M' }), + ]; + mockProvider.getMarketDataWithPrices.mockResolvedValue(markets); + + const result = await controller.getMarketDataWithPrices({ + sortBy: 'openInterest', + direction: 'desc', + }); + + expect(result[0].symbol).toBe('HIGH'); + expect(result[1].symbol).toBe('MID'); + expect(result[2].symbol).toBe('LOW'); + }); + + it('sorts by openInterest ascending when direction is "asc"', async () => { + const markets = [ + buildMarket({ symbol: 'HIGH', openInterest: '$5M' }), + buildMarket({ symbol: 'LOW', openInterest: '$1M' }), + ]; + mockProvider.getMarketDataWithPrices.mockResolvedValue(markets); + + const result = await controller.getMarketDataWithPrices({ + sortBy: 'openInterest', + direction: 'asc', + }); + + expect(result[0].symbol).toBe('LOW'); + expect(result[1].symbol).toBe('HIGH'); + }); + }); + + // ============================================================================ + // getMarketDataWithPrices — limit + // ============================================================================ + + describe('getMarketDataWithPrices — limit', () => { + it('returns at most `limit` markets', async () => { + const markets = Array.from({ length: 10 }, (_, i) => + buildMarket({ symbol: `MARKET${i}` }), + ); + mockProvider.getMarketDataWithPrices.mockResolvedValue(markets); + + const result = await controller.getMarketDataWithPrices({ limit: 3 }); + + expect(result).toHaveLength(3); + }); + + it('returns all markets when limit exceeds total count', async () => { + const markets = [ + buildMarket({ symbol: 'A' }), + buildMarket({ symbol: 'B' }), + ]; + mockProvider.getMarketDataWithPrices.mockResolvedValue(markets); + + const result = await controller.getMarketDataWithPrices({ limit: 100 }); + + expect(result).toHaveLength(2); + }); + }); + + // ============================================================================ + // getMarketDataWithPrices — params compose together + // ============================================================================ + + describe('getMarketDataWithPrices — composed params', () => { + it('applies categories filter, then sort, then limit in order', async () => { + const markets = [ + buildMarket({ + symbol: 'xyz:TSLA', + isHip3: true, + marketType: MarketCategory.Stock, + openInterest: '$5M', + }), + buildMarket({ + symbol: 'xyz:NVDA', + isHip3: true, + marketType: MarketCategory.Stock, + openInterest: '$3M', + }), + buildMarket({ + symbol: 'xyz:AAPL', + isHip3: true, + marketType: MarketCategory.Stock, + openInterest: '$8M', + }), + buildMarket({ symbol: 'BTC', isHip3: false, openInterest: '$100M' }), + ]; + mockProvider.getMarketDataWithPrices.mockResolvedValue(markets); + + const result = await controller.getMarketDataWithPrices({ + categories: ['stocks'], + sortBy: 'openInterest', + direction: 'desc', + limit: 2, + }); + + // Only stocks, sorted by OI desc, top 2 + expect(result).toHaveLength(2); + expect(result[0].symbol).toBe('xyz:AAPL'); // $8M + expect(result[1].symbol).toBe('xyz:TSLA'); // $5M + }); + }); +}); diff --git a/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts b/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts index 76fc7a3137..a711e3c5b5 100644 --- a/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts @@ -176,6 +176,9 @@ const mockMarketDataServiceInstance = { getPositions: jest.fn(), getAccountState: jest.fn(), getMarkets: jest.fn(), + getMarketDataWithPrices: jest.fn().mockImplementation(({ provider }: { provider: { getMarketDataWithPrices: () => Promise } }) => + provider.getMarketDataWithPrices(), + ), getWithdrawalRoutes: jest.fn().mockReturnValue([]), validateClosePosition: jest.fn().mockResolvedValue({ isValid: true }), validateOrder: jest.fn(), @@ -483,6 +486,10 @@ describe('PerpsController', () => { returnOnEquity: '0', }); mockMarketDataServiceInstance.getMarkets.mockResolvedValue([]); + mockMarketDataServiceInstance.getMarketDataWithPrices.mockImplementation( + ({ provider }: { provider: { getMarketDataWithPrices: () => Promise } }) => + provider.getMarketDataWithPrices(), + ); mockMarketDataServiceInstance.getWithdrawalRoutes.mockReturnValue([]); mockMarketDataServiceInstance.validateClosePosition.mockResolvedValue({ isValid: true, diff --git a/packages/perps-controller/tests/src/constants/hyperLiquidConfig.test.ts b/packages/perps-controller/tests/src/constants/hyperLiquidConfig.test.ts index 84ee8e319e..2d5cf519fc 100644 --- a/packages/perps-controller/tests/src/constants/hyperLiquidConfig.test.ts +++ b/packages/perps-controller/tests/src/constants/hyperLiquidConfig.test.ts @@ -1,28 +1,152 @@ import { HIP3_ASSET_MARKET_TYPES } from '../../../src/constants/hyperLiquidConfig'; +import { MarketCategory, MARKET_CATEGORIES } from '../../../src/types'; +import type { MarketType, MarketTypeFilter } from '../../../src/types'; describe('HIP3_ASSET_MARKET_TYPES', () => { - it('classifies URNM as commodity (Sprott Uranium Miners ETF)', () => { - expect(HIP3_ASSET_MARKET_TYPES['xyz:URNM']).toBe('commodity'); + it('classifies known US stocks correctly', () => { + expect(HIP3_ASSET_MARKET_TYPES['xyz:TSLA']).toBe('stock'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:NVDA']).toBe('stock'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:AAPL']).toBe('stock'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:GOOGL']).toBe('stock'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:AMZN']).toBe('stock'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:META']).toBe('stock'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:MSFT']).toBe('stock'); }); - it('classifies USAR as equity (US equity fund)', () => { - expect(HIP3_ASSET_MARKET_TYPES['xyz:USAR']).toBe('equity'); + it('classifies newly added US stocks correctly', () => { + expect(HIP3_ASSET_MARKET_TYPES['xyz:DKNG']).toBe('stock'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:BIRD']).toBe('stock'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:RKLB']).toBe('stock'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:MRVL']).toBe('stock'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:ZM']).toBe('stock'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:EBAY']).toBe('stock'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:CBRS']).toBe('stock'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:PURRDAT']).toBe('stock'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:ARM']).toBe('stock'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:BX']).toBe('stock'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:LITE']).toBe('stock'); }); - it('classifies known equities correctly', () => { - expect(HIP3_ASSET_MARKET_TYPES['xyz:TSLA']).toBe('equity'); - expect(HIP3_ASSET_MARKET_TYPES['xyz:NVDA']).toBe('equity'); - expect(HIP3_ASSET_MARKET_TYPES['xyz:AAPL']).toBe('equity'); + it('classifies USAR as stock (USA Rare Earth)', () => { + expect(HIP3_ASSET_MARKET_TYPES['xyz:USAR']).toBe('stock'); + }); + + it('classifies Korean stocks correctly', () => { + expect(HIP3_ASSET_MARKET_TYPES['xyz:SKHX']).toBe('stock'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:SMSN']).toBe('stock'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:HYUNDAI']).toBe('stock'); + }); + + it('classifies pre-IPO markets correctly', () => { + expect(HIP3_ASSET_MARKET_TYPES['xyz:SPCX']).toBe('pre-ipo'); + }); + + it('classifies known indices correctly', () => { + expect(HIP3_ASSET_MARKET_TYPES['xyz:SP500']).toBe('index'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:XYZ100']).toBe('index'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:JP225']).toBe('index'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:KR200']).toBe('index'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:VIX']).toBe('index'); + }); + + it('classifies known ETFs correctly', () => { + expect(HIP3_ASSET_MARKET_TYPES['xyz:EWY']).toBe('etf'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:EWJ']).toBe('etf'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:EWT']).toBe('etf'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:EWZ']).toBe('etf'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:URNM']).toBe('etf'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:DRAM']).toBe('etf'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:XLE']).toBe('etf'); }); it('classifies known commodities correctly', () => { expect(HIP3_ASSET_MARKET_TYPES['xyz:GOLD']).toBe('commodity'); expect(HIP3_ASSET_MARKET_TYPES['xyz:SILVER']).toBe('commodity'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:CL']).toBe('commodity'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:WTIOIL']).toBe('commodity'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:COPPER']).toBe('commodity'); expect(HIP3_ASSET_MARKET_TYPES['xyz:URANIUM']).toBe('commodity'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:BRENTOIL']).toBe('commodity'); }); it('classifies known forex pairs correctly', () => { expect(HIP3_ASSET_MARKET_TYPES['xyz:EUR']).toBe('forex'); expect(HIP3_ASSET_MARKET_TYPES['xyz:JPY']).toBe('forex'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:GBP']).toBe('forex'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:DXY']).toBe('forex'); + }); + + it('derives unique market categories from config', () => { + const categories: MarketType[] = [ + ...new Set(Object.values(HIP3_ASSET_MARKET_TYPES)), + ]; + expect(categories).toContain('stock'); + expect(categories).toContain('pre-ipo'); + expect(categories).toContain('index'); + expect(categories).toContain('etf'); + expect(categories).toContain('commodity'); + expect(categories).toContain('forex'); + expect(categories).not.toContain('equity'); + expect(categories).not.toContain('crypto'); + }); +}); + +describe('MarketCategory', () => { + it('has string values for all 7 data-model categories', () => { + expect(MarketCategory.CryptoCurrency).toBe('crypto'); + expect(MarketCategory.Stock).toBe('stock'); + expect(MarketCategory.PreIpo).toBe('pre-ipo'); + expect(MarketCategory.Index).toBe('index'); + expect(MarketCategory.Etf).toBe('etf'); + expect(MarketCategory.Commodity).toBe('commodity'); + expect(MarketCategory.Forex).toBe('forex'); + }); + + it('has exactly 7 members', () => { + const values = Object.values(MarketCategory); + expect(values).toHaveLength(7); + }); +}); + +describe('MARKET_CATEGORIES', () => { + it('has exactly 7 entries (one per data-model category)', () => { + expect(MARKET_CATEGORIES).toHaveLength(7); + }); + + it('does not include the all or new sentinel values', () => { + expect(MARKET_CATEGORIES).not.toContain('all'); + expect(MARKET_CATEGORIES).not.toContain('new'); + }); + + it('includes all 7 MarketTypeFilter data categories', () => { + const dataCategories: MarketTypeFilter[] = [ + 'crypto', + 'stocks', + 'pre-ipo', + 'indices', + 'etfs', + 'commodities', + 'forex', + ]; + for (const category of dataCategories) { + expect(MARKET_CATEGORIES).toContain(category); + } + }); + + it('satisfies MarketTypeFilter[] so no unknown values exist', () => { + // Compile-time guarantee: MARKET_CATEGORIES is typed as readonly MarketTypeFilter[] + // The runtime check here mirrors that constraint. + const validValues: readonly string[] = [ + 'crypto', + 'stocks', + 'pre-ipo', + 'indices', + 'etfs', + 'commodities', + 'forex', + ]; + for (const entry of MARKET_CATEGORIES) { + expect(validValues).toContain(entry); + } }); }); From 878c3df192d992a7241fc757c58eae74b4414c11 Mon Sep 17 00:00:00 2001 From: Alejandro Garcia Date: Mon, 25 May 2026 16:04:33 +0200 Subject: [PATCH 02/12] chore: changelog --- packages/perps-controller/CHANGELOG.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 5d936965aa..46f2923668 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -9,14 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `MarketCategory` enum, `MARKET_CATEGORIES` ordered array (includes `'all'` and `'new'` UI sentinel values), and `getMarketCategories` messenger action ([#TBD](https://github.com/MetaMask/core/pull/TBD)) -- Expand `HIP3_ASSET_MARKET_TYPES` with new stock, ETF, pre-IPO, forex, and commodity markets ([#TBD](https://github.com/MetaMask/core/pull/TBD)) -- Add `categories`, `sortBy`, `direction`, and `limit` optional params to `GetMarketsParams` and `getMarketDataWithPrices()` for post-processing filtering, sorting, and pagination of market data ([#TBD](https://github.com/MetaMask/core/pull/TBD)) -- Export `SortField` and `SortDirection` types from the package root ([#TBD](https://github.com/MetaMask/core/pull/TBD)) +- Add `MarketCategory` enum, `MARKET_CATEGORIES` ordered array (7 data-model category pills), and `getMarketCategories` messenger action ([#8892](https://github.com/MetaMask/core/pull/8892)) +- Expand `HIP3_ASSET_MARKET_TYPES` with new stock, ETF, pre-IPO, forex, and commodity markets ([#8892](https://github.com/MetaMask/core/pull/8892)) +- Add `categories`, `sortBy`, `direction`, and `limit` optional params to `GetMarketsParams` and `getMarketDataWithPrices()` for post-processing filtering, sorting, and pagination of market data ([#8892](https://github.com/MetaMask/core/pull/8892)) +- Export `SortField` and `SortDirection` types from the package root ([#8892](https://github.com/MetaMask/core/pull/8892)) ### Changed -- **BREAKING:** Replace `'equity'` with granular `MarketType` values: `'stock'`, `'pre-ipo'`, `'index'`, and `'etf'` ([#TBD](https://github.com/MetaMask/core/pull/TBD)) +- **BREAKING:** Replace `'equity'` with granular `MarketType` values: `'stock'`, `'pre-ipo'`, `'index'`, and `'etf'` ([#8892](https://github.com/MetaMask/core/pull/8892)) - Update any code matching `marketType === 'equity'` to use the specific sub-type. ## [6.3.0] From d296c99f6ab985dd9eab5f5969513b6415c213c1 Mon Sep 17 00:00:00 2001 From: Alejandro Garcia Date: Mon, 25 May 2026 16:15:57 +0200 Subject: [PATCH 03/12] chore: lint --- eslint-suppressions.json | 3 --- .../src/constants/hyperLiquidConfig.ts | 1 - packages/perps-controller/src/index.ts | 6 +++++- .../src/services/MarketDataService.ts | 6 +++++- packages/perps-controller/src/types/index.ts | 13 +++++++++++-- .../src/PerpsController.configuration.test.ts | 19 ++++++++++++++----- .../PerpsController.market-filtering.test.ts | 10 +++++----- .../PerpsController.providers-cache.test.ts | 19 ++++++++++++++----- 8 files changed, 54 insertions(+), 23 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 5ba155be96..2cc4729620 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1568,9 +1568,6 @@ } }, "packages/perps-controller/src/PerpsController.ts": { - "@typescript-eslint/no-unused-vars": { - "count": 1 - }, "no-restricted-syntax": { "count": 3 } diff --git a/packages/perps-controller/src/constants/hyperLiquidConfig.ts b/packages/perps-controller/src/constants/hyperLiquidConfig.ts index 1e186958dc..ec2354bc97 100644 --- a/packages/perps-controller/src/constants/hyperLiquidConfig.ts +++ b/packages/perps-controller/src/constants/hyperLiquidConfig.ts @@ -2,7 +2,6 @@ import type { CaipAssetId, CaipChainId, Hex } from '@metamask/utils'; import { MarketCategory } from '../types'; import type { MarketType } from '../types'; - import type { HyperLiquidNetwork, HyperLiquidEndpoints, diff --git a/packages/perps-controller/src/index.ts b/packages/perps-controller/src/index.ts index f5dccaeb27..77d1005d11 100644 --- a/packages/perps-controller/src/index.ts +++ b/packages/perps-controller/src/index.ts @@ -133,7 +133,11 @@ export type { export { HyperLiquidProvider } from './providers/HyperLiquidProvider'; // Type definitions (explicit named exports) -export { WebSocketConnectionState, PerpsAnalyticsEvent, MARKET_CATEGORIES } from './types'; +export { + WebSocketConnectionState, + PerpsAnalyticsEvent, + MARKET_CATEGORIES, +} from './types'; export type { RawLedgerUpdate, UserHistoryItem, diff --git a/packages/perps-controller/src/services/MarketDataService.ts b/packages/perps-controller/src/services/MarketDataService.ts index 70dab3ed80..f4372622e2 100644 --- a/packages/perps-controller/src/services/MarketDataService.ts +++ b/packages/perps-controller/src/services/MarketDataService.ts @@ -4,7 +4,11 @@ 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 { + MarketCategory, + PerpsTraceNames, + PerpsTraceOperations, +} from '../types'; import type { PerpsProvider, Position, diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index fe5343e683..f582fd0910 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -816,7 +816,11 @@ export type GetSupportedPathsParams = { export type GetAvailableDexsParams = Record; /** Field to sort markets by. */ -export type SortField = 'volume' | 'priceChange' | 'fundingRate' | 'openInterest'; +export type SortField = + | 'volume' + | 'priceChange' + | 'fundingRate' + | 'openInterest'; /** Direction for market sorting. */ export type SortDirection = 'asc' | 'desc'; @@ -1041,7 +1045,12 @@ export type PerpsProvider = { getPositions(params?: GetPositionsParams): Promise; getAccountState(params?: GetAccountStateParams): Promise; getMarkets(params?: GetMarketsParams): Promise; - getMarketDataWithPrices(params?: Pick): Promise; + getMarketDataWithPrices( + params?: Pick< + GetMarketsParams, + 'categories' | 'sortBy' | 'direction' | 'limit' | 'standalone' + >, + ): Promise; withdraw(params: WithdrawParams): Promise; // API operation - stays in provider // Note: deposit() is handled by PerpsController routing (blockchain operation) validateDeposit( diff --git a/packages/perps-controller/tests/src/PerpsController.configuration.test.ts b/packages/perps-controller/tests/src/PerpsController.configuration.test.ts index 384eb9ba8c..944c5e8fd0 100644 --- a/packages/perps-controller/tests/src/PerpsController.configuration.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.configuration.test.ts @@ -176,9 +176,15 @@ const mockMarketDataServiceInstance = { getPositions: jest.fn(), getAccountState: jest.fn(), getMarkets: jest.fn(), - getMarketDataWithPrices: jest.fn().mockImplementation(({ provider }: { provider: { getMarketDataWithPrices: () => Promise } }) => - provider.getMarketDataWithPrices(), - ), + getMarketDataWithPrices: jest + .fn() + .mockImplementation( + ({ + provider, + }: { + provider: { getMarketDataWithPrices: () => Promise }; + }) => provider.getMarketDataWithPrices(), + ), getWithdrawalRoutes: jest.fn().mockReturnValue([]), validateClosePosition: jest.fn().mockResolvedValue({ isValid: true }), validateOrder: jest.fn(), @@ -487,8 +493,11 @@ describe('PerpsController', () => { }); mockMarketDataServiceInstance.getMarkets.mockResolvedValue([]); mockMarketDataServiceInstance.getMarketDataWithPrices.mockImplementation( - ({ provider }: { provider: { getMarketDataWithPrices: () => Promise } }) => - provider.getMarketDataWithPrices(), + ({ + provider, + }: { + provider: { getMarketDataWithPrices: () => Promise }; + }) => provider.getMarketDataWithPrices(), ); mockMarketDataServiceInstance.getWithdrawalRoutes.mockReturnValue([]); mockMarketDataServiceInstance.validateClosePosition.mockResolvedValue({ 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 a6e7cd2e43..1b4b9fda66 100644 --- a/packages/perps-controller/tests/src/PerpsController.market-filtering.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.market-filtering.test.ts @@ -6,11 +6,6 @@ /* eslint-disable */ -import { createMockHyperLiquidProvider } from '../helpers/providerMocks'; -import { - createMockInfrastructure, - createMockMessenger, -} from '../helpers/serviceMocks'; import { PerpsController, getDefaultPerpsControllerState, @@ -24,6 +19,11 @@ import type { PerpsMarketData, } from '../../src/types'; import { MARKET_CATEGORIES, MarketCategory } from '../../src/types'; +import { createMockHyperLiquidProvider } from '../helpers/providerMocks'; +import { + createMockInfrastructure, + createMockMessenger, +} from '../helpers/serviceMocks'; jest.mock('@nktkas/hyperliquid', () => ({})); jest.mock('@myx-trade/sdk', () => ({ diff --git a/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts b/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts index a711e3c5b5..637bcfffb9 100644 --- a/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts @@ -176,9 +176,15 @@ const mockMarketDataServiceInstance = { getPositions: jest.fn(), getAccountState: jest.fn(), getMarkets: jest.fn(), - getMarketDataWithPrices: jest.fn().mockImplementation(({ provider }: { provider: { getMarketDataWithPrices: () => Promise } }) => - provider.getMarketDataWithPrices(), - ), + getMarketDataWithPrices: jest + .fn() + .mockImplementation( + ({ + provider, + }: { + provider: { getMarketDataWithPrices: () => Promise }; + }) => provider.getMarketDataWithPrices(), + ), getWithdrawalRoutes: jest.fn().mockReturnValue([]), validateClosePosition: jest.fn().mockResolvedValue({ isValid: true }), validateOrder: jest.fn(), @@ -487,8 +493,11 @@ describe('PerpsController', () => { }); mockMarketDataServiceInstance.getMarkets.mockResolvedValue([]); mockMarketDataServiceInstance.getMarketDataWithPrices.mockImplementation( - ({ provider }: { provider: { getMarketDataWithPrices: () => Promise } }) => - provider.getMarketDataWithPrices(), + ({ + provider, + }: { + provider: { getMarketDataWithPrices: () => Promise }; + }) => provider.getMarketDataWithPrices(), ); mockMarketDataServiceInstance.getWithdrawalRoutes.mockReturnValue([]); mockMarketDataServiceInstance.validateClosePosition.mockResolvedValue({ From 9da78b20a5262f625c6e861e1bd1fddd735bfd7b Mon Sep 17 00:00:00 2001 From: Alejandro Garcia Date: Mon, 25 May 2026 16:21:44 +0200 Subject: [PATCH 04/12] chore: lint --- .../PerpsController-method-action-types.ts | 33 +++++++++++-------- packages/perps-controller/src/index.ts | 2 +- 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/packages/perps-controller/src/PerpsController-method-action-types.ts b/packages/perps-controller/src/PerpsController-method-action-types.ts index eba605346d..3295b178b0 100644 --- a/packages/perps-controller/src/PerpsController-method-action-types.ts +++ b/packages/perps-controller/src/PerpsController-method-action-types.ts @@ -404,25 +404,18 @@ export type PerpsControllerGetMarketsAction = { }; /** - * Get the list of unique market categories available in HIP-3 markets. - * Derives the categories from the HIP3_ASSET_MARKET_TYPES config - * so the UI can render category filter tabs without hard-coding the list. - * - * @returns Array of unique MarketType values present in the config. - */ -export type PerpsControllerGetMarketCategoriesAction = { - type: `PerpsController:getMarketCategories`; - handler: PerpsController['getMarketCategories']; -}; - -/** - * Get market data with prices (includes price, volume, 24h change) + * Get market data with prices (includes price, volume, 24h change). + * Optionally filter by category, sort, and limit the results. * * For standalone mode, bypasses getActiveProvider() to allow market data queries * without full perps initialization (e.g., for background preloading on app start) * * @param params - The operation parameters. * @param params.standalone - Whether to use standalone mode. + * @param params.categories - Filter to markets matching any of these categories. + * @param params.sortBy - Sort results by this field. + * @param params.direction - Sort direction (default: desc). + * @param params.limit - Maximum number of results to return. * @returns A promise that resolves to the market data. */ export type PerpsControllerGetMarketDataWithPricesAction = { @@ -586,6 +579,18 @@ export type PerpsControllerGetCurrentNetworkAction = { handler: PerpsController['getCurrentNetwork']; }; +/** + * Get the ordered list of all market categories for HIP-3 markets. + * Returns a stable, explicitly ordered array so the UI can render + * category filter tabs without deriving order from config insertion. + * + * @returns Ordered array of {@link MarketTypeFilter} values (includes 'all' and 'new'). + */ +export type PerpsControllerGetMarketCategoriesAction = { + type: `PerpsController:getMarketCategories`; + handler: PerpsController['getMarketCategories']; +}; + /** * Get the current WebSocket connection state from the active provider. * Used by the UI to monitor connection health and show notifications. @@ -1046,7 +1051,6 @@ export type PerpsControllerMethodActions = | PerpsControllerGetFundingAction | PerpsControllerGetAccountStateAction | PerpsControllerGetHistoricalPortfolioAction - | PerpsControllerGetMarketCategoriesAction | PerpsControllerGetMarketsAction | PerpsControllerGetMarketDataWithPricesAction | PerpsControllerStartMarketDataPreloadAction @@ -1063,6 +1067,7 @@ export type PerpsControllerMethodActions = | PerpsControllerToggleTestnetAction | PerpsControllerSwitchProviderAction | PerpsControllerGetCurrentNetworkAction + | PerpsControllerGetMarketCategoriesAction | PerpsControllerGetWebSocketConnectionStateAction | PerpsControllerSubscribeToConnectionStateAction | PerpsControllerReconnectAction diff --git a/packages/perps-controller/src/index.ts b/packages/perps-controller/src/index.ts index 77d1005d11..8dc5d632df 100644 --- a/packages/perps-controller/src/index.ts +++ b/packages/perps-controller/src/index.ts @@ -137,6 +137,7 @@ export { WebSocketConnectionState, PerpsAnalyticsEvent, MARKET_CATEGORIES, + MarketCategory, } from './types'; export type { RawLedgerUpdate, @@ -144,7 +145,6 @@ export type { GetUserHistoryParams, TradeConfiguration, OrderType, - MarketCategory, MarketType, MarketTypeFilter, InputMethod, From 908bf91ec59a1388a4a70fb89244be0be8e5d084 Mon Sep 17 00:00:00 2001 From: Alejandro Garcia Date: Tue, 26 May 2026 11:54:15 +0200 Subject: [PATCH 05/12] chore: review fixes --- .../src/services/MarketDataService.ts | 8 +++----- packages/perps-controller/src/types/index.ts | 2 +- .../src/PerpsController.market-filtering.test.ts | 16 +++++++++++++--- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/packages/perps-controller/src/services/MarketDataService.ts b/packages/perps-controller/src/services/MarketDataService.ts index f4372622e2..51f014b5db 100644 --- a/packages/perps-controller/src/services/MarketDataService.ts +++ b/packages/perps-controller/src/services/MarketDataService.ts @@ -1276,7 +1276,7 @@ export class MarketDataService { */ export function matchesCategory( market: PerpsMarketData, - category: MarketTypeFilter | 'watchlist', + category: MarketTypeFilter, ): boolean { switch (category) { case 'all': @@ -1284,7 +1284,8 @@ export function matchesCategory( case 'new': return market.isNewMarket === true; case 'crypto': - return !market.isHip3; + // 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': @@ -1297,9 +1298,6 @@ export function matchesCategory( return market.marketType === MarketCategory.Commodity; case 'forex': return market.marketType === MarketCategory.Forex; - case 'watchlist': - // Watchlist state lives in the calling client; always false here. - return false; default: return true; } diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index f582fd0910..72c5718ff9 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -831,7 +831,7 @@ export type GetMarketsParams = { skipFilters?: boolean; // Skip market filtering (both allowlist and blocklist, default: false). When true, returns all markets without filtering. standalone?: boolean; // Lightweight mode: skip full initialization, only fetch market metadata (no wallet/WebSocket needed). Only main DEX markets returned. Use for discovery use cases like checking if a perps market exists. // Category / sort / pagination (applied as post-processing in PerpsController): - categories?: (MarketTypeFilter | 'watchlist')[]; // Filter to markets in any of these categories; omit for all markets + categories?: MarketTypeFilter[]; // Filter to markets in any of these categories; omit for all markets sortBy?: SortField; // Sort results by this field direction?: SortDirection; // Sort direction (default: desc) limit?: number; // Maximum number of results to return 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 1b4b9fda66..5e028b1f5e 100644 --- a/packages/perps-controller/tests/src/PerpsController.market-filtering.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.market-filtering.test.ts @@ -183,10 +183,15 @@ describe('PerpsController — market categories & filtering', () => { expect(result).toHaveLength(2); }); - it('filters to only crypto (non-HIP3) markets when categories is ["crypto"]', async () => { + it('filters to only crypto markets when categories is ["crypto"]', async () => { const markets = [ buildMarket({ symbol: 'BTC', isHip3: false }), buildMarket({ symbol: 'ETH', isHip3: false }), + buildMarket({ + symbol: 'xyz:CRYPTO1', + isHip3: true, + marketType: MarketCategory.CryptoCurrency, + }), buildMarket({ symbol: 'xyz:TSLA', isHip3: true, @@ -199,8 +204,13 @@ describe('PerpsController — market categories & filtering', () => { categories: ['crypto'], }); - expect(result).toHaveLength(2); - expect(result.every((m) => !m.isHip3)).toBe(true); + // Non-HIP3 markets + HIP-3 assets explicitly typed CryptoCurrency; excludes Stock + expect(result).toHaveLength(3); + const symbols = result.map((m) => m.symbol); + expect(symbols).toContain('BTC'); + expect(symbols).toContain('ETH'); + expect(symbols).toContain('xyz:CRYPTO1'); + expect(symbols).not.toContain('xyz:TSLA'); }); it('filters to only stock markets when categories is ["stocks"]', async () => { From 70aa7af6ed329ab5051e33c056dbe1e06f780d6a Mon Sep 17 00:00:00 2001 From: Alejandro Garcia Date: Tue, 26 May 2026 13:05:48 +0200 Subject: [PATCH 06/12] chore: review fixes --- .../perps-controller/src/PerpsController.ts | 6 ++---- packages/perps-controller/src/index.ts | 1 + .../src/services/MarketDataService.ts | 11 +++-------- packages/perps-controller/src/types/index.ts | 17 +++++++++++------ 4 files changed, 17 insertions(+), 18 deletions(-) diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index 359ad66b92..d1adadf572 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -68,6 +68,7 @@ import type { GetAccountStateParams, GetAvailableDexsParams, GetFundingParams, + GetMarketDataWithPricesParams, GetMarketsParams, GetOrderFillsParams, GetOrdersParams, @@ -2950,10 +2951,7 @@ export class PerpsController extends BaseController< * @returns A promise that resolves to the market data. */ async getMarketDataWithPrices( - params?: Pick< - GetMarketsParams, - 'categories' | 'sortBy' | 'direction' | 'limit' | 'standalone' - >, + params?: GetMarketDataWithPricesParams, ): Promise { if (params?.standalone) { // Use activeProviderInstance if available (respects provider abstraction) diff --git a/packages/perps-controller/src/index.ts b/packages/perps-controller/src/index.ts index 8dc5d632df..1c08d25812 100644 --- a/packages/perps-controller/src/index.ts +++ b/packages/perps-controller/src/index.ts @@ -201,6 +201,7 @@ export type { GetSupportedPathsParams, GetAvailableDexsParams, GetMarketsParams, + GetMarketDataWithPricesParams, SubscribePricesParams, SubscribePositionsParams, SubscribeOrderFillsParams, diff --git a/packages/perps-controller/src/services/MarketDataService.ts b/packages/perps-controller/src/services/MarketDataService.ts index 51f014b5db..5e5a76cf7d 100644 --- a/packages/perps-controller/src/services/MarketDataService.ts +++ b/packages/perps-controller/src/services/MarketDataService.ts @@ -24,6 +24,7 @@ import type { Order, GetOrdersParams, MarketInfo, + GetMarketDataWithPricesParams, GetMarketsParams, GetAvailableDexsParams, LiquidationPriceParams, @@ -818,10 +819,7 @@ export class MarketDataService { */ async getMarketDataWithPrices(options: { provider: PerpsProvider; - params?: Pick< - GetMarketsParams, - 'categories' | 'sortBy' | 'direction' | 'limit' - >; + params?: GetMarketDataWithPricesParams; context: ServiceContext; }): Promise { const { provider, params, context } = options; @@ -1312,10 +1310,7 @@ export function matchesCategory( */ export function applyMarketFilters( markets: PerpsMarketData[], - params?: Pick< - GetMarketsParams, - 'categories' | 'sortBy' | 'direction' | 'limit' - >, + params?: GetMarketDataWithPricesParams, ): PerpsMarketData[] { let result = markets; diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index 72c5718ff9..c9f09b4b86 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -830,8 +830,16 @@ export type GetMarketsParams = { dex?: string; // HyperLiquid HIP-3: DEX name (empty string '' or undefined for main DEX). Other protocols: ignored. skipFilters?: boolean; // Skip market filtering (both allowlist and blocklist, default: false). When true, returns all markets without filtering. standalone?: boolean; // Lightweight mode: skip full initialization, only fetch market metadata (no wallet/WebSocket needed). Only main DEX markets returned. Use for discovery use cases like checking if a perps market exists. - // Category / sort / pagination (applied as post-processing in PerpsController): - categories?: MarketTypeFilter[]; // Filter to markets in any of these categories; omit for all markets +}; + +/** + * Parameters for {@link PerpsController.getMarketDataWithPrices}. + * Extends the base market-fetch params with optional category filtering, + * sorting, and pagination that are applied as post-processing. + */ +export type GetMarketDataWithPricesParams = { + standalone?: boolean; // Lightweight mode: see GetMarketsParams.standalone + categories?: MarketTypeFilter[]; // Filter to markets matching any of these categories; omit for all markets sortBy?: SortField; // Sort results by this field direction?: SortDirection; // Sort direction (default: desc) limit?: number; // Maximum number of results to return @@ -1046,10 +1054,7 @@ export type PerpsProvider = { getAccountState(params?: GetAccountStateParams): Promise; getMarkets(params?: GetMarketsParams): Promise; getMarketDataWithPrices( - params?: Pick< - GetMarketsParams, - 'categories' | 'sortBy' | 'direction' | 'limit' | 'standalone' - >, + params?: GetMarketDataWithPricesParams, ): Promise; withdraw(params: WithdrawParams): Promise; // API operation - stays in provider // Note: deposit() is handled by PerpsController routing (blockchain operation) From 46c4f27b7b5b3faacf67a863a744c2d4da93ec10 Mon Sep 17 00:00:00 2001 From: Alejandro Garcia Date: Tue, 26 May 2026 13:42:19 +0200 Subject: [PATCH 07/12] chore: review fixes --- packages/perps-controller/src/types/index.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index c9f09b4b86..e50929371e 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -1053,9 +1053,7 @@ export type PerpsProvider = { getPositions(params?: GetPositionsParams): Promise; getAccountState(params?: GetAccountStateParams): Promise; getMarkets(params?: GetMarketsParams): Promise; - getMarketDataWithPrices( - params?: GetMarketDataWithPricesParams, - ): Promise; + getMarketDataWithPrices(): Promise; withdraw(params: WithdrawParams): Promise; // API operation - stays in provider // Note: deposit() is handled by PerpsController routing (blockchain operation) validateDeposit( From 916163980034ab83afdf7a9adb90ce3d8f9d6cd0 Mon Sep 17 00:00:00 2001 From: Alejandro Garcia Date: Tue, 26 May 2026 15:25:21 +0200 Subject: [PATCH 08/12] feat(perps): excludeSymbols params --- packages/perps-controller/CHANGELOG.md | 4 +- .../src/services/MarketDataService.ts | 13 +++- packages/perps-controller/src/types/index.ts | 3 + .../PerpsController.market-filtering.test.ts | 60 +++++++++++++++++++ 4 files changed, 75 insertions(+), 5 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 46f2923668..daccb00c91 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -11,8 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `MarketCategory` enum, `MARKET_CATEGORIES` ordered array (7 data-model category pills), and `getMarketCategories` messenger action ([#8892](https://github.com/MetaMask/core/pull/8892)) - Expand `HIP3_ASSET_MARKET_TYPES` with new stock, ETF, pre-IPO, forex, and commodity markets ([#8892](https://github.com/MetaMask/core/pull/8892)) -- Add `categories`, `sortBy`, `direction`, and `limit` optional params to `GetMarketsParams` and `getMarketDataWithPrices()` for post-processing filtering, sorting, and pagination of market data ([#8892](https://github.com/MetaMask/core/pull/8892)) -- Export `SortField` and `SortDirection` types from the package root ([#8892](https://github.com/MetaMask/core/pull/8892)) +- Add `categories`, `sortBy`, `direction`, `limit`, and `excludeSymbols` optional params to `GetMarketDataWithPricesParams` and `getMarketDataWithPrices()` for post-processing filtering, sorting, and pagination of market data ([#8892](https://github.com/MetaMask/core/pull/8892)) +- Export `SortField`, `SortDirection`, and `GetMarketDataWithPricesParams` types from the package root ([#8892](https://github.com/MetaMask/core/pull/8892)) ### Changed diff --git a/packages/perps-controller/src/services/MarketDataService.ts b/packages/perps-controller/src/services/MarketDataService.ts index 5e5a76cf7d..15919a2450 100644 --- a/packages/perps-controller/src/services/MarketDataService.ts +++ b/packages/perps-controller/src/services/MarketDataService.ts @@ -828,7 +828,7 @@ export class MarketDataService { try { this.#deps.tracer.trace({ - name: PerpsTraceNames.GetMarkets, + name: PerpsTraceNames.GetMarketDataWithPrices, id: traceId, op: PerpsTraceOperations.Operation, tags: { @@ -877,7 +877,7 @@ export class MarketDataService { throw error; } finally { this.#deps.tracer.endTrace({ - name: PerpsTraceNames.GetMarkets, + name: PerpsTraceNames.GetMarketDataWithPrices, id: traceId, data: traceData, }); @@ -1283,7 +1283,9 @@ export function matchesCategory( 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; + return ( + !market.isHip3 || market.marketType === MarketCategory.CryptoCurrency + ); case 'stocks': return market.marketType === MarketCategory.Stock; case 'pre-ipo': @@ -1322,6 +1324,11 @@ export function applyMarketFilters( ); } + 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, diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index e50929371e..b69db674f4 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -840,6 +840,7 @@ export type GetMarketsParams = { export type GetMarketDataWithPricesParams = { standalone?: boolean; // Lightweight mode: see GetMarketsParams.standalone categories?: MarketTypeFilter[]; // Filter to markets matching any of these categories; omit for all markets + excludeSymbols?: string[]; // Symbols to exclude from results (e.g. the currently viewed market) sortBy?: SortField; // Sort results by this field direction?: SortDirection; // Sort direction (default: desc) limit?: number; // Maximum number of results to return @@ -1358,6 +1359,7 @@ export type PerpsTraceName = | 'Perps Get Account State' | 'Perps Get Historical Portfolio' | 'Perps Get Markets' + | 'Perps Get Market Data With Prices' | 'Perps Fetch Historical Candles' | 'Perps WebSocket Connected' | 'Perps WebSocket Disconnected' @@ -1395,6 +1397,7 @@ export const PerpsTraceNames = { GetPositions: 'Perps Get Positions', GetAccountState: 'Perps Get Account State', GetMarkets: 'Perps Get Markets', + GetMarketDataWithPrices: 'Perps Get Market Data With Prices', OrderFillsFetch: 'Perps Order Fills Fetch', OrdersFetch: 'Perps Orders Fetch', FundingFetch: 'Perps Funding Fetch', 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 5e028b1f5e..c75c4c5899 100644 --- a/packages/perps-controller/tests/src/PerpsController.market-filtering.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.market-filtering.test.ts @@ -324,6 +324,66 @@ describe('PerpsController — market categories & filtering', () => { }); }); + // ============================================================================ + // getMarketDataWithPrices — excludeSymbols + // ============================================================================ + + describe('getMarketDataWithPrices — excludeSymbols', () => { + it('excludes a single symbol from results', async () => { + const markets = [ + buildMarket({ symbol: 'BTC' }), + buildMarket({ symbol: 'ETH' }), + buildMarket({ symbol: 'SOL' }), + ]; + mockProvider.getMarketDataWithPrices.mockResolvedValue(markets); + + const result = await controller.getMarketDataWithPrices({ + excludeSymbols: ['ETH'], + }); + + expect(result.map((m) => m.symbol)).toStrictEqual(['BTC', 'SOL']); + }); + + it('excludes multiple symbols from results', async () => { + const markets = [ + buildMarket({ symbol: 'BTC' }), + buildMarket({ symbol: 'ETH' }), + buildMarket({ symbol: 'SOL' }), + buildMarket({ symbol: 'AVAX' }), + ]; + mockProvider.getMarketDataWithPrices.mockResolvedValue(markets); + + const result = await controller.getMarketDataWithPrices({ + excludeSymbols: ['ETH', 'SOL'], + }); + + expect(result.map((m) => m.symbol)).toStrictEqual(['BTC', 'AVAX']); + }); + + it('applies excludeSymbols after category filter and before limit', async () => { + const markets = [ + buildMarket({ symbol: 'BTC', isHip3: false }), + buildMarket({ symbol: 'ETH', isHip3: false }), + buildMarket({ symbol: 'SOL', isHip3: false }), + buildMarket({ + symbol: 'xyz:TSLA', + isHip3: true, + marketType: MarketCategory.Stock, + }), + ]; + mockProvider.getMarketDataWithPrices.mockResolvedValue(markets); + + const result = await controller.getMarketDataWithPrices({ + categories: ['crypto'], + excludeSymbols: ['ETH'], + limit: 10, + }); + + // Stock excluded by category, ETH excluded by excludeSymbols + expect(result.map((m) => m.symbol)).toStrictEqual(['BTC', 'SOL']); + }); + }); + // ============================================================================ // getMarketDataWithPrices — sorting // ============================================================================ From 4a7057c7d18843661e21b839eb8546c64eee079a Mon Sep 17 00:00:00 2001 From: Alejandro Garcia Date: Tue, 26 May 2026 16:31:42 +0200 Subject: [PATCH 09/12] chore: review fixes --- packages/perps-controller/src/types/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index b69db674f4..f9402d5bce 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -84,7 +84,7 @@ export enum MarketCategory { Forex = 'forex', } -export type MarketType = 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 From c35be1a69664c3d34e8741a8c1e660c5e9cb0612 Mon Sep 17 00:00:00 2001 From: Alejandro Garcia Date: Wed, 27 May 2026 17:28:21 +0200 Subject: [PATCH 10/12] chore: review fixes --- .../src/PerpsController-method-action-types.ts | 2 +- packages/perps-controller/src/PerpsController.ts | 2 +- packages/perps-controller/src/index.ts | 4 +++- packages/perps-controller/src/utils/sortMarkets.ts | 2 -- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/perps-controller/src/PerpsController-method-action-types.ts b/packages/perps-controller/src/PerpsController-method-action-types.ts index 3295b178b0..1351266616 100644 --- a/packages/perps-controller/src/PerpsController-method-action-types.ts +++ b/packages/perps-controller/src/PerpsController-method-action-types.ts @@ -584,7 +584,7 @@ export type PerpsControllerGetCurrentNetworkAction = { * Returns a stable, explicitly ordered array so the UI can render * category filter tabs without deriving order from config insertion. * - * @returns Ordered array of {@link MarketTypeFilter} values (includes 'all' and 'new'). + * @returns Ordered array of {@link MarketTypeFilter} values. Does not include the 'all' or 'new' sentinels — those are separate UI controls. */ export type PerpsControllerGetMarketCategoriesAction = { type: `PerpsController:getMarketCategories`; diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index d1adadf572..7dd04165f6 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -3981,7 +3981,7 @@ export class PerpsController extends BaseController< * Returns a stable, explicitly ordered array so the UI can render * category filter tabs without deriving order from config insertion. * - * @returns Ordered array of {@link MarketTypeFilter} values (includes 'all' and 'new'). + * @returns Ordered array of {@link MarketTypeFilter} values. Does not include the 'all' or 'new' sentinels — those are separate UI controls. */ getMarketCategories(): MarketTypeFilter[] { return MARKET_CATEGORIES; diff --git a/packages/perps-controller/src/index.ts b/packages/perps-controller/src/index.ts index 1c08d25812..6a4382638f 100644 --- a/packages/perps-controller/src/index.ts +++ b/packages/perps-controller/src/index.ts @@ -202,6 +202,8 @@ export type { GetAvailableDexsParams, GetMarketsParams, GetMarketDataWithPricesParams, + SortField, + SortDirection, SubscribePricesParams, SubscribePositionsParams, SubscribeOrderFillsParams, @@ -518,7 +520,7 @@ export { hasExceededSignificantFigures, roundToSignificantFigures, } from './utils'; -export type { SortField, SortDirection, SortMarketsParams } from './utils'; +export type { SortMarketsParams } from './utils'; export { parseVolume, sortMarkets } from './utils'; export type { StandaloneInfoClientOptions } from './utils'; export { diff --git a/packages/perps-controller/src/utils/sortMarkets.ts b/packages/perps-controller/src/utils/sortMarkets.ts index c3dff7cd3d..cb643c4a0d 100644 --- a/packages/perps-controller/src/utils/sortMarkets.ts +++ b/packages/perps-controller/src/utils/sortMarkets.ts @@ -4,8 +4,6 @@ import { } from '../constants/perpsConfig'; import type { PerpsMarketData, SortDirection, SortField } from '../types'; -export type { SortField, SortDirection }; - export type SortMarketsParams = { markets: PerpsMarketData[]; sortBy: SortField; From 03c1ac41b0f9767a245b9a3deb067901a681c7c3 Mon Sep 17 00:00:00 2001 From: Alejandro Garcia Date: Wed, 27 May 2026 18:40:59 +0200 Subject: [PATCH 11/12] chore: fix build --- packages/perps-controller/src/PerpsController.ts | 2 +- packages/perps-controller/src/selectors.ts | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index 7dd04165f6..0298b8379e 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -130,7 +130,7 @@ import { persistMarketEntriesToDisk, persistUserEntriesToDisk, } from './utils/perpsDiskPersistence'; -import type { SortDirection } from './utils/sortMarkets'; +import type { SortDirection } from './types'; import { wait } from './utils/wait'; /** Derived type for logger options from PerpsLogger interface */ diff --git a/packages/perps-controller/src/selectors.ts b/packages/perps-controller/src/selectors.ts index a7e0b1cf4a..f47b186061 100644 --- a/packages/perps-controller/src/selectors.ts +++ b/packages/perps-controller/src/selectors.ts @@ -2,8 +2,7 @@ import { createSelector } from 'reselect'; import { MARKET_SORTING_CONFIG, SortOptionId } from './constants/perpsConfig'; import type { PerpsControllerState } from './PerpsController'; -import type { PerpsSelectedPaymentToken } from './types'; -import type { SortDirection } from './utils/sortMarkets'; +import type { PerpsSelectedPaymentToken, SortDirection } from './types'; /** * Select whether the user is a first-time perps user From d7084622c7926030fa9f4e2475d1a0f6873b62e5 Mon Sep 17 00:00:00 2001 From: Alejandro Garcia Date: Wed, 27 May 2026 18:52:07 +0200 Subject: [PATCH 12/12] chore: fix build --- packages/perps-controller/src/PerpsController.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index 0298b8379e..8e57403634 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -114,6 +114,7 @@ import type { MarketTypeFilter, MYXCredentials, } from './types'; +import type { SortDirection } from './types'; import type { PerpsControllerAllowedActions, PerpsControllerAllowedEvents, @@ -130,7 +131,6 @@ import { persistMarketEntriesToDisk, persistUserEntriesToDisk, } from './utils/perpsDiskPersistence'; -import type { SortDirection } from './types'; import { wait } from './utils/wait'; /** Derived type for logger options from PerpsLogger interface */