From e49372d52817ba51967f4f37bf77050285108fa0 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Thu, 24 Sep 2026 08:51:47 +0800 Subject: [PATCH 1/8] feat(perps): expose the venue margin-mode lock per market Add PerpsController.getMarginModeLock and the optional PerpsProvider.getMarginModeLock, reporting the margin mode an asset is bound to by an open position or a resting order/TWAP. HyperLiquid reads it through the same helper order validation uses, so clients can keep their margin-mode picker in sync with what placement accepts. --- packages/perps-controller/CHANGELOG.md | 4 + .../PerpsController-method-action-types.ts | 14 +++ .../perps-controller/src/PerpsController.ts | 73 ++++++++++++ packages/perps-controller/src/index.ts | 5 + .../src/providers/AggregatedPerpsProvider.ts | 50 +++++++++ .../src/providers/HyperLiquidProvider.ts | 81 +++++++++++-- packages/perps-controller/src/types/index.ts | 48 ++++++++ .../src/PerpsController.lifecycle.test.ts | 2 +- .../src/PerpsController.operations.test.ts | 68 +++++++++++ .../providers/AggregatedPerpsProvider.test.ts | 92 +++++++++++++++ ...yperLiquidProvider.advanced-orders.test.ts | 106 ++++++++++++++++++ 11 files changed, 531 insertions(+), 12 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index da6e8d54f45..466d3ebe63d 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `PerpsController.getMarginModeLock` (and the `PerpsController:getMarginModeLock` messenger action) plus the optional `PerpsProvider.getMarginModeLock`, reporting the margin mode an asset is locked to by an open position or resting order/TWAP so clients can keep their margin-mode picker in sync with what order placement will accept. HyperLiquid implements it; other providers report `not_implemented`. ([#TBD](https://github.com/MetaMask/core/pull/TBD)) + ## [18.0.0] ### Added diff --git a/packages/perps-controller/src/PerpsController-method-action-types.ts b/packages/perps-controller/src/PerpsController-method-action-types.ts index 7c0e6daaa3e..c90175c285f 100644 --- a/packages/perps-controller/src/PerpsController-method-action-types.ts +++ b/packages/perps-controller/src/PerpsController-method-action-types.ts @@ -98,6 +98,19 @@ export type PerpsControllerGetOrderCapabilitiesAction = { handler: PerpsController['getOrderCapabilities']; }; +/** + * Get the margin mode the market is currently locked to by an open + * position or resting order, through the active provider route used by + * order placement. Never throws; failures report an unavailable status. + * + * @param params - Market and optional provider route. + * @returns The provider-reported margin-mode lock. + */ +export type PerpsControllerGetMarginModeLockAction = { + type: `PerpsController:getMarginModeLock`; + handler: PerpsController['getMarginModeLock']; +}; + /** * Build a Scale price ladder using the active provider's venue rules. * @@ -1362,6 +1375,7 @@ export type PerpsControllerMethodActions = | PerpsControllerGetActiveProviderAction | PerpsControllerGetActiveProviderOrNullAction | PerpsControllerGetOrderCapabilitiesAction + | PerpsControllerGetMarginModeLockAction | PerpsControllerGetScalePriceLadderAction | PerpsControllerPlaceOrderAction | PerpsControllerEditOrderAction diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index 641d05742c4..07ada128d6d 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -95,6 +95,7 @@ import type { GetMarketDataWithPricesParams, GetMarketsParams, GetOrderCapabilitiesParams, + GetMarginModeLockParams, GetScalePriceLadderParams, GetOrderFillsParams, GetOrdersParams, @@ -109,6 +110,7 @@ import type { MarketInfo, Order, OrderCapabilitiesUnavailableReason, + MarginModeLockUnavailableReason, OrderDirection, OrderFill, OrderParams, @@ -116,6 +118,7 @@ import type { PerpsControllerConfig, PerpsMarketData, PerpsOrderCapabilities, + PerpsMarginModeLock, PerpsScalePriceLadder, ScalePriceLadderUnavailableReason, PerpsPendingManualRecovery, @@ -930,6 +933,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'getOrderBookGrouping', 'getOrderBookPreferences', 'getOrderCapabilities', + 'getMarginModeLock', 'getScalePriceLadder', 'getOrderFills', 'getOrders', @@ -2800,6 +2804,75 @@ export class PerpsController extends BaseController< } } + /** + * Get the margin mode the market is currently locked to by an open + * position or resting order, through the active provider route used by + * order placement. Never throws; failures report an unavailable status. + * + * @param params - Market and optional provider route. + * @returns The provider-reported margin-mode lock. + */ + async getMarginModeLock( + params: GetMarginModeLockParams, + ): Promise { + let activeProvider: PerpsProvider; + try { + activeProvider = await this.#getActiveProviderWhenReady(); + } catch (error) { + this.#debugLog('PerpsController: Margin mode lock unavailable', { + error: ensureError(error, 'PerpsController.getMarginModeLock').message, + }); + return this.#getUnavailableMarginModeLock( + 'provider_unavailable', + params.providerId, + ); + } + + const resolvedProviderId = + params.providerId ?? this.#getDirectProviderId(activeProvider); + if (this.#hasConflictingProviderRoute(params.providerId, activeProvider)) { + return this.#getUnavailableMarginModeLock( + 'provider_not_routable', + resolvedProviderId, + ); + } + if (!activeProvider.getMarginModeLock) { + return this.#getUnavailableMarginModeLock( + 'not_implemented', + resolvedProviderId, + ); + } + + try { + const lock = await activeProvider.getMarginModeLock(params); + if ( + lock.status === 'unavailable' && + lock.providerId === undefined && + resolvedProviderId !== undefined + ) { + return { ...lock, providerId: resolvedProviderId }; + } + return lock; + } catch (error) { + this.#debugLog('PerpsController: Margin mode lock unavailable', { + error: ensureError(error, 'PerpsController.getMarginModeLock').message, + }); + return this.#getUnavailableMarginModeLock( + 'provider_unavailable', + resolvedProviderId, + ); + } + } + + #getUnavailableMarginModeLock( + reason: MarginModeLockUnavailableReason, + providerId: PerpsProviderType | undefined, + ): PerpsMarginModeLock { + return providerId + ? { status: 'unavailable', providerId, reason } + : { status: 'unavailable', reason }; + } + /** * Build a Scale price ladder using the active provider's venue rules. * diff --git a/packages/perps-controller/src/index.ts b/packages/perps-controller/src/index.ts index e97b7f34822..bf5bd8af8a2 100644 --- a/packages/perps-controller/src/index.ts +++ b/packages/perps-controller/src/index.ts @@ -102,6 +102,7 @@ export type { PerpsControllerGetOrderBookGroupingAction, PerpsControllerGetOrderBookPreferencesAction, PerpsControllerGetOrderCapabilitiesAction, + PerpsControllerGetMarginModeLockAction, PerpsControllerGetScalePriceLadderAction, PerpsControllerGetOrderFillsAction, PerpsControllerGetOrdersAction, @@ -293,6 +294,10 @@ export type { FeeCalculationParams, FeeCalculationResult, GetOrderCapabilitiesParams, + GetMarginModeLockParams, + MarginModeLockReason, + MarginModeLockUnavailableReason, + PerpsMarginModeLock, GetScalePriceLadderParams, OrderCapabilitiesUnavailableReason, DirectProviderOrderCapabilitiesUnavailableReason, diff --git a/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts b/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts index d6f94f9b18e..b6c25dab49d 100644 --- a/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts +++ b/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts @@ -45,6 +45,7 @@ import type { GetHistoricalPortfolioParams, GetMarketsParams, GetOrderCapabilitiesParams, + GetMarginModeLockParams, GetScalePriceLadderParams, GetOrderFillsParams, GetOrdersParams, @@ -68,6 +69,7 @@ import type { OrderResult, PerpsMarketData, PerpsOrderCapabilities, + PerpsMarginModeLock, PerpsScalePriceLadder, PerpsPendingManualRecovery, PerpsRecoveredDispatch, @@ -342,6 +344,54 @@ export class AggregatedPerpsProvider implements PerpsProvider { } } + /** + * Resolve the margin-mode lock with the same explicit-provider/default- + * provider selection used by order placement. + * + * @param params - Market and optional provider route. + * @returns The lock reported by the selected provider. + */ + async getMarginModeLock( + params: GetMarginModeLockParams, + ): Promise { + const providerId = params.providerId ?? this.#defaultProvider; + const provider = this.#providers.get(providerId); + if (!provider) { + return { + status: 'unavailable', + providerId, + reason: 'provider_not_found', + }; + } + if (!provider.getMarginModeLock) { + return { status: 'unavailable', providerId, reason: 'not_implemented' }; + } + try { + const lock = await provider.getMarginModeLock({ ...params, providerId }); + if (lock.providerId !== undefined && lock.providerId !== providerId) { + return { + status: 'unavailable', + providerId, + reason: 'provider_not_routable', + }; + } + return { ...lock, providerId }; + } catch (error) { + this.#deps.debugLogger.log( + '[AggregatedPerpsProvider] Margin mode lock unavailable', + { + providerId, + error: error instanceof Error ? error.message : String(error), + }, + ); + return { + status: 'unavailable', + providerId, + reason: 'provider_unavailable', + }; + } + } + /** * Normalize a Scale price ladder through the selected provider route. * diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 8dc0755d5d6..64f72b06c6b 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -99,6 +99,7 @@ import type { GetHistoricalPortfolioParams, GetMarketsParams, GetOrderCapabilitiesParams, + GetMarginModeLockParams, GetScalePriceLadderParams, GetOrderFillsParams, GetOrdersParams, @@ -154,6 +155,9 @@ import type { PerpsReadOptions, PerpsUserDataSnapshot, PerpsFeeResolution, + MarginMode, + MarginModeLockReason, + PerpsMarginModeLock, } from '../types/index.js'; import type { PerpsControllerMessengerBase } from '../types/messenger.js'; import type { OrderType, StrategyOrderType } from '../types/perps-types.js'; @@ -1699,6 +1703,42 @@ export class HyperLiquidProvider implements PerpsProvider { return result.status === 'ready' ? HYPERLIQUID_ORDER_CAPABILITIES : result; } + /** + * Report the margin mode HyperLiquid currently binds to a market, so + * clients can match what order placement will accept. + * + * @param params - Market and optional provider route. + * @returns The current lock, or unavailable when it cannot be read. + */ + async getMarginModeLock( + params: GetMarginModeLockParams, + ): Promise { + const lifecycleGeneration = this.#lifecycleGeneration; + try { + await this.#ensureClientsInitialized(); + this.#clientService.ensureInitialized(); + const lock = await this.#readMarginModeLock(params.symbol); + this.#assertProviderLifecycleCurrent( + lifecycleGeneration, + 'Margin mode lock read', + ); + return lock + ? { status: 'locked', providerId: this.protocolId, ...lock } + : { status: 'unlocked', providerId: this.protocolId }; + } catch (error) { + this.#deps.debugLogger.log('HyperLiquid: Margin mode lock unavailable', { + symbol: params.symbol, + error: ensureError(error, 'HyperLiquidProvider.getMarginModeLock') + .message, + }); + return { + status: 'unavailable', + providerId: this.protocolId, + reason: 'provider_unavailable', + }; + } + } + /** * Normalize a Scale price ladder with HyperLiquid market precision. * @@ -5146,16 +5186,36 @@ export class HyperLiquidProvider implements PerpsProvider { // unavailable there until that account-mode-aware path is supported. throw new Error(PERPS_ERROR_CODES.ORDER_MARGIN_MODE_UNSUPPORTED); } + const lock = await this.#readMarginModeLock(params.symbol); + if (lock && lock.marginMode !== params.marginMode) { + throw new Error( + lock.reason === 'position' + ? PERPS_ERROR_CODES.ORDER_MARGIN_MODE_POSITION_OPEN + : PERPS_ERROR_CODES.ORDER_MARGIN_MODE_ORDER_OPEN, + ); + } + } + + /** + * Read the margin mode the venue currently binds to an asset. An open + * position fixes it first; otherwise a resting order or active TWAP fixes + * the mode reported by the asset's leverage data. + * + * @param symbol - Market symbol, including its DEX route when applicable. + * @returns The locked mode and its cause, or null when nothing locks it. + * @throws When the target DEX positions cannot be read. + */ + async #readMarginModeLock( + symbol: string, + ): Promise<{ marginMode: MarginMode; reason: MarginModeLockReason } | null> { + const { dex: dexName } = parseAssetName(symbol); const { answered, positions } = await this.#queryDexPositions(dexName); if (!answered) { throw new Error(PERPS_ERROR_CODES.PROVIDER_NOT_AVAILABLE); } - const position = positions.find((item) => item.symbol === params.symbol); + const position = positions.find((item) => item.symbol === symbol); if (position) { - if (position.leverage.type !== params.marginMode) { - throw new Error(PERPS_ERROR_CODES.ORDER_MARGIN_MODE_POSITION_OPEN); - } - return; + return { marginMode: position.leverage.type, reason: 'position' }; } const user = await this.#walletService.getUserAddressWithDefault(); const infoClient = this.#clientService.getInfoClient(); @@ -5171,7 +5231,7 @@ export class HyperLiquidProvider implements PerpsProvider { HyperLiquidTwapHistoryEntry >(); for (const entry of twapHistory) { - if (entry.state.coin !== params.symbol) { + if (entry.state.coin !== symbol) { continue; } const previous = latestTwaps.get(entry.twapId); @@ -5195,15 +5255,14 @@ export class HyperLiquidProvider implements PerpsProvider { return true; } }); - if (orders.some((order) => order.coin === params.symbol) || hasActiveTwap) { + if (orders.some((order) => order.coin === symbol) || hasActiveTwap) { const asset = await infoClient.activeAssetData({ user, - coin: params.symbol, + coin: symbol, }); - if (asset.leverage.type !== params.marginMode) { - throw new Error(PERPS_ERROR_CODES.ORDER_MARGIN_MODE_ORDER_OPEN); - } + return { marginMode: asset.leverage.type, reason: 'open_order' }; } + return null; } /** diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index e253b8bfa8a..8b31245dc55 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -1619,6 +1619,42 @@ export type GetOrderCapabilitiesParams = { providerId?: PerpsProviderType; }; +/** Market and optional route whose current margin-mode lock is requested. */ +export type GetMarginModeLockParams = { + /** Provider-specific market identifier, including any routing prefix. */ + symbol: string; + providerId?: PerpsProviderType; +}; + +/** What fixes the asset's margin mode on the venue. */ +export type MarginModeLockReason = 'position' | 'open_order'; + +/** Reasons the current margin-mode lock cannot be reported. */ +export type MarginModeLockUnavailableReason = + | 'provider_unavailable' + | 'provider_not_found' + | 'provider_not_routable' + | 'not_implemented'; + +/** + * Margin mode an asset is bound to on the venue. `locked` means an open + * position or resting order/TWAP fixes the mode, so orders in the other mode + * are rejected; `unlocked` means either mode is accepted. + */ +export type PerpsMarginModeLock = + | Readonly<{ + status: 'locked'; + providerId: PerpsProviderType; + marginMode: MarginMode; + reason: MarginModeLockReason; + }> + | Readonly<{ status: 'unlocked'; providerId: PerpsProviderType }> + | Readonly<{ + status: 'unavailable'; + providerId?: PerpsProviderType; + reason: MarginModeLockUnavailableReason; + }>; + /** Inputs for a provider-normalized Scale price ladder preview. */ export type GetScalePriceLadderParams = { /** Market symbol, including its provider route when applicable. */ @@ -1947,6 +1983,18 @@ export type PerpsProvider = { params: GetOrderCapabilitiesParams, ): Promise; + /** + * Return the margin mode the market is currently locked to by an open + * position or resting order. Providers may omit this hook; the controller + * then reports the lock as unavailable. + * + * @param params - Market and optional provider route. + * @returns The current lock, or a typed unavailable result. + */ + getMarginModeLock?( + params: GetMarginModeLockParams, + ): Promise; + /** * Normalize a Scale ladder using the selected provider's venue rules. * diff --git a/packages/perps-controller/tests/src/PerpsController.lifecycle.test.ts b/packages/perps-controller/tests/src/PerpsController.lifecycle.test.ts index da144b6c9ca..53b92a6a07a 100644 --- a/packages/perps-controller/tests/src/PerpsController.lifecycle.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.lifecycle.test.ts @@ -1975,7 +1975,7 @@ describe('PerpsController', () => { expect(controller.testGetProviders().has('hyperliquid')).toBe(true); expect(mockMessenger.registerMethodActionHandlers).toHaveBeenCalledWith( controller, - expect.arrayContaining(['getOrderCapabilities']), + expect.arrayContaining(['getOrderCapabilities', 'getMarginModeLock']), ); }); diff --git a/packages/perps-controller/tests/src/PerpsController.operations.test.ts b/packages/perps-controller/tests/src/PerpsController.operations.test.ts index 4852c3a225f..0abfe6a7ece 100644 --- a/packages/perps-controller/tests/src/PerpsController.operations.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.operations.test.ts @@ -825,6 +825,74 @@ describe('PerpsController', () => { }); }); + describe('margin mode lock', () => { + it('returns the lock from the active provider', async () => { + mockProvider.getMarginModeLock = jest.fn().mockResolvedValue({ + status: 'locked', + providerId: 'hyperliquid', + marginMode: 'isolated', + reason: 'open_order', + }); + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + await expect( + controller.getMarginModeLock({ symbol: 'BTC' }), + ).resolves.toStrictEqual({ + status: 'locked', + providerId: 'hyperliquid', + marginMode: 'isolated', + reason: 'open_order', + }); + expect(mockProvider.getMarginModeLock).toHaveBeenCalledWith({ + symbol: 'BTC', + }); + }); + + it('reports not_implemented when the provider omits the optional hook', async () => { + mockProvider.getMarginModeLock = undefined; + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + await expect( + controller.getMarginModeLock({ symbol: 'BTC' }), + ).resolves.toStrictEqual({ + status: 'unavailable', + providerId: 'hyperliquid', + reason: 'not_implemented', + }); + }); + + it('reports provider_unavailable when the provider read throws', async () => { + mockProvider.getMarginModeLock = jest + .fn() + .mockRejectedValue(new Error('offline')); + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + await expect( + controller.getMarginModeLock({ symbol: 'BTC' }), + ).resolves.toStrictEqual({ + status: 'unavailable', + providerId: 'hyperliquid', + reason: 'provider_unavailable', + }); + }); + + it('reports provider_unavailable while no provider is ready', async () => { + expect(controller.state.initializationState).toBe( + InitializationState.Uninitialized, + ); + + await expect( + controller.getMarginModeLock({ symbol: 'BTC' }), + ).resolves.toStrictEqual({ + status: 'unavailable', + reason: 'provider_unavailable', + }); + }); + }); + describe('order capabilities', () => { const setAggregatedProvider = (): AggregatedPerpsProvider => { const lighterProvider: PerpsProvider = { diff --git a/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts b/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts index 2e3a61be89f..1935fc277ac 100644 --- a/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts +++ b/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts @@ -1554,6 +1554,98 @@ describe('AggregatedPerpsProvider', () => { { providerId: 'hyperliquid', error: 'offline' }, ); }); + + it('gets the margin mode lock from the routed provider', async () => { + mockLighterProvider.getMarginModeLock = jest.fn().mockResolvedValue({ + status: 'locked', + providerId: 'lighter', + marginMode: 'cross', + reason: 'position', + }); + + await expect( + aggregatedProvider.getMarginModeLock({ + symbol: 'BTC', + providerId: 'lighter', + }), + ).resolves.toStrictEqual({ + status: 'locked', + providerId: 'lighter', + marginMode: 'cross', + reason: 'position', + }); + expect(mockLighterProvider.getMarginModeLock).toHaveBeenCalledWith({ + symbol: 'BTC', + providerId: 'lighter', + }); + }); + + it('reports a margin mode lock route that is not registered', async () => { + const providerWithoutLighter = new AggregatedPerpsProvider({ + providers: new Map([['hyperliquid', mockHLProvider]]), + defaultProvider: 'hyperliquid', + infrastructure: mockInfrastructure, + }); + + await expect( + providerWithoutLighter.getMarginModeLock({ + symbol: 'BTC', + providerId: 'lighter', + }), + ).resolves.toStrictEqual({ + status: 'unavailable', + providerId: 'lighter', + reason: 'provider_not_found', + }); + }); + + it('reports not_implemented when the routed provider has no margin mode lock hook', async () => { + mockHLProvider.getMarginModeLock = undefined; + + await expect( + aggregatedProvider.getMarginModeLock({ symbol: 'BTC' }), + ).resolves.toStrictEqual({ + status: 'unavailable', + providerId: 'hyperliquid', + reason: 'not_implemented', + }); + }); + + it('rejects a margin mode lock attributed to a different provider', async () => { + mockLighterProvider.getMarginModeLock = jest.fn().mockResolvedValue({ + status: 'unlocked', + providerId: 'hyperliquid', + }); + + await expect( + aggregatedProvider.getMarginModeLock({ + symbol: 'BTC', + providerId: 'lighter', + }), + ).resolves.toStrictEqual({ + status: 'unavailable', + providerId: 'lighter', + reason: 'provider_not_routable', + }); + }); + + it('reports unavailable when the routed margin mode lock read throws', async () => { + mockHLProvider.getMarginModeLock = jest + .fn() + .mockRejectedValue(new Error('offline')); + + await expect( + aggregatedProvider.getMarginModeLock({ symbol: 'BTC' }), + ).resolves.toStrictEqual({ + status: 'unavailable', + providerId: 'hyperliquid', + reason: 'provider_unavailable', + }); + expect(mockInfrastructure.debugLogger.log).toHaveBeenCalledWith( + '[AggregatedPerpsProvider] Margin mode lock unavailable', + { providerId: 'hyperliquid', error: 'offline' }, + ); + }); }); describe('Scale price ladder', () => { 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 01daad4c5d4..2de54a4218e 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 @@ -844,6 +844,112 @@ describe('HyperLiquidProvider', () => { }); }); + describe('getMarginModeLock', () => { + const mockInfoClient = (overrides: Record): void => { + mockClientService.getInfoClient.mockReturnValue( + createMockInfoClient(overrides) as unknown as ReturnType< + HyperLiquidClientService['getInfoClient'] + >, + ); + }; + + it('reports the mode of an open position', async () => { + const activeAssetData = jest.fn(); + mockInfoClient({ activeAssetData }); + + const lock = await provider.getMarginModeLock({ symbol: 'BTC' }); + + expect(lock).toStrictEqual({ + status: 'locked', + providerId: 'hyperliquid', + marginMode: 'cross', + reason: 'position', + }); + expect(activeAssetData).not.toHaveBeenCalled(); + }); + + it('reports the asset mode of a resting order', async () => { + const activeAssetData = jest + .fn() + .mockResolvedValue({ leverage: { type: 'cross', value: 5 } }); + mockInfoClient({ + clearinghouseState: jest.fn().mockResolvedValue({ assetPositions: [] }), + frontendOpenOrders: jest.fn().mockResolvedValue([{ coin: 'BTC' }]), + activeAssetData, + }); + + const lock = await provider.getMarginModeLock({ symbol: 'BTC' }); + + expect(lock).toStrictEqual({ + status: 'locked', + providerId: 'hyperliquid', + marginMode: 'cross', + reason: 'open_order', + }); + expect(activeAssetData).toHaveBeenCalledWith( + expect.objectContaining({ coin: 'BTC' }), + ); + }); + + it('reports the asset mode of an active TWAP with no resting order', async () => { + mockInfoClient({ + clearinghouseState: jest.fn().mockResolvedValue({ assetPositions: [] }), + frontendOpenOrders: jest.fn().mockResolvedValue([]), + twapHistory: jest.fn().mockResolvedValue([ + { + time: 1_700_000_030, + twapId: 987, + state: { coin: 'BTC' }, + status: { status: 'activated' }, + }, + ]), + activeAssetData: jest + .fn() + .mockResolvedValue({ leverage: { type: 'isolated', value: 5 } }), + }); + + const lock = await provider.getMarginModeLock({ symbol: 'BTC' }); + + expect(lock).toStrictEqual({ + status: 'locked', + providerId: 'hyperliquid', + marginMode: 'isolated', + reason: 'open_order', + }); + }); + + it('reports unlocked when nothing is open for the asset', async () => { + const activeAssetData = jest.fn(); + mockInfoClient({ + clearinghouseState: jest.fn().mockResolvedValue({ assetPositions: [] }), + frontendOpenOrders: jest.fn().mockResolvedValue([{ coin: 'ETH' }]), + activeAssetData, + }); + + const lock = await provider.getMarginModeLock({ symbol: 'BTC' }); + + expect(lock).toStrictEqual({ + status: 'unlocked', + providerId: 'hyperliquid', + }); + expect(activeAssetData).not.toHaveBeenCalled(); + }); + + it('reports unavailable when positions cannot be read', async () => { + mockInfoClient({ + clearinghouseState: jest.fn().mockRejectedValue(new Error('offline')), + }); + + const lock = await provider.getMarginModeLock({ symbol: 'BTC' }); + + expect(lock).toStrictEqual({ + status: 'unavailable', + providerId: 'hyperliquid', + reason: 'provider_unavailable', + }); + }); + }); + describe('Advanced order placement', () => { const TPSL_SLIPPAGE = ORDER_SLIPPAGE_CONFIG.DefaultTpslSlippageBps / 10000; From 79ed8d579d2ed984adf99dbd80482506ab125b4a Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Thu, 24 Sep 2026 09:03:28 +0800 Subject: [PATCH 2/8] chore(perps): link changelog entry to #10414 --- 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 466d3ebe63d..5f42298fdd3 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `PerpsController.getMarginModeLock` (and the `PerpsController:getMarginModeLock` messenger action) plus the optional `PerpsProvider.getMarginModeLock`, reporting the margin mode an asset is locked to by an open position or resting order/TWAP so clients can keep their margin-mode picker in sync with what order placement will accept. HyperLiquid implements it; other providers report `not_implemented`. ([#TBD](https://github.com/MetaMask/core/pull/TBD)) +- Add `PerpsController.getMarginModeLock` (and the `PerpsController:getMarginModeLock` messenger action) plus the optional `PerpsProvider.getMarginModeLock`, reporting the margin mode an asset is locked to by an open position or resting order/TWAP so clients can keep their margin-mode picker in sync with what order placement will accept. HyperLiquid implements it; other providers report `not_implemented`. ([#10414](https://github.com/MetaMask/core/pull/10414)) ## [18.0.0] From e2772960b487a9597f73b4d325f2018863575a6b Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Thu, 24 Sep 2026 09:40:07 +0800 Subject: [PATCH 3/8] feat(perps): report margin-mode support and Lighter margin-mode lock Add optional supportedMarginModes to ready order capabilities so clients stop inferring margin-mode support from the provider. HyperLiquid derives it from the same Cross rule order validation uses. Implement getMarginModeLock for Lighter from the open position's mode. --- packages/perps-controller/CHANGELOG.md | 2 + .../src/providers/HyperLiquidProvider.ts | 52 +++++++++--- .../src/providers/LighterProvider.ts | 79 ++++++++++++++++--- packages/perps-controller/src/types/index.ts | 6 ++ .../providers/AggregatedPerpsProvider.test.ts | 1 + ...yperLiquidProvider.strategy-orders.test.ts | 44 +++++++++++ .../src/providers/LighterProvider.test.ts | 76 ++++++++++++++++++ 7 files changed, 239 insertions(+), 21 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 5f42298fdd3..c7799c9e714 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add `PerpsController.getMarginModeLock` (and the `PerpsController:getMarginModeLock` messenger action) plus the optional `PerpsProvider.getMarginModeLock`, reporting the margin mode an asset is locked to by an open position or resting order/TWAP so clients can keep their margin-mode picker in sync with what order placement will accept. HyperLiquid implements it; other providers report `not_implemented`. ([#10414](https://github.com/MetaMask/core/pull/10414)) +- Implement `getMarginModeLock` for Lighter, reporting the mode an open position binds to its market. ([#TBD2](https://github.com/MetaMask/core/pull/TBD2)) +- Add optional `supportedMarginModes` to ready order capabilities; HyperLiquid reports `['isolated', 'cross']` for main-DEX markets and `['isolated']` for HIP-3 or isolated-only assets, so clients stop inferring margin-mode support from the provider. ([#TBD2](https://github.com/MetaMask/core/pull/TBD2)) ## [18.0.0] diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 64f72b06c6b..3d8a7d2be7f 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -1688,19 +1688,52 @@ export class HyperLiquidProvider implements PerpsProvider { } /** - * Return provider-owned strategy support for a routed market. - * HyperLiquid metadata has no per-market strategy flags. This provider - * advertises its implemented strategies uniformly after confirming the - * routed market exists. + * Return provider-owned strategy and margin-mode support for a routed + * market. HyperLiquid metadata has no per-market strategy flags. This + * provider advertises its implemented strategies uniformly after confirming + * the routed market exists; margin modes follow the market's metadata. * * @param params - Required market route context. - * @returns Supported strategy order types. + * @returns Supported strategy order types and margin modes. */ async getOrderCapabilities( params: GetOrderCapabilitiesParams, ): Promise { const result = await this.#getOrderCapabilityMarket(params.symbol); - return result.status === 'ready' ? HYPERLIQUID_ORDER_CAPABILITIES : result; + if (result.status === 'unavailable') { + return result; + } + const supportedMarginModes: readonly MarginMode[] = Object.freeze( + this.#isCrossMarginSupported(params.symbol, result.market) + ? ['isolated', 'cross'] + : ['isolated'], + ); + return Object.freeze({ + ...HYPERLIQUID_ORDER_CAPABILITIES, + supportedMarginModes, + }); + } + + /** + * Whether the venue accepts Cross margin for a market. Both SDK + * `marginMode` values (strictIsolated and noCross) prohibit Cross, and any + * future restriction value is treated as unsupported until handled + * explicitly. HIP-3 collateral transfers still assume isolated margin, so + * Cross stays unavailable there until that account-mode-aware path is + * supported. + * + * @param symbol - Market symbol, including its DEX route when applicable. + * @param asset - Venue margin metadata for the market. + * @param asset.onlyIsolated - Whether the market only allows isolated margin. + * @param asset.marginMode - Venue margin-mode restriction, if any. + * @returns True when Cross margin orders are accepted. + */ + #isCrossMarginSupported( + symbol: string, + asset: { onlyIsolated?: boolean; marginMode?: string }, + ): boolean { + const { dex } = parseAssetName(symbol); + return !asset.onlyIsolated && !asset.marginMode && dex === null; } /** @@ -5175,15 +5208,10 @@ export class HyperLiquidProvider implements PerpsProvider { if (!Number.isInteger(params.leverage) || (params.leverage ?? 0) < 1) { throw new Error(PERPS_ERROR_CODES.ORDER_LEVERAGE_INVALID); } - const { dex: dexName } = parseAssetName(params.symbol); - // Both SDK marginMode values (strictIsolated and noCross) prohibit Cross. - // Treat any future restriction value as unsupported until handled explicitly. if ( params.marginMode === 'cross' && - (assetInfo.onlyIsolated || assetInfo.marginMode || dexName !== null) + !this.#isCrossMarginSupported(params.symbol, assetInfo) ) { - // HIP-3 collateral transfers still assume isolated margin. Keep Cross - // unavailable there until that account-mode-aware path is supported. throw new Error(PERPS_ERROR_CODES.ORDER_MARGIN_MODE_UNSUPPORTED); } const lock = await this.#readMarginModeLock(params.symbol); diff --git a/packages/perps-controller/src/providers/LighterProvider.ts b/packages/perps-controller/src/providers/LighterProvider.ts index 36841a231b5..4f71bdeb2fe 100644 --- a/packages/perps-controller/src/providers/LighterProvider.ts +++ b/packages/perps-controller/src/providers/LighterProvider.ts @@ -80,6 +80,7 @@ import type { GetAccountStateParams, GetFundingParams, GetHistoricalPortfolioParams, + GetMarginModeLockParams, GetMarketsParams, GetOrderFillsParams, GetOrdersParams, @@ -97,6 +98,7 @@ import type { OrderFill, OrderParams, OrderResult, + PerpsMarginModeLock, PerpsMarketData, PerpsPlatformDependencies, PerpsProvider, @@ -4863,6 +4865,51 @@ export class LighterProvider implements PerpsProvider { } } + /** + * Report the margin mode Lighter currently binds to a market. Only an + * open position locks the mode here: the venue refuses a mode change + * while a position is open. Resting orders are not treated as a lock. + * + * @param params - Market and optional provider route. + * @returns The current lock, or unavailable when it cannot be read. + */ + async getMarginModeLock( + params: GetMarginModeLockParams, + ): Promise { + try { + this.#ensureSessionBinding(); + const generation = this.#sessionGeneration; + const wireMarginMode = await this.#readPositionMarginMode(params.symbol); + this.#assertSession(generation); + if (wireMarginMode === null) { + return { status: 'unlocked', providerId: this.protocolId }; + } + return { + status: 'locked', + providerId: this.protocolId, + marginMode: + wireMarginMode === LIGHTER_MARGIN_MODE_ISOLATED + ? 'isolated' + : 'cross', + reason: 'position', + }; + } catch (error) { + this.#deps.debugLogger.log( + '[LighterProvider] getMarginModeLock unavailable', + { + symbol: params.symbol, + error: ensureError(error, 'LighterProvider.getMarginModeLock') + .message, + }, + ); + return { + status: 'unavailable', + providerId: this.protocolId, + reason: 'provider_unavailable', + }; + } + } + async getAccountState( _params?: GetAccountStateParams, ): Promise { @@ -7341,15 +7388,9 @@ export class LighterProvider implements PerpsProvider { symbol: string, ): Promise => { try { - const accountIndex = await this.#ensureAccountIndex(); - const response = - await this.#clientService.getAccountByIndex(accountIndex); - const row = response.accounts?.[0]?.positions?.find( - (position) => - position.symbol === symbol && parseFloat(position.position) !== 0, - ); - if (row) { - return row.marginMode ?? LIGHTER_MARGIN_MODE_CROSS; + const positionMarginMode = await this.#readPositionMarginMode(symbol); + if (positionMarginMode !== null) { + return positionMarginMode; } } catch { // Fall through: prefer isolated; a wrong guess surfaces as an @@ -7358,6 +7399,26 @@ export class LighterProvider implements PerpsProvider { return LIGHTER_MARGIN_MODE_ISOLATED; }; + /** + * Wire margin mode of the open position on this market, if any. A missing + * `marginMode` field means the venue default, cross. + * + * @param symbol - Market symbol. + * @returns The position's wire margin mode, or null when flat. + * @throws When the account or its positions cannot be read. + */ + readonly #readPositionMarginMode = async ( + symbol: string, + ): Promise => { + const accountIndex = await this.#ensureAccountIndex(); + const response = await this.#clientService.getAccountByIndex(accountIndex); + const row = response.accounts?.[0]?.positions?.find( + (position) => + position.symbol === symbol && parseFloat(position.position) !== 0, + ); + return row ? (row.marginMode ?? LIGHTER_MARGIN_MODE_CROSS) : null; + }; + /** Per-market margin fractions + last price from orderBookDetails. */ readonly #marginBySymbol = new Map(); diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index 8b31245dc55..9711426e31e 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -1689,6 +1689,12 @@ type ReadyPerpsOrderCapabilities = Readonly<{ status: 'ready'; providerId: PerpsProviderType; supportedStrategies: readonly StrategyOrderType[]; + /** + * Margin modes the market accepts for `OrderParams.marginMode`. Omitted + * means the provider does not report it, and clients should not offer an + * explicit margin mode. + */ + supportedMarginModes?: readonly MarginMode[]; }>; export type DirectProviderOrderCapabilities = diff --git a/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts b/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts index 1935fc277ac..a44cc721ac4 100644 --- a/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts +++ b/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts @@ -1433,6 +1433,7 @@ describe('AggregatedPerpsProvider', () => { status: 'ready', providerId: 'hyperliquid', supportedStrategies: Object.freeze(['twap', 'scale', 'chase']), + supportedMarginModes: Object.freeze(['isolated', 'cross']), }); mockHLProvider.getOrderCapabilities.mockResolvedValue(capabilities); 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 061d02a8d4b..e0ae5c15e1f 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 @@ -6776,6 +6776,7 @@ describe('HyperLiquidProvider - strategy order types', () => { status: 'ready', providerId: 'hyperliquid', supportedStrategies: ['twap', 'scale', 'chase'], + supportedMarginModes: ['isolated', 'cross'], }); expect(Object.isFrozen(capabilities)).toBe(true); expect(Object.isFrozen(capabilities.supportedStrategies)).toBe(true); @@ -6791,6 +6792,7 @@ describe('HyperLiquidProvider - strategy order types', () => { status: 'ready', providerId: 'hyperliquid', supportedStrategies: ['twap', 'scale', 'chase'], + supportedMarginModes: ['isolated', 'cross'], }); }); @@ -6803,10 +6805,39 @@ describe('HyperLiquidProvider - strategy order types', () => { status: 'ready', providerId: 'hyperliquid', supportedStrategies: ['twap', 'scale', 'chase'], + supportedMarginModes: ['isolated'], }); expect(infoClient.meta).toHaveBeenCalledWith({ dex: 'xyz' }); }); + it.each([ + ['isolated-only', { onlyIsolated: true }], + ['strictIsolated', { marginMode: 'strictIsolated' }], + ['noCross', { marginMode: 'noCross' }], + ])( + 'reports only isolated margin for a %s market', + async (_, restriction) => { + useStrategyClients({ + info: { + meta: jest.fn().mockResolvedValue({ + universe: [ + { name: 'ETH', szDecimals: 4, maxLeverage: 50, ...restriction }, + ], + }), + }, + }); + + expect( + await provider.getOrderCapabilities({ symbol: 'ETH' }), + ).toStrictEqual({ + status: 'ready', + providerId: 'hyperliquid', + supportedStrategies: ['twap', 'scale', 'chase'], + supportedMarginModes: ['isolated'], + }); + }, + ); + it.each([ ['the HIP-3 kill switch is off', { hip3Enabled: false }], ['the market is blocklisted', { blocklistMarkets: ['xyz:TSLA'] }], @@ -6887,6 +6918,7 @@ describe('HyperLiquidProvider - strategy order types', () => { status: 'ready', providerId: 'hyperliquid', supportedStrategies: ['twap', 'scale', 'chase'], + supportedMarginModes: ['isolated', 'cross'], }); }); @@ -6996,6 +7028,7 @@ describe('HyperLiquidProvider - strategy order types', () => { status: 'ready', providerId: 'hyperliquid', supportedStrategies: ['twap', 'scale', 'chase'], + supportedMarginModes: ['isolated', 'cross'], }); expect(infoClient.meta).toHaveBeenCalledTimes(2); }); @@ -7011,6 +7044,7 @@ describe('HyperLiquidProvider - strategy order types', () => { status: 'ready', providerId: 'hyperliquid', supportedStrategies: ['twap', 'scale', 'chase'], + supportedMarginModes: ['isolated', 'cross'], }); expect(infoClient.meta).toHaveBeenCalledTimes(2); }); @@ -7082,6 +7116,7 @@ describe('HyperLiquidProvider - strategy order types', () => { status: 'ready', providerId: 'hyperliquid', supportedStrategies: ['twap', 'scale', 'chase'], + supportedMarginModes: ['isolated', 'cross'], }); pendingSharedMeta.resolve({ @@ -7095,6 +7130,7 @@ describe('HyperLiquidProvider - strategy order types', () => { status: 'ready', providerId: 'hyperliquid', supportedStrategies: ['twap', 'scale', 'chase'], + supportedMarginModes: ['isolated', 'cross'], }); expect(infoClient.meta).toHaveBeenCalledTimes(2); }); @@ -7131,11 +7167,13 @@ describe('HyperLiquidProvider - strategy order types', () => { status: 'ready', providerId: 'hyperliquid', supportedStrategies: ['twap', 'scale', 'chase'], + supportedMarginModes: ['isolated', 'cross'], }, { status: 'ready', providerId: 'hyperliquid', supportedStrategies: ['twap', 'scale', 'chase'], + supportedMarginModes: ['isolated', 'cross'], }, ]); }); @@ -7225,6 +7263,7 @@ describe('HyperLiquidProvider - strategy order types', () => { status: 'ready', providerId: 'hyperliquid', supportedStrategies: ['twap', 'scale', 'chase'], + supportedMarginModes: ['isolated', 'cross'], }); expect(infoClient.meta).toHaveBeenCalledTimes(1); }); @@ -7333,6 +7372,7 @@ describe('HyperLiquidProvider - strategy order types', () => { status: 'ready', providerId: 'hyperliquid', supportedStrategies: ['twap', 'scale', 'chase'], + supportedMarginModes: ['isolated', 'cross'], }); expect(infoClient.meta).toHaveBeenCalledTimes(1); }); @@ -7382,6 +7422,7 @@ describe('HyperLiquidProvider - strategy order types', () => { status: 'ready', providerId: 'hyperliquid', supportedStrategies: ['twap', 'scale', 'chase'], + supportedMarginModes: ['isolated', 'cross'], }); expect(infoClient.meta).toHaveBeenCalledTimes(2); }); @@ -7411,6 +7452,7 @@ describe('HyperLiquidProvider - strategy order types', () => { status: 'ready', providerId: 'hyperliquid', supportedStrategies: ['twap', 'scale', 'chase'], + supportedMarginModes: ['isolated', 'cross'], }); expect(infoClient.meta).toHaveBeenCalledTimes(2); }); @@ -7447,6 +7489,7 @@ describe('HyperLiquidProvider - strategy order types', () => { status: 'ready', providerId: 'hyperliquid', supportedStrategies: ['twap', 'scale', 'chase'], + supportedMarginModes: ['isolated', 'cross'], }); expect(infoClient.meta).toHaveBeenCalledTimes(2); }); @@ -7662,6 +7705,7 @@ describe('HyperLiquidProvider - strategy order types', () => { status: 'ready', providerId: 'hyperliquid', supportedStrategies: ['twap', 'scale', 'chase'], + supportedMarginModes: ['isolated', 'cross'], }); expect(infoClient.meta).toHaveBeenCalledTimes(1); }); diff --git a/packages/perps-controller/tests/src/providers/LighterProvider.test.ts b/packages/perps-controller/tests/src/providers/LighterProvider.test.ts index 5c00df47f5a..c3ef9f81126 100644 --- a/packages/perps-controller/tests/src/providers/LighterProvider.test.ts +++ b/packages/perps-controller/tests/src/providers/LighterProvider.test.ts @@ -1433,6 +1433,82 @@ describe('LighterProvider', () => { }); }); + describe('getMarginModeLock', () => { + it('reports cross when an open position omits its margin mode', async () => { + const { provider } = buildProvider(); + + const lock = await provider.getMarginModeLock({ symbol: 'BTC' }); + + expect(lock).toStrictEqual({ + status: 'locked', + providerId: 'lighter', + marginMode: 'cross', + reason: 'position', + }); + }); + + it('reports isolated when the open position is isolated', async () => { + const { provider, clientInstance } = buildProvider(); + clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [ + { + ...ACCOUNT, + positions: [{ ...ACCOUNT.positions[0], marginMode: 1 }], + }, + ], + }); + + const lock = await provider.getMarginModeLock({ symbol: 'BTC' }); + + expect(lock).toStrictEqual({ + status: 'locked', + providerId: 'lighter', + marginMode: 'isolated', + reason: 'position', + }); + }); + + it.each([ + ['no position row', 'ETH', ACCOUNT.positions], + [ + 'a zero-size position', + 'BTC', + [{ ...ACCOUNT.positions[0], position: '0' }], + ], + ])('reports unlocked for %s', async (_case, symbol, positions) => { + const { provider, clientInstance } = buildProvider(); + clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [{ ...ACCOUNT, positions }], + }); + + const lock = await provider.getMarginModeLock({ symbol }); + + expect(lock).toStrictEqual({ status: 'unlocked', providerId: 'lighter' }); + }); + + it('reports unavailable when the account read fails', async () => { + const infra = createMockInfrastructure(); + const { provider, clientInstance } = buildProvider({ + platformDependencies: infra, + }); + clientInstance.getAccountByIndex.mockRejectedValue(new Error('down')); + + const lock = await provider.getMarginModeLock({ symbol: 'BTC' }); + + expect(lock).toStrictEqual({ + status: 'unavailable', + providerId: 'lighter', + reason: 'provider_unavailable', + }); + expect(infra.debugLogger.log).toHaveBeenCalledWith( + '[LighterProvider] getMarginModeLock unavailable', + { symbol: 'BTC', error: 'down' }, + ); + }); + }); + describe('placeOrder', () => { it.each(['cross', 'isolated'] as const)( 'rejects explicit %s before signing', From 85804e736b35992ff5b63f3d1d6f2dcd99d752cc Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Thu, 24 Sep 2026 09:40:39 +0800 Subject: [PATCH 4/8] chore(perps): link changelog entries to #10415 --- packages/perps-controller/CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index c7799c9e714..0c56e06edaa 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -10,8 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add `PerpsController.getMarginModeLock` (and the `PerpsController:getMarginModeLock` messenger action) plus the optional `PerpsProvider.getMarginModeLock`, reporting the margin mode an asset is locked to by an open position or resting order/TWAP so clients can keep their margin-mode picker in sync with what order placement will accept. HyperLiquid implements it; other providers report `not_implemented`. ([#10414](https://github.com/MetaMask/core/pull/10414)) -- Implement `getMarginModeLock` for Lighter, reporting the mode an open position binds to its market. ([#TBD2](https://github.com/MetaMask/core/pull/TBD2)) -- Add optional `supportedMarginModes` to ready order capabilities; HyperLiquid reports `['isolated', 'cross']` for main-DEX markets and `['isolated']` for HIP-3 or isolated-only assets, so clients stop inferring margin-mode support from the provider. ([#TBD2](https://github.com/MetaMask/core/pull/TBD2)) +- Implement `getMarginModeLock` for Lighter, reporting the mode an open position binds to its market. ([#10415](https://github.com/MetaMask/core/pull/10415)) +- Add optional `supportedMarginModes` to ready order capabilities; HyperLiquid reports `['isolated', 'cross']` for main-DEX markets and `['isolated']` for HIP-3 or isolated-only assets, so clients stop inferring margin-mode support from the provider. ([#10415](https://github.com/MetaMask/core/pull/10415)) ## [18.0.0] From ed697d8fc928b88244b9f655533b1eb975c19d19 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Thu, 24 Sep 2026 09:41:37 +0800 Subject: [PATCH 5/8] chore(perps): link changelog entries to #10414 --- packages/perps-controller/CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 0c56e06edaa..bf90ce64918 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -10,8 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add `PerpsController.getMarginModeLock` (and the `PerpsController:getMarginModeLock` messenger action) plus the optional `PerpsProvider.getMarginModeLock`, reporting the margin mode an asset is locked to by an open position or resting order/TWAP so clients can keep their margin-mode picker in sync with what order placement will accept. HyperLiquid implements it; other providers report `not_implemented`. ([#10414](https://github.com/MetaMask/core/pull/10414)) -- Implement `getMarginModeLock` for Lighter, reporting the mode an open position binds to its market. ([#10415](https://github.com/MetaMask/core/pull/10415)) -- Add optional `supportedMarginModes` to ready order capabilities; HyperLiquid reports `['isolated', 'cross']` for main-DEX markets and `['isolated']` for HIP-3 or isolated-only assets, so clients stop inferring margin-mode support from the provider. ([#10415](https://github.com/MetaMask/core/pull/10415)) +- Implement `getMarginModeLock` for Lighter, reporting the mode an open position binds to its market. ([#10414](https://github.com/MetaMask/core/pull/10414)) +- Add optional `supportedMarginModes` to ready order capabilities; HyperLiquid reports `['isolated', 'cross']` for main-DEX markets and `['isolated']` for HIP-3 or isolated-only assets, so clients stop inferring margin-mode support from the provider. ([#10414](https://github.com/MetaMask/core/pull/10414)) ## [18.0.0] From 72b7d72b8b345b252c2958936276754710d3c692 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Thu, 24 Sep 2026 14:12:48 +0800 Subject: [PATCH 6/8] fix(perps): pin one account per margin-mode lock read HyperLiquid now resolves the selected account once for the lock read and reports it unavailable if the account changes mid-read, so positions and orders from different accounts are never combined. Lighter treats an account response without the account or its positions as unavailable rather than unlocked. Clarify that unlocked does not imply Cross support. --- .../src/providers/HyperLiquidProvider.ts | 16 ++++++++++++-- .../src/providers/LighterProvider.ts | 10 +++++++-- packages/perps-controller/src/types/index.ts | 4 +++- ...yperLiquidProvider.advanced-orders.test.ts | 22 +++++++++++++++++++ .../src/providers/LighterProvider.test.ts | 19 ++++++++++++++++ 5 files changed, 66 insertions(+), 5 deletions(-) diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 3d8a7d2be7f..f35ef4f75f9 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -5231,26 +5231,38 @@ export class HyperLiquidProvider implements PerpsProvider { * * @param symbol - Market symbol, including its DEX route when applicable. * @returns The locked mode and its cause, or null when nothing locks it. - * @throws When the target DEX positions cannot be read. + * @throws When the target DEX positions cannot be read, or when the + * selected account changes during the read. */ async #readMarginModeLock( symbol: string, ): Promise<{ marginMode: MarginMode; reason: MarginModeLockReason } | null> { const { dex: dexName } = parseAssetName(symbol); + // Each venue read resolves the selected account on its own. Pin one + // account for the whole read so positions and orders from different + // accounts can never be combined. + const user = await this.#walletService.getUserAddressWithDefault(); + const assertSameAccount = async (): Promise => { + const current = await this.#walletService.getUserAddressWithDefault(); + if (current.toLowerCase() !== user.toLowerCase()) { + throw new Error(PERPS_ERROR_CODES.PROVIDER_NOT_AVAILABLE); + } + }; const { answered, positions } = await this.#queryDexPositions(dexName); if (!answered) { throw new Error(PERPS_ERROR_CODES.PROVIDER_NOT_AVAILABLE); } + await assertSameAccount(); const position = positions.find((item) => item.symbol === symbol); if (position) { return { marginMode: position.leverage.type, reason: 'position' }; } - const user = await this.#walletService.getUserAddressWithDefault(); const infoClient = this.#clientService.getInfoClient(); const [orders, twapHistory] = await Promise.all([ this.#fetchOpenOrders({ dexName }), infoClient.twapHistory({ user }), ]); + await assertSameAccount(); // Native TWAP schedules are absent from frontendOpenOrders before a slice // rests or fills. Read history directly: getTwapOrders can rebalance HIP-3 // collateral, which must not run as part of pre-sign validation. diff --git a/packages/perps-controller/src/providers/LighterProvider.ts b/packages/perps-controller/src/providers/LighterProvider.ts index 4f71bdeb2fe..40ca86035f0 100644 --- a/packages/perps-controller/src/providers/LighterProvider.ts +++ b/packages/perps-controller/src/providers/LighterProvider.ts @@ -7405,14 +7405,20 @@ export class LighterProvider implements PerpsProvider { * * @param symbol - Market symbol. * @returns The position's wire margin mode, or null when flat. - * @throws When the account or its positions cannot be read. + * @throws When the account or its positions cannot be read, including an + * account response without the account or its positions array. */ readonly #readPositionMarginMode = async ( symbol: string, ): Promise => { const accountIndex = await this.#ensureAccountIndex(); const response = await this.#clientService.getAccountByIndex(accountIndex); - const row = response.accounts?.[0]?.positions?.find( + const positions = response.accounts?.[0]?.positions; + // A missing account or positions array is not proof of a flat account. + if (!Array.isArray(positions)) { + throw new Error(PERPS_ERROR_CODES.PROVIDER_NOT_AVAILABLE); + } + const row = positions.find( (position) => position.symbol === symbol && parseFloat(position.position) !== 0, ); diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index 9711426e31e..a4f8cdc831a 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -1639,7 +1639,9 @@ export type MarginModeLockUnavailableReason = /** * Margin mode an asset is bound to on the venue. `locked` means an open * position or resting order/TWAP fixes the mode, so orders in the other mode - * are rejected; `unlocked` means either mode is accepted. + * are rejected; `unlocked` means nothing currently fixes the mode. It does not + * mean the market supports Cross: use `supportedMarginModes` from + * `getOrderCapabilities` for that. */ export type PerpsMarginModeLock = | Readonly<{ 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 2de54a4218e..21d145b7843 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 @@ -935,6 +935,28 @@ describe('HyperLiquidProvider', () => { expect(activeAssetData).not.toHaveBeenCalled(); }); + it('reports unavailable when the selected account changes mid-read', async () => { + mockWalletService.getUserAddressWithDefault + .mockResolvedValueOnce('0x1234567890123456789012345678901234567890') + .mockResolvedValueOnce('0x1234567890123456789012345678901234567890') + .mockResolvedValue('0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'); + const activeAssetData = jest.fn(); + mockInfoClient({ + clearinghouseState: jest.fn().mockResolvedValue({ assetPositions: [] }), + frontendOpenOrders: jest.fn().mockResolvedValue([{ coin: 'BTC' }]), + activeAssetData, + }); + + const lock = await provider.getMarginModeLock({ symbol: 'BTC' }); + + expect(lock).toStrictEqual({ + status: 'unavailable', + providerId: 'hyperliquid', + reason: 'provider_unavailable', + }); + expect(activeAssetData).not.toHaveBeenCalled(); + }); + it('reports unavailable when positions cannot be read', async () => { mockInfoClient({ clearinghouseState: jest.fn().mockRejectedValue(new Error('offline')), diff --git a/packages/perps-controller/tests/src/providers/LighterProvider.test.ts b/packages/perps-controller/tests/src/providers/LighterProvider.test.ts index c3ef9f81126..462adf3b83a 100644 --- a/packages/perps-controller/tests/src/providers/LighterProvider.test.ts +++ b/packages/perps-controller/tests/src/providers/LighterProvider.test.ts @@ -1488,6 +1488,25 @@ describe('LighterProvider', () => { expect(lock).toStrictEqual({ status: 'unlocked', providerId: 'lighter' }); }); + it.each([ + ['no account', { code: 200, accounts: [] }], + ['no positions array', { code: 200, accounts: [{ ...ACCOUNT, positions: undefined }] }], + ])( + 'reports unavailable when the account response has %s', + async (_case, response) => { + const { provider, clientInstance } = buildProvider(); + clientInstance.getAccountByIndex.mockResolvedValue(response); + + const lock = await provider.getMarginModeLock({ symbol: 'BTC' }); + + expect(lock).toStrictEqual({ + status: 'unavailable', + providerId: 'lighter', + reason: 'provider_unavailable', + }); + }, + ); + it('reports unavailable when the account read fails', async () => { const infra = createMockInfrastructure(); const { provider, clientInstance } = buildProvider({ From 2c5e30953d2215bca070875dbb542f970e44943c Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Thu, 24 Sep 2026 14:50:41 +0800 Subject: [PATCH 7/8] fix(perps): re-check the account after the asset-mode read The margin-mode lock read now confirms the selected account is unchanged after activeAssetData resolves, so a switch during that call reports the lock as unavailable instead of returning the previous account's mode. --- .../src/providers/HyperLiquidProvider.ts | 1 + ...yperLiquidProvider.advanced-orders.test.ts | 30 +++++++++++++++++++ .../src/providers/LighterProvider.test.ts | 5 +++- 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index f35ef4f75f9..44ac068be56 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -5300,6 +5300,7 @@ export class HyperLiquidProvider implements PerpsProvider { user, coin: symbol, }); + await assertSameAccount(); return { marginMode: asset.leverage.type, reason: 'open_order' }; } return null; 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 21d145b7843..6434213d558 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 @@ -957,6 +957,36 @@ describe('HyperLiquidProvider', () => { expect(activeAssetData).not.toHaveBeenCalled(); }); + it('reports unavailable when the selected account changes while the asset mode is read', async () => { + let resolveAssetData: (value: unknown) => void = () => undefined; + const activeAssetData = jest.fn().mockReturnValue( + new Promise((resolve) => { + resolveAssetData = resolve; + }), + ); + mockInfoClient({ + clearinghouseState: jest.fn().mockResolvedValue({ assetPositions: [] }), + frontendOpenOrders: jest.fn().mockResolvedValue([{ coin: 'BTC' }]), + activeAssetData, + }); + + const pendingLock = provider.getMarginModeLock({ symbol: 'BTC' }); + await new Promise(process.nextTick); + await new Promise(process.nextTick); + mockWalletService.getUserAddressWithDefault.mockResolvedValue( + '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', + ); + resolveAssetData({ leverage: { type: 'cross', value: 5 } }); + const lock = await pendingLock; + + expect(activeAssetData).toHaveBeenCalled(); + expect(lock).toStrictEqual({ + status: 'unavailable', + providerId: 'hyperliquid', + reason: 'provider_unavailable', + }); + }); + it('reports unavailable when positions cannot be read', async () => { mockInfoClient({ clearinghouseState: jest.fn().mockRejectedValue(new Error('offline')), diff --git a/packages/perps-controller/tests/src/providers/LighterProvider.test.ts b/packages/perps-controller/tests/src/providers/LighterProvider.test.ts index 462adf3b83a..aceb2d0293b 100644 --- a/packages/perps-controller/tests/src/providers/LighterProvider.test.ts +++ b/packages/perps-controller/tests/src/providers/LighterProvider.test.ts @@ -1490,7 +1490,10 @@ describe('LighterProvider', () => { it.each([ ['no account', { code: 200, accounts: [] }], - ['no positions array', { code: 200, accounts: [{ ...ACCOUNT, positions: undefined }] }], + [ + 'no positions array', + { code: 200, accounts: [{ ...ACCOUNT, positions: undefined }] }, + ], ])( 'reports unavailable when the account response has %s', async (_case, response) => { From 1827be4f9bd1964d4c3d03a982051df691fbad53 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Tue, 29 Sep 2026 20:07:47 +0800 Subject: [PATCH 8/8] test(perps): avoid unbound-method reference in getMarginModeLock test --- .../tests/src/providers/LighterProvider.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/perps-controller/tests/src/providers/LighterProvider.test.ts b/packages/perps-controller/tests/src/providers/LighterProvider.test.ts index a13db8d3ba0..bee1c363327 100644 --- a/packages/perps-controller/tests/src/providers/LighterProvider.test.ts +++ b/packages/perps-controller/tests/src/providers/LighterProvider.test.ts @@ -1512,6 +1512,7 @@ describe('LighterProvider', () => { it('reports unavailable when the account read fails', async () => { const infra = createMockInfrastructure(); + const logSpy = jest.spyOn(infra.debugLogger, 'log'); const { provider, clientInstance } = buildProvider({ platformDependencies: infra, }); @@ -1524,7 +1525,7 @@ describe('LighterProvider', () => { providerId: 'lighter', reason: 'provider_unavailable', }); - expect(infra.debugLogger.log).toHaveBeenCalledWith( + expect(logSpy).toHaveBeenCalledWith( '[LighterProvider] getMarginModeLock unavailable', { symbol: 'BTC', error: 'down' }, );