diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 899aabac1b5..a5c0f38050e 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -9,15 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- 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)) - **BREAKING:** Add `EXCHANGE_ACCOUNT_NOT_FOUND` to `PERPS_ERROR_CODES`, returned by `HyperLiquidProvider.placeOrder` when the wallet has no HyperLiquid account yet (TAT-3343) ([#9709](https://github.com/MetaMask/core/pull/9709)) - This widens the exported `PerpsErrorCode` union, so consumers that key an exhaustive `Record` stop compiling until they add an entry for the new code. Both first-party clients do: Mobile's `app/components/UI/Perps/utils/translatePerpsError.ts` and Extension's `ui/components/app/perps/utils/translate-perps-error.ts`. - To migrate: add a translation entry for `EXCHANGE_ACCOUNT_NOT_FOUND`. It signals that the wallet has no HyperLiquid account yet, so the message should direct the user to fund the account before trading. -- 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)) ### Fixed -- Map HyperLiquid's `"User or API Wallet 0x... does not exist."` order rejection to `PERPS_ERROR_CODES.EXCHANGE_ACCOUNT_NOT_FOUND` instead of returning the raw exchange message as `OrderResult.error`, so clients can render an actionable "fund your account" message (TAT-3343) ([#9709](https://github.com/MetaMask/core/pull/9709)) -- Stop reporting the `"User or API Wallet 0x... does not exist."` order rejection to the error logger; it is an expected pre-account state, matching the handling already applied to the other user-scoped HyperLiquid exchange writes (TAT-3343) ([#9709](https://github.com/MetaMask/core/pull/9709)) +- Size the max order amount off the price a resting limit order is submitted at, fixing `order 0: insufficient margin to place order` rejections on max-size limit orders resting above the market price ([#9694](https://github.com/MetaMask/core/pull/9694)) + - `getMaxAllowedAmount` derived the maximum from the market price, but HyperLiquid reserves initial margin for a resting order against the price that order is submitted at. A max-size limit order resting above the market price - typically a sell - therefore reserved more margin than the account had and the exchange rejected it. + - `getMaxAllowedAmount` now accepts optional `orderType` and `limitPrice` params. When a limit order rests above the market price the maximum is scaled by `limitPrice / marketPrice`; orders at or below the market price, and market orders, are unchanged. Both params are optional, so existing callers keep the previous behavior. - 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)) - 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 ` 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 `; 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. - 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%". @@ -26,6 +27,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `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. - `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. - `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). +- Map HyperLiquid's `"User or API Wallet 0x... does not exist."` order rejection to `PERPS_ERROR_CODES.EXCHANGE_ACCOUNT_NOT_FOUND` instead of returning the raw exchange message as `OrderResult.error`, so clients can render an actionable "fund your account" message (TAT-3343) ([#9709](https://github.com/MetaMask/core/pull/9709)) +- Stop reporting the `"User or API Wallet 0x... does not exist."` order rejection to the error logger; it is an expected pre-account state, matching the handling already applied to the other user-scoped HyperLiquid exchange writes (TAT-3343) ([#9709](https://github.com/MetaMask/core/pull/9709)) ## [10.0.0] @@ -36,7 +39,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Bump `@metamask/transaction-controller` from `^69.1.0` to `^69.3.0` ([#9589](https://github.com/MetaMask/core/pull/9589), [#9593](https://github.com/MetaMask/core/pull/9593), [#9693](https://github.com/MetaMask/core/pull/9693)) +- Bump `@metamask/transaction-controller` from `^69.1.0` to `^69.2.1` ([#9589](https://github.com/MetaMask/core/pull/9589), [#9593](https://github.com/MetaMask/core/pull/9593)) - Gate HIP-3 markets to USDC collateral only, following HyperLiquid's USDH sunset (TAT-3304) ([#9530](https://github.com/MetaMask/core/pull/9530)) - Market discovery (`getMarkets`) now filters a HIP-3 DEX out entirely when its collateral token positively resolves to something other than USDC, so such a market can never be surfaced to trade, even via an allowlist entry naming the DEX. - `getMarketDataWithPrices` applies the same check before merging each HIP-3 DEX's results (both the initial fetch and the empty-universe retry), and before caching the snapshot used for stale fallbacks, so a non-USDC-collateral HIP-3 DEX can no longer appear in overview data (fresh or stale) while order placement rejects it. diff --git a/packages/perps-controller/src/utils/orderCalculations.ts b/packages/perps-controller/src/utils/orderCalculations.ts index c4d5d4b757c..51953fc829d 100644 --- a/packages/perps-controller/src/utils/orderCalculations.ts +++ b/packages/perps-controller/src/utils/orderCalculations.ts @@ -41,6 +41,13 @@ type MaxAllowedAmountParams = { assetPrice: number; assetSzDecimals: number; leverage: number; + // Placement type. Only a resting order is margin-checked against its own + // submitted price; a marketable order is charged at the fill price. Defaults + // to 'market'. + orderType?: 'market' | 'limit'; + // Price a limit order will rest at. Needed to size a limit order that rests + // above the market price. + limitPrice?: number; }; // Advanced order calculation interfaces @@ -160,13 +167,33 @@ export function calculateMarginRequired(params: MarginRequiredParams): string { } export function getMaxAllowedAmount(params: MaxAllowedAmountParams): number { - const { spendableBalance, assetPrice, assetSzDecimals, leverage } = params; + const { + spendableBalance, + assetPrice, + assetSzDecimals, + leverage, + orderType = 'market', + limitPrice, + } = params; if (spendableBalance === 0 || !assetPrice || assetSzDecimals === undefined) { return 0; } - // The theoretical maximum is simply spendableBalance * leverage - const theoreticalMax = spendableBalance * leverage; + // HyperLiquid reserves initial margin for a RESTING order against the price + // the order is submitted at, not the market price its size was derived from. + // A limit order resting above the market price - typically a sell - therefore + // needs more margin than a market-priced notional budgets for, and the + // exchange refuses it with "insufficient margin to place order". Price the max + // off that submitted price instead. A marketable order is charged at the fill + // price, so it needs no adjustment. + const executionPriceRatio = + orderType === 'limit' && limitPrice && limitPrice > assetPrice + ? limitPrice / assetPrice + : 1; + + // The theoretical maximum is spendableBalance * leverage, expressed in the + // market-price notional the caller works with. + const theoreticalMax = (spendableBalance * leverage) / executionPriceRatio; // But we need to account for position size rounding // Find the largest whole dollar amount that fits within this limit @@ -179,7 +206,8 @@ export function getMaxAllowedAmount(params: MaxAllowedAmountParams): number { szDecimals: assetSzDecimals, }); - const actualNotionalValue = parseFloat(testPositionSize) * assetPrice; + const actualNotionalValue = + parseFloat(testPositionSize) * assetPrice * executionPriceRatio; const requiredMargin = actualNotionalValue / leverage; // If rounding caused us to exceed available balance, step down by one position increment diff --git a/packages/perps-controller/tests/src/utils/orderCalculations.test.ts b/packages/perps-controller/tests/src/utils/orderCalculations.test.ts index f09d9406cd0..814e488adee 100644 --- a/packages/perps-controller/tests/src/utils/orderCalculations.test.ts +++ b/packages/perps-controller/tests/src/utils/orderCalculations.test.ts @@ -2,8 +2,182 @@ import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes.js'; import { calculateFinalPositionSize, floorToSizeDecimals, + getMaxAllowedAmount, } from '../../../src/utils/orderCalculations.js'; +/** + * Margin HyperLiquid reserves for a resting order, which is based on the price + * the order is submitted at, not the market price the size was derived from. + * + * @param options - The order details. + * @param options.usdAmount - Order notional in USD, priced at the market price. + * @param options.assetPrice - Current market (mid) price of the asset. + * @param options.assetSzDecimals - Size decimals for the asset. + * @param options.leverage - Leverage the order is placed with. + * @param options.orderPrice - Price the order is submitted at. + * @returns The margin the exchange reserves for the resulting order. + */ +function exchangeRequiredMargin({ + usdAmount, + assetPrice, + assetSzDecimals, + leverage, + orderPrice, +}: { + usdAmount: number; + assetPrice: number; + assetSzDecimals: number; + leverage: number; + orderPrice: number; +}): number { + const { finalPositionSize } = calculateFinalPositionSize({ + usdAmount: usdAmount.toString(), + currentPrice: assetPrice, + szDecimals: assetSzDecimals, + leverage, + }); + + return (finalPositionSize * orderPrice) / leverage; +} + +describe('getMaxAllowedAmount', () => { + it('returns 0 when inputs are missing', () => { + expect( + getMaxAllowedAmount({ + spendableBalance: 0, + assetPrice: 100000, + assetSzDecimals: 5, + leverage: 3, + }), + ).toBe(0); + + expect( + getMaxAllowedAmount({ + spendableBalance: 100, + assetPrice: 0, + assetSzDecimals: 5, + leverage: 3, + }), + ).toBe(0); + }); + + // Regression: TAT-3344 - "order 0: insufficient margin to place order". + // A limit order resting above the market price has margin reserved at that + // submitted price, so the max must be sized off it and not off the mid. + it('keeps a max limit order resting above the market price within the balance', () => { + const spendableBalance = 1000; + const assetPrice = 100000; + const assetSzDecimals = 5; + const leverage = 5; + const limitPrice = assetPrice * 1.05; + + const maxAmount = getMaxAllowedAmount({ + spendableBalance, + assetPrice, + assetSzDecimals, + leverage, + orderType: 'limit', + limitPrice, + }); + + expect(maxAmount).toBeGreaterThan(0); + expect( + exchangeRequiredMargin({ + usdAmount: maxAmount, + assetPrice, + assetSzDecimals, + leverage, + orderPrice: limitPrice, + }), + ).toBeLessThanOrEqual(spendableBalance); + }); + + it('scales the reduction with how far above the market price the order rests', () => { + const params = { + spendableBalance: 1000, + assetPrice: 100000, + assetSzDecimals: 5, + leverage: 5, + orderType: 'limit' as const, + }; + + const near = getMaxAllowedAmount({ + ...params, + limitPrice: params.assetPrice * 1.02, + }); + const far = getMaxAllowedAmount({ + ...params, + limitPrice: params.assetPrice * 1.2, + }); + + expect(far).toBeLessThan(near); + expect( + exchangeRequiredMargin({ + usdAmount: far, + assetPrice: params.assetPrice, + assetSzDecimals: params.assetSzDecimals, + leverage: params.leverage, + orderPrice: params.assetPrice * 1.2, + }), + ).toBeLessThanOrEqual(params.spendableBalance); + }); + + it('does not reduce the max for orders that are not priced above the market', () => { + const params = { + spendableBalance: 1000, + assetPrice: 100000, + assetSzDecimals: 5, + leverage: 5, + }; + const uncapped = getMaxAllowedAmount(params); + + // A resting buy sits below the market price, so it reserves less margin. + expect( + getMaxAllowedAmount({ + ...params, + orderType: 'limit', + limitPrice: params.assetPrice * 0.9, + }), + ).toBe(uncapped); + + // A marketable order is charged at the fill price, not the padded price it + // is submitted with, so it is unaffected too. + expect(getMaxAllowedAmount({ ...params, orderType: 'market' })).toBe( + uncapped, + ); + + expect(uncapped).toBeLessThanOrEqual( + params.spendableBalance * params.leverage, + ); + }); + + it('steps down by one size increment when rounding pushes margin over the balance', () => { + // szDecimals 0 means the size increment is a whole unit, so the rounded-up + // size can exceed the balance without the step-down. + const spendableBalance = 100; + const assetPrice = 30; + const assetSzDecimals = 0; + const leverage = 3; + + const maxAmount = getMaxAllowedAmount({ + spendableBalance, + assetPrice, + assetSzDecimals, + leverage, + }); + + expect( + exchangeRequiredMargin({ + usdAmount: maxAmount, + assetPrice, + assetSzDecimals, + leverage, + orderPrice: assetPrice, + }), + ).toBeLessThanOrEqual(spendableBalance); + }); +}); + describe('floorToSizeDecimals', () => { it('never returns a size larger than its input', () => { // The invariant the helper exists to enforce: a reduce-only size may be