diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index d46e0debae..224a5b6b8a 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -16,6 +16,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Report the take profit (or stop loss) price on a `Position` when its only trigger for that direction is a partial, quantity-scoped one ([#9912](https://github.com/MetaMask/core/pull/9912)) - `takeProfitPrice`/`stopLossPrice` were only ever scanned from position-bound triggers, so a position whose sole take profit closed it partially reported `takeProfitCount: 1` with no price, and clients rendering the scalar showed none. Applies to the REST `getPositions`, `getUserDataSnapshot`, and WebSocket position paths alike. - Two or more triggers in a direction still report the scanned price, because no single price describes them and clients render the count instead. +- Map Terminal v1 `category` aliases (`pre_ipo` → `pre-ipo`, `stocks` → `stock`) when `marketType` is absent, so Pre-IPO markets such as Unitree appear under that filter without a static symbol list +- Clear `isNewMarket` on the v1 Terminal enrich path once a `marketType` is applied, matching the v2 snapshot rule so categorized HIP-3 markets are not also in the controller `new` bucket +- Reclassify `xyz:CBRS` and `xyz:SPCX` from `pre-ipo` to `stock` in `HIP3_ASSET_MARKET_TYPES` now that they are public (Terminal already sends `stocks`). Leave `xyz:IPOP` as Pre-IPO ## [12.1.0] diff --git a/packages/perps-controller/src/constants/hyperLiquidConfig.ts b/packages/perps-controller/src/constants/hyperLiquidConfig.ts index a23f61a070..10decceb48 100644 --- a/packages/perps-controller/src/constants/hyperLiquidConfig.ts +++ b/packages/perps-controller/src/constants/hyperLiquidConfig.ts @@ -367,6 +367,8 @@ export const HIP3_ASSET_MARKET_TYPES: Record = { 'xyz:ARM': MarketCategory.Stock, 'xyz:BX': MarketCategory.Stock, 'xyz:LITE': MarketCategory.Stock, + 'xyz:CBRS': MarketCategory.Stock, + 'xyz:SPCX': MarketCategory.Stock, // xyz DEX - Stocks (Korea) 'xyz:SKHX': MarketCategory.Stock, @@ -378,8 +380,6 @@ export const HIP3_ASSET_MARKET_TYPES: Record = { 'xyz:KIOXIA': MarketCategory.Stock, // xyz DEX - Pre-IPO - 'xyz:CBRS': MarketCategory.PreIpo, - 'xyz:SPCX': MarketCategory.PreIpo, 'xyz:IPOP': MarketCategory.PreIpo, // xyz DEX - Indices diff --git a/packages/perps-controller/src/services/MarketDataService.ts b/packages/perps-controller/src/services/MarketDataService.ts index df8d9758ae..1eda036599 100644 --- a/packages/perps-controller/src/services/MarketDataService.ts +++ b/packages/perps-controller/src/services/MarketDataService.ts @@ -1414,8 +1414,10 @@ export class MarketDataService { * Merge Terminal API metadata into provider-sourced PerpsMarketData. * For each market, if the terminal metadata map contains an entry for its * symbol, override name/description/marketType and attach - * keywords/tags/categories. Unmatched markets keep their provider-sourced - * values. + * keywords/tags/categories. Applying a marketType clears a stale + * isNewMarket flag so the v1 enrich path matches the v2 snapshot rule + * (categorized HIP-3 is not the controller "new" bucket). Unmatched markets + * keep their provider-sourced values. * * @param markets - Markets from the provider. * @param metadata - Per-symbol metadata from the Terminal API. @@ -1437,7 +1439,10 @@ export class MarketDataService { ...(meta.description !== undefined && { description: meta.description, }), - ...(meta.marketType !== undefined && { marketType: meta.marketType }), + ...(meta.marketType !== undefined && { + marketType: meta.marketType, + ...(market.isNewMarket === true && { isNewMarket: false }), + }), ...(meta.keywords !== undefined && { keywords: meta.keywords }), ...(meta.tags !== undefined && { tags: meta.tags }), ...(meta.categories !== undefined && { categories: meta.categories }), diff --git a/packages/perps-controller/src/services/TerminalMarketService.ts b/packages/perps-controller/src/services/TerminalMarketService.ts index 62c9ce85a5..cc2e78ca4a 100644 --- a/packages/perps-controller/src/services/TerminalMarketService.ts +++ b/packages/perps-controller/src/services/TerminalMarketService.ts @@ -111,6 +111,7 @@ const TerminalPerpetualItemStruct = type({ minimumOrderSize: optional(number()), keywords: optional(nullable(array(string()))), tags: optional(nullable(array(string()))), + category: optional(nullable(string())), categories: optional(nullable(array(string()))), marketType: optional(nullable(string())), listedAt: optional(nullable(union([number(), string()]))), @@ -633,13 +634,9 @@ export class TerminalMarketService { }; } - #marketTypeFor( - dex: string, - category: string | null, + #categoryToMarketType( + category: string | null | undefined, ): TerminalAssetMetadata['marketType'] | undefined { - if (dex === 'main') { - return MarketCategory.CryptoCurrency; - } if (category === 'stocks') { return MarketCategory.Stock; } @@ -652,6 +649,16 @@ export class TerminalMarketService { return undefined; } + #marketTypeFor( + dex: string, + category: string | null, + ): TerminalAssetMetadata['marketType'] | undefined { + if (dex === 'main') { + return MarketCategory.CryptoCurrency; + } + return this.#categoryToMarketType(category); + } + #cloneGlobalSnapshotResult( result: PerpsGlobalSnapshotResult, ): PerpsGlobalSnapshotResult { @@ -788,6 +795,11 @@ export class TerminalMarketService { ) { entry.marketType = item.marketType as TerminalAssetMetadata['marketType']; + } else { + const fromCategory = this.#categoryToMarketType(item.category); + if (fromCategory) { + entry.marketType = fromCategory; + } } if (item.listedAt !== null && item.listedAt !== undefined) { diff --git a/packages/perps-controller/tests/src/constants/hyperLiquidConfig.test.ts b/packages/perps-controller/tests/src/constants/hyperLiquidConfig.test.ts index f6c3d22cc1..468821780f 100644 --- a/packages/perps-controller/tests/src/constants/hyperLiquidConfig.test.ts +++ b/packages/perps-controller/tests/src/constants/hyperLiquidConfig.test.ts @@ -24,6 +24,8 @@ describe('HIP3_ASSET_MARKET_TYPES', () => { 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'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:CBRS']).toBe('stock'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:SPCX']).toBe('stock'); }); it('classifies USAR as stock (USA Rare Earth)', () => { @@ -37,8 +39,6 @@ describe('HIP3_ASSET_MARKET_TYPES', () => { }); it('classifies pre-IPO markets correctly', () => { - expect(HIP3_ASSET_MARKET_TYPES['xyz:CBRS']).toBe('pre-ipo'); - expect(HIP3_ASSET_MARKET_TYPES['xyz:SPCX']).toBe('pre-ipo'); expect(HIP3_ASSET_MARKET_TYPES['xyz:IPOP']).toBe('pre-ipo'); }); diff --git a/packages/perps-controller/tests/src/services/MarketDataService.test.ts b/packages/perps-controller/tests/src/services/MarketDataService.test.ts index 1fab3de302..37aeed10c4 100644 --- a/packages/perps-controller/tests/src/services/MarketDataService.test.ts +++ b/packages/perps-controller/tests/src/services/MarketDataService.test.ts @@ -19,6 +19,7 @@ import type { } from '../../../src/types/index.js'; import type { CandleData } from '../../../src/types/perps-types.js'; import { resetPerpsRestCacheForTests } from '../../../src/utils/coalescePerpsRestRequest.js'; +import { matchesCategory } from '../../../src/utils/marketUtils.js'; /* eslint-disable */ import { createMockHyperLiquidProvider, @@ -1683,11 +1684,85 @@ describe('MarketDataService', () => { expect(result[0]?.tags).toEqual(['top-10']); expect(result[0]?.categories).toEqual(['crypto']); expect(result[0]?.marketType).toBe('crypto'); + expect(result[0]).not.toHaveProperty('isNewMarket'); expect(result[1]?.name).toBe('Ethereum'); expect(result[1]?.keywords).toEqual(['defi']); expect(result[1]?.description).toBeUndefined(); }); + it('clears isNewMarket when Terminal metadata supplies a marketType', async () => { + const unitreeMarket: PerpsMarketData = { + symbol: 'xyz:UNITREE', + name: 'xyz:UNITREE', + maxLeverage: '10x', + price: '$1.00', + change24h: '+$0.10', + change24hPercent: '+10.00%', + volume: '$100000', + isHip3: true, + isNewMarket: true, + }; + mockTerminalService.fetchMarkets.mockResolvedValue({ + markets: [ + { + name: 'xyz:UNITREE', + szDecimals: 0, + maxLeverage: 10, + marginTableId: 0, + }, + ], + metadata: new Map([ + ['xyz:UNITREE', { name: 'Unitree', marketType: 'pre-ipo' }], + ]), + }); + mockProvider.getMarketDataWithPrices.mockResolvedValue([unitreeMarket]); + + const result = await serviceWithTerminal.getMarketDataWithPrices({ + provider: mockProvider, + params: { useTerminalApi: true }, + context: mockContext, + }); + + expect(result[0]?.marketType).toBe('pre-ipo'); + expect(result[0]?.isNewMarket).toBe(false); + expect(matchesCategory(result[0] as PerpsMarketData, 'pre-ipo')).toBe( + true, + ); + expect(matchesCategory(result[0] as PerpsMarketData, 'new')).toBe( + false, + ); + }); + + it('preserves isNewMarket when Terminal metadata has no marketType', async () => { + const unitreeMarket: PerpsMarketData = { + symbol: 'xyz:UNITREE', + name: 'xyz:UNITREE', + maxLeverage: '10x', + price: '$1.00', + change24h: '+$0.10', + change24hPercent: '+10.00%', + volume: '$100000', + isHip3: true, + isNewMarket: true, + }; + mockTerminalService.fetchMarkets.mockResolvedValue({ + markets: terminalMarkets, + metadata: new Map([ + ['xyz:UNITREE', { name: 'Unitree' }], + ]), + }); + mockProvider.getMarketDataWithPrices.mockResolvedValue([unitreeMarket]); + + const result = await serviceWithTerminal.getMarketDataWithPrices({ + provider: mockProvider, + params: { useTerminalApi: true }, + context: mockContext, + }); + + expect(result[0]?.marketType).toBeUndefined(); + expect(result[0]?.isNewMarket).toBe(true); + }); + it('preserves provider name when terminal metadata omits name', async () => { const metadataWithoutName = new Map([ ['BTC', { keywords: ['crypto'] }], diff --git a/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts b/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts index a0341d04a2..847137fe42 100644 --- a/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts +++ b/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts @@ -372,6 +372,95 @@ describe('TerminalMarketService', () => { expect(metadata.get('FOO')?.marketType).toBeUndefined(); }); + it('maps Terminal category pre_ipo to marketType pre-ipo when marketType is absent', async () => { + jest.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: () => + Promise.resolve([ + { + symbol: 'xyz:UNITREE', + name: 'Unitree Technology', + category: 'pre_ipo', + }, + { + symbol: 'xyz:CXMT', + name: 'ChangXin Technology', + category: 'pre_ipo', + }, + { + symbol: 'xyz:SKHY', + name: 'SK Hynix ADR', + category: 'pre_ipo', + }, + ]), + } as Response); + + const { metadata } = await service.fetchMarkets(); + + expect(metadata.get('xyz:UNITREE')?.marketType).toBe('pre-ipo'); + expect(metadata.get('xyz:CXMT')?.marketType).toBe('pre-ipo'); + expect(metadata.get('xyz:SKHY')?.marketType).toBe('pre-ipo'); + }); + + it('prefers explicit marketType over category pre_ipo', async () => { + jest.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: () => + Promise.resolve([ + { + symbol: 'xyz:UNITREE', + name: 'Unitree Technology', + category: 'pre_ipo', + marketType: 'stock', + }, + ]), + } as Response); + + const { metadata } = await service.fetchMarkets(); + + expect(metadata.get('xyz:UNITREE')?.marketType).toBe('stock'); + }); + + it('maps Terminal category stocks to marketType stock when marketType is absent', async () => { + jest.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: () => + Promise.resolve([ + { + symbol: 'xyz:CBRS', + name: 'Cerebras Systems', + category: 'stocks', + }, + ]), + } as Response); + + const { metadata } = await service.fetchMarkets(); + + expect(metadata.get('xyz:CBRS')?.marketType).toBe('stock'); + }); + + it('does not map an unknown Terminal category to marketType', async () => { + jest.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: () => + Promise.resolve([ + { symbol: 'xyz:FOO', name: 'Foo', category: 'unknown' }, + ]), + } as Response); + + const { metadata } = await service.fetchMarkets(); + + expect(metadata.get('xyz:FOO')?.marketType).toBeUndefined(); + }); + it('uses defaults for missing numeric fields', async () => { jest.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true,