@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
@@ -9,7 +9,7 @@ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (
9
9
  if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
10
10
  return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
11
11
  };
12
- var _HyperLiquidProvider_instances, _HyperLiquidProvider_deps, _HyperLiquidProvider_clientService, _HyperLiquidProvider_walletService, _HyperLiquidProvider_subscriptionService, _HyperLiquidProvider_symbolToAssetId, _HyperLiquidProvider_userFeeCache, _HyperLiquidProvider_maxLeverageCache, _HyperLiquidProvider_cachedMetaByDex, _HyperLiquidProvider_cachedMarketDataWithPrices, _HyperLiquidProvider_cachedSpotMeta, _HyperLiquidProvider_dexDiscoveryCache, _HyperLiquidProvider_referralCheckCache, _HyperLiquidProvider_builderFeeCheckCache, _HyperLiquidProvider_ensureReadyPromise, _HyperLiquidProvider_pendingBuilderFeeApprovals, _HyperLiquidProvider_compiledAllowlistPatterns, _HyperLiquidProvider_compiledBlocklistPatterns, _HyperLiquidProvider_userFeeDiscountBips, _HyperLiquidProvider_hip3Enabled, _HyperLiquidProvider_allowlistMarkets, _HyperLiquidProvider_blocklistMarkets, _HyperLiquidProvider_useUnifiedAccount, _HyperLiquidProvider_dexDiscoveryComplete, _HyperLiquidProvider_unifiedAccountSetupNeedsRetry, _HyperLiquidProvider_pendingValidatedDexsPromise, _HyperLiquidProvider_cachedUsdcTokenId, _HyperLiquidProvider_errorMappings, _HyperLiquidProvider_clientsInitialized, _HyperLiquidProvider_initializationPromise, _HyperLiquidProvider_messenger, _HyperLiquidProvider_builderAddressTestnet, _HyperLiquidProvider_builderAddressMainnet, _HyperLiquidProvider_priceDeviationLimit, _HyperLiquidProvider_compilePatternsSafely, _HyperLiquidProvider_ensureClientsInitialized, _HyperLiquidProvider_isWalletOnHyperliquid, _HyperLiquidProvider_ensureUnifiedAccountEnabled, _HyperLiquidProvider_ensureReady, _HyperLiquidProvider_tradingSetupPromise, _HyperLiquidProvider_tradingSetupComplete, _HyperLiquidProvider_ensureReadyForTrading, _HyperLiquidProvider_getOrFetchPrice, _HyperLiquidProvider_filterFills, _HyperLiquidProvider_getAllAvailableDexs, _HyperLiquidProvider_getValidatedDexs, _HyperLiquidProvider_fetchValidatedDexsInternal, _HyperLiquidProvider_getCachedMeta, _HyperLiquidProvider_backfillAssetMapForDex, _HyperLiquidProvider_getAssetIdWithRepair, _HyperLiquidProvider_getCachedSpotMeta, _HyperLiquidProvider_getCachedPerpDexs, _HyperLiquidProvider_calculateHip3FeeMultiplier, _HyperLiquidProvider_getCacheKey, _HyperLiquidProvider_fetchMarketsForDex, _HyperLiquidProvider_getUsdcTokenId, _HyperLiquidProvider_isUsdcCollateralDex, _HyperLiquidProvider_buildAssetMapping, _HyperLiquidProvider_queryUserDataAcrossDexs, _HyperLiquidProvider_mapError, _HyperLiquidProvider_getErrorContext, _HyperLiquidProvider_checkBuilderFeeApproval, _HyperLiquidProvider_ensureBuilderFeeApproval, _HyperLiquidProvider_checkBuilderFeeStatus, _HyperLiquidProvider_getBalanceForDex, _HyperLiquidProvider_findSourceDexWithBalance, _HyperLiquidProvider_autoTransferForHip3Order, _HyperLiquidProvider_autoTransferBackAfterClose, _HyperLiquidProvider_calculateHip3RequiredMargin, _HyperLiquidProvider_handleHip3PostOrderRebalance, _HyperLiquidProvider_handleHip3OrderRollback, _HyperLiquidProvider_validateOrderBeforePlacement, _HyperLiquidProvider_getAssetInfo, _HyperLiquidProvider_prepareAssetForTrading, _HyperLiquidProvider_handleHip3PreOrder, _HyperLiquidProvider_submitOrderWithRollback, _HyperLiquidProvider_handleOrderError, _HyperLiquidProvider_getStandaloneValidatedDexs, _HyperLiquidProvider_getAllMids, _HyperLiquidProvider_fetchSingleDexFresh, _HyperLiquidProvider_excludeNonUsdcCollateralResults, _HyperLiquidProvider_mergeDexResultsInto, _HyperLiquidProvider_cacheFreshMarketDataSnapshot, _HyperLiquidProvider_getStaleMarketDataSnapshot, _HyperLiquidProvider_isFeeCacheValid, _HyperLiquidProvider_getBuilderAddress, _HyperLiquidProvider_getReferralCode, _HyperLiquidProvider_ensureReferralSet, _HyperLiquidProvider_isReferralCodeReady, _HyperLiquidProvider_checkReferralSet, _HyperLiquidProvider_setReferralCode;
12
+ var _HyperLiquidProvider_instances, _HyperLiquidProvider_deps, _HyperLiquidProvider_clientService, _HyperLiquidProvider_walletService, _HyperLiquidProvider_subscriptionService, _HyperLiquidProvider_symbolToAssetId, _HyperLiquidProvider_userFeeCache, _HyperLiquidProvider_maxLeverageCache, _HyperLiquidProvider_cachedMetaByDex, _HyperLiquidProvider_cachedMarketDataWithPrices, _HyperLiquidProvider_cachedSpotMeta, _HyperLiquidProvider_dexDiscoveryCache, _HyperLiquidProvider_referralCheckCache, _HyperLiquidProvider_builderFeeCheckCache, _HyperLiquidProvider_ensureReadyPromise, _HyperLiquidProvider_pendingBuilderFeeApprovals, _HyperLiquidProvider_compiledAllowlistPatterns, _HyperLiquidProvider_compiledBlocklistPatterns, _HyperLiquidProvider_userFeeDiscountBips, _HyperLiquidProvider_hip3Enabled, _HyperLiquidProvider_allowlistMarkets, _HyperLiquidProvider_blocklistMarkets, _HyperLiquidProvider_useUnifiedAccount, _HyperLiquidProvider_dexDiscoveryComplete, _HyperLiquidProvider_unifiedAccountSetupNeedsRetry, _HyperLiquidProvider_pendingValidatedDexsPromise, _HyperLiquidProvider_cachedUsdcTokenId, _HyperLiquidProvider_errorMappings, _HyperLiquidProvider_clientsInitialized, _HyperLiquidProvider_initializationPromise, _HyperLiquidProvider_messenger, _HyperLiquidProvider_builderAddressTestnet, _HyperLiquidProvider_builderAddressMainnet, _HyperLiquidProvider_priceDeviationLimit, _HyperLiquidProvider_compilePatternsSafely, _HyperLiquidProvider_ensureClientsInitialized, _HyperLiquidProvider_isWalletOnHyperliquid, _HyperLiquidProvider_ensureUnifiedAccountEnabled, _HyperLiquidProvider_ensureReady, _HyperLiquidProvider_tradingSetupPromise, _HyperLiquidProvider_tradingSetupComplete, _HyperLiquidProvider_ensureReadyForTrading, _HyperLiquidProvider_getOrFetchPrice, _HyperLiquidProvider_filterFills, _HyperLiquidProvider_getAllAvailableDexs, _HyperLiquidProvider_getValidatedDexs, _HyperLiquidProvider_fetchValidatedDexsInternal, _HyperLiquidProvider_getCachedMeta, _HyperLiquidProvider_backfillAssetMapForDex, _HyperLiquidProvider_getAssetIdWithRepair, _HyperLiquidProvider_getCachedSpotMeta, _HyperLiquidProvider_getCachedPerpDexs, _HyperLiquidProvider_calculateHip3FeeMultiplier, _HyperLiquidProvider_getCacheKey, _HyperLiquidProvider_fetchMarketsForDex, _HyperLiquidProvider_getUsdcTokenId, _HyperLiquidProvider_isUsdcCollateralDex, _HyperLiquidProvider_buildAssetMapping, _HyperLiquidProvider_queryUserDataAcrossDexs, _HyperLiquidProvider_mapError, _HyperLiquidProvider_getErrorContext, _HyperLiquidProvider_checkBuilderFeeApproval, _HyperLiquidProvider_ensureBuilderFeeApproval, _HyperLiquidProvider_checkBuilderFeeStatus, _HyperLiquidProvider_getBalanceForDex, _HyperLiquidProvider_findSourceDexWithBalance, _HyperLiquidProvider_autoTransferForHip3Order, _HyperLiquidProvider_autoTransferBackAfterClose, _HyperLiquidProvider_calculateHip3RequiredMargin, _HyperLiquidProvider_handleHip3PostOrderRebalance, _HyperLiquidProvider_handleHip3OrderRollback, _HyperLiquidProvider_validateOrderBeforePlacement, _HyperLiquidProvider_getAssetInfo, _HyperLiquidProvider_prepareAssetForTrading, _HyperLiquidProvider_handleHip3PreOrder, _HyperLiquidProvider_submitOrderWithRollback, _HyperLiquidProvider_handleOrderError, _HyperLiquidProvider_fetchOpenOrders, _HyperLiquidProvider_resolveReplacementOrderId, _HyperLiquidProvider_getStandaloneValidatedDexs, _HyperLiquidProvider_queryDexPositions, _HyperLiquidProvider_getAllMids, _HyperLiquidProvider_fetchSingleDexFresh, _HyperLiquidProvider_excludeNonUsdcCollateralResults, _HyperLiquidProvider_mergeDexResultsInto, _HyperLiquidProvider_cacheFreshMarketDataSnapshot, _HyperLiquidProvider_getStaleMarketDataSnapshot, _HyperLiquidProvider_isFeeCacheValid, _HyperLiquidProvider_getBuilderAddress, _HyperLiquidProvider_getReferralCode, _HyperLiquidProvider_ensureReferralSet, _HyperLiquidProvider_isReferralCodeReady, _HyperLiquidProvider_checkReferralSet, _HyperLiquidProvider_setReferralCode;
13
13
  import { hasProperty } from "@metamask/utils";
