@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.
- package/CHANGELOG.md +15 -0
- package/dist/providers/HyperLiquidProvider.cjs +219 -30
- package/dist/providers/HyperLiquidProvider.cjs.map +1 -1
- package/dist/providers/HyperLiquidProvider.d.cts.map +1 -1
- package/dist/providers/HyperLiquidProvider.d.mts.map +1 -1
- package/dist/providers/HyperLiquidProvider.mjs +220 -31
- package/dist/providers/HyperLiquidProvider.mjs.map +1 -1
- package/dist/services/HyperLiquidSubscriptionService.cjs +23 -0
- package/dist/services/HyperLiquidSubscriptionService.cjs.map +1 -1
- package/dist/services/HyperLiquidSubscriptionService.d.cts +21 -0
- package/dist/services/HyperLiquidSubscriptionService.d.cts.map +1 -1
- package/dist/services/HyperLiquidSubscriptionService.d.mts +21 -0
- package/dist/services/HyperLiquidSubscriptionService.d.mts.map +1 -1
- package/dist/services/HyperLiquidSubscriptionService.mjs +23 -0
- package/dist/services/HyperLiquidSubscriptionService.mjs.map +1 -1
- package/dist/types/index.cjs.map +1 -1
- package/dist/types/index.d.cts +18 -2
- package/dist/types/index.d.cts.map +1 -1
- package/dist/types/index.d.mts +18 -2
- package/dist/types/index.d.mts.map +1 -1
- package/dist/types/index.mjs.map +1 -1
- package/dist/utils/orderCalculations.cjs +126 -21
- package/dist/utils/orderCalculations.cjs.map +1 -1
- package/dist/utils/orderCalculations.d.cts +24 -0
- package/dist/utils/orderCalculations.d.cts.map +1 -1
- package/dist/utils/orderCalculations.d.mts +24 -0
- package/dist/utils/orderCalculations.d.mts.map +1 -1
- package/dist/utils/orderCalculations.mjs +124 -20
- package/dist/utils/orderCalculations.mjs.map +1 -1
- 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
|
-
|
|
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
|
-
//
|
|
748
|
-
|
|
749
|
-
//
|
|
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
|
-
//
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
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:
|
|
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
|
-
|
|
833
|
-
|
|
834
|
-
|
|
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
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
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
|
|
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 =
|
|
1146
|
-
const closeSizeNum =
|
|
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
|
|
1153
|
-
//
|
|
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
|
|
1324
|
+
isFullClose,
|
|
1185
1325
|
// Pass through price and slippage parameters for consistent validation
|
|
1186
1326
|
currentPrice,
|
|
1187
|
-
|
|
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.
|