@metamask-previews/perps-controller 10.0.0-preview-a42e8d0d2 → 10.0.0-preview-5a03e1b92

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/dist/constants/eventNames.cjs +6 -0
  3. package/dist/constants/eventNames.cjs.map +1 -1
  4. package/dist/constants/eventNames.d.cts +4 -0
  5. package/dist/constants/eventNames.d.cts.map +1 -1
  6. package/dist/constants/eventNames.d.mts +4 -0
  7. package/dist/constants/eventNames.d.mts.map +1 -1
  8. package/dist/constants/eventNames.mjs +6 -0
  9. package/dist/constants/eventNames.mjs.map +1 -1
  10. package/dist/index.cjs +86 -74
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.cts +3 -1
  13. package/dist/index.d.cts.map +1 -1
  14. package/dist/index.d.mts +3 -1
  15. package/dist/index.d.mts.map +1 -1
  16. package/dist/index.mjs +2 -0
  17. package/dist/index.mjs.map +1 -1
  18. package/dist/perpsErrorCodes.cjs +16 -0
  19. package/dist/perpsErrorCodes.cjs.map +1 -1
  20. package/dist/perpsErrorCodes.d.cts +12 -0
  21. package/dist/perpsErrorCodes.d.cts.map +1 -1
  22. package/dist/perpsErrorCodes.d.mts +12 -0
  23. package/dist/perpsErrorCodes.d.mts.map +1 -1
  24. package/dist/perpsErrorCodes.mjs +16 -0
  25. package/dist/perpsErrorCodes.mjs.map +1 -1
  26. package/dist/providers/HyperLiquidProvider.cjs +674 -77
  27. package/dist/providers/HyperLiquidProvider.cjs.map +1 -1
  28. package/dist/providers/HyperLiquidProvider.d.cts +13 -0
  29. package/dist/providers/HyperLiquidProvider.d.cts.map +1 -1
  30. package/dist/providers/HyperLiquidProvider.d.mts +13 -0
  31. package/dist/providers/HyperLiquidProvider.d.mts.map +1 -1
  32. package/dist/providers/HyperLiquidProvider.mjs +676 -79
  33. package/dist/providers/HyperLiquidProvider.mjs.map +1 -1
  34. package/dist/selectors.cjs.map +1 -1
  35. package/dist/selectors.d.cts +17 -17
  36. package/dist/selectors.d.cts.map +1 -1
  37. package/dist/selectors.d.mts +17 -17
  38. package/dist/selectors.d.mts.map +1 -1
  39. package/dist/selectors.mjs.map +1 -1
  40. package/dist/services/HyperLiquidSubscriptionService.cjs +121 -11
  41. package/dist/services/HyperLiquidSubscriptionService.cjs.map +1 -1
  42. package/dist/services/HyperLiquidSubscriptionService.d.cts +21 -0
  43. package/dist/services/HyperLiquidSubscriptionService.d.cts.map +1 -1
  44. package/dist/services/HyperLiquidSubscriptionService.d.mts +21 -0
  45. package/dist/services/HyperLiquidSubscriptionService.d.mts.map +1 -1
  46. package/dist/services/HyperLiquidSubscriptionService.mjs +121 -11
  47. package/dist/services/HyperLiquidSubscriptionService.mjs.map +1 -1
  48. package/dist/services/TradingService.cjs +6 -2
  49. package/dist/services/TradingService.cjs.map +1 -1
  50. package/dist/services/TradingService.d.cts.map +1 -1
  51. package/dist/services/TradingService.d.mts.map +1 -1
  52. package/dist/services/TradingService.mjs +6 -2
  53. package/dist/services/TradingService.mjs.map +1 -1
  54. package/dist/types/index.cjs.map +1 -1
  55. package/dist/types/index.d.cts +69 -4
  56. package/dist/types/index.d.cts.map +1 -1
  57. package/dist/types/index.d.mts +69 -4
  58. package/dist/types/index.d.mts.map +1 -1
  59. package/dist/types/index.mjs.map +1 -1
  60. package/dist/types/perps-types.cjs.map +1 -1
  61. package/dist/types/perps-types.d.cts +35 -1
  62. package/dist/types/perps-types.d.cts.map +1 -1
  63. package/dist/types/perps-types.d.mts +35 -1
  64. package/dist/types/perps-types.d.mts.map +1 -1
  65. package/dist/types/perps-types.mjs.map +1 -1
  66. package/dist/utils/hyperLiquidAdapter.cjs +168 -10
  67. package/dist/utils/hyperLiquidAdapter.cjs.map +1 -1
  68. package/dist/utils/hyperLiquidAdapter.d.cts +35 -1
  69. package/dist/utils/hyperLiquidAdapter.d.cts.map +1 -1
  70. package/dist/utils/hyperLiquidAdapter.d.mts +35 -1
  71. package/dist/utils/hyperLiquidAdapter.d.mts.map +1 -1
  72. package/dist/utils/hyperLiquidAdapter.mjs +166 -11
  73. package/dist/utils/hyperLiquidAdapter.mjs.map +1 -1
  74. package/dist/utils/hyperLiquidValidation.cjs +160 -5
  75. package/dist/utils/hyperLiquidValidation.cjs.map +1 -1
  76. package/dist/utils/hyperLiquidValidation.d.cts +23 -4
  77. package/dist/utils/hyperLiquidValidation.d.cts.map +1 -1
  78. package/dist/utils/hyperLiquidValidation.d.mts +23 -4
  79. package/dist/utils/hyperLiquidValidation.d.mts.map +1 -1
  80. package/dist/utils/hyperLiquidValidation.mjs +160 -5
  81. package/dist/utils/hyperLiquidValidation.mjs.map +1 -1
  82. package/dist/utils/index.cjs +5 -1
  83. package/dist/utils/index.cjs.map +1 -1
  84. package/dist/utils/index.d.cts +2 -1
  85. package/dist/utils/index.d.cts.map +1 -1
  86. package/dist/utils/index.d.mts +2 -1
  87. package/dist/utils/index.d.mts.map +1 -1
  88. package/dist/utils/index.mjs +2 -1
  89. package/dist/utils/index.mjs.map +1 -1
  90. package/dist/utils/orderCalculations.cjs +363 -37
  91. package/dist/utils/orderCalculations.cjs.map +1 -1
  92. package/dist/utils/orderCalculations.d.cts +87 -2
  93. package/dist/utils/orderCalculations.d.cts.map +1 -1
  94. package/dist/utils/orderCalculations.d.mts +87 -2
  95. package/dist/utils/orderCalculations.d.mts.map +1 -1
  96. package/dist/utils/orderCalculations.mjs +359 -36
  97. package/dist/utils/orderCalculations.mjs.map +1 -1
  98. package/dist/utils/orderTypes.cjs +222 -0
  99. package/dist/utils/orderTypes.cjs.map +1 -0
  100. package/dist/utils/orderTypes.d.cts +114 -0
  101. package/dist/utils/orderTypes.d.cts.map +1 -0
  102. package/dist/utils/orderTypes.d.mts +114 -0
  103. package/dist/utils/orderTypes.d.mts.map +1 -0
  104. package/dist/utils/orderTypes.mjs +210 -0
  105. package/dist/utils/orderTypes.mjs.map +1 -0
  106. package/package.json +7 -6
