From e4ccf2dfcbf713b316b27a892c17dfdddb0d5ead Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Thu, 27 Aug 2026 22:53:26 +0800 Subject: [PATCH 1/8] fix(perps): handle partial Scale order acceptance --- .../src/constants/eventNames.ts | 10 ++ .../src/providers/HyperLiquidProvider.ts | 72 ++++++++++--- .../src/services/TradingService.ts | 13 +-- .../tests/src/constants/eventNames.test.ts | 29 +++++ ...yperLiquidProvider.strategy-orders.test.ts | 100 ++++++++++++------ .../tests/src/services/TradingService.test.ts | 37 +++++++ 6 files changed, 211 insertions(+), 50 deletions(-) diff --git a/packages/perps-controller/src/constants/eventNames.ts b/packages/perps-controller/src/constants/eventNames.ts index 529788c04e..1c9cac54d8 100644 --- a/packages/perps-controller/src/constants/eventNames.ts +++ b/packages/perps-controller/src/constants/eventNames.ts @@ -23,6 +23,10 @@ export const PERPS_EVENT_PROPERTY = { ORDER_SIZE: 'order_size', MARGIN_USED: 'margin_used', ORDER_TYPE: 'order_type', // lowercase per dashboard + SCALE_ORDER_COUNT: 'scale_order_count', + SCALE_RANGE_PCT: 'scale_range_pct', + SCALE_SKEW: 'scale_skew', + REDUCE_ONLY: 'reduce_only', ORDER_TIMESTAMP: 'order_timestamp', LIMIT_PRICE: 'limit_price', FEES: 'fees', @@ -478,6 +482,8 @@ export const PERPS_EVENT_VALUE = { SLIPPAGE_CONFIG_OPENED: 'slippage_config_opened', SLIPPAGE_CONFIG_CHANGED: 'slippage_config_changed', SLIPPAGE_LIMIT_BLOCKED_ORDER: 'slippage_limit_blocked_order', + SCALE_CONFIG_CHANGED: 'scale_config_changed', + SCALE_VALIDATION_ERROR_SHOWN: 'scale_validation_error_shown', // Auto Close TP/SL RoE sign toggle TPSL_ROE_SIGN_TOGGLED: 'tpsl_roe_sign_toggled', // Discovery analytics @@ -587,6 +593,10 @@ export const PERPS_EVENT_VALUE = { SETTING_TYPE: { LEVERAGE: 'leverage', SLIPPAGE: 'slippage', + SCALE_START_PRICE: 'start_price', + SCALE_END_PRICE: 'end_price', + SCALE_TOTAL_ORDERS: 'total_orders', + SCALE_SIZE_SKEW: 'size_skew', }, SCREEN_NAME: { CONNECTION_ERROR: 'connection_error', diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 909aef544b..e54048dc05 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -5775,8 +5775,8 @@ export class HyperLiquidProvider implements PerpsProvider { * * The whole ladder goes in a single `order` action, which is one round trip * and one signature rather than one per rung. It is **not** atomic: an `na` - * grouping evaluates each entry independently. Every rung must either rest - * or fill; otherwise all known resting rungs are retracted before failure. + * grouping evaluates each entry independently. Accepted rungs remain live and + * are returned to the caller when another rung is rejected. * * @param params - Order parameters. * @param context - Prepared asset and sizing context. @@ -5788,7 +5788,7 @@ export class HyperLiquidProvider implements PerpsProvider { context: StrategyPlacementContext, generation: number, ): Promise { - const { assetId, formattedSize, ladder, builder } = context; + const { assetId, szDecimals, formattedSize, ladder, builder } = context; // Built and validated in `#prepareStrategyPlacement`, before anything was // signed, so what is submitted here is exactly what the minimums were // applied to. @@ -5824,19 +5824,56 @@ export class HyperLiquidProvider implements PerpsProvider { ...(builder && { builder }), }); - const statuses = result.response?.data?.statuses ?? []; + const rawStatuses = result.response?.data?.statuses; + const statuses = Array.isArray(rawStatuses) ? rawStatuses : []; const outcomes = statuses .slice(0, count) .map((status) => this.#readOrderPlacementOutcome(status)); const acceptedCount = outcomes.filter( (outcome) => outcome !== undefined, ).length; - const restingChildOrderIds = outcomes.flatMap((outcome) => - outcome?.state === 'resting' ? [outcome.orderId] : [], + const acceptedRungs = outcomes.flatMap((outcome, index) => + outcome === undefined + ? [] + : [ + { + orderId: outcome.orderId, + state: outcome.state, + price: prices[index], + size: sizes[index], + }, + ], ); - const filledChildOrderIds = outcomes.flatMap((outcome) => - outcome?.state === 'filled' ? [outcome.orderId] : [], + const acceptedChildOrderIds = acceptedRungs.map((rung) => rung.orderId); + const restingChildOrderIds = acceptedRungs.flatMap((rung) => + rung.state === 'resting' ? [rung.orderId] : [], ); + const filledChildOrderIds = acceptedRungs.flatMap((rung) => + rung.state === 'filled' ? [rung.orderId] : [], + ); + const buildAcceptedResult = (): OrderResult => { + const submittedSize = + acceptedCount === count + ? formattedSize + : formatHyperLiquidSize({ + size: acceptedRungs.reduce( + (total, rung) => total + parseFloat(rung.size), + 0, + ), + szDecimals, + }); + const submittedValue = acceptedRungs.reduce( + (total, rung) => total + parseFloat(rung.size) * parseFloat(rung.price), + 0, + ); + return { + success: true, + orderId: groupId, + childOrderIds: acceptedChildOrderIds, + submittedSize, + averagePrice: String(submittedValue / parseFloat(submittedSize)), + }; + }; if (generation !== this.#strategyGeneration) { const remainingOrderIds = await this.#cancelOrderRequests( @@ -5874,6 +5911,18 @@ export class HyperLiquidProvider implements PerpsProvider { requested: count, statuses, }); + const isValidPartial = + result.status === 'ok' && + statuses.length === count && + acceptedCount > 0 && + acceptedCount < count; + if (isValidPartial) { + this.#scaleOrderGroups.set(groupId, { + symbol: params.symbol, + orderIds: restingChildOrderIds, + }); + return buildAcceptedResult(); + } const remainingOrderIds = await this.#cancelOrderRequests( exchangeClient, restingChildOrderIds.map((orderId) => ({ @@ -5917,12 +5966,7 @@ export class HyperLiquidProvider implements PerpsProvider { symbol: params.symbol, orderIds: restingChildOrderIds, }); - return { - success: true, - orderId: groupId, - childOrderIds: restingChildOrderIds, - submittedSize: formattedSize, - }; + return buildAcceptedResult(); } /** diff --git a/packages/perps-controller/src/services/TradingService.ts b/packages/perps-controller/src/services/TradingService.ts index f0bee6b908..c1b5c15657 100644 --- a/packages/perps-controller/src/services/TradingService.ts +++ b/packages/perps-controller/src/services/TradingService.ts @@ -188,6 +188,9 @@ export class TradingService { result?.success === true ? PERPS_EVENT_VALUE.STATUS.EXECUTED : PERPS_EVENT_VALUE.STATUS.FAILED; + const trackedOrderSize = parseFloat( + result?.filledSize ?? result?.submittedSize ?? params.size, + ); // Build base properties const properties: PerpsAnalyticsProperties = { @@ -198,9 +201,7 @@ export class TradingService { : PERPS_EVENT_VALUE.DIRECTION.SHORT, [PERPS_EVENT_PROPERTY.ORDER_TYPE]: params.orderType, [PERPS_EVENT_PROPERTY.LEVERAGE]: parseFloat(String(params.leverage ?? 1)), - [PERPS_EVENT_PROPERTY.ORDER_SIZE]: parseFloat( - result?.filledSize ?? params.size, - ), + [PERPS_EVENT_PROPERTY.ORDER_SIZE]: trackedOrderSize, [PERPS_EVENT_PROPERTY.COMPLETION_DURATION]: duration, }; @@ -250,12 +251,12 @@ export class TradingService { } // Calculate order value in USD (size * price) - const orderSize = parseFloat(result?.filledSize ?? params.size); const assetPrice = result?.averagePrice ? parseFloat(result.averagePrice) : params.trackingData?.marketPrice; - if (assetPrice && orderSize) { - properties[PERPS_EVENT_PROPERTY.ORDER_VALUE] = orderSize * assetPrice; + if (assetPrice && trackedOrderSize) { + properties[PERPS_EVENT_PROPERTY.ORDER_VALUE] = + trackedOrderSize * assetPrice; } // Add success-specific properties diff --git a/packages/perps-controller/tests/src/constants/eventNames.test.ts b/packages/perps-controller/tests/src/constants/eventNames.test.ts index f788305e69..23853b3f13 100644 --- a/packages/perps-controller/tests/src/constants/eventNames.test.ts +++ b/packages/perps-controller/tests/src/constants/eventNames.test.ts @@ -321,6 +321,35 @@ describe('PERPS_EVENT_VALUE.INTERACTION_TYPE extensions', () => { }); }); +describe('Scale analytics constants', () => { + it('exports Scale property keys', () => { + expect(PERPS_EVENT_PROPERTY.SCALE_ORDER_COUNT).toBe('scale_order_count'); + expect(PERPS_EVENT_PROPERTY.SCALE_RANGE_PCT).toBe('scale_range_pct'); + expect(PERPS_EVENT_PROPERTY.SCALE_SKEW).toBe('scale_skew'); + expect(PERPS_EVENT_PROPERTY.REDUCE_ONLY).toBe('reduce_only'); + }); + + it('exports Scale interaction values', () => { + expect(PERPS_EVENT_VALUE.INTERACTION_TYPE.SCALE_CONFIG_CHANGED).toBe( + 'scale_config_changed', + ); + expect( + PERPS_EVENT_VALUE.INTERACTION_TYPE.SCALE_VALIDATION_ERROR_SHOWN, + ).toBe('scale_validation_error_shown'); + }); + + it('exports Scale setting values', () => { + expect(PERPS_EVENT_VALUE.SETTING_TYPE.SCALE_START_PRICE).toBe( + 'start_price', + ); + expect(PERPS_EVENT_VALUE.SETTING_TYPE.SCALE_END_PRICE).toBe('end_price'); + expect(PERPS_EVENT_VALUE.SETTING_TYPE.SCALE_TOTAL_ORDERS).toBe( + 'total_orders', + ); + expect(PERPS_EVENT_VALUE.SETTING_TYPE.SCALE_SIZE_SKEW).toBe('size_skew'); + }); +}); + describe('PERPS_EVENT_VALUE.BUTTON_CLICKED extensions', () => { it('exports WATCHLIST', () => { expect(PERPS_EVENT_VALUE.BUTTON_CLICKED.WATCHLIST).toBe('watchlist'); diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts index 63b30fcb12..1fd8950ce7 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts @@ -2871,16 +2871,24 @@ describe('HyperLiquidProvider - strategy order types', () => { expect(result.success).toBe(true); expect(result.childOrderIds).toStrictEqual(['11', '22', '33']); + expect(result.submittedSize).toBe('1'); + expect(result.averagePrice).toBe('2499.95'); expect(result.orderId).toMatch(/^scale:/u); }); - it('fails when the ladder rested nothing', async () => { + it('fails when every rung is rejected', async () => { useStrategyClients({ exchange: { order: jest.fn().mockResolvedValue({ status: 'ok', response: { - data: { statuses: [{ error: 'Insufficient margin' }] }, + data: { + statuses: [ + { error: 'Insufficient margin' }, + { error: 'Insufficient margin' }, + { error: 'Insufficient margin' }, + ], + }, }, }), }, @@ -2898,6 +2906,33 @@ describe('HyperLiquidProvider - strategy order types', () => { expect(result.error).toBe(PERPS_ERROR_CODES.ORDER_REJECTED); }); + it.each([ + ['missing', undefined], + ['non-array', { resting: { oid: 11 } }], + ])('rejects %s placement statuses', async (_label, statuses) => { + useStrategyClients({ + exchange: { + order: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses } }, + }), + }, + }); + + const result = await provider.placeOrder({ + ...baseOrder, + orderType: 'scale', + scaleMinPrice: '2000', + scaleMaxPrice: '3000', + scaleNumOrders: 3, + } satisfies OrderParams); + + expect(result).toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.ORDER_REJECTED, + }); + }); + it('reports filled rungs but keeps only resting rungs in a recovery group', async () => { const cancel = jest .fn() @@ -2914,7 +2949,7 @@ describe('HyperLiquidProvider - strategy order types', () => { const { exchangeClient } = useStrategyClients({ exchange: { order: jest.fn().mockResolvedValue({ - status: 'ok', + status: 'err', response: { data: { statuses: [ @@ -4718,14 +4753,10 @@ describe('HyperLiquidProvider - strategy order types', () => { }, }; - it('retracts every rung when the ladder only partly rests', async () => { + it('keeps accepted rungs when the ladder only partly rests', async () => { const { exchangeClient } = useStrategyClients({ exchange: { order: jest.fn().mockResolvedValue(partlyRested), - cancel: jest.fn().mockResolvedValue({ - status: 'ok', - response: { data: { statuses: ['success', 'success'] } }, - }), }, }); @@ -4738,18 +4769,15 @@ describe('HyperLiquidProvider - strategy order types', () => { } satisfies OrderParams); expect(result).toMatchObject({ - success: false, - error: PERPS_ERROR_CODES.ORDER_REJECTED, - }); - expect(exchangeClient.cancel).toHaveBeenCalledWith({ - cancels: [ - { a: 1, o: 11 }, - { a: 1, o: 33 }, - ], + success: true, + childOrderIds: ['11', '33'], + submittedSize: '0.6667', + averagePrice: '2499.9250037498123', }); + expect(exchangeClient.cancel).not.toHaveBeenCalled(); }); - it('reports submitted exposure when a partial ladder fills a rung', async () => { + it('returns filled and resting IDs but cancels only resting rungs', async () => { const { exchangeClient } = useStrategyClients({ exchange: { order: jest.fn().mockResolvedValue({ @@ -4780,11 +4808,19 @@ describe('HyperLiquidProvider - strategy order types', () => { } satisfies OrderParams); expect(result).toMatchObject({ - success: false, - error: PERPS_ERROR_CODES.ORDER_REJECTED, - childOrderIds: ['11'], - submittedSize: '1', + success: true, + childOrderIds: ['11', '33'], + submittedSize: '0.6667', + averagePrice: '2499.9250037498123', + }); + + const cancelled = await provider.cancelOrder({ + orderId: result.orderId, + symbol: 'ETH', + orderType: 'scale', }); + + expect(cancelled.success).toBe(true); expect(exchangeClient.cancel).toHaveBeenCalledWith({ cancels: [{ a: 1, o: 33 }], }); @@ -4805,7 +4841,10 @@ describe('HyperLiquidProvider - strategy order types', () => { }); const { exchangeClient } = useStrategyClients({ exchange: { - order: jest.fn().mockResolvedValue(partlyRested), + order: jest.fn().mockResolvedValue({ + ...partlyRested, + status: 'err', + }), cancel, }, }); @@ -4843,7 +4882,7 @@ describe('HyperLiquidProvider - strategy order types', () => { }); }); - it('accepts filled rungs but exposes only resting rungs for cancellation', async () => { + it('accepts filled rungs and exposes all accepted IDs in the result', async () => { const { exchangeClient } = useStrategyClients({ exchange: { order: jest.fn().mockResolvedValue({ @@ -4875,8 +4914,9 @@ describe('HyperLiquidProvider - strategy order types', () => { expect(placed).toMatchObject({ success: true, - childOrderIds: ['11', '33'], + childOrderIds: ['11', '22', '33'], submittedSize: '1', + averagePrice: '2499.95', }); const cancelled = await provider.cancelOrder({ @@ -4900,7 +4940,7 @@ describe('HyperLiquidProvider - strategy order types', () => { ['unsafe', Number.MAX_SAFE_INTEGER + 1], ['non-numeric', '22'], ])( - 'rejects a %s scale order ID and retracts valid rungs', + 'ignores a %s scale order ID while preserving valid rungs', async (_label, oid) => { const { exchangeClient } = useStrategyClients({ exchange: { @@ -4928,12 +4968,12 @@ describe('HyperLiquidProvider - strategy order types', () => { } satisfies OrderParams); expect(placed).toMatchObject({ - success: false, - error: PERPS_ERROR_CODES.ORDER_REJECTED, - }); - expect(exchangeClient.cancel).toHaveBeenCalledWith({ - cancels: [{ a: 1, o: 11 }], + success: true, + childOrderIds: ['11'], + submittedSize: '0.3334', + averagePrice: '2000', }); + expect(exchangeClient.cancel).not.toHaveBeenCalled(); }, ); }); diff --git a/packages/perps-controller/tests/src/services/TradingService.test.ts b/packages/perps-controller/tests/src/services/TradingService.test.ts index cee0872a44..187cad0b8a 100644 --- a/packages/perps-controller/tests/src/services/TradingService.test.ts +++ b/packages/perps-controller/tests/src/services/TradingService.test.ts @@ -411,6 +411,43 @@ describe('TradingService', () => { ); }); + it('tracks accepted Scale size and weighted average price', async () => { + const orderParams: OrderParams = { + symbol: 'ETH', + isBuy: true, + size: '1', + orderType: 'scale', + scaleMinPrice: '2000', + scaleMaxPrice: '3000', + scaleNumOrders: 3, + trackingData: { marketPrice: 3000 }, + }; + mockProvider.placeOrder.mockResolvedValue({ + success: true, + orderId: 'scale:group', + submittedSize: '0.6667', + averagePrice: '2499.9250037498123', + }); + + await tradingService.placeOrder({ + provider: mockProvider, + params: orderParams, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.TradeTransaction, + expect.objectContaining({ + order_size: 0.6667, + asset_price: 2499.9250037498123, + }), + ); + const resultProperties = + mockDeps.metrics.trackPerpsEvent.mock.calls[1][1]; + expect(resultProperties.order_value).toBeCloseTo(1666.7); + }); + it('includes trade_with_token and mm_pay fields when trackingData has tradeWithToken and pay token/network', async () => { const orderParams: OrderParams = { symbol: 'BTC', From f06f21378111bc5faeda8e139c2b548bfd5923af Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Thu, 27 Aug 2026 22:54:01 +0800 Subject: [PATCH 2/8] docs(perps): document Scale partial acceptance --- packages/perps-controller/CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index bb1210d6e5..d24d87f906 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -7,8 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add typed Scale analytics property, interaction, and setting constants ([#9989](https://github.com/MetaMask/core/pull/9989)) + ### Fixed +- Preserve accepted HyperLiquid Scale rungs after a partial batch rejection, report accepted size and weighted average price, and use that exposure in trade analytics ([#9989](https://github.com/MetaMask/core/pull/9989)) - 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] From b99db42822186376b44955516c93e6c38dea4715 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Thu, 27 Aug 2026 22:58:46 +0800 Subject: [PATCH 3/8] test(perps): clarify Scale recovery coverage --- packages/perps-controller/CHANGELOG.md | 2 +- .../src/providers/HyperLiquidProvider.strategy-orders.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index d24d87f906..5d5e32cf35 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Preserve accepted HyperLiquid Scale rungs after a partial batch rejection, report accepted size and weighted average price, and use that exposure in trade analytics ([#9989](https://github.com/MetaMask/core/pull/9989)) +- Preserve accepted HyperLiquid Scale rungs after a partial batch rejection, include resting and filled child IDs in successful results, report accepted size and weighted average price, and use submitted exposure in trade analytics ([#9989](https://github.com/MetaMask/core/pull/9989)) - 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/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts index 1fd8950ce7..41a564a634 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts @@ -2933,7 +2933,7 @@ describe('HyperLiquidProvider - strategy order types', () => { }); }); - it('reports filled rungs but keeps only resting rungs in a recovery group', async () => { + it('reports accepted IDs after a non-ok response but keeps only resting rungs recoverable', async () => { const cancel = jest .fn() .mockResolvedValueOnce({ From f684d2e5d8d4e5f4a98f5dc73fa683df2fcb3ef5 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Thu, 27 Aug 2026 23:05:53 +0800 Subject: [PATCH 4/8] docs(perps): clarify Scale child order IDs --- packages/perps-controller/src/types/index.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index 585156f595..1fcce39ceb 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -336,18 +336,19 @@ export type OrderResult = { // strategy placement, `orderId` carries the strategy handle and these IDs // identify its individual children. // - // For a `scale` ladder they stay valid: the rungs are placed once and are not - // replaced, so they remain cancellable even after the session-scoped handle is + // For a `scale` ladder these identify every accepted rung, including fills. + // Only resting IDs are cancellable and retained by the strategy handle. Scale + // rungs are never replaced, so a resting ID stays valid after the handle is // gone. For a `chase` this is only the order resting at placement time — the // strategy cancels and re-places as the touch moves, and each replacement has // a new ID that is held in the session rather than reported here, so the value // goes stale on the first re-price. Cancel a live chase by its handle. // - // Failure results can mix filled IDs with orders that may still rest, so a - // caller must not blindly cancel every ID. When TP/SL protection cannot be - // fully restored, these identify the old orders that survived, may still be - // live when reconciliation failed, or were recreated; an empty array means - // none are known or potentially live. + // Scale success and failure results can mix filled IDs with orders that may + // still rest, so a caller must not blindly cancel every ID. When TP/SL + // protection cannot be fully restored, these identify the old orders that + // survived, may still be live when reconciliation failed, or were recreated; + // an empty array means none are known or potentially live. childOrderIds?: string[]; providerId?: PerpsProviderType; // Multi-provider: which provider executed this order (injected by aggregator) }; From 7c09aac856eb8284e61eb387c03dbbd3b02a77db Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Thu, 27 Aug 2026 23:19:06 +0800 Subject: [PATCH 5/8] fix(perps): recover partial Scale SDK responses --- packages/perps-controller/CHANGELOG.md | 2 +- .../src/providers/HyperLiquidProvider.ts | 77 +++++++++++- ...yperLiquidProvider.strategy-orders.test.ts | 111 +++++++++++++++++- 3 files changed, 183 insertions(+), 7 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 5d5e32cf35..cce4967c33 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Preserve accepted HyperLiquid Scale rungs after a partial batch rejection, include resting and filled child IDs in successful results, report accepted size and weighted average price, and use submitted exposure in trade analytics ([#9989](https://github.com/MetaMask/core/pull/9989)) +- Preserve accepted HyperLiquid Scale rungs after a partial batch rejection, including when the SDK wraps the bulk response in `ApiRequestError`; include resting and filled child IDs in successful results, report accepted size and weighted average price, and use submitted exposure in trade analytics ([#9989](https://github.com/MetaMask/core/pull/9989)) - 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 e54048dc05..a3cd946238 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -5,6 +5,7 @@ import type { InfoClient, UserAbstractionResponse, } from '@nktkas/hyperliquid'; +import { HyperliquidError } from '@nktkas/hyperliquid'; import { BigNumber } from 'bignumber.js'; import { v4 as uuidv4 } from 'uuid'; @@ -234,6 +235,63 @@ import { parseBoundedNonNegativeDecimal } from '../utils/stringParseUtils.js'; const isStatusObject = (status: unknown): status is Record => typeof status === 'object' && status !== null; +type ScaleBulkOrderResponse = { + status: 'ok'; + response: { + type: 'order'; + data: { statuses: unknown[] }; + }; +}; + +/** + * Recover the bulk order response that the pinned SDK wraps in an + * `ApiRequestError` when any rung is rejected. + * + * @param error - The SDK error thrown by `ExchangeClient.order`. + * @param expectedStatusCount - Number of Scale rungs submitted. + * @returns The complete bulk order response, or undefined for any other error. + */ +const getScaleBulkOrderResponseFromError = ( + error: unknown, + expectedStatusCount: number, +): ScaleBulkOrderResponse | undefined => { + if ( + !(error instanceof HyperliquidError) || + error.name !== 'ApiRequestError' || + !hasProperty(error, 'response') + ) { + return undefined; + } + + const result = error.response; + if (!isStatusObject(result) || result.status !== 'ok') { + return undefined; + } + + const { response } = result; + if (!isStatusObject(response) || response.type !== 'order') { + return undefined; + } + + const { data } = response; + if (!isStatusObject(data)) { + return undefined; + } + + const { statuses } = data; + if ( + !Array.isArray(statuses) || + statuses.length !== expectedStatusCount || + !statuses.some( + (status) => isStatusObject(status) && typeof status.error === 'string', + ) + ) { + return undefined; + } + + return result as ScaleBulkOrderResponse; +}; + /** * Exchange messages that mean a cancel was refused because the order is not on * the book any more. @@ -5818,11 +5876,20 @@ export class HyperLiquidProvider implements PerpsProvider { }); const exchangeClient = this.#clientService.getExchangeClient(); - const result = await exchangeClient.order({ - orders, - grouping: 'na', - ...(builder && { builder }), - }); + let result: ScaleBulkOrderResponse; + try { + result = await exchangeClient.order({ + orders, + grouping: 'na', + ...(builder && { builder }), + }); + } catch (error) { + const bulkResponse = getScaleBulkOrderResponseFromError(error, count); + if (!bulkResponse) { + throw error; + } + result = bulkResponse; + } const rawStatuses = result.response?.data?.statuses; const statuses = Array.isArray(rawStatuses) ? rawStatuses : []; diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts index 41a564a634..836d87bf68 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts @@ -1,3 +1,5 @@ +import { HyperliquidError } from '@nktkas/hyperliquid'; + import { BUILDER_FEE_CONFIG } from '../../../src/constants/hyperLiquidConfig.js'; import { CHASE_ORDER_CONFIG, @@ -39,7 +41,9 @@ import { // The HyperLiquid SDK is never exercised directly: every exchange and info call // goes through the mocked client service below. -jest.mock('@nktkas/hyperliquid', () => ({})); +jest.mock('@nktkas/hyperliquid', () => ({ + HyperliquidError: class MockHyperliquidError extends Error {}, +})); jest.mock('../../../src/services/HyperLiquidClientService'); jest.mock('../../../src/services/HyperLiquidWalletService'); jest.mock('../../../src/services/HyperLiquidSubscriptionService'); @@ -92,6 +96,16 @@ jest.mock('../../../src/utils/hyperLiquidAdapter', () => { // Use jest.createMockFromModule for proper mock creation jest.mock('../../../src/services/TradingReadinessCache'); +class TestApiRequestError extends HyperliquidError { + readonly response: unknown; + + constructor(response: unknown, message?: string) { + super(message); + this.name = 'ApiRequestError'; + this.response = response; + } +} + const MockedHyperLiquidClientService = HyperLiquidClientService as jest.MockedClass; const MockedHyperLiquidWalletService = @@ -4743,6 +4757,7 @@ describe('HyperLiquidProvider - strategy order types', () => { const partlyRested = { status: 'ok', response: { + type: 'order', data: { statuses: [ { resting: { oid: 11 } }, @@ -4753,6 +4768,100 @@ describe('HyperLiquidProvider - strategy order types', () => { }, }; + it('recovers a mixed Scale response thrown by the SDK', async () => { + const response = { + status: 'ok' as const, + response: { + type: 'order' as const, + data: { + statuses: [ + { resting: { oid: 11 } }, + { filled: { oid: 22 } }, + { error: 'Insufficient margin' }, + ], + }, + }, + }; + const { exchangeClient } = useStrategyClients({ + exchange: { + order: jest + .fn() + .mockRejectedValue( + new TestApiRequestError(response, 'order 2: Insufficient margin'), + ), + cancel: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: ['success'] } }, + }), + }, + }); + + const placed = await provider.placeOrder({ + ...baseOrder, + orderType: 'scale', + scaleMinPrice: '2000', + scaleMaxPrice: '3000', + scaleNumOrders: 3, + } satisfies OrderParams); + + expect(placed).toMatchObject({ + success: true, + childOrderIds: ['11', '22'], + submittedSize: '0.6667', + averagePrice: '2249.962501874906', + }); + + expect( + await provider.cancelOrder({ + orderId: placed.orderId, + symbol: 'ETH', + orderType: 'scale', + }), + ).toMatchObject({ success: true }); + expect(exchangeClient.cancel).toHaveBeenCalledWith({ + cancels: [{ a: 1, o: 11 }], + }); + }); + + it.each([ + [ + 'top-level', + new TestApiRequestError( + { status: 'err', response: 'Invalid nonce' }, + 'Invalid nonce', + ), + PERPS_ERROR_CODES.EXCHANGE_INVALID_NONCE, + ], + [ + 'malformed', + new TestApiRequestError( + { + status: 'ok', + response: { type: 'order', data: { statuses: 'invalid' } }, + }, + 'Malformed bulk response', + ), + 'Malformed bulk response', + ], + ['unrelated SDK', new HyperliquidError('SDK failure'), 'SDK failure'], + ['non-SDK', new Error('Network unavailable'), 'Network unavailable'], + ])('does not unwrap a %s SDK error', async (_label, error, message) => { + const { exchangeClient } = useStrategyClients({ + exchange: { order: jest.fn().mockRejectedValue(error) }, + }); + + const result = await provider.placeOrder({ + ...baseOrder, + orderType: 'scale', + scaleMinPrice: '2000', + scaleMaxPrice: '3000', + scaleNumOrders: 3, + } satisfies OrderParams); + + expect(result).toMatchObject({ success: false, error: message }); + expect(exchangeClient.cancel).not.toHaveBeenCalled(); + }); + it('keeps accepted rungs when the ladder only partly rests', async () => { const { exchangeClient } = useStrategyClients({ exchange: { From 679a6f02ca924ef4393a982d3b9826392334d8b1 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Thu, 27 Aug 2026 23:29:40 +0800 Subject: [PATCH 6/8] fix(perps): validate partial Scale statuses --- .../src/providers/HyperLiquidProvider.ts | 42 ++++++++- ...yperLiquidProvider.strategy-orders.test.ts | 91 +++++++++++++++++++ 2 files changed, 128 insertions(+), 5 deletions(-) diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index a3cd946238..49977bf480 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -243,6 +243,35 @@ type ScaleBulkOrderResponse = { }; }; +type ScaleBulkOrderStatusKind = 'accepted' | 'error'; + +const getScaleBulkOrderStatusKind = ( + status: unknown, +): ScaleBulkOrderStatusKind | undefined => { + if (!isStatusObject(status) || Object.keys(status).length !== 1) { + return undefined; + } + + if (hasProperty(status, 'error')) { + return typeof status.error === 'string' ? 'error' : undefined; + } + + for (const state of ['resting', 'filled'] as const) { + if (!hasProperty(status, state)) { + continue; + } + const order = status[state]; + return isStatusObject(order) && + typeof order.oid === 'number' && + Number.isSafeInteger(order.oid) && + order.oid >= 0 + ? 'accepted' + : undefined; + } + + return undefined; +}; + /** * Recover the bulk order response that the pinned SDK wraps in an * `ApiRequestError` when any rung is rejected. @@ -279,12 +308,15 @@ const getScaleBulkOrderResponseFromError = ( } const { statuses } = data; + if (!Array.isArray(statuses) || statuses.length !== expectedStatusCount) { + return undefined; + } + + const statusKinds = statuses.map(getScaleBulkOrderStatusKind); if ( - !Array.isArray(statuses) || - statuses.length !== expectedStatusCount || - !statuses.some( - (status) => isStatusObject(status) && typeof status.error === 'string', - ) + statusKinds.some((kind) => kind === undefined) || + !statusKinds.includes('accepted') || + !statusKinds.includes('error') ) { return undefined; } diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts index 836d87bf68..5919f906a0 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts @@ -4823,6 +4823,97 @@ describe('HyperLiquidProvider - strategy order types', () => { }); }); + it.each([ + [ + 'waiting status', + [ + { resting: { oid: 11 } }, + { error: 'Insufficient margin' }, + 'waitingForFill', + ], + ], + [ + 'invalid order ID', + [ + { resting: { oid: 11 } }, + { error: 'Insufficient margin' }, + { resting: { oid: -1 } }, + ], + ], + [ + 'hybrid accepted and error entry', + [ + { resting: { oid: 11 } }, + { error: 'Insufficient margin' }, + { resting: { oid: 33 }, error: 'Invalid status' }, + ], + ], + ])( + 'does not unwrap a mixed response with a %s', + async (label, statuses) => { + const error = new TestApiRequestError( + { + status: 'ok', + response: { type: 'order', data: { statuses } }, + }, + `Malformed bulk response: ${label}`, + ); + const { exchangeClient } = useStrategyClients({ + exchange: { order: jest.fn().mockRejectedValue(error) }, + }); + + const result = await provider.placeOrder({ + ...baseOrder, + orderType: 'scale', + scaleMinPrice: '2000', + scaleMaxPrice: '3000', + scaleNumOrders: 3, + } satisfies OrderParams); + + expect(result).toMatchObject({ + success: false, + error: `Malformed bulk response: ${label}`, + }); + expect(exchangeClient.cancel).not.toHaveBeenCalled(); + }, + ); + + it('preserves an all-rejected SDK error for existing error mapping', async () => { + const error = new TestApiRequestError( + { + status: 'ok', + response: { + type: 'order', + data: { + statuses: [ + { error: 'Multi-sig required' }, + { error: 'Multi-sig required' }, + { error: 'Multi-sig required' }, + ], + }, + }, + }, + 'order 0: Multi-sig required', + ); + const { exchangeClient } = useStrategyClients({ + exchange: { order: jest.fn().mockRejectedValue(error) }, + }); + + const result = await provider.placeOrder({ + ...baseOrder, + orderType: 'scale', + scaleMinPrice: '2000', + scaleMaxPrice: '3000', + scaleNumOrders: 3, + } satisfies OrderParams); + + expect(result).toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED, + }); + expect(exchangeClient.cancel).not.toHaveBeenCalled(); + }); + it.each([ [ 'top-level', From 8e3b7fc4353ed29e51751932713db07a50598508 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Fri, 28 Aug 2026 14:30:34 +0800 Subject: [PATCH 7/8] fix(perps): align Scale result contract --- packages/perps-controller/CHANGELOG.md | 4 +- packages/perps-controller/src/index.ts | 1 + .../src/providers/HyperLiquidProvider.ts | 429 +++++++++++++----- .../src/services/TradingService.ts | 51 ++- packages/perps-controller/src/types/index.ts | 48 +- ...yperLiquidProvider.strategy-orders.test.ts | 323 +++++++++++-- .../tests/src/services/TradingService.test.ts | 90 +++- 7 files changed, 774 insertions(+), 172 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 7cb5bf1859..e00a9a549c 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Preserve accepted HyperLiquid Scale rungs after a partial batch rejection, including when the SDK wraps the bulk response in `ApiRequestError`; include resting and filled child IDs in successful results, report accepted size and weighted average price, and use submitted exposure in trade analytics ([#9989](https://github.com/MetaMask/core/pull/9989)) +- Preserve every accepted HyperLiquid Scale rung after a partial batch rejection, including `waitingForFill` and `waitingForTrigger` statuses and responses wrapped in `ApiRequestError`; keep `childOrderIds` limited to resting orders, expose each accepted status through `acceptedChildren`, distinguish `acceptedSize` from the full `submittedSize`, and report `weightedAverageLimitPrice` separately from the fill-weighted `averagePrice` ([#9989](https://github.com/MetaMask/core/pull/9989)) ## [13.1.0] @@ -107,7 +107,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `OrderType` is a wider union again, so — exactly as for the trigger types added in 11.0.0 — any consumer signature that narrows it back to a smaller set no longer accepts a value typed `OrderType`. Such signatures must widen to `OrderType` or narrow explicitly at the call site. - A strategy placement expands one request into an execution schedule rather than a single resting order, so `OrderResult.orderId` carries a _handle_ — a venue TWAP id, or a client-generated group/session id — rather than an exchange order id. Its documentation says so; the individual exchange ids are in `childOrderIds`. - `twap` slices the size over `OrderParams.twapDuration` whole minutes, optionally varying each suborder's size by up to ±20% with `OrderParams.twapRandomize`. On HyperLiquid it is submitted through the venue's own TWAP action, not the order book, and `HYPERLIQUID_TWAP_LIMITS` bounds the window to the pinned SDK's 5–1440-minute range. - - `scale` fans out `OrderParams.scaleNumOrders` limit orders on an inclusive price ladder between `OrderParams.scaleMinPrice` and `OrderParams.scaleMaxPrice`, submitted as a single batch. Sizes are split in whole units of the asset's size grid, so the rungs sum to exactly the submitted size. The batch is not atomic — the venue can rest some rungs and reject others — so `OrderResult.submittedSize` reports only the rungs that actually rested. + - `scale` fans out `OrderParams.scaleNumOrders` limit orders on an inclusive price ladder between `OrderParams.scaleMinPrice` and `OrderParams.scaleMaxPrice`, submitted as a single batch. Sizes are split in whole units of the asset's size grid, so the rungs sum to exactly the submitted size. The batch is not atomic. The venue can accept some rungs and reject others, so `OrderResult.submittedSize` reports the full normalized batch while `acceptedSize`, `acceptedChildren`, and `weightedAverageLimitPrice` describe the accepted rungs. `childOrderIds` remains limited to resting, cancellable orders, and `averagePrice` describes fills only. - The venue applies its minimum order value to what it receives, not to the strategy total: a `scale` ladder's notional must leave every submitted rung above the per-order minimum, and a `twap`'s total must clear the venue's own minimum TWAP size (`HYPERLIQUID_TWAP_LIMITS.MinNotionalUsd`). Both are rejected locally rather than by the exchange. The ladder check needs the asset's size grid, so it runs during placement — before anything is signed — rather than in `validateOrder`, which cannot see the grid and could only guess. - A `chase` verifies its order is still live whenever its own price stops showing on the book, so an order that fills without the loop noticing ends the session and releases its concurrency slot instead of holding both until the window closes. - A `chase` interrupted by `disconnect` resolves as a failure with `ORDER_CHASE_ABANDONED` rather than a success, because no strategy is running behind it. Interrupted anywhere before its submission it signs nothing at all. Interrupted while that submission is in flight — the one window it cannot check ahead of — it tries to take the order back before returning, through the client it signed with rather than one asked for after the teardown, so an account switch cannot strand it. That attempt is best-effort: the venue can refuse the cancel, and the transport underneath the client may already be closing. When it does not take, the order is reported in `OrderResult.childOrderIds`, where the ordinary single-order cancel can still reach it for as long as the provider signs as the account that placed it. diff --git a/packages/perps-controller/src/index.ts b/packages/perps-controller/src/index.ts index f8ce4dba42..56fadd1d14 100644 --- a/packages/perps-controller/src/index.ts +++ b/packages/perps-controller/src/index.ts @@ -198,6 +198,7 @@ export type { TPSLTrackingData, OrderParams, OrderResult, + ScaleOrderChild, ChaseOrder, ChaseOrderMaxDistanceReached, ChaseOrderStatus, diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 49977bf480..dba34535c6 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -116,6 +116,7 @@ import type { OrderFill, OrderParams, OrderResult, + ScaleOrderChild, PerpsMarketData, DirectProviderOrderCapabilities, Position, @@ -243,30 +244,80 @@ type ScaleBulkOrderResponse = { }; }; -type ScaleBulkOrderStatusKind = 'accepted' | 'error'; +type ScaleBulkOrderStatus = + | { kind: 'accepted'; state: 'resting'; orderId: string } + | { + kind: 'accepted'; + state: 'filled'; + orderId: string; + averagePrice: string; + filledSize: string; + } + | { kind: 'accepted'; state: 'waitingForFill' | 'waitingForTrigger' } + | { kind: 'error'; error: string }; -const getScaleBulkOrderStatusKind = ( +/** + * Parse every status the HyperLiquid bulk order API can return for a Scale + * rung. Unknown or malformed statuses are not exchange rejections. + * + * @param status - One status from a bulk order response. + * @returns The classified status, or undefined when it is malformed. + */ +const parseScaleBulkOrderStatus = ( status: unknown, -): ScaleBulkOrderStatusKind | undefined => { +): ScaleBulkOrderStatus | undefined => { + if (status === 'waitingForFill' || status === 'waitingForTrigger') { + return { kind: 'accepted', state: status }; + } + if (!isStatusObject(status) || Object.keys(status).length !== 1) { return undefined; } if (hasProperty(status, 'error')) { - return typeof status.error === 'string' ? 'error' : undefined; + return typeof status.error === 'string' + ? { kind: 'error', error: status.error } + : undefined; } - for (const state of ['resting', 'filled'] as const) { - if (!hasProperty(status, state)) { - continue; - } - const order = status[state]; - return isStatusObject(order) && + if (hasProperty(status, 'resting')) { + const order = status.resting; + if ( + isStatusObject(order) && typeof order.oid === 'number' && Number.isSafeInteger(order.oid) && order.oid >= 0 - ? 'accepted' - : undefined; + ) { + return { + kind: 'accepted', + state: 'resting', + orderId: order.oid.toString(), + }; + } + } + + if (hasProperty(status, 'filled')) { + const order = status.filled; + if ( + isStatusObject(order) && + typeof order.oid === 'number' && + Number.isSafeInteger(order.oid) && + order.oid >= 0 && + typeof order.avgPx === 'string' && + new BigNumber(order.avgPx).isFinite() && + new BigNumber(order.avgPx).gt(0) && + typeof order.totalSz === 'string' && + new BigNumber(order.totalSz).isFinite() && + new BigNumber(order.totalSz).gt(0) + ) { + return { + kind: 'accepted', + state: 'filled', + orderId: order.oid.toString(), + averagePrice: order.avgPx, + filledSize: order.totalSz, + }; + } } return undefined; @@ -312,11 +363,11 @@ const getScaleBulkOrderResponseFromError = ( return undefined; } - const statusKinds = statuses.map(getScaleBulkOrderStatusKind); + const parsedStatuses = statuses.map(parseScaleBulkOrderStatus); if ( - statusKinds.some((kind) => kind === undefined) || - !statusKinds.includes('accepted') || - !statusKinds.includes('error') + parsedStatuses.some((status) => status === undefined) || + !parsedStatuses.some((status) => status?.kind === 'accepted') || + !parsedStatuses.some((status) => status?.kind === 'error') ) { return undefined; } @@ -324,6 +375,46 @@ const getScaleBulkOrderResponseFromError = ( return result as ScaleBulkOrderResponse; }; +/** + * Read a complete cancel response from an SDK `ApiRequestError`. + * + * The SDK throws when any cancel entry is an error, including the benign + * already-gone response that confirms an order is no longer live. + * + * @param error - Error thrown by an exchange cancel method. + * @param expectedStatusCount - Number of cancel requests submitted. + * @returns The per-request statuses, or undefined for another error shape. + */ +const getCancelStatusesFromError = ( + error: unknown, + expectedStatusCount: number, +): unknown[] | undefined => { + if ( + !(error instanceof HyperliquidError) || + error.name !== 'ApiRequestError' || + !hasProperty(error, 'response') + ) { + return undefined; + } + + const result = error.response; + if (!isStatusObject(result) || result.status !== 'ok') { + return undefined; + } + const { response } = result; + if ( + !isStatusObject(response) || + response.type !== 'cancel' || + !isStatusObject(response.data) || + !Array.isArray(response.data.statuses) || + response.data.statuses.length !== expectedStatusCount + ) { + return undefined; + } + + return response.data.statuses; +}; + /** * Exchange messages that mean a cancel was refused because the order is not on * the book any more. @@ -431,6 +522,8 @@ type HyperLiquidTwapSliceFillEntry = Awaited< type ExchangeCancelRequest = { a: number; o: number }; +type ExchangeCancelByCloidRequest = { asset: number; cloid: Hex }; + type CancelOrderBatchOutcome = { remainingOrderIds: number[]; cancelledOrderIds: number[]; @@ -809,6 +902,7 @@ type ScaleOrderIdentity = { type ScaleOrderGroup = { symbol: string; orderIds: string[]; + clientOrderIds: Hex[]; }; /** @@ -5468,10 +5562,12 @@ export class HyperLiquidProvider implements PerpsProvider { throw error; } + const resultFilledSize = new BigNumber(result.filledSize ?? 0); const hasVenueExposure = result.success === true || result.orderId !== undefined || - (result.childOrderIds?.length ?? 0) > 0; + (result.childOrderIds?.length ?? 0) > 0 || + (resultFilledSize.isFinite() && resultFilledSize.gt(0)); if (dexName && transferInfo && !hasVenueExposure) { await this.#handleHip3OrderRollback({ dexName, transferInfo }); return result; @@ -5878,7 +5974,7 @@ export class HyperLiquidProvider implements PerpsProvider { context: StrategyPlacementContext, generation: number, ): Promise { - const { assetId, szDecimals, formattedSize, ladder, builder } = context; + const { assetId, formattedSize, ladder, builder } = context; // Built and validated in `#prepareStrategyPlacement`, before anything was // signed, so what is submitted here is exactly what the minimums were // applied to. @@ -5925,75 +6021,130 @@ export class HyperLiquidProvider implements PerpsProvider { const rawStatuses = result.response?.data?.statuses; const statuses = Array.isArray(rawStatuses) ? rawStatuses : []; - const outcomes = statuses - .slice(0, count) - .map((status) => this.#readOrderPlacementOutcome(status)); + const outcomes = statuses.slice(0, count).map(parseScaleBulkOrderStatus); const acceptedCount = outcomes.filter( - (outcome) => outcome !== undefined, + (outcome) => outcome?.kind === 'accepted', ).length; const acceptedRungs = outcomes.flatMap((outcome, index) => - outcome === undefined - ? [] - : [ + outcome?.kind === 'accepted' + ? [ { - orderId: outcome.orderId, - state: outcome.state, + outcome, + clientOrderId: clientOrderIds[index], price: prices[index], size: sizes[index], }, - ], + ] + : [], ); - const acceptedChildOrderIds = acceptedRungs.map((rung) => rung.orderId); const restingChildOrderIds = acceptedRungs.flatMap((rung) => - rung.state === 'resting' ? [rung.orderId] : [], + rung.outcome.state === 'resting' ? [rung.outcome.orderId] : [], ); - const filledChildOrderIds = acceptedRungs.flatMap((rung) => - rung.state === 'filled' ? [rung.orderId] : [], + const waitingClientOrderIds = acceptedRungs.flatMap((rung) => + rung.outcome.state === 'waitingForFill' || + rung.outcome.state === 'waitingForTrigger' + ? [rung.clientOrderId] + : [], ); - const buildAcceptedResult = (): OrderResult => { - const submittedSize = - acceptedCount === count - ? formattedSize - : formatHyperLiquidSize({ - size: acceptedRungs.reduce( - (total, rung) => total + parseFloat(rung.size), - 0, - ), - szDecimals, - }); - const submittedValue = acceptedRungs.reduce( - (total, rung) => total + parseFloat(rung.size) * parseFloat(rung.price), - 0, - ); + const acceptedChildren: ScaleOrderChild[] = acceptedRungs.map( + ({ outcome }) => + outcome.state === 'resting' || outcome.state === 'filled' + ? { orderId: outcome.orderId, state: outcome.state } + : { state: outcome.state }, + ); + const acceptedSize = acceptedRungs.reduce( + (total, rung) => total.plus(rung.size), + new BigNumber(0), + ); + const acceptedNotional = acceptedRungs.reduce( + (total, rung) => total.plus(new BigNumber(rung.size).times(rung.price)), + new BigNumber(0), + ); + const filledRungs = acceptedRungs.flatMap((rung) => + rung.outcome.state === 'filled' + ? [{ ...rung, outcome: rung.outcome }] + : [], + ); + const filledSize = filledRungs.reduce( + (total, rung) => total.plus(rung.outcome.filledSize), + new BigNumber(0), + ); + const executedNotional = filledRungs.reduce( + (total, rung) => + total.plus( + new BigNumber(rung.outcome.filledSize).times( + rung.outcome.averagePrice, + ), + ), + new BigNumber(0), + ); + const acceptedResult = { + childOrderIds: restingChildOrderIds, + submittedSize: formattedSize, + acceptedSize: acceptedSize.toFixed(), + ...(acceptedSize.gt(0) && { + weightedAverageLimitPrice: acceptedNotional + .dividedBy(acceptedSize) + .toFixed(), + }), + acceptedChildren, + ...(filledSize.gt(0) && { + filledSize: filledSize.toFixed(), + averagePrice: executedNotional.dividedBy(filledSize).toFixed(), + }), + } satisfies Partial; + const buildAcceptedResult = (): OrderResult => ({ + success: true, + orderId: groupId, + ...acceptedResult, + }); + const cancelAcceptedOrders = async (): Promise<{ + orderIds: string[]; + clientOrderIds: Hex[]; + }> => { + const [orderIds, pendingClientOrderIds] = await Promise.all([ + this.#cancelOrderRequests( + exchangeClient, + restingChildOrderIds.map((orderId) => ({ + a: assetId, + o: Number(orderId), + })), + ), + this.#cancelOrderCloidRequests( + exchangeClient, + waitingClientOrderIds.map((clientOrderId) => ({ + asset: assetId, + cloid: clientOrderId, + })), + ), + ]); return { - success: true, - orderId: groupId, - childOrderIds: acceptedChildOrderIds, - submittedSize, - averagePrice: String(submittedValue / parseFloat(submittedSize)), + orderIds: orderIds.map(String), + clientOrderIds: pendingClientOrderIds, }; }; if (generation !== this.#strategyGeneration) { - const remainingOrderIds = await this.#cancelOrderRequests( - exchangeClient, - restingChildOrderIds.map((orderId) => ({ - a: assetId, - o: Number(orderId), - })), - ); - const recoverableOrderIds = [ - ...filledChildOrderIds, - ...remainingOrderIds.map(String), - ]; + const remaining = await cancelAcceptedOrders(); + if ( + remaining.orderIds.length > 0 || + remaining.clientOrderIds.length > 0 + ) { + this.#scaleOrderGroups.set(groupId, { + symbol: params.symbol, + ...remaining, + }); + } return createErrorResult( new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE), { success: false, - submittedSize: formattedSize, - ...(recoverableOrderIds.length > 0 && { - childOrderIds: recoverableOrderIds, - }), + ...acceptedResult, + ...(remaining.orderIds.length > 0 || + remaining.clientOrderIds.length > 0 + ? { orderId: groupId } + : {}), + childOrderIds: remaining.orderIds, }, ); } @@ -6005,56 +6156,55 @@ export class HyperLiquidProvider implements PerpsProvider { ) { this.#deps.debugLogger.log('Scale ladder was not fully accepted', { accepted: acceptedCount, - filled: filledChildOrderIds.length, + filled: filledRungs.length, resting: restingChildOrderIds.length, requested: count, statuses, }); + const everyStatusClassified = + statuses.length === count && + outcomes.every((outcome) => outcome !== undefined); + const rejectedCount = outcomes.filter( + (outcome) => outcome?.kind === 'error', + ).length; const isValidPartial = result.status === 'ok' && - statuses.length === count && + everyStatusClassified && acceptedCount > 0 && - acceptedCount < count; + rejectedCount > 0; if (isValidPartial) { this.#scaleOrderGroups.set(groupId, { symbol: params.symbol, orderIds: restingChildOrderIds, + clientOrderIds: waitingClientOrderIds, }); return buildAcceptedResult(); } - const remainingOrderIds = await this.#cancelOrderRequests( - exchangeClient, - restingChildOrderIds.map((orderId) => ({ - a: assetId, - o: Number(orderId), - })), - ); - const recoverableOrderIds = [ - ...filledChildOrderIds, - ...remainingOrderIds.map(String), - ]; - if (remainingOrderIds.length > 0) { - const remainingRestingOrderIds = remainingOrderIds.map(String); + const remaining = await cancelAcceptedOrders(); + if ( + remaining.orderIds.length > 0 || + remaining.clientOrderIds.length > 0 + ) { this.#scaleOrderGroups.set(groupId, { symbol: params.symbol, - orderIds: remainingRestingOrderIds, + ...remaining, }); return createErrorResult( new Error(PERPS_ERROR_CODES.ORDER_STRATEGY_CANCEL_INCOMPLETE), { success: false, orderId: groupId, - childOrderIds: recoverableOrderIds, - submittedSize: formattedSize, + ...acceptedResult, + childOrderIds: remaining.orderIds, }, ); } - if (recoverableOrderIds.length > 0) { + if (acceptedCount > 0) { return createErrorResult(new Error(PERPS_ERROR_CODES.ORDER_REJECTED), { success: false, - childOrderIds: recoverableOrderIds, - submittedSize: formattedSize, + ...acceptedResult, + childOrderIds: [], }); } @@ -6064,6 +6214,7 @@ export class HyperLiquidProvider implements PerpsProvider { this.#scaleOrderGroups.set(groupId, { symbol: params.symbol, orderIds: restingChildOrderIds, + clientOrderIds: waitingClientOrderIds, }); return buildAcceptedResult(); } @@ -7667,9 +7818,15 @@ export class HyperLiquidProvider implements PerpsProvider { a: assetId, o: Number(orderId), })); - const remaining = ( - await this.#cancelOrderRequests(exchangeClient, cancelRequests) - ).map(String); + const cancelByCloidRequests = group.clientOrderIds.map((clientOrderId) => ({ + asset: assetId, + cloid: clientOrderId, + })); + const [remainingOrderIds, remainingClientOrderIds] = await Promise.all([ + this.#cancelOrderRequests(exchangeClient, cancelRequests), + this.#cancelOrderCloidRequests(exchangeClient, cancelByCloidRequests), + ]); + const remaining = remainingOrderIds.map(String); /* * A rung that filled or was cancelled individually comes back as a @@ -7677,7 +7834,7 @@ export class HyperLiquidProvider implements PerpsProvider { * distinguishes that result from a refusal while retaining every child * after a malformed or non-ok batch response. */ - if (remaining.length === 0) { + if (remaining.length === 0 && remainingClientOrderIds.length === 0) { this.#cancelledScaleOrderGroups.add(params.orderId); this.#scaleOrderGroups.delete(params.orderId); return { success: true, orderId: params.orderId }; @@ -7689,11 +7846,13 @@ export class HyperLiquidProvider implements PerpsProvider { this.#scaleOrderGroups.set(params.orderId, { symbol: group.symbol, orderIds: remaining, + clientOrderIds: remainingClientOrderIds, }); this.#deps.debugLogger.log('Scale group cancel left children resting', { groupId: params.orderId, - remaining: remaining.length, - total: group.orderIds.length, + remainingOrderIds: remaining.length, + remainingClientOrderIds: remainingClientOrderIds.length, + total: group.orderIds.length + group.clientOrderIds.length, }); return createErrorResult( @@ -7816,6 +7975,55 @@ export class HyperLiquidProvider implements PerpsProvider { .remainingOrderIds; } + /** + * Cancel pending orders by client order ID and retain every request that may + * still be live. + * + * @param exchangeClient - Client that owns the orders. + * @param requests - Venue cancel-by-CLOID requests. + * @returns Client order IDs that may still be pending. + */ + async #cancelOrderCloidRequests( + exchangeClient: ExchangeClient, + requests: ExchangeCancelByCloidRequest[], + ): Promise { + if (requests.length === 0) { + return []; + } + + const getRemainingClientOrderIds = (statuses: unknown[]): Hex[] => + requests.flatMap((request, index) => + classifyCancelStatus(statuses[index]) === CancelChildOutcome.Refused + ? [request.cloid] + : [], + ); + + try { + const result = await exchangeClient.cancelByCloid({ + cancels: requests, + }); + const statuses = result.response?.data?.statuses ?? []; + if (result.status !== 'ok' || statuses.length !== requests.length) { + return requests.map((request) => request.cloid); + } + + return getRemainingClientOrderIds(statuses); + } catch (error) { + const statuses = getCancelStatusesFromError(error, requests.length); + if (statuses) { + return getRemainingClientOrderIds(statuses); + } + this.#deps.debugLogger.log('Order cancellation by CLOID failed', { + error: ensureError( + error, + 'HyperLiquidProvider.cancelOrderCloidRequests', + ).message, + clientOrderIds: requests.map((request) => request.cloid), + }); + return requests.map((request) => request.cloid); + } + } + /** * Cancel a batch while distinguishing confirmed cancellations from orders * that were already gone. Replacement rollback may only restore the former. @@ -7836,17 +8044,7 @@ export class HyperLiquidProvider implements PerpsProvider { }; } - try { - const result = await exchangeClient.cancel({ cancels: requests }); - const statuses = result.response?.data?.statuses ?? []; - if (result.status !== 'ok' || statuses.length !== requests.length) { - return { - remainingOrderIds: requests.map((request) => request.o), - cancelledOrderIds: [], - responseComplete: false, - }; - } - + const classifyStatuses = (statuses: unknown[]): CancelOrderBatchOutcome => { const remainingOrderIds: number[] = []; const cancelledOrderIds: number[] = []; requests.forEach((request, index) => { @@ -7858,7 +8056,25 @@ export class HyperLiquidProvider implements PerpsProvider { } }); return { remainingOrderIds, cancelledOrderIds, responseComplete: true }; + }; + + try { + const result = await exchangeClient.cancel({ cancels: requests }); + const statuses = result.response?.data?.statuses ?? []; + if (result.status !== 'ok' || statuses.length !== requests.length) { + return { + remainingOrderIds: requests.map((request) => request.o), + cancelledOrderIds: [], + responseComplete: false, + }; + } + + return classifyStatuses(statuses); } catch (error) { + const statuses = getCancelStatusesFromError(error, requests.length); + if (statuses) { + return classifyStatuses(statuses); + } this.#deps.debugLogger.log('Order cancellation batch failed', { error: ensureError(error, 'HyperLiquidProvider.cancelOrderRequests') .message, @@ -10720,6 +10936,7 @@ export class HyperLiquidProvider implements PerpsProvider { const group = recovered.get(order.strategyGroupId) ?? { symbol: order.symbol, orderIds: [], + clientOrderIds: [], }; group.orderIds.push(order.orderId); recovered.set(order.strategyGroupId, group); diff --git a/packages/perps-controller/src/services/TradingService.ts b/packages/perps-controller/src/services/TradingService.ts index c1b5c15657..c8893c6217 100644 --- a/packages/perps-controller/src/services/TradingService.ts +++ b/packages/perps-controller/src/services/TradingService.ts @@ -189,7 +189,10 @@ export class TradingService { ? PERPS_EVENT_VALUE.STATUS.EXECUTED : PERPS_EVENT_VALUE.STATUS.FAILED; const trackedOrderSize = parseFloat( - result?.filledSize ?? result?.submittedSize ?? params.size, + result?.filledSize ?? + result?.acceptedSize ?? + result?.submittedSize ?? + params.size, ); // Build base properties @@ -220,8 +223,13 @@ export class TradingService { } // Trigger limit placements carry a real limit price too, so the companion // property must not go missing when order_type is stop_limit/take_profit_limit. - if (isLimitExecutionOrderType(params.orderType) && params.price) { - properties[PERPS_EVENT_PROPERTY.LIMIT_PRICE] = parseFloat(params.price); + const limitPrice = result?.weightedAverageLimitPrice ?? params.price; + if ( + limitPrice && + (result?.weightedAverageLimitPrice || + isLimitExecutionOrderType(params.orderType)) + ) { + properties[PERPS_EVENT_PROPERTY.LIMIT_PRICE] = parseFloat(limitPrice); } if (params.trackingData?.source) { properties[PERPS_EVENT_PROPERTY.SOURCE] = params.trackingData.source; @@ -254,9 +262,13 @@ export class TradingService { const assetPrice = result?.averagePrice ? parseFloat(result.averagePrice) : params.trackingData?.marketPrice; - if (assetPrice && trackedOrderSize) { + let orderValuePrice = assetPrice; + if (!result?.averagePrice && result?.weightedAverageLimitPrice) { + orderValuePrice = parseFloat(result.weightedAverageLimitPrice); + } + if (orderValuePrice && trackedOrderSize) { properties[PERPS_EVENT_PROPERTY.ORDER_VALUE] = - trackedOrderSize * assetPrice; + trackedOrderSize * orderValuePrice; } // Add success-specific properties @@ -317,42 +329,43 @@ export class TradingService { // Emit an additional partially filled trade event when the fill is partial, // mirroring the close path so the fill's partiality is visible in analytics // rather than hidden behind a status=executed event. Classification is based - // on the provider's final submitted size (post precision rounding, USD - // recalculation, and $10-minimum retry), not the caller's pre-normalization - // params.size — the provider transforms the size before submission and a + // on the provider's accepted size, falling back to its final submitted size + // when no accepted-size distinction applies. This avoids counting rejected + // Scale rungs as unfilled exposure. Both values are post-normalization, so a // complete fill of the normalized size must not look partial. When the - // provider did not report a submitted size we do not classify (rather than - // guess from params.size). The partial event mirrors the close schema: - // order_size = submitted size, amount_filled = filled, remaining = the rest. + // provider reports neither value we do not classify rather than guess from + // params.size. The partial event mirrors the close schema: order_size = + // accepted size, amount_filled = filled, remaining = the rest. // Compare and subtract the decimal size strings with arbitrary-precision // math (BigNumber): routing them through parseFloat can introduce // binary-float artifacts that collapse distinct values (misclassifying the // fill) or leave e-17 dust in remaining_amount. Only convert to Number for // the emitted analytics values, after the exact decimal subtraction. - const submittedSize = - result?.submittedSize === undefined + const acceptedSizeString = result?.acceptedSize ?? result?.submittedSize; + const acceptedSize = + acceptedSizeString === undefined ? undefined - : new BigNumber(result.submittedSize); + : new BigNumber(acceptedSizeString); const filledSize = result?.filledSize === undefined ? undefined : new BigNumber(result.filledSize); if ( result?.success === true && - submittedSize !== undefined && + acceptedSize !== undefined && filledSize !== undefined && - submittedSize.isFinite() && + acceptedSize.isFinite() && filledSize.isFinite() && filledSize.gt(0) && - filledSize.lt(submittedSize) + filledSize.lt(acceptedSize) ) { this.#deps.metrics.trackPerpsEvent(PerpsAnalyticsEvent.TradeTransaction, { ...properties, [PERPS_EVENT_PROPERTY.STATUS]: PERPS_EVENT_VALUE.STATUS.PARTIALLY_FILLED, - [PERPS_EVENT_PROPERTY.ORDER_SIZE]: submittedSize.toNumber(), + [PERPS_EVENT_PROPERTY.ORDER_SIZE]: acceptedSize.toNumber(), [PERPS_EVENT_PROPERTY.AMOUNT_FILLED]: filledSize.toNumber(), - [PERPS_EVENT_PROPERTY.REMAINING_AMOUNT]: submittedSize + [PERPS_EVENT_PROPERTY.REMAINING_AMOUNT]: acceptedSize .minus(filledSize) .toNumber(), }); diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index 1fcce39ceb..a51f637cb6 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -307,6 +307,16 @@ export type OrderParams = { providerId?: PerpsProviderType; }; +export type ScaleOrderChild = + | { + state: 'resting' | 'filled'; + orderId: string; + } + | { + state: 'waitingForFill' | 'waitingForTrigger'; + orderId?: never; + }; + export type OrderResult = { success?: boolean; /** @@ -326,29 +336,39 @@ export type OrderResult = { orderId?: string; error?: string; filledSize?: string; // Amount filled - // Final normalized size actually submitted to the exchange (post precision - // rounding, USD recalculation, and any $10-minimum retry). Present only when - // the provider reached submission; used to classify partial fills against the - // real submitted size rather than the caller's pre-normalization params.size. + // Full normalized size submitted to the exchange (post precision rounding, + // USD recalculation, and any $10-minimum retry). Present only when the + // provider reached submission. submittedSize?: string; - averagePrice?: string; // Average execution price + // Normalized size of the submitted rungs that the exchange accepted. This + // differs from `submittedSize` when a non-atomic Scale batch is partly + // rejected. + acceptedSize?: string; + // Size-weighted average execution price. Present only when the result + // includes fills. + averagePrice?: string; + // Size-weighted limit price of accepted Scale rungs. This is a submission + // price, not an execution price. + weightedAverageLimitPrice?: string; + // Every accepted child of a Scale batch and its immediate placement state. + // Waiting children do not carry an exchange order ID; cancel the Scale handle + // to cancel both resting and waiting children. + acceptedChildren?: ScaleOrderChild[]; // Exchange IDs tied to a multi-order or recovery result. On a successful // strategy placement, `orderId` carries the strategy handle and these IDs // identify its individual children. // - // For a `scale` ladder these identify every accepted rung, including fills. - // Only resting IDs are cancellable and retained by the strategy handle. Scale - // rungs are never replaced, so a resting ID stays valid after the handle is - // gone. For a `chase` this is only the order resting at placement time — the + // For a `scale` ladder these identify only resting, cancellable rungs. Every + // accepted rung and its state is reported in `acceptedChildren`. Scale rungs + // are never replaced, so a resting ID stays valid after the handle is gone. + // For a `chase` this is only the order resting at placement time — the // strategy cancels and re-places as the touch moves, and each replacement has // a new ID that is held in the session rather than reported here, so the value // goes stale on the first re-price. Cancel a live chase by its handle. // - // Scale success and failure results can mix filled IDs with orders that may - // still rest, so a caller must not blindly cancel every ID. When TP/SL - // protection cannot be fully restored, these identify the old orders that - // survived, may still be live when reconciliation failed, or were recreated; - // an empty array means none are known or potentially live. + // When TP/SL protection cannot be fully restored, these identify the old + // orders that survived, may still be live when reconciliation failed, or were + // recreated; an empty array means none are known or potentially live. childOrderIds?: string[]; providerId?: PerpsProviderType; // Multi-provider: which provider executed this order (injected by aggregator) }; diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts index 5919f906a0..a832628b35 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts @@ -348,6 +348,16 @@ const createMockExchangeClient = (overrides: MockClient = {}): MockClient => ({ status: 'ok', response: { data: { statuses: ['success'] } }, }), + cancelByCloid: jest + .fn() + .mockImplementation((request: { cancels: unknown[] }) => + Promise.resolve({ + status: 'ok', + response: { + data: { statuses: request.cancels.map(() => 'success') }, + }, + }), + ), withdraw3: jest.fn().mockResolvedValue({ status: 'ok', }), @@ -2885,8 +2895,15 @@ describe('HyperLiquidProvider - strategy order types', () => { expect(result.success).toBe(true); expect(result.childOrderIds).toStrictEqual(['11', '22', '33']); + expect(result.acceptedChildren).toStrictEqual([ + { orderId: '11', state: 'resting' }, + { orderId: '22', state: 'resting' }, + { orderId: '33', state: 'resting' }, + ]); expect(result.submittedSize).toBe('1'); - expect(result.averagePrice).toBe('2499.95'); + expect(result.acceptedSize).toBe('1'); + expect(result.weightedAverageLimitPrice).toBe('2499.95'); + expect(result.averagePrice).toBeUndefined(); expect(result.orderId).toMatch(/^scale:/u); }); @@ -2967,7 +2984,9 @@ describe('HyperLiquidProvider - strategy order types', () => { response: { data: { statuses: [ - { filled: { oid: 11 } }, + { + filled: { oid: 11, avgPx: '2050', totalSz: '0.2' }, + }, { resting: { oid: 22 } }, { error: 'Insufficient margin' }, ], @@ -2989,8 +3008,11 @@ describe('HyperLiquidProvider - strategy order types', () => { expect(placed).toMatchObject({ success: false, error: PERPS_ERROR_CODES.ORDER_STRATEGY_CANCEL_INCOMPLETE, - childOrderIds: ['11', '22'], + childOrderIds: ['22'], submittedSize: '1', + acceptedSize: '0.6667', + filledSize: '0.2', + averagePrice: '2050', }); expect(placed.orderId).toMatch(/^scale:/u); if (!placed.orderId) { @@ -3165,6 +3187,84 @@ describe('HyperLiquidProvider - strategy order types', () => { }); }); + it('finishes cancellation when a waiting child gains an order ID', async () => { + const cancelResponse = { + status: 'ok' as const, + response: { + type: 'cancel' as const, + data: { + statuses: [ + { + error: 'Order was never placed, already canceled, or filled.', + }, + ], + }, + }, + }; + const { exchangeClient } = useStrategyClients({ + exchange: { + order: jest.fn().mockResolvedValue({ + status: 'ok', + response: { + data: { + statuses: [ + 'waitingForFill', + { error: 'Insufficient margin' }, + { error: 'Insufficient margin' }, + ], + }, + }, + }), + cancel: jest + .fn() + .mockRejectedValue(new TestApiRequestError(cancelResponse)), + }, + }); + const placed = await provider.placeOrder({ + ...baseOrder, + orderType: 'scale', + scaleMinPrice: '2000', + scaleMaxPrice: '3000', + scaleNumOrders: 3, + } satisfies OrderParams); + if (!placed.orderId) { + throw new Error('Expected a Scale group handle'); + } + + mockSubscriptionService.getOrdersCacheIfInitialized.mockReturnValue([ + { + orderId: '44', + symbol: 'ETH', + side: 'buy', + orderType: 'limit', + size: '0.3334', + originalSize: '0.3334', + price: '2000', + filledSize: '0', + remainingSize: '0.3334', + status: 'open', + timestamp: 1_700_000_000_000, + strategyGroupId: placed.orderId, + }, + ]); + await provider.getOpenOrders(); + + const cancelled = await provider.cancelOrder({ + orderId: placed.orderId, + symbol: 'ETH', + orderType: 'scale', + }); + + expect(cancelled).toStrictEqual({ + success: true, + orderId: placed.orderId, + }); + expect(exchangeClient.cancel).toHaveBeenCalledWith({ + cancels: [{ a: 1, o: 44 }], + }); + expect(exchangeClient.cancelByCloid).toHaveBeenCalledTimes(1); + }); + it('adds later Scale rungs to a partially recovered group', async () => { const { exchangeClient } = useStrategyClients({ exchange: { @@ -4768,6 +4868,142 @@ describe('HyperLiquidProvider - strategy order types', () => { }, }; + const mixedStatuses = [ + { resting: { oid: 11 } }, + 'waitingForFill', + { filled: { oid: 33, avgPx: '2400', totalSz: '0.1' } }, + 'waitingForTrigger', + { filled: { oid: 55, avgPx: '2800', totalSz: '0.05' } }, + { error: 'Insufficient margin' }, + ]; + + it.each(['resolved', 'thrown'] as const)( + 'classifies every accepted status in a mixed %s response', + async (responseKind) => { + const response = { + status: 'ok' as const, + response: { + type: 'order' as const, + data: { statuses: mixedStatuses }, + }, + }; + const order = + responseKind === 'resolved' + ? jest.fn().mockResolvedValue(response) + : jest + .fn() + .mockRejectedValue( + new TestApiRequestError( + response, + 'order 5: Insufficient margin', + ), + ); + const { exchangeClient } = useStrategyClients({ + exchange: { order }, + }); + + const placed = await provider.placeOrder({ + ...baseOrder, + orderType: 'scale', + scaleMinPrice: '2000', + scaleMaxPrice: '3000', + scaleNumOrders: 6, + } satisfies OrderParams); + + expect(placed).toMatchObject({ + success: true, + childOrderIds: ['11'], + acceptedChildren: [ + { orderId: '11', state: 'resting' }, + { state: 'waitingForFill' }, + { orderId: '33', state: 'filled' }, + { state: 'waitingForTrigger' }, + { orderId: '55', state: 'filled' }, + ], + submittedSize: '1', + acceptedSize: '0.8334', + filledSize: '0.15', + averagePrice: '2533.33333333333333333333', + weightedAverageLimitPrice: '2399.80801535877129829614', + }); + + const cancelled = await provider.cancelOrder({ + orderId: placed.orderId, + symbol: 'ETH', + orderType: 'scale', + }); + + expect(cancelled.success).toBe(true); + expect(exchangeClient.cancel).toHaveBeenCalledWith({ + cancels: [{ a: 1, o: 11 }], + }); + const submittedOrders = order.mock.calls[0][0].orders; + expect(exchangeClient.cancelByCloid).toHaveBeenCalledWith({ + cancels: [ + { asset: 1, cloid: submittedOrders[1].c }, + { asset: 1, cloid: submittedOrders[3].c }, + ], + }); + }, + ); + + it('finishes waiting-child cancellation when the SDK throws already-gone statuses', async () => { + const response = { + status: 'ok' as const, + response: { + type: 'order' as const, + data: { + statuses: [ + 'waitingForFill', + 'waitingForTrigger', + { error: 'Insufficient margin' }, + ], + }, + }, + }; + const cancelResponse = { + status: 'ok' as const, + response: { + type: 'cancel' as const, + data: { + statuses: [ + { + error: 'Order was never placed, already canceled, or filled.', + }, + 'success', + ], + }, + }, + }; + const { exchangeClient } = useStrategyClients({ + exchange: { + order: jest.fn().mockResolvedValue(response), + cancelByCloid: jest + .fn() + .mockRejectedValue(new TestApiRequestError(cancelResponse)), + }, + }); + + const placed = await provider.placeOrder({ + ...baseOrder, + orderType: 'scale', + scaleMinPrice: '2000', + scaleMaxPrice: '3000', + scaleNumOrders: 3, + } satisfies OrderParams); + const cancelled = await provider.cancelOrder({ + orderId: placed.orderId, + symbol: 'ETH', + orderType: 'scale', + }); + + expect(cancelled).toMatchObject({ + success: true, + orderId: placed.orderId, + }); + expect(exchangeClient.cancelByCloid).toHaveBeenCalledTimes(1); + }); + it('recovers a mixed Scale response thrown by the SDK', async () => { const response = { status: 'ok' as const, @@ -4776,7 +5012,7 @@ describe('HyperLiquidProvider - strategy order types', () => { data: { statuses: [ { resting: { oid: 11 } }, - { filled: { oid: 22 } }, + { filled: { oid: 22, avgPx: '2475', totalSz: '0.2' } }, { error: 'Insufficient margin' }, ], }, @@ -4806,9 +5042,16 @@ describe('HyperLiquidProvider - strategy order types', () => { expect(placed).toMatchObject({ success: true, - childOrderIds: ['11', '22'], - submittedSize: '0.6667', - averagePrice: '2249.962501874906', + childOrderIds: ['11'], + acceptedChildren: [ + { orderId: '11', state: 'resting' }, + { orderId: '22', state: 'filled' }, + ], + submittedSize: '1', + acceptedSize: '0.6667', + filledSize: '0.2', + averagePrice: '2475', + weightedAverageLimitPrice: '2249.96250187490625468727', }); expect( @@ -4825,11 +5068,11 @@ describe('HyperLiquidProvider - strategy order types', () => { it.each([ [ - 'waiting status', + 'unknown status', [ { resting: { oid: 11 } }, { error: 'Insufficient margin' }, - 'waitingForFill', + 'unknownStatus', ], ], [ @@ -4971,9 +5214,11 @@ describe('HyperLiquidProvider - strategy order types', () => { expect(result).toMatchObject({ success: true, childOrderIds: ['11', '33'], - submittedSize: '0.6667', - averagePrice: '2499.9250037498123', + submittedSize: '1', + acceptedSize: '0.6667', + weightedAverageLimitPrice: '2499.92500374981250937453', }); + expect(result.averagePrice).toBeUndefined(); expect(exchangeClient.cancel).not.toHaveBeenCalled(); }); @@ -4985,7 +5230,9 @@ describe('HyperLiquidProvider - strategy order types', () => { response: { data: { statuses: [ - { filled: { oid: 11 } }, + { + filled: { oid: 11, avgPx: '2010', totalSz: '0.25' }, + }, { error: 'Insufficient margin' }, { resting: { oid: 33 } }, ], @@ -5009,9 +5256,16 @@ describe('HyperLiquidProvider - strategy order types', () => { expect(result).toMatchObject({ success: true, - childOrderIds: ['11', '33'], - submittedSize: '0.6667', - averagePrice: '2499.9250037498123', + childOrderIds: ['33'], + acceptedChildren: [ + { orderId: '11', state: 'filled' }, + { orderId: '33', state: 'resting' }, + ], + submittedSize: '1', + acceptedSize: '0.6667', + filledSize: '0.25', + averagePrice: '2010', + weightedAverageLimitPrice: '2499.92500374981250937453', }); const cancelled = await provider.cancelOrder({ @@ -5091,7 +5345,9 @@ describe('HyperLiquidProvider - strategy order types', () => { data: { statuses: [ { resting: { oid: 11 } }, - { filled: { oid: 22 } }, + { + filled: { oid: 22, avgPx: '2525', totalSz: '0.3' }, + }, { resting: { oid: 33 } }, ], }, @@ -5114,9 +5370,17 @@ describe('HyperLiquidProvider - strategy order types', () => { expect(placed).toMatchObject({ success: true, - childOrderIds: ['11', '22', '33'], + childOrderIds: ['11', '33'], + acceptedChildren: [ + { orderId: '11', state: 'resting' }, + { orderId: '22', state: 'filled' }, + { orderId: '33', state: 'resting' }, + ], submittedSize: '1', - averagePrice: '2499.95', + acceptedSize: '1', + filledSize: '0.3', + averagePrice: '2525', + weightedAverageLimitPrice: '2499.95', }); const cancelled = await provider.cancelOrder({ @@ -5140,7 +5404,7 @@ describe('HyperLiquidProvider - strategy order types', () => { ['unsafe', Number.MAX_SAFE_INTEGER + 1], ['non-numeric', '22'], ])( - 'ignores a %s scale order ID while preserving valid rungs', + 'rejects a malformed %s scale order ID instead of treating it as a rejected rung', async (_label, oid) => { const { exchangeClient } = useStrategyClients({ exchange: { @@ -5168,12 +5432,14 @@ describe('HyperLiquidProvider - strategy order types', () => { } satisfies OrderParams); expect(placed).toMatchObject({ - success: true, - childOrderIds: ['11'], - submittedSize: '0.3334', - averagePrice: '2000', + success: false, + error: PERPS_ERROR_CODES.ORDER_REJECTED, + childOrderIds: [], + submittedSize: '1', + }); + expect(exchangeClient.cancel).toHaveBeenCalledWith({ + cancels: [{ a: 1, o: 11 }], }); - expect(exchangeClient.cancel).not.toHaveBeenCalled(); }, ); }); @@ -8750,7 +9016,7 @@ describe('HyperLiquidProvider - strategy order types', () => { data: { statuses: [ { resting: { oid: 11 } }, - { filled: { oid: 22 } }, + { filled: { oid: 22, avgPx: '2500', totalSz: '0.3333' } }, { resting: { oid: 33 } }, ], }, @@ -8786,7 +9052,12 @@ describe('HyperLiquidProvider - strategy order types', () => { submittedSize: '1', }); expect(placed.orderId).toBeUndefined(); - expect(placed.childOrderIds).toStrictEqual(['22']); + expect(placed.childOrderIds).toStrictEqual([]); + expect(placed.acceptedChildren).toStrictEqual([ + { orderId: '11', state: 'resting' }, + { orderId: '22', state: 'filled' }, + { orderId: '33', state: 'resting' }, + ]); }); }); diff --git a/packages/perps-controller/tests/src/services/TradingService.test.ts b/packages/perps-controller/tests/src/services/TradingService.test.ts index 187cad0b8a..e45df8e9f3 100644 --- a/packages/perps-controller/tests/src/services/TradingService.test.ts +++ b/packages/perps-controller/tests/src/services/TradingService.test.ts @@ -411,7 +411,7 @@ describe('TradingService', () => { ); }); - it('tracks accepted Scale size and weighted average price', async () => { + it('tracks accepted Scale size and weighted limit price separately from execution price', async () => { const orderParams: OrderParams = { symbol: 'ETH', isBuy: true, @@ -425,8 +425,9 @@ describe('TradingService', () => { mockProvider.placeOrder.mockResolvedValue({ success: true, orderId: 'scale:group', - submittedSize: '0.6667', - averagePrice: '2499.9250037498123', + submittedSize: '1', + acceptedSize: '0.6667', + weightedAverageLimitPrice: '2499.9250037498123', }); await tradingService.placeOrder({ @@ -440,7 +441,8 @@ describe('TradingService', () => { PerpsAnalyticsEvent.TradeTransaction, expect.objectContaining({ order_size: 0.6667, - asset_price: 2499.9250037498123, + asset_price: 3000, + limit_price: 2499.9250037498123, }), ); const resultProperties = @@ -448,6 +450,45 @@ describe('TradingService', () => { expect(resultProperties.order_value).toBeCloseTo(1666.7); }); + it('pairs filled Scale size with average execution price for order value', async () => { + mockProvider.placeOrder.mockResolvedValue({ + success: true, + orderId: 'scale:group', + submittedSize: '1', + acceptedSize: '0.8334', + filledSize: '0.15', + averagePrice: '2533.33333333333333333333', + weightedAverageLimitPrice: '2399.80801535877129829614', + }); + + await tradingService.placeOrder({ + provider: mockProvider, + params: { + symbol: 'ETH', + isBuy: true, + size: '1', + orderType: 'scale', + scaleMinPrice: '2000', + scaleMaxPrice: '3000', + scaleNumOrders: 6, + trackingData: { marketPrice: 3000 }, + }, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + const resultProperties = + mockDeps.metrics.trackPerpsEvent.mock.calls[2][1]; + expect(resultProperties).toEqual( + expect.objectContaining({ + order_size: 0.15, + asset_price: 2533.3333333333335, + }), + ); + expect(resultProperties.limit_price).toBeCloseTo(2399.8080153587714); + expect(resultProperties.order_value).toBeCloseTo(380); + }); + it('includes trade_with_token and mm_pay fields when trackingData has tradeWithToken and pay token/network', async () => { const orderParams: OrderParams = { symbol: 'BTC', @@ -3088,7 +3129,7 @@ describe('TradingService', () => { }); describe('partial fill on open trade', () => { - it('emits an additional partially_filled trade event with order_size, amount_filled, and remaining_amount from the submitted size', async () => { + it('emits an additional partially_filled trade event with order_size, amount_filled, and remaining_amount from the accepted size', async () => { mockProvider.placeOrder.mockResolvedValue({ success: true, orderId: 'order-1', @@ -3129,6 +3170,45 @@ describe('TradingService', () => { ).toBeDefined(); }); + it('does not count rejected Scale rungs as remaining fill exposure', async () => { + mockProvider.placeOrder.mockResolvedValue({ + success: true, + orderId: 'scale:group', + filledSize: '4', + submittedSize: '10', + acceptedSize: '6', + averagePrice: '50000', + }); + + await tradingService.placeOrder({ + provider: mockProvider, + params: { + symbol: 'BTC', + isBuy: true, + size: '10', + orderType: 'scale', + scaleMinPrice: '49000', + scaleMaxPrice: '51000', + scaleNumOrders: 3, + }, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect( + findCall( + PerpsAnalyticsEvent.TradeTransaction, + 'partially_filled', + )?.[1], + ).toEqual( + expect.objectContaining({ + order_size: 6, + amount_filled: 4, + remaining_amount: 2, + }), + ); + }); + it('does not emit a partially_filled event on a complete fill of the normalized submitted size', async () => { // The provider rounds the requested size (params.size = 10) down to the // normalized size it actually submits (9.99) and that fills completely. From da15149f44412e66202364cb30e1989f0f442ac7 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Fri, 28 Aug 2026 16:16:40 +0800 Subject: [PATCH 8/8] fix(perps): clean up unclassified Scale rungs --- packages/perps-controller/CHANGELOG.md | 2 +- .../src/providers/HyperLiquidProvider.ts | 17 ++- ...yperLiquidProvider.strategy-orders.test.ts | 135 ++++++++++++++++++ 3 files changed, 148 insertions(+), 6 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index e00a9a549c..1776f322fe 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Preserve every accepted HyperLiquid Scale rung after a partial batch rejection, including `waitingForFill` and `waitingForTrigger` statuses and responses wrapped in `ApiRequestError`; keep `childOrderIds` limited to resting orders, expose each accepted status through `acceptedChildren`, distinguish `acceptedSize` from the full `submittedSize`, and report `weightedAverageLimitPrice` separately from the fill-weighted `averagePrice` ([#9989](https://github.com/MetaMask/core/pull/9989)) +- Preserve every accepted HyperLiquid Scale rung after a partial batch rejection, including `waitingForFill` and `waitingForTrigger` statuses and responses wrapped in `ApiRequestError`; cancel unclassified rungs by client order ID before returning failure; keep `childOrderIds` limited to resting orders; expose each accepted status through `acceptedChildren`; distinguish `acceptedSize` from the full `submittedSize`; and report `weightedAverageLimitPrice` separately from the fill-weighted `averagePrice` ([#9989](https://github.com/MetaMask/core/pull/9989)) ## [13.1.0] diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index dba34535c6..d6f9257ec1 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -6021,7 +6021,9 @@ export class HyperLiquidProvider implements PerpsProvider { const rawStatuses = result.response?.data?.statuses; const statuses = Array.isArray(rawStatuses) ? rawStatuses : []; - const outcomes = statuses.slice(0, count).map(parseScaleBulkOrderStatus); + const outcomes = Array.from({ length: count }, (_unused, index) => + parseScaleBulkOrderStatus(statuses[index]), + ); const acceptedCount = outcomes.filter( (outcome) => outcome?.kind === 'accepted', ).length; @@ -6046,6 +6048,11 @@ export class HyperLiquidProvider implements PerpsProvider { ? [rung.clientOrderId] : [], ); + const cleanupClientOrderIds = outcomes.flatMap((outcome, index) => + outcome?.kind === 'error' || outcome?.state === 'resting' + ? [] + : [clientOrderIds[index]], + ); const acceptedChildren: ScaleOrderChild[] = acceptedRungs.map( ({ outcome }) => outcome.state === 'resting' || outcome.state === 'filled' @@ -6098,7 +6105,7 @@ export class HyperLiquidProvider implements PerpsProvider { orderId: groupId, ...acceptedResult, }); - const cancelAcceptedOrders = async (): Promise<{ + const cancelNonRejectedOrders = async (): Promise<{ orderIds: string[]; clientOrderIds: Hex[]; }> => { @@ -6112,7 +6119,7 @@ export class HyperLiquidProvider implements PerpsProvider { ), this.#cancelOrderCloidRequests( exchangeClient, - waitingClientOrderIds.map((clientOrderId) => ({ + cleanupClientOrderIds.map((clientOrderId) => ({ asset: assetId, cloid: clientOrderId, })), @@ -6125,7 +6132,7 @@ export class HyperLiquidProvider implements PerpsProvider { }; if (generation !== this.#strategyGeneration) { - const remaining = await cancelAcceptedOrders(); + const remaining = await cancelNonRejectedOrders(); if ( remaining.orderIds.length > 0 || remaining.clientOrderIds.length > 0 @@ -6180,7 +6187,7 @@ export class HyperLiquidProvider implements PerpsProvider { }); return buildAcceptedResult(); } - const remaining = await cancelAcceptedOrders(); + const remaining = await cancelNonRejectedOrders(); if ( remaining.orderIds.length > 0 || remaining.clientOrderIds.length > 0 diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts index a832628b35..dd4eef13d9 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts @@ -5004,6 +5004,141 @@ describe('HyperLiquidProvider - strategy order types', () => { expect(exchangeClient.cancelByCloid).toHaveBeenCalledTimes(1); }); + it.each([ + [ + 'future status', + [ + { resting: { oid: 11 } }, + { scheduled: { oid: 22 } }, + { error: 'Insufficient margin' }, + ], + [1], + [11], + ], + [ + 'malformed status', + [ + { resting: { oid: 11 } }, + { filled: { oid: '22' } }, + { error: 'Insufficient margin' }, + ], + [1], + [11], + ], + [ + 'multi-key hybrid status', + [ + { resting: { oid: 11 } }, + { resting: { oid: 22 }, error: 'Unknown status' }, + { error: 'Insufficient margin' }, + ], + [1], + [11], + ], + ['truncated status array', [{ resting: { oid: 11 } }], [1, 2], [11]], + ['malformed status payload', { resting: { oid: 11 } }, [0, 1, 2], []], + ])( + 'cancels every unclassified rung by CLOID for a %s', + async (_label, statuses, unclassifiedIndexes, restingOrderIds) => { + const order = jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses } }, + }); + const { exchangeClient } = useStrategyClients({ + exchange: { order }, + }); + + const placed = await provider.placeOrder({ + ...baseOrder, + orderType: 'scale', + scaleMinPrice: '2000', + scaleMaxPrice: '3000', + scaleNumOrders: 3, + } satisfies OrderParams); + + expect(placed).toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.ORDER_REJECTED, + }); + const submittedOrders = order.mock.calls[0][0].orders; + expect(exchangeClient.cancelByCloid).toHaveBeenCalledWith({ + cancels: unclassifiedIndexes.map((index) => ({ + asset: 1, + cloid: submittedOrders[index].c, + })), + }); + expect(exchangeClient.cancel.mock.calls[0]?.[0]).toStrictEqual( + restingOrderIds.length > 0 + ? { + cancels: restingOrderIds.map((orderId) => ({ + a: 1, + o: orderId, + })), + } + : undefined, + ); + }, + ); + + it('keeps an unclassified rung retryable when CLOID cleanup is refused', async () => { + const order = jest.fn().mockResolvedValue({ + status: 'ok', + response: { + data: { + statuses: [ + { error: 'Insufficient margin' }, + { scheduled: { oid: 22 } }, + { error: 'Insufficient margin' }, + ], + }, + }, + }); + const cancelByCloid = jest + .fn() + .mockResolvedValueOnce({ + status: 'ok', + response: { data: { statuses: [{ error: 'Invalid nonce' }] } }, + }) + .mockResolvedValueOnce({ + status: 'ok', + response: { data: { statuses: ['success'] } }, + }); + const { exchangeClient } = useStrategyClients({ + exchange: { order, cancelByCloid }, + }); + + const placed = await provider.placeOrder({ + ...baseOrder, + orderType: 'scale', + scaleMinPrice: '2000', + scaleMaxPrice: '3000', + scaleNumOrders: 3, + } satisfies OrderParams); + + expect(placed).toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.ORDER_STRATEGY_CANCEL_INCOMPLETE, + childOrderIds: [], + }); + expect(placed.orderId).toMatch(/^scale:/u); + const submittedOrders = order.mock.calls[0][0].orders; + expect(cancelByCloid).toHaveBeenNthCalledWith(1, { + cancels: [{ asset: 1, cloid: submittedOrders[1].c }], + }); + + const retried = await provider.cancelOrder({ + orderId: placed.orderId, + symbol: 'ETH', + orderType: 'scale', + }); + + expect(retried).toMatchObject({ success: true, orderId: placed.orderId }); + expect(cancelByCloid).toHaveBeenNthCalledWith(2, { + cancels: [{ asset: 1, cloid: submittedOrders[1].c }], + }); + expect(exchangeClient.cancel).not.toHaveBeenCalled(); + }); + it('recovers a mixed Scale response thrown by the SDK', async () => { const response = { status: 'ok' as const,