14
14
  import { v4 as uuidv4 } from "uuid";
15
15
  import { PERPS_EVENT_PROPERTY, PERPS_EVENT_VALUE } from "../constants/eventNames.mjs";
@@ -27,11 +27,12 @@ import { PerpsAnalyticsEvent } from "../types/index.mjs";
27
27
  import { addSpotBalanceToAccountState, aggregateAccountStates } from "../utils/accountUtils.mjs";
28
28
  import { ensureError, isHyperLiquidUserNotFoundError, isKeyringLockedError } from "../utils/errorUtils.mjs";
29
29
  import { shouldDeferUnifiedAccountSetup } from "../utils/hyperLiquidAbstraction.mjs";
30
- import { adaptAccountStateFromSDK, adaptHyperLiquidLedgerUpdateToUserHistoryItem, adaptMarketFromSDK, adaptOrderFromSDK, adaptPositionFromSDK, buildAssetMapping, formatHyperLiquidPrice, formatHyperLiquidSize, parseAssetName } from "../utils/hyperLiquidAdapter.mjs";
30
+ import { adaptAccountStateFromSDK, adaptHyperLiquidLedgerUpdateToUserHistoryItem, adaptMarketFromSDK, adaptOrderFromSDK, adaptPositionFromSDK, adaptPositionTriggerOrderFromSDK, adaptTpslLinkageToGrouping, buildAssetMapping, formatHyperLiquidPrice, formatHyperLiquidSize, parseAssetName } from "../utils/hyperLiquidAdapter.mjs";
31
31
  import { createErrorResult, getMaxOrderValue, getSupportedPaths, validateAssetSupport, validateBalance, validateCoinExists, validateDepositParams, validateOrderParams, validateWithdrawalParams } from "../utils/hyperLiquidValidation.mjs";
32
32
  import { transformMarketData } from "../utils/marketDataTransform.mjs";
33
33
  import { compileMarketPattern, shouldIncludeMarket } from "../utils/marketUtils.mjs";
34
- import { buildOrdersArray, calculateFinalPositionSize, calculateOrderPriceAndSize } from "../utils/orderCalculations.mjs";
34
+ import { buildOrdersArray, calculateFinalPositionSize, calculateOrderPriceAndSize, floorToSizeDecimals, formatPartialTpslSize, validateOrderPrecision } from "../utils/orderCalculations.mjs";
35
+ import { getTriggerExecution, isLimitExecutionOrderType, isTriggerOrderType, toSDKTimeInForce } from "../utils/orderTypes.mjs";
35
36
  import { createStandaloneInfoClient, queryStandaloneClearinghouseStates, queryStandaloneOpenOrders } from "../utils/standaloneInfoClient.mjs";
36
37
  // getStreamManagerInstance removed: use this.#deps.streamManager instead
37
38
  /**
@@ -42,6 +43,80 @@ import { createStandaloneInfoClient, queryStandaloneClearinghouseStates, querySt
42
43
  * @returns The result of the operation.
43
44
  */
44
45
  const isStatusObject = (status) => typeof status === 'object' && status !== null;
