@metamask-previews/perps-controller 10.0.0-preview-a42e8d0d2 → 10.0.0-preview-d2f661012

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 (30) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/providers/HyperLiquidProvider.cjs +219 -30
  3. package/dist/providers/HyperLiquidProvider.cjs.map +1 -1
  4. package/dist/providers/HyperLiquidProvider.d.cts.map +1 -1
  5. package/dist/providers/HyperLiquidProvider.d.mts.map +1 -1
  6. package/dist/providers/HyperLiquidProvider.mjs +220 -31
  7. package/dist/providers/HyperLiquidProvider.mjs.map +1 -1
  8. package/dist/services/HyperLiquidSubscriptionService.cjs +23 -0
  9. package/dist/services/HyperLiquidSubscriptionService.cjs.map +1 -1
  10. package/dist/services/HyperLiquidSubscriptionService.d.cts +21 -0
  11. package/dist/services/HyperLiquidSubscriptionService.d.cts.map +1 -1
  12. package/dist/services/HyperLiquidSubscriptionService.d.mts +21 -0
  13. package/dist/services/HyperLiquidSubscriptionService.d.mts.map +1 -1
  14. package/dist/services/HyperLiquidSubscriptionService.mjs +23 -0
  15. package/dist/services/HyperLiquidSubscriptionService.mjs.map +1 -1
  16. package/dist/types/index.cjs.map +1 -1
  17. package/dist/types/index.d.cts +18 -2
  18. package/dist/types/index.d.cts.map +1 -1
  19. package/dist/types/index.d.mts +18 -2
  20. package/dist/types/index.d.mts.map +1 -1
  21. package/dist/types/index.mjs.map +1 -1
  22. package/dist/utils/orderCalculations.cjs +126 -21
  23. package/dist/utils/orderCalculations.cjs.map +1 -1
  24. package/dist/utils/orderCalculations.d.cts +24 -0
  25. package/dist/utils/orderCalculations.d.cts.map +1 -1
  26. package/dist/utils/orderCalculations.d.mts +24 -0
  27. package/dist/utils/orderCalculations.d.mts.map +1 -1
  28. package/dist/utils/orderCalculations.mjs +124 -20
  29. package/dist/utils/orderCalculations.mjs.map +1 -1
  30. package/package.json +5 -5