@@ -2,6 +2,12 @@ import { BASIS_POINTS_DIVISOR } from "../constants/hyperLiquidConfig.mjs";
2
2
  import { MAX_ORDER_MARGIN_BUFFER, ORDER_SLIPPAGE_CONFIG } from "../constants/perpsConfig.mjs";
3
3
  import { PERPS_ERROR_CODES } from "../perpsErrorCodes.mjs";
4
4
  import { formatHyperLiquidPrice, formatHyperLiquidSize } from "./hyperLiquidAdapter.mjs";
5
+ import { getTriggerDirection, isLimitExecutionOrderType, isTriggerOrderType, toSDKTimeInForce } from "./orderTypes.mjs";
6
+ /**
7
+ * Tolerance used when deciding whether a scaled size is already on the size
8
+ * grid, guarding against floating-point representation error.
9
+ */
10
+ const FLOAT_TOLERANCE = 1e-6;
5
11
  /**
6
12
  * Calculate position size based on USD amount and asset price
7
13
  *
@@ -49,12 +55,23 @@ export function calculateMarginRequired(params) {
49
55
  return (amountNum / leverage).toFixed(2);
50
56
  }
51
57
  export function getMaxAllowedAmount(params) {
52
- const { spendableBalance, assetPrice, assetSzDecimals, leverage } = params;
58
+ const { spendableBalance, assetPrice, assetSzDecimals, leverage, orderType = 'market', limitPrice, } = params;
53
59
  if (spendableBalance === 0 || !assetPrice || assetSzDecimals === undefined) {
54
60
  return 0;
55
61
  }
56
- // The theoretical maximum is simply spendableBalance * leverage
57
- const theoreticalMax = spendableBalance * leverage;
62
+ // HyperLiquid reserves initial margin for a RESTING order against the price
63
+ // the order is submitted at, not the market price its size was derived from.
64
+ // A limit order resting above the market price - typically a sell - therefore
65
+ // needs more margin than a market-priced notional budgets for, and the
66
+ // exchange refuses it with "insufficient margin to place order". Price the max
67
+ // off that submitted price instead. A marketable order is charged at the fill
68
+ // price, so it needs no adjustment.
69
+ const executionPriceRatio = orderType === 'limit' && limitPrice && limitPrice > assetPrice
70
+ ? limitPrice / assetPrice
71
+ : 1;
72
+ // The theoretical maximum is spendableBalance * leverage, expressed in the
73
+ // market-price notional the caller works with.
74
+ const theoreticalMax = (spendableBalance * leverage) / executionPriceRatio;
58
75
  // But we need to account for position size rounding
59
76
  // Find the largest whole dollar amount that fits within this limit
60
77
  let maxAmount = Math.floor(theoreticalMax);
@@ -64,7 +81,7 @@ export function getMaxAllowedAmount(params) {
64
81
  price: assetPrice,
65
82
  szDecimals: assetSzDecimals,
66
83
  });
67
- const actualNotionalValue = parseFloat(testPositionSize) * assetPrice;
84
+ const actualNotionalValue = parseFloat(testPositionSize) * assetPrice * executionPriceRatio;
68
85
  const requiredMargin = actualNotionalValue / leverage;
69
86
  // If rounding caused us to exceed available balance, step down by one position increment
70
87
  if (requiredMargin > spendableBalance) {
@@ -77,6 +94,59 @@ export function getMaxAllowedAmount(params) {
77
94
  const bufferedMax = maxAmount * (1 - MAX_ORDER_MARGIN_BUFFER);
78
95
  return Math.max(0, Math.floor(bufferedMax));
79
96
  }
97
+ /**
98
+ * Round a size down onto the asset's size grid.
99
+ *
100
+ * Used for reduce-only orders, where rounding up would push the size past the
101
+ * live position size. Values already on the grid are snapped rather than
102
+ * truncated, because floating-point math can leave them just below a grid
103
+ * point (0.0123 * 10000 === 122.99999999999999) and truncating would drop a
104
+ * whole increment.
105
+ *
106
+ * The result is never greater than `size`, for negative sizes as well as
107
+ * positive: the snap only ever recovers a grid point the input already
108
+ * represents, so a value genuinely below a grid point is stepped down even when
109
+ * the tolerance would have reached the point above it.
110
+ *
111
+ * A size whose scaled form reaches `2^53` is returned unchanged: doubles cannot
112
+ * represent consecutive integers there, so the grid is finer than the spacing
113
+ * between representable values and there is nothing to round down to.
114
+ *
115
+ * @param size - Size to round down.
116
+ * @param szDecimals - The asset's size decimal precision.
117
+ * @returns The size rounded down onto the size grid, never exceeding `size`.
118
+ */
119
+ export function floorToSizeDecimals(size, szDecimals) {
120
+ const multiplier = Math.pow(10, szDecimals);
121
+ const scaled = size * multiplier;
122
+ // Past 2^53 a double cannot represent consecutive integers, so `units -= 1`
123
+ // below would be a no-op and the step-down loop would never terminate. The
124
+ // size grid is finer than the spacing between representable values at that
125
+ // magnitude, so there is no increment to shave: return the input unchanged.
126
+ if (!Number.isFinite(scaled) || Math.abs(scaled) >= Number.MAX_SAFE_INTEGER) {
127
+ return size;
128
+ }
129
+ const nearest = Math.round(scaled);
130
+ // The tolerance scales with the magnitude, because double-precision error
131
+ // does too: a fixed epsilon would stop absorbing representation error for
132
+ // sizes that scale past ~1e10 and would then shave off a whole increment.
133
+ const tolerance = Math.max(FLOAT_TOLERANCE, Math.abs(scaled) * Number.EPSILON * 8);
134
+ let units = Math.abs(scaled - nearest) < tolerance ? nearest : Math.floor(scaled);
135
+ // Step down until the result no longer exceeds the input. One pass is not
136
+ // enough: a tolerance wide enough to absorb representation error at large
137
+ // magnitudes also reaches the next grid point, and for an input less than half
138
+ // an ulp below a grid point `size * multiplier` evaluates to exactly that grid
139
+ // integer, so flooring the scaled value returns the same too-large result.
140
+ // The comparison alone is the whole termination condition: for a non-negative
141
+ // size the loop stops at or before zero, and for a negative size it stops once
142
+ // the value is no longer above the input. Guarding on `units` instead would
143
+ // skip a negative size below the tolerance, which snaps to `-0` — and
144
+ // `-0 !== 0` is false. The 2^53 bail-out above keeps this bounded.
145
+ while (units / multiplier > size) {
146
+ units -= 1;
147
+ }
148
+ return units / multiplier;
149
+ }
80
150
  /**
81
151
  * Calculates final position size using USD as source of truth with price validation
82
152
  *
@@ -87,34 +157,63 @@ export function getMaxAllowedAmount(params) {
87
157
  * @returns Final position size as a number
88
158
  */