46
+ /**
47
+ * Collect the order IDs of every TP/SL child carried by a parent order.
48
+ *
49
+ * HyperLiquid lists `normalTpsl` children both nested under their parent and as
50
+ * top-level entries in `frontendOpenOrders`. Those children protect the pending
51
+ * parent order rather than the position, so callers use this set to exclude them.
52
+ *
53
+ * @param orders - Raw frontend open orders for the account.
54
+ * @returns The set of child order IDs.
55
+ */
56
+ function collectChildOrderIds(orders) {
57
+ const childOrderIds = new Set();
58
+ orders.forEach((order) => {
59
+ order.children?.forEach((child) => {
60
+ childOrderIds.add(child.oid);
61
+ });
62
+ });
63
+ return childOrderIds;
64
+ }
65
+ /**
66
+ * Group orders by market, so a per-position pass does not rescan every order.
67
+ *
68
+ * @param orders - Raw frontend open orders across all DEXs.
69
+ * @returns Orders keyed by market symbol.
70
+ */
71
+ function groupOrdersBySymbol(orders) {
72
+ const bySymbol = new Map();
73
+ orders.forEach((order) => {
74
+ const existing = bySymbol.get(order.coin);
75
+ if (existing) {
76
+ existing.push(order);
77
+ }
78
+ else {
79
+ bySymbol.set(order.coin, [order]);
80
+ }
81
+ });
82
+ return bySymbol;
83
+ }
84
+ /**
85
+ * Build the trigger-order view of a position: position-bound TP/SL plus
86
+ * standalone (partial) reduce-only triggers on the same market, de-duplicated by
87
+ * order ID and excluding children of pending parent orders.
88
+ *
89
+ * @param params - Collection parameters.
90
+ * @param params.orders - Raw frontend open orders across all DEXs.
91
+ * @param params.position - Position the triggers are attached to.
92
+ * @param params.childOrderIds - Order IDs that belong to a pending parent order.
93
+ * @returns The take profit and stop loss trigger orders for the position.
94
+ */
95
+ function collectPositionTriggerOrders(params) {
96
+ const { orders, position, childOrderIds } = params;
97
+ const byOrderId = new Map();
98
+ orders.forEach((rawOrder) => {
99
+ if (rawOrder.coin !== position.symbol ||
100
+ !rawOrder.isTrigger ||
101
+ !rawOrder.reduceOnly ||
102
+ childOrderIds.has(rawOrder.oid)) {
103
+ return;
104
+ }
105
+ const triggerOrder = adaptPositionTriggerOrderFromSDK({
106
+ rawOrder,
107
+ positionSize: position.size,
108
+ entryPrice: position.entryPrice,
109
+ });
110
+ if (triggerOrder && !byOrderId.has(triggerOrder.orderId)) {
111
+ byOrderId.set(triggerOrder.orderId, triggerOrder);
112
+ }
113
+ });
114
+ const triggerOrders = Array.from(byOrderId.values());
115
+ return {
116
+ takeProfitOrders: triggerOrders.filter((order) => order.direction === 'take_profit'),
117
+ stopLossOrders: triggerOrders.filter((order) => order.direction !== 'take_profit'),
118
+ };
119
+ }
45
120
  /**
46
121
  * HyperLiquid provider implementation
47
122
  *
@@ -295,6 +370,14 @@ export class HyperLiquidProvider {
295
370
  size: params.size,
296
371
  price: params.price,
297
372
  orderType: params.orderType,
373
+ triggerPrice: params.triggerPrice,
374
+ takeProfitPrice: params.takeProfitPrice,
375
+ stopLossPrice: params.stopLossPrice,
376
+ takeProfitSize: params.takeProfitSize,
377
+ stopLossSize: params.stopLossSize,
378
+ tpslLinkage: params.tpslLinkage,
379
+ grouping: params.grouping,
380
+ timeInForce: params.timeInForce,
298
381
  });
299
382
  if (!validation.isValid) {
300
383
  throw new Error(validation.error);
@@ -307,6 +390,22 @@ export class HyperLiquidProvider {
307
390
  symbol: params.symbol,
308
391
  dexName,
309
392
  });
393
+ // A price or partial size that rounds away at the asset precision is
394
+ // caught here, as soon as szDecimals is known and before anything is
395
+ // committed: the signing prompts in #ensureReadyForTrading, the leverage
396
+ // change in #prepareAssetForTrading, and the HIP-3 margin transfer all
397
+ // come later.
398
+ const precision = validateOrderPrecision({
399
+ triggerPrice: params.triggerPrice,
400
+ takeProfitPrice: params.takeProfitPrice,
401
+ stopLossPrice: params.stopLossPrice,
402
+ takeProfitSize: params.takeProfitSize,
403
+ stopLossSize: params.stopLossSize,
404
+ szDecimals: assetInfo.szDecimals,
405
+ });
406
+ if (!precision.isValid) {
407
+ throw new Error(precision.error);
408
+ }
310
409
  // Allow override with UI-provided price (optimization to avoid API call).
311
410
  effectivePrice =
312
411
  params.currentPrice && params.currentPrice > 0
@@ -359,6 +458,7 @@ export class HyperLiquidProvider {
359
458
  maxSlippageBps: normalizedMaxSlippageBps,
360
459
  szDecimals: assetInfo.szDecimals,
361
460
  leverage: params.leverage,
461
+ reduceOnly: params.reduceOnly,
362
462
  });
363
463
  const { orderPrice, formattedSize, formattedPrice } = calculateOrderPriceAndSize({
364
464
  orderType: params.orderType,
@@ -366,6 +466,7 @@ export class HyperLiquidProvider {
366
466
  finalPositionSize,
367
467
  currentPrice: effectivePrice,
368
468
  limitPrice: params.price,
469
+ triggerPrice: params.triggerPrice,
369
470
  maxSlippageBps: normalizedMaxSlippageBps,
370
471
  szDecimals: assetInfo.szDecimals,
371
472
  });
@@ -410,11 +511,19 @@ export class HyperLiquidProvider {
410
511
  formattedSize,
411
512
  reduceOnly: params.reduceOnly ?? false,
412
513
  orderType: params.orderType,
514
+ timeInForce: params.timeInForce,
413
515
  clientOrderId: params.clientOrderId,
516
+ triggerPrice: params.triggerPrice,
414
517
  takeProfitPrice: params.takeProfitPrice,
415
518
  stopLossPrice: params.stopLossPrice,
519
+ takeProfitSize: params.takeProfitSize,
520
+ stopLossSize: params.stopLossSize,
416
521
  szDecimals: assetInfo.szDecimals,
417
- grouping: params.grouping,
522
+ // The provider-agnostic linkage wins; `grouping` is the deprecated
523
+ // HyperLiquid-shaped spelling kept for existing callers.
524
+ grouping: params.tpslLinkage
525
+ ? adaptTpslLinkageToGrouping(params.tpslLinkage)
526
+ : params.grouping,
418
527
  });
419
528
  // 8. Submit order with atomic rollback
420
529
  return await __classPrivateFieldGet(this, _HyperLiquidProvider_instances, "m", _HyperLiquidProvider_submitOrderWithRollback).call(this, {
@@ -433,7 +542,13 @@ export class HyperLiquidProvider {
433
542
  const errorMessage = ensureError(error, 'HyperLiquidProvider.placeOrder').message;
434
543
  const isMinimumOrderError = errorMessage.includes('Order must have minimum value of $10') ||
435
544
  errorMessage.includes('Order 0: Order must have minimum value');
436
- if (isMinimumOrderError && retryCount === 0) {
545
+ // Reduce-only orders are excluded. The retry works by growing the order
546
+ // 1.5%, which a close cannot do: a full close already submits the whole
547
+ // position, and a partial close is capped at the size the caller asked to
548
+ // close, so the retry would either be rejected as "Reduce only order would
549
+ // increase position" or resubmit an identical order. Surfacing the
550
+ // minimum-value error names the real problem instead.
551
+ if (isMinimumOrderError && retryCount === 0 && !params.reduceOnly) {
437
552
  let adjustedUsdAmount;
438
553
  let originalValue;
439
554
  if (params.usdAmount) {
@@ -500,20 +615,84 @@ export class HyperLiquidProvider {
500
615
  error: PERPS_ERROR_CODES.ORDER_SIZE_POSITIVE,
501
616
  };
502
617
  }
618
+ // `modify` rebuilds an order as a plain limit/market order, so a trigger
619
+ // on either side of the edit would be silently dropped. Reject a resting
620
+ // trigger order as well as an edit *into* one; cancel and re-place instead.
621
+ if (isTriggerOrderType(params.newOrder.orderType)) {
622
+ return {
623
+ success: false,
624
+ error: PERPS_ERROR_CODES.ORDER_EDIT_TRIGGER_UNSUPPORTED,
625
+ };
626
+ }
627
+ // The WebSocket order cache is the cheap source for the resting order's
628
+ // placement type, but it may be cold or stale.
629
+ const cachedRestingOrder = __classPrivateFieldGet(this, _HyperLiquidProvider_subscriptionService, "f")
630
+ .getOrdersCacheIfInitialized()
631
+ ?.find((order) => order.orderId === params.orderId.toString());
632
+ if (cachedRestingOrder?.isTrigger === true) {
633
+ return {
634
+ success: false,
635
+ error: PERPS_ERROR_CODES.ORDER_EDIT_TRIGGER_UNSUPPORTED,
636
+ };
637
+ }
503
638
  // Validate new order parameters
504
639
  const validation = validateOrderParams({
505
640
  coin: params.newOrder.symbol,
506
641
  size: params.newOrder.size,
507
642
  price: params.newOrder.price,
508
643
  orderType: params.newOrder.orderType,
644
+ triggerPrice: params.newOrder.triggerPrice,
645
+ takeProfitPrice: params.newOrder.takeProfitPrice,
646
+ stopLossPrice: params.newOrder.stopLossPrice,
647
+ takeProfitSize: params.newOrder.takeProfitSize,
648
+ stopLossSize: params.newOrder.stopLossSize,
649
+ tpslLinkage: params.newOrder.tpslLinkage,
650
+ grouping: params.newOrder.grouping,
651
+ timeInForce: params.newOrder.timeInForce,
509
652
  });
510
653
  if (!validation.isValid) {
511
654
  throw new Error(validation.error);
512
655
  }
513
- // Ensure provider is ready for trading (includes signing operations)
514
- await __classPrivateFieldGet(this, _HyperLiquidProvider_instances, "m", _HyperLiquidProvider_ensureReadyForTrading).call(this);
515
656
  // Extract DEX name for API calls (main DEX = null)
516
657
  const { dex: dexName } = parseAssetName(params.newOrder.symbol);
658
+ // Initialization only — clients and the asset mapping. The signing half
659
+ // of readiness is deferred until after the checks below, so a refused
660
+ // edit never prompts for a signature or writes an approval.
661
+ await __classPrivateFieldGet(this, _HyperLiquidProvider_instances, "m", _HyperLiquidProvider_ensureReady).call(this);
662
+ // What is resting before the edit serves two purposes, and they carry
663
+ // different weight. Verifying the target is REQUIRED when the cache could
664
+ // not do it — an unverified edit can rebuild a protective stop as a plain
665
+ // order — so that read must fail closed. Providing a baseline for the
666
+ // optional orderId resolution is not: when the cache already confirmed the
667
+ // order, a failed read must not sink a modify that would otherwise
668
+ // succeed, exactly as the post-modify lookup does not.
669
+ let ordersBeforeEdit;
670
+ if (cachedRestingOrder === undefined) {
671
+ ordersBeforeEdit = await __classPrivateFieldGet(this, _HyperLiquidProvider_instances, "m", _HyperLiquidProvider_fetchOpenOrders).call(this, { dexName });
672
+ const restingOrder = ordersBeforeEdit.find((order) => order.oid.toString() === params.orderId.toString());
673
+ if (!restingOrder) {
674
+ return {
675
+ success: false,
676
+ error: PERPS_ERROR_CODES.ORDER_EDIT_ORDER_UNVERIFIABLE,
677
+ };
678
+ }
679
+ if (restingOrder.isTrigger) {
680
+ return {
681
+ success: false,
682
+ error: PERPS_ERROR_CODES.ORDER_EDIT_TRIGGER_UNSUPPORTED,
683
+ };
684
+ }
685
+ }
686
+ else {
687
+ try {
688
+ ordersBeforeEdit = await __classPrivateFieldGet(this, _HyperLiquidProvider_instances, "m", _HyperLiquidProvider_fetchOpenOrders).call(this, { dexName });
689
+ }
690
+ catch (error) {
691
+ // Only the optional identity baseline is lost. Without it novelty
692
+ // cannot be judged, so the id is omitted below rather than guessed.
693
+ __classPrivateFieldGet(this, _HyperLiquidProvider_deps, "f").debugLogger.log('Could not read the pre-edit orders baseline:', error);
694
+ }
695
+ }
517
696
  // Get asset info and prices (uses cache to avoid redundant API calls)
518
697
  const meta = await __classPrivateFieldGet(this, _HyperLiquidProvider_instances, "m", _HyperLiquidProvider_getCachedMeta).call(this, { dexName });
519
698
  // asset.name format: "BTC" for main DEX, "xyz:XYZ100" for HIP-3
@@ -553,14 +732,20 @@ export class HyperLiquidProvider {
553
732
  p: formattedPrice,
554
733
  s: formattedSize,
555
734
  r: params.newOrder.reduceOnly ?? false,
556
- // Same TIF logic as placeOrder - see documentation above for details
735
+ // Same TIF logic as placeOrder - see documentation above for details.
736
+ // A limit order honours the caller's time in force; validation above has
737
+ // already rejected one on any other order shape.
557
738
  t: params.newOrder.orderType === 'limit'
558
- ? { limit: { tif: 'Gtc' } } // Standard limit order
739
+ ? { limit: { tif: toSDKTimeInForce(params.newOrder.timeInForce) } }
559
740
  : { limit: { tif: 'FrontendMarket' } }, // True market order
560
741
  c: params.newOrder.clientOrderId
561
742
  ? params.newOrder.clientOrderId
562
743
  : undefined,
563
744
  };
745
+ // Every refusal is behind us, so the setup that may prompt for signatures
746
+ // and write builder-fee/referral approvals can run now — a rejected edit
747
+ // costs the caller nothing, matching placeOrder and updatePositionTPSL.
748
+ await __classPrivateFieldGet(this, _HyperLiquidProvider_instances, "m", _HyperLiquidProvider_ensureReadyForTrading).call(this);
564
749
  // Submit modification via SDK
565
750
  const exchangeClient = __classPrivateFieldGet(this, _HyperLiquidProvider_clientService, "f").getExchangeClient();
566
751
  const result = await exchangeClient.modify({
@@ -572,9 +757,25 @@ export class HyperLiquidProvider {
572
757
  if (result.status !== 'ok') {
573
758
  throw new Error(`Order modification failed: ${JSON.stringify(result)}`);
574
759
  }
760
+ // `params.orderId` is the order that was just REPLACED, so returning it
761
+ // as OrderResult.orderId (documented as the exchange order ID) names an
762
+ // order the venue has already cancelled. Report the replacement when it
763
+ // can be resolved unambiguously, and otherwise omit the optional id
764
+ // rather than fabricate identity.
765
+ const replacementOrderId = ordersBeforeEdit === undefined
766
+ ? undefined
767
+ : await __classPrivateFieldGet(this, _HyperLiquidProvider_instances, "m", _HyperLiquidProvider_resolveReplacementOrderId).call(this, {
768
+ previousOrders: ordersBeforeEdit,
769
+ dexName,
770
+ symbol: params.newOrder.symbol,
771
+ isBuy: params.newOrder.isBuy,
772
+ size: formattedSize,
773
+ });
575
774
  return {
576
775
  success: true,
577
- orderId: params.orderId.toString(),
776
+ ...(replacementOrderId === undefined
777
+ ? {}
778
+ : { orderId: replacementOrderId }),
578
779
  };
579
780
  }
580
781
  catch (error) {
@@ -741,10 +942,18 @@ export class HyperLiquidProvider {
741
942
  ...new Set(positionsToClose.map((pos) => parseAssetName(pos.symbol).dex ?? 'main')),
742
943
  ];
743
944
  await Promise.all(uniqueDexs.map((dex) => __classPrivateFieldGet(this, _HyperLiquidProvider_instances, "m", _HyperLiquidProvider_getCachedMeta).call(this, { dexName: dex === 'main' ? null : dex })));
744
- // Track HIP-3 positions and freed margins for post-close transfers
745
- const hip3Transfers = [];
746
- // Build orders array
945
+ // Freed-margin transfer for each submitted order, or null when that order
946
+ // needs none. One entry per order rather than one per HIP-3 position: a
947
+ // compacted list read with the response-status index credits the wrong
948
+ // order in a mixed main-DEX/HIP-3 batch.
949
+ const orderedHip3Transfers = [];
950
+ // Build orders array, plus the positions each order closes so response
951
+ // statuses stay index-aligned when a position is skipped below
747
952
  const orders = [];
953
+ const orderedPositions = [];
954
+ // Positions no order could be built for. Reported as failures so a caller
955
+ // cannot read "closed everything" from a result that left one open.
956
+ const skippedResults = [];
748
957
  for (const position of positionsToClose) {
749
958
  // Extract DEX name for HIP-3 positions
750
959
  const { dex: dexName } = parseAssetName(position.symbol);
@@ -766,13 +975,29 @@ export class HyperLiquidProvider {
766
975
  const isBuy = positionSize < 0; // Close opposite side
767
976
  const closeSize = Math.abs(positionSize);
768
977
  const totalMarginUsed = parseFloat(position.marginUsed);
769
- // Track HIP-3 transfers (full position close means all margin is freed)
770
- if (isHip3Position && dexName && !__classPrivateFieldGet(this, _HyperLiquidProvider_useUnifiedAccount, "f")) {
771
- hip3Transfers.push({
772
- sourceDex: dexName,
773
- freedMargin: totalMarginUsed,
978
+ // formatHyperLiquidSize() below rounds half-up, so floor onto the size
979
+ // grid first: a reduce-only order rounded above the position is rejected
980
+ // with "Reduce only order would increase position".
981
+ const flooredCloseSize = floorToSizeDecimals(closeSize, assetInfo.szDecimals);
982
+ // A dust position worth less than one size increment floors to 0, which
983
+ // would submit a zero-size order. Skip it rather than sending an order
984
+ // the exchange must reject; the remaining positions still close, and the
985
+ // skip is reported as a failure below.
986
+ if (flooredCloseSize <= 0) {
987
+ __classPrivateFieldGet(this, _HyperLiquidProvider_deps, "f").debugLogger.log('Skipping position smaller than one size increment', { coin: position.symbol, size: position.size });
988
+ skippedResults.push({
989
+ symbol: position.symbol,
990
+ success: false,
991
+ error: PERPS_ERROR_CODES.ORDER_SIZE_POSITIVE,
774
992
  });
993
+ continue;
775
994
  }
995
+ // Track this order's HIP-3 transfer, if it needs one (a full position
996
+ // close frees all of its margin). Pushed below alongside the order so the
997
+ // two stay index-aligned.
998
+ const hip3Transfer = isHip3Position && dexName && !__classPrivateFieldGet(this, _HyperLiquidProvider_useUnifiedAccount, "f")
999
+ ? { sourceDex: dexName, freedMargin: totalMarginUsed }
1000
+ : null;
776
1001
  const currentPrice = await __classPrivateFieldGet(this, _HyperLiquidProvider_instances, "m", _HyperLiquidProvider_getOrFetchPrice).call(this, {
777
1002
  symbol: position.symbol,
778
1003
  dexName: dexName ?? null,
@@ -782,9 +1007,8 @@ export class HyperLiquidProvider {
782
1007
  const orderPrice = isBuy
783
1008
  ? currentPrice * (1 + slippage)
784
1009
  : currentPrice * (1 - slippage);
785
- // Format size and price
786
1010
  const formattedSize = formatHyperLiquidSize({
787
- size: closeSize,
1011
+ size: flooredCloseSize,
788
1012
  szDecimals: assetInfo.szDecimals,
789
1013
  });
790
1014
  const formattedPrice = formatHyperLiquidPrice({
@@ -800,6 +1024,19 @@ export class HyperLiquidProvider {
800
1024
  r: true, // reduceOnly
801
1025
  t: { limit: { tif: 'Ioc' } }, // Immediate or cancel for market-like execution
802
1026
  });
1027
+ orderedPositions.push(position);
1028
+ orderedHip3Transfers.push(hip3Transfer);
1029
+ }
1030
+ // Every position was smaller than one size increment. Return their
1031
+ // failures rather than an empty result, which would be indistinguishable
1032
+ // from "no positions matched".
1033
+ if (orders.length === 0) {
1034
+ return {
1035
+ success: false,
1036
+ successCount: 0,
1037
+ failureCount: skippedResults.length,
1038
+ results: skippedResults,
1039
+ };
803
1040
  }
804
1041
  // Calculate discounted builder fee if reward discount is active
805
1042
  let builderFee = BUILDER_FEE_CONFIG.MaxFeeTenthsBps;
@@ -819,16 +1056,17 @@ export class HyperLiquidProvider {
819
1056
  const { statuses } = result.response.data;
820
1057
  const successCount = statuses.filter((stat) => isStatusObject(stat) &&
821
1058
  (hasProperty(stat, 'filled') || hasProperty(stat, 'resting'))).length;
822
- const failureCount = statuses.length - successCount;
1059
+ const failureCount = statuses.length - successCount + skippedResults.length;
823
1060
  // Handle HIP-3 margin transfers for successful closes
824
1061
  if (!__classPrivateFieldGet(this, _HyperLiquidProvider_useUnifiedAccount, "f")) {
825
1062
  for (let i = 0; i < statuses.length; i++) {
826
1063
  const status = statuses[i];
827
1064
  const isSuccess = isStatusObject(status) &&
828
1065
  (hasProperty(status, 'filled') || hasProperty(status, 'resting'));
829
- if (isSuccess && hip3Transfers[i]) {
830
- const { sourceDex, freedMargin } = hip3Transfers[i];
831
- __classPrivateFieldGet(this, _HyperLiquidProvider_deps, "f").debugLogger.log('Position closed successfully, initiating manual auto-transfer back', { symbol: positionsToClose[i].symbol, freedMargin });
1066
+ const transfer = orderedHip3Transfers[i];
1067
+ if (isSuccess && transfer) {
1068
+ const { sourceDex, freedMargin } = transfer;
1069
+ __classPrivateFieldGet(this, _HyperLiquidProvider_deps, "f").debugLogger.log('Position closed successfully, initiating manual auto-transfer back', { symbol: orderedPositions[i].symbol, freedMargin });
832
1070
  // Non-blocking: Transfer freed margin back to main DEX
833
1071
  await __classPrivateFieldGet(this, _HyperLiquidProvider_instances, "m", _HyperLiquidProvider_autoTransferBackAfterClose).call(this, {
834
1072
  sourceDex,
@@ -837,18 +1075,29 @@ export class HyperLiquidProvider {
837
1075
  }
838
1076
  }
839
1077
  }
840
- return {
841
- success: successCount > 0,
842
- successCount,
843
- failureCount,
844
- results: statuses.map((status, index) => ({
845
- symbol: positionsToClose[index].symbol,
1078
+ // Index submitted and skipped outcomes by symbol so `results` can keep the
1079
+ // order of the requested positions: consumers may correlate them by index.
1080
+ const submittedResults = new Map(statuses.map((status, index) => [
1081
+ orderedPositions[index].symbol,
1082
+ {
1083
+ symbol: orderedPositions[index].symbol,
846
1084
  success: isStatusObject(status) &&
847
1085
  (hasProperty(status, 'filled') || hasProperty(status, 'resting')),
848
1086
  error: isStatusObject(status) && hasProperty(status, 'error')
849
1087
  ? String(status.error)
850
1088
  : undefined,
851
- })),
1089
+ },
1090
+ ]));
1091
+ const skippedBySymbol = new Map(skippedResults.map((skipped) => [skipped.symbol, skipped]));
1092
+ return {
1093
+ success: successCount > 0,
1094
+ successCount,
1095
+ failureCount,
1096
+ results: positionsToClose.flatMap((position) => {
1097
+ const outcome = submittedResults.get(position.symbol) ??
1098
+ skippedBySymbol.get(position.symbol);
1099
+ return outcome ? [outcome] : [];
1100
+ }),
852
1101
  };
853
1102
  }
854
1103
  catch (error) {
@@ -884,18 +1133,35 @@ export class HyperLiquidProvider {
884
1133
  * 1. 'normalTpsl' - Tied to a parent order (set when placing the order)
885
1134
  * 2. 'positionTpsl' - Tied to a position (can be set/modified after fill)
886
1135
  *
1136
+ * Partial TP/SL: when `takeProfitSize` or `stopLossSize` is supplied, the
1137
+ * orders cannot use 'positionTpsl' (which always covers the whole position and
1138
+ * requires size 0). They are submitted as standalone reduce-only trigger orders
1139
+ * with 'na' grouping and explicit sizes instead.
1140
+ *
1141
+ * Note that the pre-cancel sweep clears every standalone reduce-only trigger
1142
+ * on the symbol — whether this update is partial or whole-position — not only
1143
+ * the ones this method placed. A trigger the caller placed independently
1144
+ * through `placeOrder` (for example a manual reduce-only stop) is therefore
1145
+ * cancelled too. Only TP/SL children of another pending order are protected.
1146
+ *
887
1147
  * @param params - The operation parameters.
888
1148
  * @param params.symbol - Asset symbol of the position
889
1149
  * @param params.takeProfitPrice - TP price (undefined to remove)
890
1150
  * @param params.stopLossPrice - SL price (undefined to remove)
1151
+ * @param params.takeProfitSize - Partial TP size (undefined for the whole position)
1152
+ * @param params.stopLossSize - Partial SL size (undefined for the whole position)
891
1153
  * @returns A promise that resolves to the result.
892
1154
  */
