Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/perps-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Before, they failed with the SDK's "Failed to sign the typed data using the wallet" message, or with `TPSL_UPDATE_FAILED` for a TP/SL update whose builder fee was not approved yet
- Covers orders, edits, single and batch cancels (TWAP, scale and chase cancels included), position closes, TP/SL updates and clears, margin updates, withdrawals and transfers between DEXs, including the HIP-3 transfers around an order
- HyperLiquid `cancelOrders` reports each order of a batch with its own result when an entry fails: orders the venue cancelled are no longer reported as failed with the batch's error ([#10559](https://github.com/MetaMask/core/pull/10559))
- HyperLiquid orders that set leverage without `marginMode` now keep the open position's margin mode instead of switching to isolated, so flipping a Cross position no longer fails with "Cannot switch leverage type with open position" ([#10588](https://github.com/MetaMask/core/pull/10588))

## [18.0.1]

Expand Down
37 changes: 36 additions & 1 deletion packages/perps-controller/src/providers/HyperLiquidProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5697,12 +5697,15 @@ export class HyperLiquidProvider implements PerpsProvider {
async #prepareAssetForTrading(
params: PrepareAssetForTradingParams,
): Promise<void> {
const { symbol, assetId, leverage, marginMode = 'isolated' } = params;
const { symbol, assetId, leverage } = params;

if (!leverage) {
return;
}

const marginMode =
params.marginMode ?? (await this.#resolveDefaultMarginMode(symbol));

this.#deps.debugLogger.log('Updating leverage before order:', {
symbol,
assetId,
Expand All @@ -5729,6 +5732,38 @@ export class HyperLiquidProvider implements PerpsProvider {
});
}

/**
* Resolve the margin mode for an order that omits one. HyperLiquid refuses
* to switch mode while a position is open, so an open position's mode wins
* over the isolated default (e.g. flipping a Cross position).
*
* @param symbol - Market symbol, including its DEX route when applicable.
* @returns The open position's mode, or isolated when there is none or it
* cannot be read.
*/
async #resolveDefaultMarginMode(symbol: string): Promise<MarginMode> {
const { dex: dexName } = parseAssetName(symbol);
try {
const positions = await this.#getPositionsForOperation(dexName ?? '');
return (
positions.find((position) => position.symbol === symbol)?.leverage
.type ?? 'isolated'
);
} catch (error) {
this.#deps.debugLogger.log(
'Open position margin mode unavailable: defaulting to isolated',
{
symbol,
error: ensureError(
error,
'HyperLiquidProvider.resolveDefaultMarginMode',
).message,
},
);
return 'isolated';
}
}

/**
* Handles HIP-3 pre-order balance management
*
Expand Down
3 changes: 2 additions & 1 deletion packages/perps-controller/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,8 @@ export type OrderParams = {
/**
* Explicit collateral mode. Requires leverage. HyperLiquid validates market
* support and refuses mode changes with an open position or resting order.
* Omit to retain the existing isolated-leverage behavior.
* Omit to let the provider choose: HyperLiquid keeps an open position's
* mode and otherwise uses isolated.
*/
marginMode?: MarginMode;
existingPositionLeverage?: number; // Existing position leverage for validation (protocol constraint)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
validateOrderParams,
validateWithdrawalParams,
} from '../../../src/utils/hyperLiquidValidation.js';
import { createMockPosition } from '../../helpers/providerMocks.js';
import {
createMockInfrastructure,
createMockMessenger,
Expand Down Expand Up @@ -844,6 +845,116 @@ describe('HyperLiquidProvider', () => {
});
});

