diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index d7064e7f221..9111b3475a9 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -9,6 +9,9 @@ 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. ([#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)) - Persist the Isolated/Cross margin-mode pick per market and network in `tradeConfigurations[network][symbol].marginMode`, so clients can restore it after the order form remounts and share it across Mobile and Extension ([#10464](https://github.com/MetaMask/core/pull/10464)) - Add `getMarginMode(symbol)` and `saveMarginMode(symbol, marginMode)` methods, exposed as the `PerpsController:getMarginMode` and `PerpsController:saveMarginMode` messenger actions (`PerpsControllerGetMarginModeAction`, `PerpsControllerSaveMarginModeAction`). `saveMarginMode` ignores values other than `isolated` or `cross`. - Add the `selectMarginMode(state, symbol)` selector and an optional `marginMode` field on `TradeConfiguration`. diff --git a/packages/perps-controller/src/PerpsController-method-action-types.ts b/packages/perps-controller/src/PerpsController-method-action-types.ts index 1271dd001d6..de0636cbdad 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. * @@ -1386,6 +1399,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 cb33e71c0c8..1396298f145 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, @@ -110,6 +111,7 @@ import type { MarketInfo, Order, OrderCapabilitiesUnavailableReason, + MarginModeLockUnavailableReason, OrderDirection, OrderFill, OrderParams, @@ -117,6 +119,7 @@ import type { PerpsControllerConfig, PerpsMarketData, PerpsOrderCapabilities, + PerpsMarginModeLock, PerpsScalePriceLadder, ScalePriceLadderUnavailableReason, PerpsPendingManualRecovery, @@ -934,6 +937,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'getOrderBookGrouping', 'getOrderBookPreferences', 'getOrderCapabilities', + 'getMarginModeLock', 'getScalePriceLadder', 'getOrderFills', 'getOrders', @@ -2803,6 +2807,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 75afd7d8060..b5d402d54c4 100644 --- a/packages/perps-controller/src/index.ts +++ b/packages/perps-controller/src/index.ts @@ -103,6 +103,7 @@ export type { PerpsControllerGetOrderBookGroupingAction, PerpsControllerGetOrderBookPreferencesAction, PerpsControllerGetOrderCapabilitiesAction, + PerpsControllerGetMarginModeLockAction, PerpsControllerGetScalePriceLadderAction, PerpsControllerGetOrderFillsAction, PerpsControllerGetOrdersAction, @@ -295,6 +296,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 1f6f35d168b..5ddc0295f91 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'; @@ -1684,19 +1688,88 @@ 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; + } + + /** + * 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', + }; + } } /** @@ -5135,34 +5208,61 @@ 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); + 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, 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); } - const position = positions.find((item) => item.symbol === params.symbol); + await assertSameAccount(); + 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(); 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. @@ -5171,7 +5271,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 +5295,15 @@ 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); - } + await assertSameAccount(); + return { marginMode: asset.leverage.type, reason: 'open_order' }; } + return null; } /** diff --git a/packages/perps-controller/src/providers/LighterProvider.ts b/packages/perps-controller/src/providers/LighterProvider.ts index 194b0fccf08..2d8afb7d6a2 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,32 @@ 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, 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 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, + ); + 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 0a4aecc4f1f..dd3fb6d41cd 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -1620,6 +1620,44 @@ 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 nothing currently fixes the mode. It does not + * mean the market supports Cross: use `supportedMarginModes` from + * `getOrderCapabilities` for that. + */ +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. */ @@ -1654,6 +1692,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 = @@ -1948,6 +1992,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..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); @@ -1554,6 +1555,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..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 @@ -844,6 +844,164 @@ 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 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 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')), + }); + + 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; 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 f1a066ee667..4fa47b5a675 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 71d0ee2d6f3..bee1c363327 100644 --- a/packages/perps-controller/tests/src/providers/LighterProvider.test.ts +++ b/packages/perps-controller/tests/src/providers/LighterProvider.test.ts @@ -1433,6 +1433,105 @@ 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.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 logSpy = jest.spyOn(infra.debugLogger, 'log'); + 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(logSpy).toHaveBeenCalledWith( + '[LighterProvider] getMarginModeLock unavailable', + { symbol: 'BTC', error: 'down' }, + ); + }); + }); + describe('placeOrder', () => { it.each(['cross', 'isolated'] as const)( 'rejects explicit %s before signing',