package/CHANGELOG.md CHANGED
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ### Added
11
+
12
+ - Add `floorToSizeDecimals(size, szDecimals)` (exported from `@metamask/perps-controller/utils/*`), which rounds an order size down onto an asset's size grid, snapping values that floating-point error leaves just below a grid point. The result is never greater than the input: a value genuinely below a grid point is truncated rather than snapped up ([#9719](https://github.com/MetaMask/core/pull/9719))
13
+
14
+ ### Fixed
15
+
16
+ - Stop `closePosition` from submitting reduce-only orders that HyperLiquid rejects with "Reduce only order would increase position" ([#9719](https://github.com/MetaMask/core/pull/9719))
17
+ - The position snapshot callers pass (to avoid a `getPositions()` REST call) is now re-validated against the freshest WebSocket position cache, so the order side and size follow the live position instead of a snapshot that a concurrent TP/SL fill, liquidation, or repeated close has already invalidated. No additional network request is made when the cache covers the symbol's DEX: a missing entry there means the position is already closed, so the close now fails fast with `No position found for <symbol>` instead of submitting a doomed order. When the cache does not cover that DEX — a HIP-3 DEX whose subscription has not published this session — the absence proves nothing, so a single `clearinghouseState` request for that DEX alone supplies live data, which keeps the outcome attributable to the symbol's own DEX: if the DEX answers without this symbol — including when it reports no positions at all — the position is genuinely closed and the close fails with `No position found for <symbol>`; only if that request fails does the caller's snapshot stand, since a failed lookup proves nothing and must not block a position that is open and closable. `HyperLiquidSubscriptionService` exposes the new `getCachedPositionsForDex(dexName)` method this uses, which returns that DEX's own cached positions rather than the cross-DEX aggregate: the aggregate is only rebuilt once every expected DEX has published, so after a WebSocket reconnect it can sit frozen at pre-reconnect contents while the per-DEX slices keep updating.
18
+ - A caller-supplied close size is clamped to the live position size, and that clamp is binding: for a partial close the `usdAmount` clients also send can no longer recompute the size above it. A close size that is supplied but not a positive number (e.g. `'0'` or `'abc'`) now fails with `ORDER_SIZE_POSITIVE`; only an omitted or empty `size` means "close 100%".
19
+ - The batch `closePositions` path also rounds each reduce-only size down onto the asset's size grid, instead of letting `formatHyperLiquidSize`'s half-up rounding push it above the position. A position smaller than one size increment is skipped rather than submitted as a zero-size order, and the remaining positions still close. Each skipped position is reported in `results` with `success: false` and `error: ORDER_SIZE_POSITIVE` and counted in `failureCount`, so a caller cannot read "closed everything" from a batch that left one open. `results` keeps the order of the requested positions, so a consumer correlating results to positions by index is unaffected by a skip.
20
+ - A close that covers the whole position — no `size`, or a `size` that reaches (or was clamped to) the position size — now submits exactly the live position size. The `usdAmount` clients send for slippage protection is no longer forwarded for such a close, because `placeOrder` treats it as the source of truth and would recompute the size from it, discarding the clamp and rounding the size up. Genuine partial closes still use `usdAmount`. As a result, a close of the entire position requested via an explicit `size` is now treated as a full close for validation too, so it skips the USD/$10-minimum check as an omitted `size` already did. The `priceAtCalculation` staleness check is unaffected: `calculateFinalPositionSize` now runs it whenever a caller supplies that field, rather than only inside its `usdAmount` branch, so a full close that drifted past `maxSlippageBps` is still rejected with "Price moved too much" even though its size no longer comes from `usdAmount`.
21
+ - `placeOrder` no longer retries **any** reduce-only order that the exchange rejected for the $10 minimum order value with a 1.5% larger size; the minimum-value error is surfaced instead. This covers reduce-only limit and TP/SL orders submitted directly through `placeOrder`, not just closes. **It is a behaviour change for partial closes**, which previously recovered from that rejection by closing ~1.5% more than requested: a reduce-only order can no longer grow past the position (full close) or past the size the caller asked to close (partial close), so the retry could only be rejected again or resubmit an identical order.
22
+ - `calculateFinalPositionSize` throws `ORDER_SIZE_POSITIVE` when a `reduceOnly` call supplies a `size` that is not a positive number, in both its `usdAmount` and legacy-size branches, instead of capping the USD-derived size to that value or passing it through to be formatted as a zero or negative size.
23
+ - `calculateFinalPositionSize` accepts an optional `reduceOnly` flag. When set, the size is rounded down onto the asset's size grid and the "add one increment to meet the requested USD" adjustment is skipped, so a reduce-only size can never round up past the position. If rounding down leaves a size of `0` (the order is worth less than one size increment), it throws `ORDER_SIZE_POSITIVE` rather than submitting a zero-size order. **This is a behaviour change for a reduce-only close between half an increment and one full increment**: `formatHyperLiquidSize` uses `toFixed`, which rounds half-up, so such a close previously succeeded by closing one whole increment and now fails client-side instead. It is most visible on coarse-grid (low `szDecimals`) assets, and reachable from any partial close that omits `usdAmount` — Mobile limit partial closes do (`usePerpsClosePosition` sends `usdAmount: undefined` for limit orders).
24
+
10
25
  ## [10.0.0]
11
26
 
12
27
  ### Added
@@ -10,7 +10,7 @@ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (
10
10
  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");
11
11
  return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
12
12
  };
13
- 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;
13
+ 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_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;
14
14
  Object.defineProperty(exports, "__esModule", { value: true });
15
15
  exports.HyperLiquidProvider = void 0;
16
16
  const utils_1 = require("@metamask/utils");
@@ -362,6 +362,7 @@ class HyperLiquidProvider {
362
362
  maxSlippageBps: normalizedMaxSlippageBps,
363
363
  szDecimals: assetInfo.szDecimals,
364
364
  leverage: params.leverage,
365
+ reduceOnly: params.reduceOnly,
365
366
  });
366
367
  const { orderPrice, formattedSize, formattedPrice } = (0, orderCalculations_js_1.calculateOrderPriceAndSize)({
367
368
  orderType: params.orderType,
@@ -436,7 +437,13 @@ class HyperLiquidProvider {
436
437
  const errorMessage = (0, errorUtils_js_1.ensureError)(error, 'HyperLiquidProvider.placeOrder').message;
437
438
  const isMinimumOrderError = errorMessage.includes('Order must have minimum value of $10') ||
438
439
  errorMessage.includes('Order 0: Order must have minimum value');
439
- if (isMinimumOrderError && retryCount === 0) {
440
+ // Reduce-only orders are excluded. The retry works by growing the order
441
+ // 1.5%, which a close cannot do: a full close already submits the whole
442
+ // position, and a partial close is capped at the size the caller asked to
443
+ // close, so the retry would either be rejected as "Reduce only order would
444
+ // increase position" or resubmit an identical order. Surfacing the
445
+ // minimum-value error names the real problem instead.
446
+ if (isMinimumOrderError && retryCount === 0 && !params.reduceOnly) {
440
447
  let adjustedUsdAmount;
441
448
  let originalValue;
442
449
  if (params.usdAmount) {
@@ -744,10 +751,18 @@ class HyperLiquidProvider {
744
751
  ...new Set(positionsToClose.map((pos) => (0, hyperLiquidAdapter_js_1.parseAssetName)(pos.symbol).dex ?? 'main')),
745
752
  ];
746
753
  await Promise.all(uniqueDexs.map((dex) => __classPrivateFieldGet(this, _HyperLiquidProvider_instances, "m", _HyperLiquidProvider_getCachedMeta).call(this, { dexName: dex === 'main' ? null : dex })));
747
- // Track HIP-3 positions and freed margins for post-close transfers
748
- const hip3Transfers = [];
749
- // Build orders array
754
+ // Freed-margin transfer for each submitted order, or null when that order
755
+ // needs none. One entry per order rather than one per HIP-3 position: a
756
+ // compacted list read with the response-status index credits the wrong
757
+ // order in a mixed main-DEX/HIP-3 batch.
758
+ const orderedHip3Transfers = [];
759
+ // Build orders array, plus the positions each order closes so response
760
+ // statuses stay index-aligned when a position is skipped below
750
761
  const orders = [];
762
+ const orderedPositions = [];
763
+ // Positions no order could be built for. Reported as failures so a caller
764
+ // cannot read "closed everything" from a result that left one open.
765
+ const skippedResults = [];
751
766
  for (const position of positionsToClose) {
752
767
  // Extract DEX name for HIP-3 positions
753
768
  const { dex: dexName } = (0, hyperLiquidAdapter_js_1.parseAssetName)(position.symbol);
@@ -769,13 +784,29 @@ class HyperLiquidProvider {
769
784
  const isBuy = positionSize < 0; // Close opposite side
770
785
  const closeSize = Math.abs(positionSize);
771
786
  const totalMarginUsed = parseFloat(position.marginUsed);
772
- // Track HIP-3 transfers (full position close means all margin is freed)
773
- if (isHip3Position && dexName && !__classPrivateFieldGet(this, _HyperLiquidProvider_useUnifiedAccount, "f")) {
774
- hip3Transfers.push({
775
- sourceDex: dexName,
776
- freedMargin: totalMarginUsed,
787
+ // formatHyperLiquidSize() below rounds half-up, so floor onto the size
788
+ // grid first: a reduce-only order rounded above the position is rejected
789
+ // with "Reduce only order would increase position".
790
+ const flooredCloseSize = (0, orderCalculations_js_1.floorToSizeDecimals)(closeSize, assetInfo.szDecimals);
791
+ // A dust position worth less than one size increment floors to 0, which
792
+ // would submit a zero-size order. Skip it rather than sending an order
793
+ // the exchange must reject; the remaining positions still close, and the
794
+ // skip is reported as a failure below.
795
+ if (flooredCloseSize <= 0) {
796
+ __classPrivateFieldGet(this, _HyperLiquidProvider_deps, "f").debugLogger.log('Skipping position smaller than one size increment', { coin: position.symbol, size: position.size });
797
+ skippedResults.push({
798
+ symbol: position.symbol,
799
+ success: false,
800
+ error: perpsErrorCodes_js_1.PERPS_ERROR_CODES.ORDER_SIZE_POSITIVE,
777
801
  });
802
+ continue;
778
803
  }
804
+ // Track this order's HIP-3 transfer, if it needs one (a full position
805
+ // close frees all of its margin). Pushed below alongside the order so the
806
+ // two stay index-aligned.
807
+ const hip3Transfer = isHip3Position && dexName && !__classPrivateFieldGet(this, _HyperLiquidProvider_useUnifiedAccount, "f")
808
+ ? { sourceDex: dexName, freedMargin: totalMarginUsed }
809
+ : null;
779
810
  const currentPrice = await __classPrivateFieldGet(this, _HyperLiquidProvider_instances, "m", _HyperLiquidProvider_getOrFetchPrice).call(this, {
780
811
  symbol: position.symbol,
781
812
  dexName: dexName ?? null,
@@ -785,9 +816,8 @@ class HyperLiquidProvider {
785
816
  const orderPrice = isBuy
786
817
  ? currentPrice * (1 + slippage)
787
818
  : currentPrice * (1 - slippage);
788
- // Format size and price
789
819
  const formattedSize = (0, hyperLiquidAdapter_js_1.formatHyperLiquidSize)({
790
- size: closeSize,
820
+ size: flooredCloseSize,
791
821
  szDecimals: assetInfo.szDecimals,
792
822
  });
793
823
  const formattedPrice = (0, hyperLiquidAdapter_js_1.formatHyperLiquidPrice)({
@@ -803,6 +833,19 @@ class HyperLiquidProvider {
803
833
  r: true, // reduceOnly
804
834
  t: { limit: { tif: 'Ioc' } }, // Immediate or cancel for market-like execution
805
835
  });
836
+ orderedPositions.push(position);
837
+ orderedHip3Transfers.push(hip3Transfer);
838
+ }
839
+ // Every position was smaller than one size increment. Return their
840
+ // failures rather than an empty result, which would be indistinguishable
841
+ // from "no positions matched".
842
+ if (orders.length === 0) {
843
+ return {
844
+ success: false,
845
+ successCount: 0,
846
+ failureCount: skippedResults.length,
847
+ results: skippedResults,
848
+ };
806
849
  }
807
850
  // Calculate discounted builder fee if reward discount is active
808
851
  let builderFee = hyperLiquidConfig_js_1.BUILDER_FEE_CONFIG.MaxFeeTenthsBps;
@@ -822,16 +865,17 @@ class HyperLiquidProvider {
822
865
  const { statuses } = result.response.data;
823
866
  const successCount = statuses.filter((stat) => isStatusObject(stat) &&
824
867
  ((0, utils_1.hasProperty)(stat, 'filled') || (0, utils_1.hasProperty)(stat, 'resting'))).length;
825
- const failureCount = statuses.length - successCount;
868
+ const failureCount = statuses.length - successCount + skippedResults.length;
826
869
  // Handle HIP-3 margin transfers for successful closes
827
870
  if (!__classPrivateFieldGet(this, _HyperLiquidProvider_useUnifiedAccount, "f")) {
828
871
  for (let i = 0; i < statuses.length; i++) {
829
872
  const status = statuses[i];
830
873
  const isSuccess = isStatusObject(status) &&
831
874
  ((0, utils_1.hasProperty)(status, 'filled') || (0, utils_1.hasProperty)(status, 'resting'));
832
- if (isSuccess && hip3Transfers[i]) {
833
- const { sourceDex, freedMargin } = hip3Transfers[i];
834
- __classPrivateFieldGet(this, _HyperLiquidProvider_deps, "f").debugLogger.log('Position closed successfully, initiating manual auto-transfer back', { symbol: positionsToClose[i].symbol, freedMargin });
875
+ const transfer = orderedHip3Transfers[i];
876
+ if (isSuccess && transfer) {
877
+ const { sourceDex, freedMargin } = transfer;
878
+ __classPrivateFieldGet(this, _HyperLiquidProvider_deps, "f").debugLogger.log('Position closed successfully, initiating manual auto-transfer back', { symbol: orderedPositions[i].symbol, freedMargin });
835
879
  // Non-blocking: Transfer freed margin back to main DEX
836
880
  await __classPrivateFieldGet(this, _HyperLiquidProvider_instances, "m", _HyperLiquidProvider_autoTransferBackAfterClose).call(this, {
837
881
  sourceDex,
@@ -840,18 +884,29 @@ class HyperLiquidProvider {
840
884
  }
841
885
  }
842
886
  }
843
- return {
844
- success: successCount > 0,
845
- successCount,
846
- failureCount,
847
- results: statuses.map((status, index) => ({
848
- symbol: positionsToClose[index].symbol,
887
+ // Index submitted and skipped outcomes by symbol so `results` can keep the
888
+ // order of the requested positions: consumers may correlate them by index.
889
+ const submittedResults = new Map(statuses.map((status, index) => [
890
+ orderedPositions[index].symbol,
891
+ {
892
+ symbol: orderedPositions[index].symbol,
849
893
  success: isStatusObject(status) &&
850
894
  ((0, utils_1.hasProperty)(status, 'filled') || (0, utils_1.hasProperty)(status, 'resting')),
851
895
  error: isStatusObject(status) && (0, utils_1.hasProperty)(status, 'error')
852
896
  ? String(status.error)
853
897
  : undefined,
854
- })),
898
+ },
899
+ ]));
900
+ const skippedBySymbol = new Map(skippedResults.map((skipped) => [skipped.symbol, skipped]));
901
+ return {
902
+ success: successCount > 0,
903
+ successCount,
904
+ failureCount,
905
+ results: positionsToClose.flatMap((position) => {
906
+ const outcome = submittedResults.get(position.symbol) ??
907
+ skippedBySymbol.get(position.symbol);
908
+ return outcome ? [outcome] : [];
909
+ }),
855
910
  };
856
911
  }
857
912
  catch (error) {
@@ -1130,6 +1185,68 @@ class HyperLiquidProvider {
1130
1185
  // Use provided position (from WebSocket) or fetch from cache
1131
1186
  // This avoids unnecessary API calls and prevents 429 rate limiting
1132
1187
  let { position } = params;
1188
+ // Re-validate the caller-supplied snapshot against the freshest WebSocket
1189
+ // position cache. Clients pass a throttled snapshot (~1s old on mobile),
1190
+ // so a concurrent TP/SL fill, a liquidation, or a double-tapped close
1191
+ // leaves the snapshot's side/size larger than (or opposite to) the real
1192
+ // position and HyperLiquid rejects the reduce-only order with "Reduce
1193
+ // only order would increase position". Reading the cache never issues a
1194
+ // REST request, so this does not reintroduce 429 rate limiting.
1195
+ if (position && __classPrivateFieldGet(this, _HyperLiquidProvider_subscriptionService, "f").isPositionsCacheInitialized()) {
1196
+ // Read the symbol's own DEX slice, not the aggregate. The aggregate is
1197
+ // only rebuilt once every expected DEX has published, so after a
1198
+ // WebSocket reconnect — which resets the initialized-DEX set without
1199
+ // clearing these caches — it can sit frozen at pre-reconnect contents
1200
+ // while the per-DEX slices keep updating. Deciding "this DEX is covered"
1201
+ // from the per-DEX map and then reading the position from the aggregate
1202
+ // mixed a fresh answer with stale data: a close could reuse a stale size,
1203
+ // or throw for a position that is open.
1204
+ const dexPositions = __classPrivateFieldGet(this, _HyperLiquidProvider_subscriptionService, "f").getCachedPositionsForDex((0, hyperLiquidAdapter_js_1.parseAssetName)(params.symbol).dex ?? '');
1205
+ const livePosition = dexPositions?.find((pos) => pos.symbol === params.symbol);
1206
+ if (livePosition) {
1207
+ if (livePosition.size !== position.size) {
1208
+ __classPrivateFieldGet(this, _HyperLiquidProvider_deps, "f").debugLogger.log('Stale close position snapshot: using live WebSocket position', {
1209
+ coin: params.symbol,
1210
+ snapshotSize: position.size,
1211
+ liveSize: livePosition.size,
1212
+ });
1213
+ }
1214
+ position = livePosition;
1215
+ }
1216
+ else if (dexPositions) {
1217
+ // That DEX has published and does not hold this symbol, so the position
1218
+ // is already closed (e.g. a double-tapped close). This is the same read
1219
+ // the lookup above used, so the two can never disagree. Fail here rather
1220
+ // than falling back to REST: the cache is the freshest source, so a REST
1221
+ // lookup can only burn a request that risks 429s and, if it lags, hand
1222
+ // back a position that no longer exists.
1223
+ throw new Error(`No position found for ${params.symbol}`);
1224
+ }
1225
+ else {
1226
+ // The cache holds nothing for this symbol's DEX — a HIP-3 DEX whose
1227
+ // subscription has not published this session — so the symbol's
1228
+ // absence proves nothing. Spend one REST request to get live data
1229
+ // rather than trusting a snapshot the exchange may have moved past.
1230
+ __classPrivateFieldGet(this, _HyperLiquidProvider_deps, "f").debugLogger.log('Position cache does not cover this DEX: fetching live positions', { coin: params.symbol });
1231
+ // Query the symbol's own DEX so the outcome carries provenance.
1232
+ // getPositions() fans out across every enabled DEX, flattens the subset
1233
+ // that answered and turns any failure into [], so it cannot distinguish
1234
+ // "this DEX answered and holds nothing" from "this DEX failed or was
1235
+ // never queried" — and those two need opposite decisions.
1236
+ const { answered, positions } = await __classPrivateFieldGet(this, _HyperLiquidProvider_instances, "m", _HyperLiquidProvider_queryDexPositions).call(this, (0, hyperLiquidAdapter_js_1.parseAssetName)(params.symbol).dex);
1237
+ const livePositionFromApi = positions.find((pos) => pos.symbol === params.symbol);
1238
+ if (livePositionFromApi) {
1239
+ position = livePositionFromApi;
1240
+ }
1241
+ else if (answered) {
1242
+ // The DEX answered without this symbol — even with no positions at
1243
+ // all — so it is genuinely closed.
1244
+ throw new Error(`No position found for ${params.symbol}`);
1245
+ }
1246
+ // Otherwise the query failed, so the absence proves nothing: keep the
1247
+ // caller's snapshot rather than block a position that may be closable.
1248
+ }
1249
+ }
1133
1250
  if (!position) {
1134
1251
  const positions = await this.getPositions();
1135
1252
  position = positions.find((pos) => pos.symbol === params.symbol);
@@ -1139,18 +1256,37 @@ class HyperLiquidProvider {
1139
1256
  }
1140
1257
  const positionSize = parseFloat(position.size);
1141
1258
  const isBuy = positionSize < 0;
1142
- const closeSize = params.size ?? Math.abs(positionSize).toString();
1259
+ const absPositionSize = Math.abs(positionSize);
1260
+ // Only an omitted (or empty) size means "close 100%". A supplied size must
1261
+ // be a positive number: silently promoting '0' or 'abc' to a full close
1262
+ // would liquidate the whole position on a caller-side formatting slip.
1263
+ // A supplied size is clamped to the live position size, because
1264
+ // HyperLiquid rejects reduce-only orders that exceed the position and the
1265
+ // caller computed its size from a snapshot that may already be too large.
1266
+ const hasRequestedSize = params.size !== undefined && params.size !== '';
1267
+ let closeSizeNumber = absPositionSize;
1268
+ if (hasRequestedSize) {
1269
+ const requestedSize = parseFloat(params.size);
1270
+ if (!Number.isFinite(requestedSize) || requestedSize <= 0) {
1271
+ throw new Error(perpsErrorCodes_js_1.PERPS_ERROR_CODES.ORDER_SIZE_POSITIVE);
1272
+ }
1273
+ closeSizeNumber = Math.min(requestedSize, absPositionSize);
1274
+ }
1275
+ const closeSize = closeSizeNumber.toString();
1143
1276
  // Capture position details BEFORE closing for freed margin calculation
1144
1277
  const totalMarginUsed = parseFloat(position.marginUsed);
1145
- const totalPositionSize = Math.abs(positionSize);
1146
- const closeSizeNum = parseFloat(closeSize);
1278
+ const totalPositionSize = absPositionSize;
1279
+ const closeSizeNum = closeSizeNumber;
1147
1280
  const isHip3Position = position.symbol.includes(':');
1148
1281
  const hip3Dex = isHip3Position ? position.symbol.split(':')[0] : null;
1149
1282
  // Calculate freed margin proportionally
1150
1283
  const freedMarginRatio = closeSizeNum / totalPositionSize;
1151
1284
  const freedMargin = totalMarginUsed * freedMarginRatio;
1152
- // Get current price for validation if not provided (and not a full close)
1153
- // Full closes don't need price for validation
1285
+ // Get current price for USD/minimum validation if not provided. A full
1286
+ // close skips *that* validation because it submits the exact live size —
1287
+ // but not the price-staleness guard: calculateFinalPositionSize checks
1288
+ // priceAtCalculation against the live price for every close that supplies
1289
+ // it, using the price placeOrder fetches when none is passed here.
1154
1290
  let { currentPrice } = params;
1155
1291
  if (!currentPrice && params.size && !params.usdAmount) {
1156
1292
  // Partial close without USD or price: use limit price as fallback for validation
@@ -1173,6 +1309,10 @@ class HyperLiquidProvider {
1173
1309
  closedSize: closeSize,
1174
1310
  freedMargin: freedMargin.toFixed(2),
1175
1311
  });
1312
+ // True when the order closes 100% of the position: either no size was
1313
+ // provided, or the requested size covers (or was clamped to) the whole
1314
+ // position.
1315
+ const isFullClose = closeSizeNum >= absPositionSize;
1176
1316
  // Execute position close with consistent slippage handling
1177
1317
  const result = await this.placeOrder({
1178
1318
  symbol: params.symbol,
@@ -1181,10 +1321,17 @@ class HyperLiquidProvider {
1181
1321
  orderType: params.orderType ?? 'market',
1182
1322
  price: params.price,
1183
1323
  reduceOnly: true,
1184
- isFullClose: !params.size, // True if closing 100% (size not provided)
1324
+ isFullClose,
1185
1325
  // Pass through price and slippage parameters for consistent validation
1186
1326
  currentPrice,
1187
- usdAmount: params.usdAmount,
1327
+ // A close of the whole position must submit exactly the live position
1328
+ // size. Forwarding usdAmount would make placeOrder recompute the size as
1329
+ // usdAmount / currentPrice — discarding the clamp above, since usdAmount
1330
+ // is the source of truth there — and submit more than the position
1331
+ // holds, which is rejected with "Reduce only order would increase
1332
+ // position". Genuine partial closes keep usdAmount so their size stays
1333
+ // USD-accurate.
1334
+ usdAmount: isFullClose ? undefined : params.usdAmount,
1188
1335
  priceAtCalculation: params.priceAtCalculation,
1189
1336
  maxSlippageBps: params.maxSlippageBps,
1190
1337
  });
@@ -5785,6 +5932,48 @@ async function _HyperLiquidProvider_getStandaloneValidatedDexs() {
5785
5932
  // buildAssetMapping uses state.raw for perpDexIndex computation.
5786
5933
  const state = __classPrivateFieldGet(this, _HyperLiquidProvider_dexDiscoveryCache, "f").update(allDexs);
5787
5934
  return state.validated;
5935
+ }, _HyperLiquidProvider_queryDexPositions =
5936
+ /**
5937
+ * Query one DEX's positions directly, preserving whether that DEX answered.
5938
+ *
5939
+ * `getPositions()` fans out across every enabled DEX, flattens the subset that
5940
+ * answered and converts any thrown error into an empty array, so its result
5941
+ * cannot distinguish "this DEX answered and holds no positions" from "this
5942
+ * DEX's request failed or it was never queried". `closePosition` needs that
5943
+ * distinction: the first means the position is closed and the close must fail
5944
+ * before submitting, the second means the absence proves nothing and the
5945
+ * caller's snapshot should stand.
5946
+ *
5947
+ * TP/SL enrichment is skipped, as in standalone mode: the close path only reads
5948
+ * size, side and margin.
5949
+ *
5950
+ * @param dexName - DEX identifier, or null for the main DEX.
5951
+ * @returns Whether the DEX answered, and the positions it reported.
5952
+ */
5953
+ async function _HyperLiquidProvider_queryDexPositions(dexName) {
5954
+ try {
5955
+ await __classPrivateFieldGet(this, _HyperLiquidProvider_instances, "m", _HyperLiquidProvider_ensureClientsInitialized).call(this);
5956
+ __classPrivateFieldGet(this, _HyperLiquidProvider_clientService, "f").ensureInitialized();
5957
+ const infoClient = __classPrivateFieldGet(this, _HyperLiquidProvider_clientService, "f").getInfoClient();
5958
+ const userAddress = await __classPrivateFieldGet(this, _HyperLiquidProvider_walletService, "f").getUserAddressWithDefault();
5959
+ const state = await infoClient.clearinghouseState(dexName ? { user: userAddress, dex: dexName } : { user: userAddress });
5960
+ const positions = (state.assetPositions ?? [])
5961
+ .filter((assetPos) => assetPos.position.szi !== '0')
5962
+ .map((assetPos) => (0, hyperLiquidAdapter_js_1.adaptPositionFromSDK)(assetPos));
5963
+ __classPrivateFieldGet(this, _HyperLiquidProvider_deps, "f").debugLogger.log('Target DEX position query answered', {
5964
+ dex: dexName ?? 'main',
5965
+ count: positions.length,
5966
+ });
5967
+ return { answered: true, positions };
5968
+ }
5969
+ catch (error) {
5970
+ __classPrivateFieldGet(this, _HyperLiquidProvider_deps, "f").debugLogger.log('Target DEX position query failed; its silence proves nothing', {
5971
+ dex: dexName ?? 'main',
5972
+ error: (0, errorUtils_js_1.ensureError)(error, 'HyperLiquidProvider.queryDexPositions')
5973
+ .message,
5974
+ });
5975
+ return { answered: false, positions: [] };
5976
+ }
5788
5977
  }, _HyperLiquidProvider_getAllMids =
5789
5978
  /**
5790
5979
  * Get allMids for a DEX — uses WS snapshot as primary source, REST as fallback.