Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
74 changes: 58 additions & 16 deletions app/controllers/perps/providers/HyperLiquidProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1226,7 +1226,7 @@ describe('HyperLiquidProvider', () => {
);
});

it('validates price requirement before attempting order placement', async () => {
it('retries with adjusted USD when price-less order hits $10 minimum (uses fetched price from allMids)', async () => {
// Create provider with PUMP in the asset mapping
provider = createTestProvider({
initialAssetMapping: [
Expand Down Expand Up @@ -1256,24 +1256,38 @@ describe('HyperLiquidProvider', () => {
isBuy: true,
size: '2553',
orderType: 'market',
// No currentPrice: provider fetches live price (0.003918) and uses it
// for both validation and the $10-minimum retry path.
};

const mockOrder = jest
.fn()
.mockRejectedValueOnce(
new Error('Order must have minimum value of $10'),
)
.mockResolvedValueOnce({
status: 'ok',
response: {
data: {
statuses: [
{ filled: { oid: 123, totalSz: '2553', avgPx: '0.004' } },
],
},
},
});

mockClientService.getExchangeClient = jest.fn().mockReturnValue({
...createMockExchangeClient(),
order: jest
.fn()
.mockRejectedValueOnce(
new Error('Order must have minimum value of $10'),
),
order: mockOrder,
});

const result = await provider.placeOrder(orderParams);

expect(result.success).toBe(false);
expect(result.error).toBe(PERPS_ERROR_CODES.ORDER_PRICE_REQUIRED);
expect(
mockClientService.getExchangeClient().order,
).not.toHaveBeenCalled();
// The live price is fetched → validation passes → order is submitted →
// first call hits $10 minimum → retry uses fetched price to compute
// adjusted usdAmount → second call succeeds.
expect(result.success).toBe(true);
expect(mockOrder).toHaveBeenCalledTimes(2);
});

it('closes a position successfully', async () => {
Expand Down Expand Up @@ -2825,8 +2839,10 @@ describe('HyperLiquidProvider', () => {

const result = await provider.placeOrder(orderParams);

// allMids returns {} so #getOrFetchPrice parses price as 0, which is
// invalid. The error surfaces from #getAssetInfo before validation runs.
expect(result.success).toBe(false);
expect(result.error).toContain(PERPS_ERROR_CODES.ORDER_PRICE_REQUIRED);
expect(result.error).toContain('Invalid price for BTC: 0');
Comment thread
cursor[bot] marked this conversation as resolved.
});

it('handles missing position in close operation', async () => {
Expand Down Expand Up @@ -3545,19 +3561,45 @@ describe('HyperLiquidProvider', () => {
expect(result.error).toContain('Failed to update leverage');
});

it('fails market order without current price or usdAmount', async () => {
it('succeeds with market order without current price or usdAmount (uses fetched price)', async () => {
// The provider now fetches the live price before validation so callers
// that intentionally omit currentPrice (e.g. flipPosition) work correctly.
const orderParams: OrderParams = {
symbol: 'BTC',
isBuy: true,
size: '0.1',
orderType: 'market',
// No currentPrice or usdAmount provided - should fail validation
// No currentPrice or usdAmount: provider fetches live price (50000)
};

const result = await provider.placeOrder(orderParams);

expect(result.success).toBe(false);
expect(result.error).toContain(PERPS_ERROR_CODES.ORDER_PRICE_REQUIRED);
expect(result.success).toBe(true);
expect(mockClientService.getExchangeClient().order).toHaveBeenCalled();
});

it('placeOrder validates against fetched price when params omit currentPrice (flipPosition path)', async () => {
// Simulate the exact OrderParams shape that TradingService.flipPosition
// builds: symbol + isBuy + size + orderType + leverage, no price fields.
const flipOrderParams: OrderParams = {
symbol: 'BTC',
isBuy: false, // flipping long → short
size: '1', // 2× the 0.5 BTC position
orderType: 'market',
leverage: 10,
// currentPrice, usdAmount, price intentionally absent
};

const result = await provider.placeOrder(flipOrderParams);

// Live price (50000) is fetched from allMids → validation passes
// (0.1 BTC × $50 000 = $5 000 >> $10 minimum) → order executes.
expect(result.success).toBe(true);
expect(
mockClientService.getExchangeClient().order,
).toHaveBeenCalledWith(
expect.objectContaining({ orders: expect.any(Array) }),
);
});

it('handles order with custom slippage', async () => {
Expand Down
69 changes: 42 additions & 27 deletions app/controllers/perps/providers/HyperLiquidProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3543,6 +3543,9 @@ export class HyperLiquidProvider implements PerpsProvider {
* @returns A promise that resolves to the result.
*/
async placeOrder(params: OrderParams, retryCount = 0): Promise<OrderResult> {
// Hoisted so the retry path in the catch block can use the fetched price
// even when the caller (e.g. flipPosition) omits currentPrice from params.
let effectivePrice: number | undefined;
try {
this.#deps.debugLogger.log('Placing order via HyperLiquid SDK:', params);

Expand All @@ -3557,38 +3560,18 @@ export class HyperLiquidProvider implements PerpsProvider {
throw new Error(validation.error);
}

// Validate order at provider level (enforces USD validation rules)
await this.#validateOrderBeforePlacement(params);

// Ensure provider is ready for trading (includes signing operations)
await this.#ensureReadyForTrading();

// Debug: Log asset map state before order placement
const allMapKeys = Array.from(this.#symbolToAssetId.keys());
const hip3Keys = allMapKeys.filter((key) => key.includes(':'));
const assetExists = this.#symbolToAssetId.has(params.symbol);
this.#deps.debugLogger.log('Asset map state at order time', {
requestedCoin: params.symbol,
assetExistsInMap: assetExists,
totalAssetsInMap: this.#symbolToAssetId.size,
hip3AssetsCount: hip3Keys.length,
hip3AssetsSample: hip3Keys.slice(0, 10),
hip3Enabled: this.#hip3Enabled,
allowlistMarkets: this.#allowlistMarkets,
blocklistMarkets: this.#blocklistMarkets,
});

// Extract DEX name for API calls (main DEX = null)
const { dex: dexName } = parseAssetName(params.symbol);

// 1. Get asset info and current price
// 1. Get asset info and current price before validation so price-less
// callers (e.g. flipPosition) can validate against the live fetched price.
const { assetInfo, currentPrice, meta } = await this.#getAssetInfo({
symbol: params.symbol,
dexName,
});

// Allow override with UI-provided price (optimization to avoid API call)
const effectivePrice =
// Allow override with UI-provided price (optimization to avoid API call).
effectivePrice =
params.currentPrice && params.currentPrice > 0
? params.currentPrice
: currentPrice;
Expand All @@ -3601,6 +3584,35 @@ export class HyperLiquidProvider implements PerpsProvider {
});
}

// Validate order at provider level (enforces USD validation rules).
// Pass effectivePrice so price-less market orders (e.g. flipPosition)
// validate against the live fetched price instead of failing with
// ORDER_PRICE_REQUIRED.
await this.#validateOrderBeforePlacement({
...params,
currentPrice: effectivePrice,
});

// Ensure provider is ready for trading (includes signing operations).
// Kept after validation so invalid orders never trigger signature prompts
// (builder-fee approval, DEX abstraction enablement, etc.).
await this.#ensureReadyForTrading();

// Debug: Log asset map state before order placement
const allMapKeys = Array.from(this.#symbolToAssetId.keys());
const hip3Keys = allMapKeys.filter((key) => key.includes(':'));
const assetExists = this.#symbolToAssetId.has(params.symbol);
this.#deps.debugLogger.log('Asset map state at order time', {
requestedCoin: params.symbol,
assetExistsInMap: assetExists,
totalAssetsInMap: this.#symbolToAssetId.size,
hip3AssetsCount: hip3Keys.length,
hip3AssetsSample: hip3Keys.slice(0, 10),
hip3Enabled: this.#hip3Enabled,
allowlistMarkets: this.#allowlistMarkets,
blocklistMarkets: this.#blocklistMarkets,
});

// 2. Calculate final position size with USD reconciliation
const { finalPositionSize } = calculateFinalPositionSize({
usdAmount: params.usdAmount,
Expand Down Expand Up @@ -3706,10 +3718,13 @@ export class HyperLiquidProvider implements PerpsProvider {
// USD-based order: adjust the USD amount directly
originalValue = params.usdAmount;
adjustedUsdAmount = (parseFloat(params.usdAmount) * 1.015).toFixed(2);
} else if (params.currentPrice) {
// Size-based order: calculate USD from size and adjust
} else if (effectivePrice) {
// Size-based order: calculate USD from size and adjust.
// Use the hoisted effectivePrice (fetched live price) so callers that
// omit currentPrice (e.g. flipPosition) can still recover from the
// $10-minimum edge case.
const sizeValue = parseFloat(params.size);
const estimatedUsd = sizeValue * params.currentPrice;
const estimatedUsd = sizeValue * effectivePrice;
originalValue = `${estimatedUsd.toFixed(2)} (calculated from size ${params.size})`;
adjustedUsdAmount = (estimatedUsd * 1.015).toFixed(2);
} else {
Expand Down
Loading