diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 7147ea5bc3..3d08bf7aef 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Preserve trigger prices and normalized trigger order types in HyperLiquid historical orders while retaining their lifecycle and execution semantics ([#9982](https://github.com/MetaMask/core/pull/9982)). - Classify `xyz:CBRS` and `xyz:SPCX` as stocks in the Hyperliquid fallback market map ([#9988](https://github.com/MetaMask/core/pull/9988)) ## [13.0.0] diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 909aef544b..82b58b0e75 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -32,6 +32,7 @@ import { USDC_DECIMALS, USDC_SYMBOL, } from '../constants/hyperLiquidConfig.js'; +import { DETAILED_ORDER_TYPES } from '../constants/orderTypes.js'; import { CHASE_ORDER_CONFIG, CHASE_ORDER_STATUS, @@ -59,6 +60,7 @@ import { } from '../services/TradingReadinessCache.js'; import type { FrontendOrder, + OrderType as HyperLiquidOrderType, SDKOrderParams, MetaResponse, PerpsAssetCtx, @@ -224,6 +226,15 @@ import { import { parseBoundedNonNegativeDecimal } from '../utils/stringParseUtils.js'; // getStreamManagerInstance removed: use this.#deps.streamManager instead +const HISTORICAL_ORDER_TYPE_BY_DETAILED_TYPE = { + [DETAILED_ORDER_TYPES.LIMIT]: 'limit', + [DETAILED_ORDER_TYPES.MARKET]: 'market', + [DETAILED_ORDER_TYPES.STOP_LIMIT]: 'limit', + [DETAILED_ORDER_TYPES.STOP_MARKET]: 'market', + [DETAILED_ORDER_TYPES.TAKE_PROFIT_LIMIT]: 'limit', + [DETAILED_ORDER_TYPES.TAKE_PROFIT_MARKET]: 'market', +} as const satisfies Record; + /** * Type guard to check if a status is an object (not a string literal like "waitingForFill") * The SDK returns status as a union of object types and string literals. @@ -10493,8 +10504,6 @@ export class HyperLiquidProvider implements PerpsProvider { // Transform HyperLiquid orders to abstract Order type const orders: Order[] = (rawOrders || []).map((rawOrder) => { const { order, status, statusTimestamp } = rawOrder; - // Normalize side: HyperLiquid uses 'A' (Ask/Sell) and 'B' (Bid/Buy) - const normalizedSide = order.side === 'B' ? 'buy' : 'sell'; // Normalize status let normalizedStatus: Order['status']; @@ -10529,29 +10538,23 @@ export class HyperLiquidProvider implements PerpsProvider { normalizedStatus = 'queued'; } - // Calculate filled and remaining size - const originalSize = parseFloat(order.origSz || order.sz); - const currentSize = parseFloat(order.sz); - const filledSize = originalSize - currentSize; + const adaptedOrder = adaptOrderFromSDK(order, undefined); + // limitPx is also populated as a slippage cap for market orders, so the + // exchange's detailed type is the reliable execution-mode source. + const historicalOrderType = hasProperty( + HISTORICAL_ORDER_TYPE_BY_DETAILED_TYPE, + order.orderType, + ) + ? HISTORICAL_ORDER_TYPE_BY_DETAILED_TYPE[order.orderType] + : 'market'; return { - orderId: order.oid?.toString() || '', - symbol: order.coin, - side: normalizedSide, - orderType: order.orderType?.toLowerCase().includes('limit') - ? 'limit' - : 'market', - size: order.sz, - originalSize: order.origSz || order.sz, - price: order.limitPx || '0', - filledSize: filledSize.toString(), - remainingSize: currentSize.toString(), + ...adaptedOrder, + orderType: historicalOrderType, + remainingSize: parseFloat(order.sz).toString(), status: normalizedStatus, timestamp: statusTimestamp, lastUpdated: statusTimestamp, - detailedOrderType: order.orderType, // Full order type from exchange (e.g., 'Take Profit Limit', 'Stop Market') - isTrigger: order.isTrigger, - reduceOnly: order.reduceOnly, }; }); diff --git a/packages/perps-controller/src/utils/hyperLiquidAdapter.ts b/packages/perps-controller/src/utils/hyperLiquidAdapter.ts index a6008bea6c..eb803b6b37 100644 --- a/packages/perps-controller/src/utils/hyperLiquidAdapter.ts +++ b/packages/perps-controller/src/utils/hyperLiquidAdapter.ts @@ -277,7 +277,7 @@ export function adaptOrderFromSDK( ); // Extract basic fields with appropriate conversions - const orderId = rawOrder.oid.toString(); + const orderId = rawOrder.oid?.toString() || ''; const symbol = rawOrder.coin; const side: 'buy' | 'sell' = rawOrder.side === 'B' ? 'buy' : 'sell'; const detailedOrderType = rawOrder.orderType; @@ -293,7 +293,7 @@ export function adaptOrderFromSDK( // source for how the order actually executes. orderType = getTriggerExecution(triggerOrderType); } else if ( - detailedOrderType.toLowerCase().includes('limit') || + detailedOrderType?.toLowerCase().includes('limit') || rawOrder.limitPx ) { orderType = 'limit'; diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.history.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.history.test.ts index 3585f6fc47..67f8bfe7bc 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.history.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.history.test.ts @@ -534,6 +534,88 @@ describe('HyperLiquidProvider', () => { expect(result).toEqual([]); }); + it('preserves history when an order is missing runtime-required fields', async () => { + mockClientService.fetchHistoricalOrders = jest.fn().mockResolvedValue([ + { + order: { + oid: 123, + coin: 'BTC', + side: 'A', + sz: '0.5', + origSz: '1.0', + limitPx: '50000', + orderType: 'Limit', + reduceOnly: false, + isTrigger: false, + }, + status: 'filled', + statusTimestamp: 1640995200000, + }, + { + order: { + oid: undefined, + coin: 'ETH', + side: 'B', + sz: '0.1', + origSz: '0.1', + limitPx: '', + orderType: undefined, + reduceOnly: false, + isTrigger: false, + }, + status: 'open', + statusTimestamp: 1640995300000, + }, + ]); + + const result = await provider.getOrders(); + + expect(result).toHaveLength(2); + expect(result[0]).toMatchObject({ + orderId: '123', + orderType: 'limit', + }); + expect(result[1]).toMatchObject({ + orderId: '', + orderType: 'market', + }); + }); + + it.each([ + ['Limit', 'limit'], + ['Market', 'market'], + ['Stop Limit', 'limit'], + ['Stop Market', 'market'], + ['Take Profit Limit', 'limit'], + ['Take Profit Market', 'market'], + ['Unexpected Limit', 'market'], + ])( + 'maps the exact historical order type %s to %s', + async (orderType, expected) => { + mockClientService.fetchHistoricalOrders = jest.fn().mockResolvedValue([ + { + order: { + oid: 123, + coin: 'BTC', + side: 'B', + sz: '0.1', + origSz: '0.1', + limitPx: '50000', + orderType, + reduceOnly: false, + isTrigger: false, + }, + status: 'open', + statusTimestamp: 1640995200000, + }, + ]); + + const result = await provider.getOrders(); + + expect(result[0].orderType).toBe(expected); + }, + ); + it('properly transform getOrders with reduceOnly and isTrigger fields', async () => { const historicalOrdersData = [ { @@ -559,6 +641,7 @@ describe('HyperLiquidProvider', () => { sz: '0.0', origSz: '2.0', limitPx: '3500', + triggerPx: '3450', orderType: 'Take Profit Limit', reduceOnly: true, isTrigger: true, @@ -574,6 +657,7 @@ describe('HyperLiquidProvider', () => { sz: '0.1', origSz: '0.1', limitPx: '45000', + triggerPx: '45500', orderType: 'Stop Market', reduceOnly: true, isTrigger: true, @@ -581,6 +665,22 @@ describe('HyperLiquidProvider', () => { status: 'triggered', statusTimestamp: 1640995400000, }, + { + order: { + oid: 126, + coin: 'ETH', + side: 'B', + sz: '0.0', + origSz: '1.0', + limitPx: '3600', + triggerPx: '', + orderType: 'Market', + reduceOnly: false, + isTrigger: false, + }, + status: 'filled', + statusTimestamp: 1640995500000, + }, ]; mockClientService.getInfoClient = jest.fn().mockReturnValue({ maxBuilderFee: jest.fn().mockResolvedValue(1), @@ -596,7 +696,7 @@ describe('HyperLiquidProvider', () => { const result = await provider.getOrders(); - expect(result).toHaveLength(3); + expect(result).toHaveLength(4); // Check first order - regular limit order (not closing) expect(result[0]).toMatchObject({ @@ -622,7 +722,13 @@ describe('HyperLiquidProvider', () => { size: '0.0', originalSize: '2.0', price: '3500', + triggerPrice: '3450', + triggerOrderType: 'take_profit_limit', + filledSize: '2', + remainingSize: '0', status: 'filled', + timestamp: 1640995300000, + lastUpdated: 1640995300000, detailedOrderType: 'Take Profit Limit', reduceOnly: true, isTrigger: true, @@ -637,11 +743,38 @@ describe('HyperLiquidProvider', () => { size: '0.1', originalSize: '0.1', price: '45000', + triggerPrice: '45500', + triggerOrderType: 'stop_market', + filledSize: '0', + remainingSize: '0.1', status: 'triggered', + timestamp: 1640995400000, + lastUpdated: 1640995400000, detailedOrderType: 'Stop Market', reduceOnly: true, isTrigger: true, }); + + // Check fourth order - regular market order with a slippage-cap price + expect(result[3]).toMatchObject({ + orderId: '126', + symbol: 'ETH', + side: 'buy', + orderType: 'market', + size: '0.0', + originalSize: '1.0', + price: '3600', + filledSize: '1', + remainingSize: '0', + status: 'filled', + timestamp: 1640995500000, + lastUpdated: 1640995500000, + detailedOrderType: 'Market', + reduceOnly: false, + isTrigger: false, + }); + expect(result[3].triggerPrice).toBeUndefined(); + expect(result[3].triggerOrderType).toBeUndefined(); }); it('properly transform getOpenOrders with reduceOnly and isTrigger fields', async () => { diff --git a/packages/perps-controller/tests/src/utils/hyperLiquidAdapter.advanced-orders.test.ts b/packages/perps-controller/tests/src/utils/hyperLiquidAdapter.advanced-orders.test.ts index 8ddf4c9fea..9b3ed993cf 100644 --- a/packages/perps-controller/tests/src/utils/hyperLiquidAdapter.advanced-orders.test.ts +++ b/packages/perps-controller/tests/src/utils/hyperLiquidAdapter.advanced-orders.test.ts @@ -117,6 +117,23 @@ describe('hyperLiquidAdapter - advanced order types', () => { expect(result.triggerOrderType).toBeUndefined(); }); + + it('tolerates runtime orders without an id or detailed type', () => { + const malformedOrder = buildFrontendOrder({ + oid: undefined, + orderType: undefined, + limitPx: '', + } as unknown as Partial); + + const result = adaptOrderFromSDK(malformedOrder); + + expect(result).toMatchObject({ + orderId: '', + orderType: 'market', + }); + expect(result.detailedOrderType).toBeUndefined(); + expect(result.triggerOrderType).toBeUndefined(); + }); }); describe('adaptOrderToSDK', () => {