describe('omitted margin mode', () => {
// Mirrors TradingService.flipPosition, which sends leverage without a mode.
const order: OrderParams = {
symbol: 'BTC',
isBuy: false,
size: '0.2',
orderType: 'market',
currentPrice: 50000,
leverage: 10,
};

const mockInfoClient = (overrides: Record<string, unknown>): void => {
mockClientService.getInfoClient.mockReturnValue(
createMockInfoClient(overrides) as unknown as ReturnType<
HyperLiquidClientService['getInfoClient']
>,
);
};

it.each(['cross', 'isolated'] as const)(
'keeps the open %s position mode when updating leverage',
async (positionMode) => {
mockInfoClient({
clearinghouseState: jest.fn().mockResolvedValue({
assetPositions: [
{
position: {
coin: 'BTC',
szi: '0.1',
entryPx: '50000',
positionValue: '5000',
unrealizedPnl: '0',
marginUsed: '500',
leverage: { type: positionMode, value: 10 },
liquidationPx: '45000',
maxLeverage: 50,
returnOnEquity: '0',
cumFunding: {
allTime: '0',
sinceOpen: '0',
sinceChange: '0',
},
},
type: 'oneWay',
},
],
}),
});

const result = await provider.placeOrder(order);

expect(result.success).toBe(true);
expect(
mockClientService.getExchangeClient().updateLeverage,
).toHaveBeenCalledWith({
asset: 0,
isCross: positionMode === 'cross',
leverage: 10,
});
},
);

it('reads the open position mode from fresh cached positions', async () => {
mockInfoClient({
clearinghouseState: jest.fn().mockResolvedValue({ assetPositions: [] }),
});
mockSubscriptionService.getCachedPositionsForDex.mockReturnValue([
createMockPosition({ leverage: { type: 'cross', value: 10 } }),
]);

const result = await provider.placeOrder(order);

expect(result.success).toBe(true);
expect(
mockSubscriptionService.getCachedPositionsForDex,
).toHaveBeenCalledWith('');
expect(
mockClientService.getExchangeClient().updateLeverage,
).toHaveBeenCalledWith({ asset: 0, isCross: true, leverage: 10 });
});

it('defaults to isolated without an open position in the market', async () => {
mockSubscriptionService.getCachedPositionsForDex.mockReturnValue([
createMockPosition({ symbol: 'ETH' }),
]);
mockInfoClient({
clearinghouseState: jest.fn().mockResolvedValue({ assetPositions: [] }),
});

const result = await provider.placeOrder(order);

expect(result.success).toBe(true);
expect(
mockClientService.getExchangeClient().updateLeverage,
).toHaveBeenCalledWith({ asset: 0, isCross: false, leverage: 10 });
});

it('defaults to isolated when positions cannot be read', async () => {
mockInfoClient({
clearinghouseState: jest.fn().mockRejectedValue(new Error('offline')),
});

await provider.placeOrder(order);

expect(
mockClientService.getExchangeClient().updateLeverage,
).toHaveBeenCalledWith({ asset: 0, isCross: false, leverage: 10 });
});
});

describe('getMarginModeLock', () => {
const mockInfoClient = (overrides: Record<string, unknown>): void => {
mockClientService.getInfoClient.mockReturnValue(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5021,17 +5021,22 @@ describe('HyperLiquidProvider - strategy order types', () => {
* moves to the xyz DEX before the order and back after it.
*
* @param orderResponse - The venue's answer to the order.
* @returns The provider's DEX transfers, which the test answers.
* @returns The provider's DEX transfers, which the test answers, and the
* mocked exchange client.
*/
const useHip3MarketOrder = (
orderResponse: Record<string, unknown>,
): { transfer: jest.SpyInstance; order: jest.Mock } => {
): {
transfer: jest.SpyInstance;
order: jest.Mock;
exchangeClient: MockClient;
} => {
let ordered = false;
const order = jest.fn(async () => {
ordered = true;
return orderResponse;
});
useStrategyClients({
const { exchangeClient } = useStrategyClients({
exchange: { order },
info: {
clearinghouseState: jest
Expand All @@ -5058,7 +5063,11 @@ describe('HyperLiquidProvider - strategy order types', () => {
useUnifiedAccount: false,
initialAssetMapping: [['xyz:TSLA', 110000]],
});
return { transfer: jest.spyOn(provider, 'transferBetweenDexs'), order };
return {
transfer: jest.spyOn(provider, 'transferBetweenDexs'),
order,
exchangeClient,
};
};

const HIP3_MARKET_ORDER = {
Expand Down Expand Up @@ -5116,6 +5125,35 @@ describe('HyperLiquidProvider - strategy order types', () => {
]) => [error.message, options.context.data.method],
);

it('reads an omitted margin mode from the HIP-3 DEX position slice', async () => {
const { transfer, exchangeClient } = useHip3MarketOrder(REFUSED_ORDER);
transfer.mockResolvedValueOnce({
success: false,
error: PERPS_ERROR_CODES.KEYRING_LOCKED,
});
// Only the xyz slice holds the position, so its mode proves the lookup
// routes a HIP-3 symbol to its own DEX cache.
mockSubscriptionService.getCachedPositionsForDex.mockImplementation(
(dexName: string) =>
dexName === 'xyz'
? [
createMockPosition({
symbol: 'xyz:TSLA',
leverage: { type: 'cross', value: 5 },
}),
]
: null,
);

await provider.placeOrder({ ...HIP3_MARKET_ORDER, leverage: 5 });

expect(exchangeClient.updateLeverage).toHaveBeenCalledWith({
asset: 110000,
isCross: true,
leverage: 5,
});
});

it('fails a HIP-3 order with KEYRING_LOCKED, sending nothing, when its collateral transfer cannot be signed', async () => {
const { transfer, order } = useHip3MarketOrder(REFUSED_ORDER);
transfer.mockResolvedValueOnce({
Expand Down
Loading