From e98f91ba5dac3e3435be83fc2a6633353f22bd90 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Wed, 30 Sep 2026 07:32:53 +0800 Subject: [PATCH 1/3] fix(perps-controller): keep open position margin mode on HyperLiquid orders --- packages/perps-controller/CHANGELOG.md | 1 + .../src/providers/HyperLiquidProvider.ts | 37 +++++- ...yperLiquidProvider.advanced-orders.test.ts | 111 ++++++++++++++++++ 3 files changed, 148 insertions(+), 1 deletion(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 13aaab10936..96135be9b82 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -51,6 +51,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Before, they failed with the SDK's "Failed to sign the typed data using the wallet" message, or with `TPSL_UPDATE_FAILED` for a TP/SL update whose builder fee was not approved yet - Covers orders, edits, single and batch cancels (TWAP, scale and chase cancels included), position closes, TP/SL updates and clears, margin updates, withdrawals and transfers between DEXs, including the HIP-3 transfers around an order - HyperLiquid `cancelOrders` reports each order of a batch with its own result when an entry fails: orders the venue cancelled are no longer reported as failed with the batch's error ([#10559](https://github.com/MetaMask/core/pull/10559)) +- HyperLiquid orders that set leverage without `marginMode` now keep the open position's margin mode instead of switching to isolated, so flipping a Cross position no longer fails with "Cannot switch leverage type with open position" ([#PR_NUMBER](https://github.com/MetaMask/core/pull/PR_NUMBER)) ## [18.0.1] diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 56a590c7751..dac778f9a4a 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -5697,12 +5697,15 @@ export class HyperLiquidProvider implements PerpsProvider { async #prepareAssetForTrading( params: PrepareAssetForTradingParams, ): Promise { - const { symbol, assetId, leverage, marginMode = 'isolated' } = params; + const { symbol, assetId, leverage } = params; if (!leverage) { return; } + const marginMode = + params.marginMode ?? (await this.#resolveDefaultMarginMode(symbol)); + this.#deps.debugLogger.log('Updating leverage before order:', { symbol, assetId, @@ -5729,6 +5732,38 @@ export class HyperLiquidProvider implements PerpsProvider { }); } + /** + * Resolve the margin mode for an order that omits one. HyperLiquid refuses + * to switch mode while a position is open, so an open position's mode wins + * over the isolated default (e.g. flipping a Cross position). + * + * @param symbol - Market symbol, including its DEX route when applicable. + * @returns The open position's mode, or isolated when there is none or it + * cannot be read. + */ + async #resolveDefaultMarginMode(symbol: string): Promise { + const { dex: dexName } = parseAssetName(symbol); + try { + const positions = await this.#getPositionsForOperation(dexName ?? ''); + return ( + positions.find((position) => position.symbol === symbol)?.leverage + .type ?? 'isolated' + ); + } catch (error) { + this.#deps.debugLogger.log( + 'Open position margin mode unavailable: defaulting to isolated', + { + symbol, + error: ensureError( + error, + 'HyperLiquidProvider.resolveDefaultMarginMode', + ).message, + }, + ); + return 'isolated'; + } + } + /** * Handles HIP-3 pre-order balance management * diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.advanced-orders.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.advanced-orders.test.ts index 246110967aa..f04491eccc6 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.advanced-orders.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.advanced-orders.test.ts @@ -22,6 +22,7 @@ import { validateOrderParams, validateWithdrawalParams, } from '../../../src/utils/hyperLiquidValidation.js'; +import { createMockPosition } from '../../helpers/providerMocks.js'; import { createMockInfrastructure, createMockMessenger, @@ -844,6 +845,116 @@ describe('HyperLiquidProvider', () => { }); }); + describe('omitted margin mode', () => { + // Mirrors TradingService.flipPosition, which sends leverage without a mode. + const order: OrderParams = { + symbol: 'BTC', + isBuy: false, + size: '0.2', + orderType: 'market', + currentPrice: 50000, + leverage: 10, + }; + + const mockInfoClient = (overrides: Record): void => { + mockClientService.getInfoClient.mockReturnValue( + createMockInfoClient(overrides) as unknown as ReturnType< + HyperLiquidClientService['getInfoClient'] + >, + ); + }; + + it.each(['cross', 'isolated'] as const)( + 'keeps the open %s position mode when updating leverage', + async (positionMode) => { + mockInfoClient({ + clearinghouseState: jest.fn().mockResolvedValue({ + assetPositions: [ + { + position: { + coin: 'BTC', + szi: '0.1', + entryPx: '50000', + positionValue: '5000', + unrealizedPnl: '0', + marginUsed: '500', + leverage: { type: positionMode, value: 10 }, + liquidationPx: '45000', + maxLeverage: 50, + returnOnEquity: '0', + cumFunding: { + allTime: '0', + sinceOpen: '0', + sinceChange: '0', + }, + }, + type: 'oneWay', + }, + ], + }), + }); + + const result = await provider.placeOrder(order); + + expect(result.success).toBe(true); + expect( + mockClientService.getExchangeClient().updateLeverage, + ).toHaveBeenCalledWith({ + asset: 0, + isCross: positionMode === 'cross', + leverage: 10, + }); + }, + ); + + it('reads the open position mode from fresh cached positions', async () => { + mockInfoClient({ + clearinghouseState: jest.fn().mockResolvedValue({ assetPositions: [] }), + }); + mockSubscriptionService.getCachedPositionsForDex.mockReturnValue([ + createMockPosition({ leverage: { type: 'cross', value: 10 } }), + ]); + + const result = await provider.placeOrder(order); + + expect(result.success).toBe(true); + expect( + mockSubscriptionService.getCachedPositionsForDex, + ).toHaveBeenCalledWith(''); + expect( + mockClientService.getExchangeClient().updateLeverage, + ).toHaveBeenCalledWith({ asset: 0, isCross: true, leverage: 10 }); + }); + + it('defaults to isolated without an open position in the market', async () => { + mockSubscriptionService.getCachedPositionsForDex.mockReturnValue([ + createMockPosition({ symbol: 'ETH' }), + ]); + mockInfoClient({ + clearinghouseState: jest.fn().mockResolvedValue({ assetPositions: [] }), + }); + + const result = await provider.placeOrder(order); + + expect(result.success).toBe(true); + expect( + mockClientService.getExchangeClient().updateLeverage, + ).toHaveBeenCalledWith({ asset: 0, isCross: false, leverage: 10 }); + }); + + it('defaults to isolated when positions cannot be read', async () => { + mockInfoClient({ + clearinghouseState: jest.fn().mockRejectedValue(new Error('offline')), + }); + + await provider.placeOrder(order); + + expect( + mockClientService.getExchangeClient().updateLeverage, + ).toHaveBeenCalledWith({ asset: 0, isCross: false, leverage: 10 }); + }); + }); + describe('getMarginModeLock', () => { const mockInfoClient = (overrides: Record): void => { mockClientService.getInfoClient.mockReturnValue( From 6461b890b9f3bdb76c4841a366d4bd607537dead Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Wed, 30 Sep 2026 07:33:25 +0800 Subject: [PATCH 2/3] chore(perps-controller): link changelog entry to #10588 --- packages/perps-controller/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 96135be9b82..91d702fa56a 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -51,7 +51,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Before, they failed with the SDK's "Failed to sign the typed data using the wallet" message, or with `TPSL_UPDATE_FAILED` for a TP/SL update whose builder fee was not approved yet - Covers orders, edits, single and batch cancels (TWAP, scale and chase cancels included), position closes, TP/SL updates and clears, margin updates, withdrawals and transfers between DEXs, including the HIP-3 transfers around an order - HyperLiquid `cancelOrders` reports each order of a batch with its own result when an entry fails: orders the venue cancelled are no longer reported as failed with the batch's error ([#10559](https://github.com/MetaMask/core/pull/10559)) -- HyperLiquid orders that set leverage without `marginMode` now keep the open position's margin mode instead of switching to isolated, so flipping a Cross position no longer fails with "Cannot switch leverage type with open position" ([#PR_NUMBER](https://github.com/MetaMask/core/pull/PR_NUMBER)) +- HyperLiquid orders that set leverage without `marginMode` now keep the open position's margin mode instead of switching to isolated, so flipping a Cross position no longer fails with "Cannot switch leverage type with open position" ([#10588](https://github.com/MetaMask/core/pull/10588)) ## [18.0.1] From 0d1eacbde0979a3322ab965267f8963c6d82b258 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Wed, 30 Sep 2026 08:04:36 +0800 Subject: [PATCH 3/3] test(perps-controller): cover HIP-3 margin mode lookup and update marginMode docs --- packages/perps-controller/src/types/index.ts | 3 +- ...yperLiquidProvider.strategy-orders.test.ts | 46 +++++++++++++++++-- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index 91060dd7ca9..5afb5025bfe 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -314,7 +314,8 @@ export type OrderParams = { /** * Explicit collateral mode. Requires leverage. HyperLiquid validates market * support and refuses mode changes with an open position or resting order. - * Omit to retain the existing isolated-leverage behavior. + * Omit to let the provider choose: HyperLiquid keeps an open position's + * mode and otherwise uses isolated. */ marginMode?: MarginMode; existingPositionLeverage?: number; // Existing position leverage for validation (protocol constraint) 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 a5964faa56f..2a8284ee445 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 @@ -5021,17 +5021,22 @@ describe('HyperLiquidProvider - strategy order types', () => { * moves to the xyz DEX before the order and back after it. * * @param orderResponse - The venue's answer to the order. - * @returns The provider's DEX transfers, which the test answers. + * @returns The provider's DEX transfers, which the test answers, and the + * mocked exchange client. */ const useHip3MarketOrder = ( orderResponse: Record, - ): { transfer: jest.SpyInstance; order: jest.Mock } => { + ): { + transfer: jest.SpyInstance; + order: jest.Mock; + exchangeClient: MockClient; + } => { let ordered = false; const order = jest.fn(async () => { ordered = true; return orderResponse; }); - useStrategyClients({ + const { exchangeClient } = useStrategyClients({ exchange: { order }, info: { clearinghouseState: jest @@ -5058,7 +5063,11 @@ describe('HyperLiquidProvider - strategy order types', () => { useUnifiedAccount: false, initialAssetMapping: [['xyz:TSLA', 110000]], }); - return { transfer: jest.spyOn(provider, 'transferBetweenDexs'), order }; + return { + transfer: jest.spyOn(provider, 'transferBetweenDexs'), + order, + exchangeClient, + }; }; const HIP3_MARKET_ORDER = { @@ -5116,6 +5125,35 @@ describe('HyperLiquidProvider - strategy order types', () => { ]) => [error.message, options.context.data.method], ); + it('reads an omitted margin mode from the HIP-3 DEX position slice', async () => { + const { transfer, exchangeClient } = useHip3MarketOrder(REFUSED_ORDER); + transfer.mockResolvedValueOnce({ + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + // Only the xyz slice holds the position, so its mode proves the lookup + // routes a HIP-3 symbol to its own DEX cache. + mockSubscriptionService.getCachedPositionsForDex.mockImplementation( + (dexName: string) => + dexName === 'xyz' + ? [ + createMockPosition({ + symbol: 'xyz:TSLA', + leverage: { type: 'cross', value: 5 }, + }), + ] + : null, + ); + + await provider.placeOrder({ ...HIP3_MARKET_ORDER, leverage: 5 }); + + expect(exchangeClient.updateLeverage).toHaveBeenCalledWith({ + asset: 110000, + isCross: true, + leverage: 5, + }); + }); + it('fails a HIP-3 order with KEYRING_LOCKED, sending nothing, when its collateral transfer cannot be signed', async () => { const { transfer, order } = useHip3MarketOrder(REFUSED_ORDER); transfer.mockResolvedValueOnce({