89
159
  export function calculateFinalPositionSize(params) {
90
- const { usdAmount, size, currentPrice, priceAtCalculation, maxSlippageBps, szDecimals, leverage, debugLogger, } = params;
160
+ const { usdAmount, size, currentPrice, priceAtCalculation, maxSlippageBps, szDecimals, leverage, reduceOnly, debugLogger, } = params;
91
161
  let finalPositionSize;
162
+ // Validate price staleness whenever the caller supplied a calculation-time
163
+ // price. This runs before the sizing branches on purpose: a full close submits
164
+ // the exact live position size rather than a USD-derived one, and it must still
165
+ // be rejected when the price has moved past the caller's tolerance.
166
+ if (priceAtCalculation) {
167
+ const priceDeltaBps = Math.abs(((currentPrice - priceAtCalculation) / priceAtCalculation) * 10000);
168
+ const maxSlippageBpsValue = maxSlippageBps ?? ORDER_SLIPPAGE_CONFIG.DefaultMarketSlippageBps;
169
+ if (priceDeltaBps > maxSlippageBpsValue) {
170
+ throw new Error(`Price moved too much: ${priceDeltaBps.toFixed(0)} bps (max: ${maxSlippageBpsValue} bps). ` +
171
+ `Expected: ${priceAtCalculation.toFixed(2)}, Current: ${currentPrice.toFixed(2)}`);
172
+ }
173
+ debugLogger?.log('Price validation passed:', {
174
+ priceAtCalculation,
175
+ currentPrice,
176
+ deltaBps: priceDeltaBps.toFixed(2),
177
+ maxSlippageBps: maxSlippageBpsValue,
178
+ });
179
+ }
92
180
  if (usdAmount && parseFloat(usdAmount) > 0) {
93
181
  // USD amount provided - use it as source of truth
94
182
  const usdValue = parseFloat(usdAmount);
95
- // 1. Validate price staleness if priceAtCalculation provided
96
- if (priceAtCalculation) {
97
- const priceDeltaBps = Math.abs(((currentPrice - priceAtCalculation) / priceAtCalculation) * 10000);
98
- const maxSlippageBpsValue = maxSlippageBps ?? ORDER_SLIPPAGE_CONFIG.DefaultMarketSlippageBps;
99
- if (priceDeltaBps > maxSlippageBpsValue) {
100
- throw new Error(`Price moved too much: ${priceDeltaBps.toFixed(0)} bps (max: ${maxSlippageBpsValue} bps). ` +
101
- `Expected: ${priceAtCalculation.toFixed(2)}, Current: ${currentPrice.toFixed(2)}`);
183
+ // Recalculate position size with fresh price
184
+ finalPositionSize = usdValue / currentPrice;
185
+ // A reduce-only order may never exceed the size the caller asked to close:
186
+ // that size is already clamped to the live position, while the USD amount was
187
+ // computed against an older price and can imply a larger size after an
188
+ // adverse move. Capping here keeps USD accuracy in the common case and makes
189
+ // the caller's clamp binding.
190
+ if (reduceOnly && size) {
191
+ const requestedSize = parseFloat(size);
192
+ // A supplied size must be positive, or the cap below would submit a
193
+ // zero/negative order. Reject it rather than silently falling back to the
194
+ // USD-derived size, matching how closePosition treats the same input.
195
+ if (!Number.isFinite(requestedSize) || requestedSize <= 0) {
196
+ throw new Error(PERPS_ERROR_CODES.ORDER_SIZE_POSITIVE);
102
197
  }
103
- debugLogger?.log('Price validation passed:', {
104
- priceAtCalculation,
105
- currentPrice,
106
- deltaBps: priceDeltaBps.toFixed(2),
107
- maxSlippageBps: maxSlippageBpsValue,
108
- });
198
+ finalPositionSize = Math.min(finalPositionSize, requestedSize);
109
199
  }
110
- // 2. Recalculate position size with fresh price
111
- finalPositionSize = usdValue / currentPrice;
112
- // 3. Apply size decimals rounding
200
+ // 3. Apply size decimals rounding (reduce-only never rounds up)
113
201
  const multiplier = Math.pow(10, szDecimals);
114
- finalPositionSize = Math.round(finalPositionSize * multiplier) / multiplier;
115
- // 4. Ensure rounded size meets requested USD (fix validation gap)
202
+ const sizeBeforeRounding = finalPositionSize;
203
+ finalPositionSize = reduceOnly
204
+ ? floorToSizeDecimals(finalPositionSize, szDecimals)
205
+ : Math.round(finalPositionSize * multiplier) / multiplier;
206
+ // Rounding down can zero out a reduce-only order whose USD value is worth
207
+ // less than one size increment. Fail with a clear error instead of
208
+ // submitting a size of "0" the exchange will reject.
209
+ if (reduceOnly && finalPositionSize <= 0 && sizeBeforeRounding > 0) {
210
+ throw new Error(PERPS_ERROR_CODES.ORDER_SIZE_POSITIVE);
211
+ }
212
+ // 4. Ensure rounded size meets requested USD (fix validation gap).
213
+ // Skipped for reduce-only orders: adding an increment there would submit
214
+ // more than the position holds and HyperLiquid rejects the order.
116
215
  let actualNotionalValue = finalPositionSize * currentPrice;
117
- if (actualNotionalValue < usdValue) {
216
+ if (!reduceOnly && actualNotionalValue < usdValue) {
118
217
  // Add 1 minimum increment to meet requested USD
119
218
  finalPositionSize += 1 / multiplier;
120
219
  actualNotionalValue = finalPositionSize * currentPrice;
@@ -149,6 +248,23 @@ export function calculateFinalPositionSize(params) {
149
248
  else {
150
249
  // Legacy: Use provided size (backward compatibility)
151
250
  finalPositionSize = parseFloat(size ?? '0');
251
+ // Reduce-only sizes are formatted with toFixed() further down, which rounds
252
+ // up; truncate onto the size grid first so a close can never exceed the
253
+ // position it is closing.
254
+ if (reduceOnly) {
255
+ // A supplied size must be positive, or formatHyperLiquidSize would render
256
+ // a zero or negative order size. The USD branch above rejects the same
257
+ // input.
258
+ if (size && !(finalPositionSize > 0)) {
259
+ throw new Error(PERPS_ERROR_CODES.ORDER_SIZE_POSITIVE);
260
+ }
261
+ const sizeBeforeFlooring = finalPositionSize;
262
+ finalPositionSize = floorToSizeDecimals(finalPositionSize, szDecimals);
263
+ // A positive size that floors to zero is worth less than one increment
264
+ if (finalPositionSize <= 0 && sizeBeforeFlooring > 0) {
265
+ throw new Error(PERPS_ERROR_CODES.ORDER_SIZE_POSITIVE);
266
+ }
267
+ }
152
268
  debugLogger?.log('Using legacy size calculation (no USD amount provided):', {
153
269
  providedSize: size,
154
270
  finalSize: finalPositionSize,
@@ -163,10 +279,41 @@ export function calculateFinalPositionSize(params) {
163
279
  * @returns Formatted order price, size, and price string
164
280
  */
165
281
  export function calculateOrderPriceAndSize(params) {
166
- const { orderType, isBuy, finalPositionSize, currentPrice, limitPrice, maxSlippageBps, szDecimals, } = params;
282
+ const { orderType, isBuy, finalPositionSize, currentPrice, limitPrice, triggerPrice, maxSlippageBps, szDecimals, } = params;
167
283
  let orderPrice;
168
284
  let formattedSize;
169
- if (orderType === 'market') {
285
+ if (isTriggerOrderType(orderType)) {
286
+ // Trigger placements price off the trigger, not the live market: the order
287
+ // rests off-book until the trigger fires.
288
+ if (!triggerPrice) {
289
+ throw new Error(PERPS_ERROR_CODES.ORDER_TRIGGER_PRICE_REQUIRED);
290
+ }
291
+ const triggerPriceNum = parseFloat(triggerPrice);
292
+ if (isNaN(triggerPriceNum) || triggerPriceNum <= 0) {
293
+ throw new Error(PERPS_ERROR_CODES.ORDER_TRIGGER_PRICE_POSITIVE);
294
+ }
295
+ if (isLimitExecutionOrderType(orderType)) {
296
+ if (!limitPrice) {
297
+ throw new Error(PERPS_ERROR_CODES.ORDER_LIMIT_PRICE_REQUIRED);
298
+ }
299
+ orderPrice = parseFloat(limitPrice);
300
+ }
301
+ else {
302
+ // Market execution on trigger: HyperLiquid still needs a limit price, used
303
+ // as a slippage cap. The caller's tolerance wins when supplied; otherwise
304
+ // the 10% convention of the existing TP/SL children applies.
305
+ const effectiveBps = maxSlippageBps ?? ORDER_SLIPPAGE_CONFIG.DefaultTpslSlippageBps;
306
+ const slippageValue = effectiveBps / BASIS_POINTS_DIVISOR;
307
+ orderPrice = isBuy
308
+ ? triggerPriceNum * (1 + slippageValue)
309
+ : triggerPriceNum * (1 - slippageValue);
310
+ }
311
+ formattedSize = formatHyperLiquidSize({
312
+ size: finalPositionSize,
313
+ szDecimals,
314
+ });
315
+ }
316
+ else if (orderType === 'market') {
170
317
  // Market orders: apply slippage buffer to the live price so HyperLiquid
171
318
  // receives a worst-case acceptable limit price. Falls back to the
172
319
  // documented default if the caller does not provide one.
@@ -197,6 +344,173 @@ export function calculateOrderPriceAndSize(params) {
197
344
  });
198
345
  return { orderPrice, formattedSize, formattedPrice };
199
346
  }
347
+ /**
348
+ * Build the SDK order-type field for the main order.
349
+ *
350
+ * Trigger placements map to the SDK's trigger shape; everything else keeps the
351
+ * existing Gtc/FrontendMarket limit shape.
352
+ *
353
+ * @param params - Order type parameters
354
+ * @param params.orderType - Placement type
355
+ * @param params.timeInForce - Time in force; only limit orders may carry one
356
+ * @param params.triggerPrice - Trigger price (required for trigger placements)
357
+ * @param params.szDecimals - Asset size decimals, for price formatting
358
+ * @returns The SDK `t` field for the main order
359
+ */
360
+ function buildMainOrderTypeField(params) {
361
+ const { orderType, timeInForce, triggerPrice, szDecimals } = params;
362
+ if (!isTriggerOrderType(orderType)) {
363
+ if (orderType === 'limit') {
364
+ return { limit: { tif: toSDKTimeInForce(timeInForce) } };
365
+ }
366
+ if (timeInForce !== undefined) {
367
+ throw new Error(PERPS_ERROR_CODES.ORDER_TIME_IN_FORCE_NOT_SUPPORTED);
368
+ }
369
+ return { limit: { tif: 'FrontendMarket' } };
370
+ }
371
+ if (timeInForce !== undefined) {
372
+ throw new Error(PERPS_ERROR_CODES.ORDER_TIME_IN_FORCE_NOT_SUPPORTED);
373
+ }
374
+ if (!triggerPrice) {
375
+ throw new Error(PERPS_ERROR_CODES.ORDER_TRIGGER_PRICE_REQUIRED);
376
+ }
377
+ return {
378
+ trigger: {
379
+ isMarket: !isLimitExecutionOrderType(orderType),
380
+ triggerPx: formatTriggerPrice({
381
+ price: triggerPrice,
382
+ szDecimals,
383
+ error: PERPS_ERROR_CODES.ORDER_TRIGGER_PRICE_POSITIVE,
384
+ }),
385
+ tpsl: getTriggerDirection(orderType) === 'stop' ? 'sl' : 'tp',
386
+ },
387
+ };
388
+ }
389
+ /**
390
+ * Format a price that becomes a `triggerPx`, rejecting one that disappears at
391
+ * the asset's precision.
392
+ *
393
+ * A positive price below the asset's tick (`0.0004` where the asset quotes to
394
+ * three places) formats to `'0'`, which the exchange rejects. Callers validate
395
+ * this up front via `validateOrderPrecision`; this is the guard on the build
396
+ * path itself, so no caller can assemble an order that cannot be accepted.
397
+ *
398
+ * @param params - Price parameters
399
+ * @param params.price - The requested price
400
+ * @param params.szDecimals - Asset size decimals
401
+ * @param params.error - Typed error to throw when the price rounds away
402
+ * @returns The exchange-formatted price, guaranteed positive.
403
+ */
404
+ function formatTriggerPrice(params) {
405
+ const { price, szDecimals, error } = params;
406
+ const formatted = formatHyperLiquidPrice({ price, szDecimals });
407
+ if (parseFloat(formatted) <= 0) {
408
+ throw new Error(error);
409
+ }
410
+ return formatted;
411
+ }
412
+ /**
413
+ * Resolve the size of an attached TP/SL order.
414
+ *
415
+ * @param params - Size parameters
416
+ * @param params.tpslSize - Requested partial size, if any
417
+ * @param params.formattedSize - Full order size, used when no partial size is given
418
+ * @param params.szDecimals - Asset size decimals
419
+ * @returns The exchange-formatted TP/SL order size
420
+ */
421
+ function formatTpslSize(params) {
422
+ const { tpslSize, formattedSize, szDecimals } = params;
423
+ if (tpslSize === undefined) {
424
+ return formattedSize;
425
+ }
426
+ // Validation compares the requested size against `params.size`, but a
427
+ // usdAmount-based order is finally sized from a fresher price, so the parent
428
+ // can end up smaller than the child that validated cleanly. Clamp so the
429
+ // attached TP/SL never exceeds the order it protects.
430
+ const requested = parseFloat(tpslSize);
431
+ const parentSize = parseFloat(formattedSize);
432
+ const size = Number.isFinite(parentSize) && Number.isFinite(requested)
433
+ ? Math.min(requested, parentSize)
434
+ : requested;
435
+ return formatPartialTpslSize({ size, szDecimals });
436
+ }
437
+ /**
438
+ * Check that an order's prices and partial sizes survive the asset's precision.
439
+ *
440
+ * Validation elsewhere sees the values the caller supplied; this sees what the
441
+ * exchange will actually receive. A positive value below the asset's tick
442
+ * formats to `'0'`, which either changes the order's meaning (a zero-sized
443
+ * trigger covers the whole position) or is rejected outright (a zero
444
+ * `triggerPx`).
445
+ *
446
+ * Callers run this before taking any side effect — cancelling the position's
447
+ * existing triggers, changing leverage, moving HIP-3 margin — so a value that
448
+ * would only fail once the orders are built cannot leave a position stripped of
449
+ * its protection, or an account with leverage moved, for an order that was
450
+ * never going to be accepted.
451
+ *
452
+ * @param params - Price and size parameters
453
+ * @param params.triggerPrice - Trigger price for a trigger placement, if any
454
+ * @param params.takeProfitPrice - Attached take profit price, if any
455
+ * @param params.stopLossPrice - Attached stop loss price, if any
456
+ * @param params.takeProfitSize - Requested partial take profit size, if any
457
+ * @param params.stopLossSize - Requested partial stop loss size, if any
458
+ * @param params.szDecimals - Asset size decimals
459
+ * @returns Validation result with isValid flag and optional error message
460
+ */
461
+ export function validateOrderPrecision(params) {
462
+ const { triggerPrice, takeProfitPrice, stopLossPrice, takeProfitSize, stopLossSize, szDecimals, } = params;
463
+ for (const size of [takeProfitSize, stopLossSize]) {
464
+ if (size === undefined) {
465
+ continue;
466
+ }
467
+ if (parseFloat(formatHyperLiquidSize({ size, szDecimals })) <= 0) {
468
+ return {
469
+ isValid: false,
470
+ error: PERPS_ERROR_CODES.ORDER_TPSL_SIZE_INVALID,
471
+ };
472
+ }
473
+ }
474
+ // Prices carry their own precision: an asset quotes to
475
+ // `MaxPriceDecimals - szDecimals` places, so a positive price under that tick
476
+ // formats to '0'. Every one of these becomes a `triggerPx` the exchange
477
+ // rejects outright.
478
+ const priceChecks = [
479
+ [triggerPrice, PERPS_ERROR_CODES.ORDER_TRIGGER_PRICE_POSITIVE],
480
+ [takeProfitPrice, PERPS_ERROR_CODES.ORDER_PRICE_POSITIVE],
481
+ [stopLossPrice, PERPS_ERROR_CODES.ORDER_PRICE_POSITIVE],
482
+ ];
483
+ for (const [price, error] of priceChecks) {
484
+ if (price === undefined) {
485
+ continue;
486
+ }
487
+ if (parseFloat(formatHyperLiquidPrice({ price, szDecimals })) <= 0) {
488
+ return { isValid: false, error };
489
+ }
490
+ }
491
+ return { isValid: true };
492
+ }
493
+ /**
494
+ * Format a partial TP/SL size, rejecting one that disappears at the asset
495
+ * precision.
496
+ *
497
+ * Validation only sees the requested size, so a positive value below the
498
+ * asset's precision (0.0004 against `szDecimals: 3`) passes and then formats to
499
+ * `'0'`. HyperLiquid reads a zero-sized trigger as covering the whole position,
500
+ * which would silently turn a partial TP/SL into a full close.
501
+ *
502
+ * @param params - Size parameters
503
+ * @param params.size - The requested partial size
504
+ * @param params.szDecimals - Asset size decimals
505
+ * @returns The exchange-formatted size, guaranteed positive.
506
+ */
507
+ export function formatPartialTpslSize(params) {
508
+ const formatted = formatHyperLiquidSize(params);
509
+ if (parseFloat(formatted) <= 0) {
510
+ throw new Error(PERPS_ERROR_CODES.ORDER_TPSL_SIZE_INVALID);
511
+ }
512
+ return formatted;
513
+ }
200
514
  /**
201
515
  * Builds orders array including main order and optional TP/SL orders
202
516
  *
@@ -204,7 +518,7 @@ export function calculateOrderPriceAndSize(params) {
204
518
  * @returns Array of SDK order params and grouping type
205
519
  */
206
520
  export function buildOrdersArray(params) {
207
- const { assetId, isBuy, formattedPrice, formattedSize, reduceOnly, orderType, clientOrderId, takeProfitPrice, stopLossPrice, szDecimals, grouping, } = params;
521
+ const { assetId, isBuy, formattedPrice, formattedSize, reduceOnly, orderType, timeInForce, clientOrderId, triggerPrice, takeProfitPrice, stopLossPrice, takeProfitSize, stopLossSize, szDecimals, grouping, } = params;
208
522
  const orders = [];
209
523
  // 1. Main order
210
524
  const mainOrder = {
@@ -213,9 +527,12 @@ export function buildOrdersArray(params) {
213
527
  p: formattedPrice,
214
528
  s: formattedSize,
215
529
  r: reduceOnly || false,
216
- t: orderType === 'limit'
217
- ? { limit: { tif: 'Gtc' } }
218
- : { limit: { tif: 'FrontendMarket' } },
530
+ t: buildMainOrderTypeField({
531
+ orderType,
532
+ timeInForce,
533
+ triggerPrice,
534
+ szDecimals,
535
+ }),
219
536
  c: clientOrderId ? clientOrderId : undefined,
220
537
  };
221
538
  orders.push(mainOrder);
@@ -228,14 +545,19 @@ export function buildOrdersArray(params) {
228
545
  price: parseFloat(takeProfitPrice),
229
546
  szDecimals,
230
547
  }),
231
- s: formattedSize,
548
+ s: formatTpslSize({
549
+ tpslSize: takeProfitSize,
550
+ formattedSize,
551
+ szDecimals,
552
+ }),
232
553
  r: true,
233
554
  t: {
234
555
  trigger: {
235
556
  isMarket: false,
236
- triggerPx: formatHyperLiquidPrice({
237
- price: parseFloat(takeProfitPrice),
557
+ triggerPx: formatTriggerPrice({
558
+ price: takeProfitPrice,
238
559
  szDecimals,
560
+ error: PERPS_ERROR_CODES.ORDER_PRICE_POSITIVE,
239
561
  }),
240
562
  tpsl: 'tp',
241
563
  },
@@ -259,14 +581,15 @@ export function buildOrdersArray(params) {
259
581
  price: limitPriceWithSlippage,
260
582
  szDecimals,
261
583
  }),
262
- s: formattedSize,
584
+ s: formatTpslSize({ tpslSize: stopLossSize, formattedSize, szDecimals }),
263
585
  r: true,
264
586
  t: {
265
587
  trigger: {
266
588
  isMarket: true,
267
- triggerPx: formatHyperLiquidPrice({
268
- price: stopLossPriceNum,
589
+ triggerPx: formatTriggerPrice({
590
+ price: stopLossPrice,
269
591
  szDecimals,
592
+ error: PERPS_ERROR_CODES.ORDER_PRICE_POSITIVE,
270
593
  }),
271
594
  tpsl: 'sl',
272
595
  },