893
1155
  async updatePositionTPSL(params) {
894
1156
  try {
895
1157
  __classPrivateFieldGet(this, _HyperLiquidProvider_deps, "f").debugLogger.log('Updating position TP/SL:', params);
896
- const { symbol, takeProfitPrice, stopLossPrice, position: livePosition, } = params;
897
- // Ensure provider is ready for trading (includes signing operations)
898
- await __classPrivateFieldGet(this, _HyperLiquidProvider_instances, "m", _HyperLiquidProvider_ensureReadyForTrading).call(this);
1158
+ const { symbol, takeProfitPrice, stopLossPrice, takeProfitSize, stopLossSize, position: livePosition, } = params;
1159
+ const isPartialTpsl = takeProfitSize !== undefined || stopLossSize !== undefined;
1160
+ // Basic initialization only. The trading setup that can prompt a hardware
1161
+ // wallet and write the referral / builder-fee approvals is deferred until
1162
+ // every validation below has passed, so a rejected update leaves nothing
1163
+ // behind.
1164
+ await __classPrivateFieldGet(this, _HyperLiquidProvider_instances, "m", _HyperLiquidProvider_ensureReady).call(this);
899
1165
  // Use live position (from WebSocket) if available, otherwise fetch via REST
900
1166
  // Preferring WebSocket data avoids rate limiting issues with the REST API
901
1167
  let position = livePosition;
@@ -925,12 +1191,67 @@ export class HyperLiquidProvider {
925
1191
  }
926
1192
  const positionSize = Math.abs(parseFloat(position.size));
927
1193
  const isLong = parseFloat(position.size) > 0;
928
- // Get clients for API calls (ensureReady already called at method start)
1194
+ // Partial TP/SL sizes must be positive, paired with their price, and no
1195
+ // larger than the position they close.
1196
+ const tpslSizeValidation = validateOrderParams({
1197
+ coin: symbol,
1198
+ size: positionSize.toString(),
1199
+ takeProfitPrice,
1200
+ stopLossPrice,
1201
+ takeProfitSize,
1202
+ stopLossSize,
1203
+ });
1204
+ if (!tpslSizeValidation.isValid) {
1205
+ return {
1206
+ success: false,
1207
+ error: tpslSizeValidation.error,
1208
+ };
1209
+ }
1210
+ // Get clients for API calls (#ensureReady already called at method start).
1211
+ // Holding the exchange client reference is not itself a write; it is only
1212
+ // used below, after the trading setup has run.
929
1213
  const infoClient = __classPrivateFieldGet(this, _HyperLiquidProvider_clientService, "f").getInfoClient();
930
1214
  const exchangeClient = __classPrivateFieldGet(this, _HyperLiquidProvider_clientService, "f").getExchangeClient();
931
1215
  const userAddress = await __classPrivateFieldGet(this, _HyperLiquidProvider_walletService, "f").getUserAddressWithDefault();
932
1216
  // Extract DEX name for API calls (main DEX = null)
933
1217
  const { dex: dexName } = parseAssetName(symbol);
1218
+ // Asset info is resolved before the pre-cancel sweep so a partial size
1219
+ // that rounds away at the asset precision is rejected while the
1220
+ // position's existing triggers are still in place. Rejecting it after the
1221
+ // sweep would leave the position unprotected with nothing put back.
1222
+ const meta = await __classPrivateFieldGet(this, _HyperLiquidProvider_instances, "m", _HyperLiquidProvider_getCachedMeta).call(this, { dexName });
1223
+ // Check if meta is an error response (string) or doesn't have universe property
1224
+ if (!meta ||
1225
+ typeof meta === 'string' ||
1226
+ !meta.universe ||
1227
+ !Array.isArray(meta.universe)) {
1228
+ __classPrivateFieldGet(this, _HyperLiquidProvider_deps, "f").debugLogger.log('Failed to fetch metadata for asset mapping', {
1229
+ meta,
1230
+ dex: dexName ?? 'main',
1231
+ });
1232
+ throw new Error(`Failed to fetch market metadata for DEX ${dexName ?? 'main'}`);
1233
+ }
1234
+ // asset.name format: "BTC" for main DEX, "xyz:XYZ100" for HIP-3
1235
+ const assetInfo = meta.universe.find((asset) => asset.name === symbol);
1236
+ if (!assetInfo) {
1237
+ throw new Error(`Asset ${symbol} not found in ${dexName ?? 'main'} DEX universe`);
1238
+ }
1239
+ const precision = validateOrderPrecision({
1240
+ takeProfitPrice,
1241
+ stopLossPrice,
1242
+ takeProfitSize,
1243
+ stopLossSize,
1244
+ szDecimals: assetInfo.szDecimals,
1245
+ });
1246
+ if (!precision.isValid) {
1247
+ return {
1248
+ success: false,
1249
+ error: precision.error,
1250
+ };
1251
+ }
1252
+ // Everything is validated: only now run the trading setup that can prompt
1253
+ // for signatures and write the referral / builder-fee approvals.
1254
+ await __classPrivateFieldGet(this, _HyperLiquidProvider_instances, "m", _HyperLiquidProvider_ensureReadyForTrading).call(this);
934
1255
  // Cancel existing TP/SL orders for this position
935
1256
  // OPTIMIZATION: Use WebSocket cache first (0 weight), fall back to single-DEX REST (20 weight)
936
1257
  // Previously: queryUserDataAcrossDexs queried ALL DEXs (20 weight × N DEXs = 40+ weight)
@@ -941,18 +1262,48 @@ export class HyperLiquidProvider {
941
1262
  let cancelRequests = [];
942
1263
  // Use atomic getter to prevent race condition between check and get
943
1264
  const cachedOrders = __classPrivateFieldGet(this, _HyperLiquidProvider_subscriptionService, "f").getOrdersCacheIfInitialized();
944
- if (cachedOrders === null) {
1265
+ // Replacing TP/SL has to consider standalone ('na' grouping) triggers —
1266
+ // left by a partial update or placed independently — which are not
1267
+ // position-bound. Telling those apart from a pending order's normalTpsl
1268
+ // child requires the parent/child relationship, which only the REST
1269
+ // payload carries. The cache path is therefore only safe when the cache
1270
+ // shows no such trigger on this market: a partial update always places
1271
+ // standalone triggers, and a whole-position update must still clear any
1272
+ // standalone leftovers instead of letting them fire beside the new
1273
+ // position-bound orders.
1274
+ const cacheShowsStandaloneTriggers = Boolean(cachedOrders?.some((order) => order.symbol === symbol &&
1275
+ order.reduceOnly === true &&
1276
+ order.isTrigger === true &&
1277
+ order.isPositionTpsl !==
1278
+ Boolean(TP_SL_CONFIG.UsePositionBoundTpsl) &&
1279
+ order.detailedOrderType &&
1280
+ (order.detailedOrderType.includes('Take Profit') ||
1281
+ order.detailedOrderType.includes('Stop'))));
1282
+ if (cachedOrders === null ||
1283
+ isPartialTpsl ||
1284
+ cacheShowsStandaloneTriggers) {
945
1285
  // Fallback: Query only the specific DEX (20 weight instead of 40+)
946
- __classPrivateFieldGet(this, _HyperLiquidProvider_deps, "f").debugLogger.log('WebSocket cache not initialized, falling back to single-DEX REST query', { dex: dexName ?? 'main' });
1286
+ __classPrivateFieldGet(this, _HyperLiquidProvider_deps, "f").debugLogger.log(cachedOrders === null
1287
+ ? 'WebSocket cache not initialized, falling back to single-DEX REST query'
1288
+ : 'TP/SL update needs parent/child order context: using single-DEX REST query', { dex: dexName ?? 'main', isPartialTpsl });
947
1289
  const orders = await infoClient.frontendOpenOrders({
948
1290
  user: userAddress,
949
1291
  dex: dexName ?? undefined,
950
1292
  });
1293
+ // Orders that belong to a pending parent order (normalTpsl children) are
1294
+ // also listed at the top level, so collect their IDs to exclude them:
1295
+ // they protect that pending order, not this position.
1296
+ const childOrderIds = collectChildOrderIds(orders);
951
1297
  // Filter using raw SDK response properties
952
1298
  const tpslOrders = orders.filter((order) => order.coin === symbol &&
953
1299
  order.reduceOnly &&
954
- order.isPositionTpsl ===
955
- Boolean(TP_SL_CONFIG.UsePositionBoundTpsl) &&
1300
+ // Position-bound TP/SL always qualifies, and so do standalone
1301
+ // triggers on this market (they belong to the position too, whether
1302
+ // this update is partial or whole) — but never another order's
1303
+ // TP/SL children.
1304
+ (order.isPositionTpsl ===
1305
+ Boolean(TP_SL_CONFIG.UsePositionBoundTpsl) ||
1306
+ !childOrderIds.has(order.oid)) &&
956
1307
  order.isTrigger &&
957
1308
  (order.orderType.includes('Take Profit') ||
958
1309
  order.orderType.includes('Stop')));
@@ -991,33 +1342,24 @@ export class HyperLiquidProvider {
991
1342
  });
992
1343
  __classPrivateFieldGet(this, _HyperLiquidProvider_deps, "f").debugLogger.log('Cancel result:', cancelResult);
993
1344
  }
994
- // Get asset info (dexName already extracted above) - uses cache
995
- const meta = await __classPrivateFieldGet(this, _HyperLiquidProvider_instances, "m", _HyperLiquidProvider_getCachedMeta).call(this, { dexName });
996
- // Check if meta is an error response (string) or doesn't have universe property
997
- if (!meta ||
998
- typeof meta === 'string' ||
999
- !meta.universe ||
1000
- !Array.isArray(meta.universe)) {
1001
- __classPrivateFieldGet(this, _HyperLiquidProvider_deps, "f").debugLogger.log('Failed to fetch metadata for asset mapping', {
1002
- meta,
1003
- dex: dexName ?? 'main',
1004
- });
1005
- throw new Error(`Failed to fetch market metadata for DEX ${dexName ?? 'main'}`);
1006
- }
1007
- // asset.name format: "BTC" for main DEX, "xyz:XYZ100" for HIP-3
1008
- const assetInfo = meta.universe.find((asset) => asset.name === symbol);
1009
- if (!assetInfo) {
1010
- throw new Error(`Asset ${symbol} not found in ${dexName ?? 'main'} DEX universe`);
1011
- }
1012
1345
  // assetId already validated above when building cancelRequests
1013
1346
  // Build orders array for TP/SL
1014
1347
  const orders = [];
1015
- const size = TP_SL_CONFIG.UsePositionBoundTpsl
1348
+ const fullSize = TP_SL_CONFIG.UsePositionBoundTpsl && !isPartialTpsl
1016
1349
  ? '0'
1017
1350
  : formatHyperLiquidSize({
1018
1351
  size: positionSize,
1019
1352
  szDecimals: assetInfo.szDecimals,
1020
1353
  });
1354
+ // Partial TP/SL orders carry their own size; the rest cover the position.
1355
+ // A partial size that rounds away at the asset precision is rejected
1356
+ // rather than sent as '0', which the exchange reads as whole-position.
1357
+ const resolveTpslSize = (tpslSize) => tpslSize === undefined
1358
+ ? fullSize
1359
+ : formatPartialTpslSize({
1360
+ size: parseFloat(tpslSize),
1361
+ szDecimals: assetInfo.szDecimals,
1362
+ });
1021
1363
  // Take Profit order
1022
1364
  if (takeProfitPrice) {
1023
1365
  const tpOrder = {
@@ -1027,7 +1369,7 @@ export class HyperLiquidProvider {
1027
1369
  price: parseFloat(takeProfitPrice),
1028
1370
  szDecimals: assetInfo.szDecimals,
1029
1371
  }),
1030
- s: size,
1372
+ s: resolveTpslSize(takeProfitSize),
1031
1373
  r: true, // Always reduce-only for position TP
1032
1374
  t: {
1033
1375
  trigger: {
@@ -1051,7 +1393,7 @@ export class HyperLiquidProvider {
1051
1393
  price: parseFloat(stopLossPrice),
1052
1394
  szDecimals: assetInfo.szDecimals,
1053
1395
  }),
1054
- s: size,
1396
+ s: resolveTpslSize(stopLossSize),
1055
1397
  r: true, // Always reduce-only for position SL
1056
1398
  t: {
1057
1399
  trigger: {
@@ -1084,10 +1426,12 @@ export class HyperLiquidProvider {
1084
1426
  discountedFee: builderFee,
1085
1427
  });
1086
1428
  }
1087
- // Submit via SDK exchange client with positionTpsl grouping
1429
+ // Submit via SDK exchange client. Position-bound TP/SL uses 'positionTpsl';
1430
+ // partial TP/SL must be standalone reduce-only triggers ('na'), since a
1431
+ // position-bound TP/SL always closes the whole position.
1088
1432
  const result = await exchangeClient.order({
1089
1433
  orders,
1090
- grouping: 'positionTpsl',
1434
+ grouping: isPartialTpsl ? 'na' : 'positionTpsl',
1091
1435
  builder: {
1092
1436
  b: __classPrivateFieldGet(this, _HyperLiquidProvider_instances, "m", _HyperLiquidProvider_getBuilderAddress).call(this, __classPrivateFieldGet(this, _HyperLiquidProvider_clientService, "f").isTestnetMode()),
1093
1437
  f: builderFee,
@@ -1127,6 +1471,68 @@ export class HyperLiquidProvider {
1127
1471
  // Use provided position (from WebSocket) or fetch from cache
1128
1472
  // This avoids unnecessary API calls and prevents 429 rate limiting
1129
1473
  let { position } = params;
1474
+ // Re-validate the caller-supplied snapshot against the freshest WebSocket
1475
+ // position cache. Clients pass a throttled snapshot (~1s old on mobile),
1476
+ // so a concurrent TP/SL fill, a liquidation, or a double-tapped close
1477
+ // leaves the snapshot's side/size larger than (or opposite to) the real
1478
+ // position and HyperLiquid rejects the reduce-only order with "Reduce
1479
+ // only order would increase position". Reading the cache never issues a
1480
+ // REST request, so this does not reintroduce 429 rate limiting.
1481
+ if (position && __classPrivateFieldGet(this, _HyperLiquidProvider_subscriptionService, "f").isPositionsCacheInitialized()) {
1482
+ // Read the symbol's own DEX slice, not the aggregate. The aggregate is
1483
+ // only rebuilt once every expected DEX has published, so after a
1484
+ // WebSocket reconnect — which resets the initialized-DEX set without
1485
+ // clearing these caches — it can sit frozen at pre-reconnect contents
1486
+ // while the per-DEX slices keep updating. Deciding "this DEX is covered"
1487
+ // from the per-DEX map and then reading the position from the aggregate
1488
+ // mixed a fresh answer with stale data: a close could reuse a stale size,
1489
+ // or throw for a position that is open.
1490
+ const dexPositions = __classPrivateFieldGet(this, _HyperLiquidProvider_subscriptionService, "f").getCachedPositionsForDex(parseAssetName(params.symbol).dex ?? '');
1491
+ const livePosition = dexPositions?.find((pos) => pos.symbol === params.symbol);
1492
+ if (livePosition) {
1493
+ if (livePosition.size !== position.size) {
1494
+ __classPrivateFieldGet(this, _HyperLiquidProvider_deps, "f").debugLogger.log('Stale close position snapshot: using live WebSocket position', {
1495
+ coin: params.symbol,
1496
+ snapshotSize: position.size,
1497
+ liveSize: livePosition.size,
1498
+ });
1499
+ }
1500
+ position = livePosition;
1501
+ }
1502
+ else if (dexPositions) {
1503
+ // That DEX has published and does not hold this symbol, so the position
1504
+ // is already closed (e.g. a double-tapped close). This is the same read
1505
+ // the lookup above used, so the two can never disagree. Fail here rather
1506
+ // than falling back to REST: the cache is the freshest source, so a REST
1507
+ // lookup can only burn a request that risks 429s and, if it lags, hand
1508
+ // back a position that no longer exists.
1509
+ throw new Error(`No position found for ${params.symbol}`);
1510
+ }
1511
+ else {
1512
+ // The cache holds nothing for this symbol's DEX — a HIP-3 DEX whose
1513
+ // subscription has not published this session — so the symbol's
1514
+ // absence proves nothing. Spend one REST request to get live data
1515
+ // rather than trusting a snapshot the exchange may have moved past.
1516
+ __classPrivateFieldGet(this, _HyperLiquidProvider_deps, "f").debugLogger.log('Position cache does not cover this DEX: fetching live positions', { coin: params.symbol });
1517
+ // Query the symbol's own DEX so the outcome carries provenance.
1518
+ // getPositions() fans out across every enabled DEX, flattens the subset
1519
+ // that answered and turns any failure into [], so it cannot distinguish
1520
+ // "this DEX answered and holds nothing" from "this DEX failed or was
1521
+ // never queried" — and those two need opposite decisions.
1522
+ const { answered, positions } = await __classPrivateFieldGet(this, _HyperLiquidProvider_instances, "m", _HyperLiquidProvider_queryDexPositions).call(this, parseAssetName(params.symbol).dex);
1523
+ const livePositionFromApi = positions.find((pos) => pos.symbol === params.symbol);
1524
+ if (livePositionFromApi) {
1525
+ position = livePositionFromApi;
1526
+ }
1527
+ else if (answered) {
1528
+ // The DEX answered without this symbol — even with no positions at
1529
+ // all — so it is genuinely closed.
1530
+ throw new Error(`No position found for ${params.symbol}`);
1531
+ }
1532
+ // Otherwise the query failed, so the absence proves nothing: keep the
1533
+ // caller's snapshot rather than block a position that may be closable.
1534
+ }
1535
+ }
1130
1536
  if (!position) {
1131
1537
  const positions = await this.getPositions();
1132
1538
  position = positions.find((pos) => pos.symbol === params.symbol);
@@ -1136,18 +1542,37 @@ export class HyperLiquidProvider {
1136
1542
  }
1137
1543
  const positionSize = parseFloat(position.size);
1138
1544
  const isBuy = positionSize < 0;
1139
- const closeSize = params.size ?? Math.abs(positionSize).toString();
1545
+ const absPositionSize = Math.abs(positionSize);
1546
+ // Only an omitted (or empty) size means "close 100%". A supplied size must
1547
+ // be a positive number: silently promoting '0' or 'abc' to a full close
1548
+ // would liquidate the whole position on a caller-side formatting slip.
1549
+ // A supplied size is clamped to the live position size, because
1550
+ // HyperLiquid rejects reduce-only orders that exceed the position and the
1551
+ // caller computed its size from a snapshot that may already be too large.
1552
+ const hasRequestedSize = params.size !== undefined && params.size !== '';
1553
+ let closeSizeNumber = absPositionSize;
1554
+ if (hasRequestedSize) {
1555
+ const requestedSize = parseFloat(params.size);
1556
+ if (!Number.isFinite(requestedSize) || requestedSize <= 0) {
1557
+ throw new Error(PERPS_ERROR_CODES.ORDER_SIZE_POSITIVE);
1558
+ }
1559
+ closeSizeNumber = Math.min(requestedSize, absPositionSize);
1560
+ }
1561
+ const closeSize = closeSizeNumber.toString();
1140
1562
  // Capture position details BEFORE closing for freed margin calculation
1141
1563
  const totalMarginUsed = parseFloat(position.marginUsed);
1142
- const totalPositionSize = Math.abs(positionSize);
1143
- const closeSizeNum = parseFloat(closeSize);
1564
+ const totalPositionSize = absPositionSize;
1565
+ const closeSizeNum = closeSizeNumber;
1144
1566
  const isHip3Position = position.symbol.includes(':');
1145
1567
  const hip3Dex = isHip3Position ? position.symbol.split(':')[0] : null;
1146
1568
  // Calculate freed margin proportionally
1147
1569
  const freedMarginRatio = closeSizeNum / totalPositionSize;
1148
1570
  const freedMargin = totalMarginUsed * freedMarginRatio;
1149
- // Get current price for validation if not provided (and not a full close)
1150
- // Full closes don't need price for validation
1571
+ // Get current price for USD/minimum validation if not provided. A full
1572
+ // close skips *that* validation because it submits the exact live size —
1573
+ // but not the price-staleness guard: calculateFinalPositionSize checks
1574
+ // priceAtCalculation against the live price for every close that supplies
1575
+ // it, using the price placeOrder fetches when none is passed here.
1151
1576
  let { currentPrice } = params;
1152
1577
  if (!currentPrice && params.size && !params.usdAmount) {
1153
1578
  // Partial close without USD or price: use limit price as fallback for validation
@@ -1170,6 +1595,10 @@ export class HyperLiquidProvider {
1170
1595
  closedSize: closeSize,
1171
1596
  freedMargin: freedMargin.toFixed(2),
1172
1597
  });
1598
+ // True when the order closes 100% of the position: either no size was
1599
+ // provided, or the requested size covers (or was clamped to) the whole
1600
+ // position.
1601
+ const isFullClose = closeSizeNum >= absPositionSize;
1173
1602
  // Execute position close with consistent slippage handling
1174
1603
  const result = await this.placeOrder({
1175
1604
  symbol: params.symbol,
@@ -1178,10 +1607,17 @@ export class HyperLiquidProvider {
1178
1607
  orderType: params.orderType ?? 'market',
1179
1608
  price: params.price,
1180
1609
  reduceOnly: true,
1181
- isFullClose: !params.size, // True if closing 100% (size not provided)
1610
+ isFullClose,
1182
1611
  // Pass through price and slippage parameters for consistent validation
1183
1612
  currentPrice,
1184
- usdAmount: params.usdAmount,
1613
+ // A close of the whole position must submit exactly the live position
1614
+ // size. Forwarding usdAmount would make placeOrder recompute the size as
1615
+ // usdAmount / currentPrice — discarding the clamp above, since usdAmount
1616
+ // is the source of truth there — and submit more than the position
1617
+ // holds, which is rejected with "Reduce only order would increase
1618
+ // position". Genuine partial closes keep usdAmount so their size stays
1619
+ // USD-accurate.
1620
+ usdAmount: isFullClose ? undefined : params.usdAmount,
1185
1621
  priceAtCalculation: params.priceAtCalculation,
1186
1622
  maxSlippageBps: params.maxSlippageBps,
1187
1623
  });
@@ -1365,6 +1801,12 @@ export class HyperLiquidProvider {
1365
1801
  }
1366
1802
  // Combine all orders from all DEXs for TP/SL lookup
1367
1803
  const allOrders = orderResults.flatMap((result) => result.data);
1804
+ // TP/SL children of pending parent orders are listed at the top level too;
1805
+ // they belong to that order, not to a position.
1806
+ const allOrdersChildIds = collectChildOrderIds(allOrders);
1807
+ // Grouped once here rather than rescanned per position, mirroring the
1808
+ // positionsBySymbol map on the WebSocket path.
1809
+ const ordersBySymbol = groupOrdersBySymbol(allOrders);
1368
1810
  __classPrivateFieldGet(this, _HyperLiquidProvider_deps, "f").debugLogger.log('Frontend open orders (all DEXs):', {
1369
1811
  count: allOrders.length,
1370
1812
  orders: allOrders.map((ord) => ({
@@ -1400,6 +1842,16 @@ export class HyperLiquidProvider {
1400
1842
  // Look for TP and SL trigger orders
1401
1843
  let takeProfitPrice;
1402
1844
  let stopLossPrice;
1845
+ // Trigger orders attached to this position: position-bound TP/SL plus
1846
+ // standalone ('na' grouping) partial TP/SL. A pending order's
1847
+ // normalTpsl children are excluded — they are also listed at the top
1848
+ // level, but they protect that order, not this position (same rule as
1849
+ // the positionOrders filter above).
1850
+ const { takeProfitOrders, stopLossOrders } = collectPositionTriggerOrders({
1851
+ orders: ordersBySymbol.get(position.symbol) ?? [],
1852
+ position,
1853
+ childOrderIds: allOrdersChildIds,
1854
+ });
1403
1855
  // Check direct trigger orders
1404
1856
  positionOrders.forEach((order) => {
1405
1857
  // Frontend orders have explicit orderType field
@@ -1457,6 +1909,10 @@ export class HyperLiquidProvider {
1457
1909
  ...position,
1458
1910
  takeProfitPrice,
1459
1911
  stopLossPrice,
1912
+ takeProfitCount: takeProfitOrders.length,
1913
+ stopLossCount: stopLossOrders.length,
1914
+ takeProfitOrders,
1915
+ stopLossOrders,
1460
1916
  };
1461
1917
  }));
1462
1918
  return allPositions;
@@ -2461,6 +2917,14 @@ export class HyperLiquidProvider {
2461
2917
  size: params.size,
2462
2918
  price: params.price,
2463
2919
  orderType: params.orderType,
2920
+ triggerPrice: params.triggerPrice,
2921
+ takeProfitPrice: params.takeProfitPrice,
2922
+ stopLossPrice: params.stopLossPrice,
2923
+ takeProfitSize: params.takeProfitSize,
2924
+ stopLossSize: params.stopLossSize,
2925
+ tpslLinkage: params.tpslLinkage,
2926
+ grouping: params.grouping,
2927
+ timeInForce: params.timeInForce,
2464
2928
  });
2465
2929
  if (!basicValidation.isValid) {
2466
2930
  return basicValidation;
@@ -2489,16 +2953,29 @@ export class HyperLiquidProvider {
2489
2953
  // Fallback: Calculate from size × price
2490
2954
  const size = parseFloat(params.size || '0');
2491
2955
  let priceForValidation = params.currentPrice;
2492
- // For limit orders without currentPrice, use limit price as fallback
2956
+ // For limit-executing orders without currentPrice, use limit price as
2957
+ // fallback (plain limit, stop_limit, take_profit_limit)
2493
2958
  if (!priceForValidation &&
2494
2959
  params.price &&
2495
- params.orderType === 'limit') {
2960
+ isLimitExecutionOrderType(params.orderType)) {
2496
2961
  priceForValidation = parseFloat(params.price);
2497
2962
  __classPrivateFieldGet(this, _HyperLiquidProvider_deps, "f").debugLogger.log('Using limit price for order validation (limit order):', {
2498
2963
  size,
2499
2964
  limitPrice: priceForValidation,
2500
2965
  });
2501
2966
  }
2967
+ // Market-executing trigger orders (stop_market, take_profit_market)
2968
+ // have no limit price; the trigger price is the best notional estimate.
2969
+ if (!priceForValidation &&
2970
+ params.triggerPrice &&
2971
+ isTriggerOrderType(params.orderType)) {
2972
+ priceForValidation = parseFloat(params.triggerPrice);
2973
+ __classPrivateFieldGet(this, _HyperLiquidProvider_deps, "f").debugLogger.log('Using trigger price for order validation (trigger order):', {
2974
+ size,
2975
+ triggerPrice: priceForValidation,
2976
+ orderType: params.orderType,
2977
+ });
2978
+ }
2502
2979
  if (!priceForValidation) {
2503
2980
  return {
2504
2981
  isValid: false,
@@ -3285,8 +3762,11 @@ export class HyperLiquidProvider {
3285
3762
  */
3286
3763
  async calculateFees(params) {
3287
3764
  const { orderType, isMaker = false, amount, symbol } = params;
3765
+ // Trigger placements are charged as their execution kind: a stop_market fills
3766
+ // as a market order (taker), a stop_limit as a limit order.
3767
+ const isMarketExecution = getTriggerExecution(orderType) === 'market';
3288
3768
  // Start with base rates from config
3289
- let feeRate = orderType === 'market' || !isMaker ? FEE_RATES.taker : FEE_RATES.maker;
3769
+ let feeRate = isMarketExecution || !isMaker ? FEE_RATES.taker : FEE_RATES.maker;
3290
3770
  // Parse symbol to detect HIP-3 DEX (e.g., "xyz:TSLA" → dex="xyz", parsedSymbol="TSLA")
3291
3771
  const { dex, symbol: parsedSymbol } = parseAssetName(symbol);
3292
3772
  const isHip3Asset = dex !== null;
@@ -3331,7 +3811,7 @@ export class HyperLiquidProvider {
3331
3811
  const cached = __classPrivateFieldGet(this, _HyperLiquidProvider_userFeeCache, "f").get(userAddress);
3332
3812
  if (cached) {
3333
3813
  // Market orders always use taker rate, limit orders check isMaker
3334
- let userFeeRate = orderType === 'market' || !isMaker
3814
+ let userFeeRate = isMarketExecution || !isMaker
3335
3815
  ? cached.perpsTakerRate
3336
3816
  : cached.perpsMakerRate;
3337
3817
  // Apply HIP-3 dynamic multiplier to user-specific rates (includes Growth Mode)
@@ -3439,7 +3919,7 @@ export class HyperLiquidProvider {
3439
3919
  };
3440
3920
  __classPrivateFieldGet(this, _HyperLiquidProvider_userFeeCache, "f").set(userAddress, rates);
3441
3921
  // Market orders always use taker rate, limit orders check isMaker
3442
- let userFeeRate = orderType === 'market' || !isMaker
3922
+ let userFeeRate = isMarketExecution || !isMaker
3443
3923
  ? rates.perpsTakerRate
3444
3924
  : rates.perpsMakerRate;
3445
3925
  // Apply HIP-3 dynamic multiplier to API-fetched rates (includes Growth Mode)
@@ -4972,6 +5452,14 @@ async function _HyperLiquidProvider_queryUserDataAcrossDexs(baseParams, queryFn)
4972
5452
  return { results, failedDexs };
4973
5453
  }, _HyperLiquidProvider_mapError = function _HyperLiquidProvider_mapError(error) {
4974
5454
  const { message } = ensureError(error, 'HyperLiquidProvider.mapError');
5455
+ // "User or API Wallet 0x... does not exist." carries the user's address, so
5456
+ // it cannot be matched by the static substring table below. It means the
5457
+ // wallet has no Hyperliquid account yet — surface an actionable code the
5458
+ // client can translate ("fund your account") instead of leaking the raw
5459
+ // exchange string to the UI and to failed-trade analytics.
5460
+ if (isHyperLiquidUserNotFoundError(error)) {
5461
+ return new Error(PERPS_ERROR_CODES.EXCHANGE_ACCOUNT_NOT_FOUND);
5462
+ }
4975
5463
  for (const [pattern, code] of Object.entries(__classPrivateFieldGet(this, _HyperLiquidProvider_errorMappings, "f"))) {
4976
5464
  if (message.toLowerCase().includes(pattern.toLowerCase())) {
4977
5465
  return new Error(code);
@@ -5734,13 +6222,80 @@ async function _HyperLiquidProvider_submitOrderWithRollback(params) {
5734
6222
  }
5735
6223
  }, _HyperLiquidProvider_handleOrderError = function _HyperLiquidProvider_handleOrderError(params) {
5736
6224
  const { error, symbol, orderType, isBuy } = params;
5737
- __classPrivateFieldGet(this, _HyperLiquidProvider_deps, "f").logger.error(ensureError(error, 'HyperLiquidProvider.handleOrderError'), __classPrivateFieldGet(this, _HyperLiquidProvider_instances, "m", _HyperLiquidProvider_getErrorContext).call(this, 'placeOrder', {
5738
- symbol,
5739
- orderType,
5740
- isBuy,
5741
- }));
6225
+ // A wallet with no Hyperliquid account is an expected pre-account state,
6226
+ // not an app defect — same policy already applied to every other
6227
+ // user-scoped exchange write in this provider. Keep it out of Sentry; the
6228
+ // failure is still reported to the caller (and to trade analytics) via the
6229
+ // mapped EXCHANGE_ACCOUNT_NOT_FOUND code below.
6230
+ if (isHyperLiquidUserNotFoundError(error)) {
6231
+ __classPrivateFieldGet(this, _HyperLiquidProvider_deps, "f").debugLogger.log('[handleOrderError] Wallet has no Hyperliquid account, order cannot be placed', { symbol, orderType, isBuy });
6232
+ }
6233
+ else {
6234
+ __classPrivateFieldGet(this, _HyperLiquidProvider_deps, "f").logger.error(ensureError(error, 'HyperLiquidProvider.handleOrderError'), __classPrivateFieldGet(this, _HyperLiquidProvider_instances, "m", _HyperLiquidProvider_getErrorContext).call(this, 'placeOrder', {
6235
+ symbol,
6236
+ orderType,
6237
+ isBuy,
6238
+ }));
6239
+ }
5742
6240
  const mappedError = __classPrivateFieldGet(this, _HyperLiquidProvider_instances, "m", _HyperLiquidProvider_mapError).call(this, error);
5743
6241
  return createErrorResult(mappedError, { success: false });
6242
+ }, _HyperLiquidProvider_fetchOpenOrders =
6243
+ /**
6244
+ * Read the account's currently resting orders.
6245
+ *
6246
+ * @param params - The lookup parameters.
6247
+ * @param params.dexName - DEX to query, or null for the main DEX.
6248
+ * @returns The raw open orders.
6249
+ */
6250
+ async function _HyperLiquidProvider_fetchOpenOrders(params) {
6251
+ const userAddress = await __classPrivateFieldGet(this, _HyperLiquidProvider_walletService, "f").getUserAddressWithDefault();
6252
+ return await __classPrivateFieldGet(this, _HyperLiquidProvider_clientService, "f").getInfoClient().frontendOpenOrders({
6253
+ user: userAddress,
6254
+ dex: params.dexName ?? undefined,
6255
+ });
6256
+ }, _HyperLiquidProvider_resolveReplacementOrderId =
6257
+ /**
6258
+ * Resolve the order id that a `modify` rested the replacement under.
6259
+ *
6260
+ * HyperLiquid does not edit an order in place: it cancels the target and
6261
+ * rests a replacement under a NEW oid, which the SDK's modify response does
6262
+ * not carry. The submitted oid therefore names an order that no longer
6263
+ * exists, so the only honest source of identity is a post-modify read.
6264
+ *
6265
+ * An id is returned only when exactly one newly-rested order carries the
6266
+ * attributes just submitted. Everything else leaves it absent: a market edit
6267
+ * that filled rather than rested, a read that has not caught up yet, or two
6268
+ * equally plausible candidates. Novelty is judged against the pre-edit
6269
+ * snapshot rather than attributes alone, because an order that was already
6270
+ * resting can share a market, side and size with the replacement.
6271
+ *
6272
+ * @param params - The resolution parameters.
6273
+ * @param params.previousOrders - Orders resting immediately before the edit.
6274
+ * @param params.dexName - DEX to query, or null for the main DEX.
6275
+ * @param params.symbol - Market the edit was submitted against.
6276
+ * @param params.isBuy - Direction submitted.
6277
+ * @param params.size - Formatted size submitted.
6278
+ * @returns The replacement order id, or undefined when it cannot be resolved unambiguously.
6279
+ */
6280
+ async function _HyperLiquidProvider_resolveReplacementOrderId(params) {
6281
+ try {
6282
+ const previousOrderIds = new Set(params.previousOrders.map((order) => order.oid.toString()));
6283
+ const ordersAfterEdit = await __classPrivateFieldGet(this, _HyperLiquidProvider_instances, "m", _HyperLiquidProvider_fetchOpenOrders).call(this, {
6284
+ dexName: params.dexName,
6285
+ });
6286
+ const submittedSize = parseFloat(params.size);
6287
+ const candidates = ordersAfterEdit.filter((order) => !previousOrderIds.has(order.oid.toString()) &&
6288
+ order.coin === params.symbol &&
6289
+ (order.side === 'B') === params.isBuy &&
6290
+ parseFloat(order.sz) === submittedSize);
6291
+ return candidates.length === 1 ? candidates[0].oid.toString() : undefined;
6292
+ }
6293
+ catch (error) {
6294
+ // The modify was accepted; only the identity lookup failed. Reporting a
6295
+ // failed edit here would misstate an order that really was changed.
6296
+ __classPrivateFieldGet(this, _HyperLiquidProvider_deps, "f").debugLogger.log('Could not resolve the replacement order id after modify:', error);
6297
+ return undefined;
6298
+ }
5744
6299
  }, _HyperLiquidProvider_getStandaloneValidatedDexs =
5745
6300
  /**
5746
6301
  * Get validated DEXs for standalone mode using a standalone InfoClient.
@@ -5781,6 +6336,48 @@ async function _HyperLiquidProvider_getStandaloneValidatedDexs() {
5781
6336
  // buildAssetMapping uses state.raw for perpDexIndex computation.
5782
6337
  const state = __classPrivateFieldGet(this, _HyperLiquidProvider_dexDiscoveryCache, "f").update(allDexs);
5783
6338
  return state.validated;
6339
+ }, _HyperLiquidProvider_queryDexPositions =
6340
+ /**
6341
+ * Query one DEX's positions directly, preserving whether that DEX answered.
6342
+ *
6343
+ * `getPositions()` fans out across every enabled DEX, flattens the subset that
6344
+ * answered and converts any thrown error into an empty array, so its result
6345
+ * cannot distinguish "this DEX answered and holds no positions" from "this
6346
+ * DEX's request failed or it was never queried". `closePosition` needs that
6347
+ * distinction: the first means the position is closed and the close must fail
6348
+ * before submitting, the second means the absence proves nothing and the
6349
+ * caller's snapshot should stand.
6350
+ *
6351
+ * TP/SL enrichment is skipped, as in standalone mode: the close path only reads
6352
+ * size, side and margin.
6353
+ *
6354
+ * @param dexName - DEX identifier, or null for the main DEX.
6355
+ * @returns Whether the DEX answered, and the positions it reported.
6356
+ */
6357
+ async function _HyperLiquidProvider_queryDexPositions(dexName) {
6358
+ try {
6359
+ await __classPrivateFieldGet(this, _HyperLiquidProvider_instances, "m", _HyperLiquidProvider_ensureClientsInitialized).call(this);
6360
+ __classPrivateFieldGet(this, _HyperLiquidProvider_clientService, "f").ensureInitialized();
6361
+ const infoClient = __classPrivateFieldGet(this, _HyperLiquidProvider_clientService, "f").getInfoClient();
6362
+ const userAddress = await __classPrivateFieldGet(this, _HyperLiquidProvider_walletService, "f").getUserAddressWithDefault();
6363
+ const state = await infoClient.clearinghouseState(dexName ? { user: userAddress, dex: dexName } : { user: userAddress });
6364
+ const positions = (state.assetPositions ?? [])
6365
+ .filter((assetPos) => assetPos.position.szi !== '0')
6366
+ .map((assetPos) => adaptPositionFromSDK(assetPos));
6367
+ __classPrivateFieldGet(this, _HyperLiquidProvider_deps, "f").debugLogger.log('Target DEX position query answered', {
6368
+ dex: dexName ?? 'main',
6369
+ count: positions.length,
6370
+ });
6371
+ return { answered: true, positions };
6372
+ }
6373
+ catch (error) {
6374
+ __classPrivateFieldGet(this, _HyperLiquidProvider_deps, "f").debugLogger.log('Target DEX position query failed; its silence proves nothing', {
6375
+ dex: dexName ?? 'main',
6376
+ error: ensureError(error, 'HyperLiquidProvider.queryDexPositions')
6377
+ .message,
6378
+ });
6379
+ return { answered: false, positions: [] };
6380
+ }
5784
6381
  }, _HyperLiquidProvider_getAllMids =
5785
6382
  /**
5786
6383
  * Get allMids for a DEX — uses WS snapshot as primary source, REST as fallback.