From 98903d27ab7aa0b796ae7e37a43f0b50133e0a9b Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Wed, 29 Jul 2026 22:50:08 +0800 Subject: [PATCH 1/6] fix(perps): size max order amount off the submitted order price Market buys are sent to HyperLiquid as limit orders priced at market * (1 + slippage), and the exchange charges initial margin against that submitted price. getMaxAllowedAmount sized the max off the market price with only a 0.5% buffer, so a max-size market buy asked for ~3% more margin than the account had and was rejected with 'order 0: insufficient margin to place order'. Apply the slippage haircut to the max for market buys, with optional orderType/isBuy/maxSlippageBps params so sells and limit orders are unaffected. --- packages/perps-controller/CHANGELOG.md | 6 + .../src/utils/orderCalculations.ts | 43 +++- .../tests/src/utils/orderCalculations.test.ts | 197 ++++++++++++++++++ 3 files changed, 242 insertions(+), 4 deletions(-) create mode 100644 packages/perps-controller/tests/src/utils/orderCalculations.test.ts diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 5c4a7d3f327..8be60e6e5d0 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Size the max order amount off the price the order is actually submitted at, fixing `order 0: insufficient margin to place order` rejections on max-size market buys (TAT-3344) + - `getMaxAllowedAmount` previously derived the maximum from the market price while market buys are submitted as limit orders priced at `market price * (1 + slippage)`; because HyperLiquid charges initial margin against the submitted price, a max-size buy asked for ~3% (up to 10% at the highest slippage setting) more margin than the account had and the exchange rejected it. + - `getMaxAllowedAmount` now accepts optional `orderType`, `isBuy`, and `maxSlippageBps` params. The slippage haircut applies to market buys only; sells and limit orders are unchanged. The params default to a market buy at the default slippage, so existing callers get the safe behavior without changes. + ## [10.0.0] ### Added diff --git a/packages/perps-controller/src/utils/orderCalculations.ts b/packages/perps-controller/src/utils/orderCalculations.ts index 360990cb33c..60e70ad7f70 100644 --- a/packages/perps-controller/src/utils/orderCalculations.ts +++ b/packages/perps-controller/src/utils/orderCalculations.ts @@ -35,6 +35,16 @@ type MaxAllowedAmountParams = { assetPrice: number; assetSzDecimals: number; leverage: number; + // Order type the max is being computed for. Market orders are submitted with a + // slippage-adjusted price, which is what the exchange charges margin against. + // Defaults to 'market' (the conservative case) when omitted. + orderType?: 'market' | 'limit'; + // Direction of the order. Only buys are priced above the market price, so only + // buys need the slippage haircut. Defaults to a buy (the conservative case). + isBuy?: boolean; + // Max slippage in basis points (e.g. 300 = 3%). Falls back to + // ORDER_SLIPPAGE_CONFIG.DefaultMarketSlippageBps. + maxSlippageBps?: number; }; // Advanced order calculation interfaces @@ -150,13 +160,37 @@ 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', + isBuy = true, + maxSlippageBps, + } = params; if (spendableBalance === 0 || !assetPrice || assetSzDecimals === undefined) { return 0; } - // The theoretical maximum is simply spendableBalance * leverage - const theoreticalMax = spendableBalance * leverage; + // Market buys are sent to HyperLiquid as limit orders priced at + // `assetPrice * (1 + slippage)` (see calculateOrderPriceAndSize), and the + // exchange charges initial margin against that submitted price - not against + // the market price the size was derived from. Sizing the max off the market + // price therefore requires ~slippage% more margin than the account has and the + // exchange rejects with "insufficient margin to place order". Price the max + // off the worst-case execution price instead. Sells are priced below the + // market price, so they need no haircut. + const slippageMultiplier = + orderType === 'market' && isBuy + ? 1 + + (maxSlippageBps ?? ORDER_SLIPPAGE_CONFIG.DefaultMarketSlippageBps) / + BASIS_POINTS_DIVISOR + : 1; + + // The theoretical maximum is spendableBalance * leverage, expressed in the + // market-price notional the caller works with. + const theoreticalMax = (spendableBalance * leverage) / slippageMultiplier; // But we need to account for position size rounding // Find the largest whole dollar amount that fits within this limit @@ -169,7 +203,8 @@ export function getMaxAllowedAmount(params: MaxAllowedAmountParams): number { szDecimals: assetSzDecimals, }); - const actualNotionalValue = parseFloat(testPositionSize) * assetPrice; + const actualNotionalValue = + parseFloat(testPositionSize) * assetPrice * slippageMultiplier; 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 new file mode 100644 index 00000000000..b878e51f509 --- /dev/null +++ b/packages/perps-controller/tests/src/utils/orderCalculations.test.ts @@ -0,0 +1,197 @@ +import { BASIS_POINTS_DIVISOR } from '../../../src/constants/hyperLiquidConfig.js'; +import { ORDER_SLIPPAGE_CONFIG } from '../../../src/constants/perpsConfig.js'; +import { + calculateFinalPositionSize, + calculateOrderPriceAndSize, + getMaxAllowedAmount, +} from '../../../src/utils/orderCalculations.js'; + +/** + * Margin HyperLiquid charges for an order, which is based on the price the order + * is actually 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.isBuy - Whether the order is a buy. + * @param options.maxSlippageBps - Max slippage in basis points. + * @returns The margin the exchange requires for the resulting order. + */ +function exchangeRequiredMargin({ + usdAmount, + assetPrice, + assetSzDecimals, + leverage, + isBuy, + maxSlippageBps, +}: { + usdAmount: number; + assetPrice: number; + assetSzDecimals: number; + leverage: number; + isBuy: boolean; + maxSlippageBps?: number; +}): number { + const { finalPositionSize } = calculateFinalPositionSize({ + usdAmount: usdAmount.toString(), + currentPrice: assetPrice, + szDecimals: assetSzDecimals, + leverage, + }); + + const { orderPrice, formattedSize } = calculateOrderPriceAndSize({ + orderType: 'market', + isBuy, + finalPositionSize, + currentPrice: assetPrice, + maxSlippageBps, + szDecimals: assetSzDecimals, + }); + + return (parseFloat(formattedSize) * 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". + // Market buys are submitted at market price * (1 + slippage) and the exchange + // charges margin against that price, so the max must be sized off it too. + it('keeps a max market buy within the spendable balance', () => { + const spendableBalance = 1000; + const assetPrice = 100000; + const assetSzDecimals = 5; + const leverage = 5; + + const maxAmount = getMaxAllowedAmount({ + spendableBalance, + assetPrice, + assetSzDecimals, + leverage, + }); + + expect(maxAmount).toBeGreaterThan(0); + expect( + exchangeRequiredMargin({ + usdAmount: maxAmount, + assetPrice, + assetSzDecimals, + leverage, + isBuy: true, + }), + ).toBeLessThanOrEqual(spendableBalance); + }); + + it('honors a custom max slippage for market buys', () => { + const spendableBalance = 500; + const assetPrice = 2500; + const assetSzDecimals = 4; + const leverage = 10; + const maxSlippageBps = 1000; + + const maxAmount = getMaxAllowedAmount({ + spendableBalance, + assetPrice, + assetSzDecimals, + leverage, + isBuy: true, + maxSlippageBps, + }); + + expect( + exchangeRequiredMargin({ + usdAmount: maxAmount, + assetPrice, + assetSzDecimals, + leverage, + isBuy: true, + maxSlippageBps, + }), + ).toBeLessThanOrEqual(spendableBalance); + }); + + it('applies the slippage haircut by default so unaware callers stay safe', () => { + const params = { + spendableBalance: 1000, + assetPrice: 100000, + assetSzDecimals: 5, + leverage: 5, + }; + const slippageMultiplier = + 1 + + ORDER_SLIPPAGE_CONFIG.DefaultMarketSlippageBps / BASIS_POINTS_DIVISOR; + + expect(getMaxAllowedAmount(params)).toBe( + getMaxAllowedAmount({ ...params, isBuy: true, orderType: 'market' }), + ); + expect(getMaxAllowedAmount(params)).toBeLessThan( + (params.spendableBalance * params.leverage) / slippageMultiplier, + ); + }); + + it('does not apply the slippage haircut to sells or limit orders', () => { + const params = { + spendableBalance: 1000, + assetPrice: 100000, + assetSzDecimals: 5, + leverage: 5, + }; + + const buyMax = getMaxAllowedAmount({ ...params, isBuy: true }); + const sellMax = getMaxAllowedAmount({ ...params, isBuy: false }); + const limitMax = getMaxAllowedAmount({ ...params, orderType: 'limit' }); + + expect(sellMax).toBeGreaterThan(buyMax); + expect(limitMax).toBe(sellMax); + expect(sellMax).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, + isBuy: false, + }); + + expect( + exchangeRequiredMargin({ + usdAmount: maxAmount, + assetPrice, + assetSzDecimals, + leverage, + isBuy: false, + }), + ).toBeLessThanOrEqual(spendableBalance); + }); +}); From ce5527491c8ff1b51960c83f384f065672dcdc28 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Wed, 29 Jul 2026 23:31:24 +0800 Subject: [PATCH 2/6] fix: address CI feedback (changelog PR link, prettier formatting) --- packages/perps-controller/CHANGELOG.md | 2 +- .../perps-controller/tests/src/utils/orderCalculations.test.ts | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 8be60e6e5d0..b7974ddb443 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Size the max order amount off the price the order is actually submitted at, fixing `order 0: insufficient margin to place order` rejections on max-size market buys (TAT-3344) +- Size the max order amount off the price the order is actually submitted at, fixing `order 0: insufficient margin to place order` rejections on max-size market buys ([#9694](https://github.com/MetaMask/core/pull/9694)) - `getMaxAllowedAmount` previously derived the maximum from the market price while market buys are submitted as limit orders priced at `market price * (1 + slippage)`; because HyperLiquid charges initial margin against the submitted price, a max-size buy asked for ~3% (up to 10% at the highest slippage setting) more margin than the account had and the exchange rejected it. - `getMaxAllowedAmount` now accepts optional `orderType`, `isBuy`, and `maxSlippageBps` params. The slippage haircut applies to market buys only; sells and limit orders are unchanged. The params default to a market buy at the default slippage, so existing callers get the safe behavior without changes. diff --git a/packages/perps-controller/tests/src/utils/orderCalculations.test.ts b/packages/perps-controller/tests/src/utils/orderCalculations.test.ts index b878e51f509..106c3edca93 100644 --- a/packages/perps-controller/tests/src/utils/orderCalculations.test.ts +++ b/packages/perps-controller/tests/src/utils/orderCalculations.test.ts @@ -138,8 +138,7 @@ describe('getMaxAllowedAmount', () => { leverage: 5, }; const slippageMultiplier = - 1 + - ORDER_SLIPPAGE_CONFIG.DefaultMarketSlippageBps / BASIS_POINTS_DIVISOR; + 1 + ORDER_SLIPPAGE_CONFIG.DefaultMarketSlippageBps / BASIS_POINTS_DIVISOR; expect(getMaxAllowedAmount(params)).toBe( getMaxAllowedAmount({ ...params, isBuy: true, orderType: 'market' }), From 0ed32d9bb9dc11e8622d1e4a585b4e7ecfeee221 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Thu, 30 Jul 2026 13:20:58 +0800 Subject: [PATCH 3/6] test(perps): add TAT-3344 live testnet max-order proof recipe Sizes a max market buy through getMaxAllowedAmount against live account state and submits that exact amount, so the max-sizing path is covered by a real order rather than a fixed notional. mode=prefix recomputes the pre-fix formula from the same inputs as a counterfactual. Testnet-guarded with deterministic cleanup. --- .../e2e/tat-3344/compute-max-order-amount.mjs | 156 ++++++++++++++++++ .../tat-3344/prove-max-order.core.recipe.json | 143 ++++++++++++++++ .../e2e/tat-3344/run-compute.sh | 39 +++++ 3 files changed, 338 insertions(+) create mode 100644 packages/perps-controller/e2e/tat-3344/compute-max-order-amount.mjs create mode 100644 packages/perps-controller/e2e/tat-3344/prove-max-order.core.recipe.json create mode 100755 packages/perps-controller/e2e/tat-3344/run-compute.sh diff --git a/packages/perps-controller/e2e/tat-3344/compute-max-order-amount.mjs b/packages/perps-controller/e2e/tat-3344/compute-max-order-amount.mjs new file mode 100644 index 00000000000..07c7cc6109a --- /dev/null +++ b/packages/perps-controller/e2e/tat-3344/compute-max-order-amount.mjs @@ -0,0 +1,156 @@ +// TAT-3344 — compute a max order amount through the CHANGED code path. +// +// This is the first half of the live proof for +// `getMaxAllowedAmount` (packages/perps-controller/src/utils/orderCalculations.ts). +// A fixed-notional live order proves nothing about max sizing, so this step +// derives the notional the way the client does: +// +// live spendableBalance + live mid + live szDecimals +// -> getMaxAllowedAmount(...) <-- the changed function +// -> stdout +// +// The recipe feeds that stdout straight into metamask.perps.place_order as +// `amount`, which the provider uses as `usdAmount`: +// +// calculateFinalPositionSize -> finalPositionSize = usdAmount / currentPrice +// calculateOrderPriceAndSize -> orderPrice = currentPrice * (1 + slippage) +// +// so the exchange charges initial margin against `orderPrice` — the exact +// mismatch TAT-3344 is about. +// +// mode=fixed emits the current (fixed) getMaxAllowedAmount result. +// mode=prefix emits the pre-fix formula (no slippage haircut) recomputed from +// the SAME live inputs, so the counterfactual is a controlled A/B +// rather than a remembered number. +// +// Only the amount goes to stdout. Diagnostics go to stderr and the full record +// is written to --out as JSON evidence. +// +// This is a standalone proof script run by the harness's `command` action, not +// library code: it reads its runtime location from the environment and must exit +// explicitly because the controller holds a HyperLiquid WebSocket open. +/* eslint-disable n/no-process-env, n/no-process-exit */ + +import { writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +function arg(name, fallback) { + const hit = process.argv.find((entry) => entry.startsWith(`--${name}=`)); + return hit === undefined ? fallback : hit.slice(name.length + 3); +} + +const projectRoot = path.resolve(arg('project-root', process.cwd())); +const network = arg('network', 'testnet'); +const market = arg('market', 'BTC'); +const leverage = Number(arg('leverage', '3')); +const mode = arg('mode', 'fixed'); +const outPath = arg('out', ''); + +if (network !== 'testnet') { + throw new Error( + `TAT-3344 proof is testnet-only; refusing network=${network}.`, + ); +} +if (!['fixed', 'prefix'].includes(mode)) { + throw new Error(`mode must be fixed | prefix, got ${mode}.`); +} + +// The headless controller bootstrap lives in the harness, keyed off the +// provisioned MM_HARNESS_BIN so this stays pinned to the same runtime the +// recipe uses. +const harnessBin = process.env.MM_HARNESS_BIN ?? process.env.METAMASK_HARNESS_BIN; +if (!harnessBin) { + throw new Error('MM_HARNESS_BIN (or METAMASK_HARNESS_BIN) must be set.'); +} +const harnessRoot = path.dirname(path.dirname(harnessBin)); +const controllerHelpers = pathToFileURL( + path.join(harnessRoot, 'library/actions/core/perps/_controller.mjs'), +).href; + +// The signer-bound controller, exactly as metamask.perps.place_order builds it: +// getMarketDataWithPrices goes through the provider's client init, which needs a +// wallet adapter even for a read. +const { getCoreControllerWithSigner, currentMarketPrice } = + await import(controllerHelpers); + +// The changed function, imported from the checkout entrypoint the harness's own +// core adapter imports — same source tree, same module instance. +const { getMaxAllowedAmount } = await import( + pathToFileURL(path.join(projectRoot, 'packages/perps-controller/src/index.ts')) + .href +); + +const input = { action: 'tat-3344.compute-max', context: { projectRoot }, node: { network } }; +const { controller, accountAddress } = await getCoreControllerWithSigner(input); + +const accountState = await controller.getAccountState({ + standalone: true, + userAddress: accountAddress, +}); +const spendableBalance = Number(accountState?.spendableBalance); +if (!Number.isFinite(spendableBalance) || spendableBalance <= 0) { + throw new Error( + `Unusable spendableBalance from the controller: ${accountState?.spendableBalance}.`, + ); +} + +const assetPrice = await currentMarketPrice(controller, market); + +const markets = await controller.getMarkets({ standalone: true }); +const meta = (Array.isArray(markets) ? markets : []).find( + (item) => String(item?.name ?? '').toUpperCase() === market.toUpperCase(), +); +if (!meta || typeof meta.szDecimals !== 'number') { + throw new Error(`No szDecimals for ${market} on ${network}.`); +} +const assetSzDecimals = meta.szDecimals; + +const fixedMax = getMaxAllowedAmount({ + spendableBalance, + assetPrice, + assetSzDecimals, + leverage, +}); + +// Pre-fix formula, verbatim from the parent commit of the fix: theoretical max +// off the MID price with only MAX_ORDER_MARGIN_BUFFER (0.5%) shaved, and no +// slippage haircut. +const MAX_ORDER_MARGIN_BUFFER = 0.005; +const prefixMax = Math.max( + 0, + Math.floor(Math.floor(spendableBalance * leverage) * (1 - MAX_ORDER_MARGIN_BUFFER)), +); + +const amount = mode === 'fixed' ? fixedMax : prefixMax; + +const record = { + ticket: 'TAT-3344', + mode, + network, + market, + account: accountAddress, + leverage, + inputs: { spendableBalance, assetPrice, assetSzDecimals }, + fixedMax, + prefixMax, + emittedAmount: amount, + // What the exchange will be asked for, at the default 300 bps market slippage. + projectedMarginAtOrderPrice: { + fixed: (fixedMax * 1.03) / leverage, + prefix: (prefixMax * 1.03) / leverage, + spendableBalance, + }, + computedVia: 'packages/perps-controller/src/utils/orderCalculations.ts#getMaxAllowedAmount', +}; + +process.stderr.write(`[tat-3344] ${JSON.stringify(record)}\n`); +if (outPath) { + await writeFile(path.resolve(outPath), `${JSON.stringify(record, null, 2)}\n`, 'utf-8'); +} + +process.stdout.write(String(amount)); + +// The controller opens a persistent HyperLiquid WebSocket; exit explicitly so +// the command node does not hang on an idle socket. +process.exit(0); diff --git a/packages/perps-controller/e2e/tat-3344/prove-max-order.core.recipe.json b/packages/perps-controller/e2e/tat-3344/prove-max-order.core.recipe.json new file mode 100644 index 00000000000..24f6028d5b9 --- /dev/null +++ b/packages/perps-controller/e2e/tat-3344/prove-max-order.core.recipe.json @@ -0,0 +1,143 @@ +{ + "$schema": "https://farmslot.io/schemas/recipe-v1.schema.json", + "title": "TAT-3344 — prove the max order amount is placeable", + "description": "Derive a max order amount through the changed getMaxAllowedAmount against live account state, then submit that exact amount as a real market order on HyperLiquid testnet. A fixed-notional order proves nothing here: the defect is in how the maximum is SIZED, so the size under test must come from the changed function. mode=fixed expects the order to be accepted; mode=prefix recomputes the pre-fix formula from the same live inputs and expects the venue to refuse it, which is the TAT-3344 report reproduced live.", + "paramsSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "network": { + "type": "string", + "enum": ["testnet"], + "default": "testnet", + "description": "Testnet only. This proof spends the entire spendable balance as margin and must never touch real funds." + }, + "market": { "type": "string", "default": "BTC", "description": "Perps market symbol." }, + "leverage": { "type": "number", "default": 3, "description": "Order leverage; also fed to getMaxAllowedAmount." }, + "mode": { + "type": "string", + "enum": ["fixed", "prefix"], + "default": "fixed", + "description": "fixed = current getMaxAllowedAmount result. prefix = pre-fix formula on the same live inputs." + }, + "expected_state": { + "type": "string", + "enum": ["open_position", "none"], + "default": "open_position", + "description": "State the submission should produce. Use 'none' with expect_error." + }, + "expect_error": { + "type": "string", + "default": "", + "description": "Exact controller/venue error the submission must be refused with. Empty expects acceptance." + }, + "compute_out": { + "type": "string", + "default": "temp/tasks/fix/tat-3344-0729-223401/artifacts/proof/compute.json", + "description": "Where the compute step writes its inputs/outputs record as evidence." + } + } + }, + "proofTargets": [ + { + "id": "max-sizing", + "claim": "The order amount submitted is the value getMaxAllowedAmount returned for the live spendable balance, price and szDecimals — not a fixed notional." + }, + { + "id": "venue-verdict", + "claim": "HyperLiquid accepts a max-size market buy sized by the fixed function, and refuses the same order sized by the pre-fix formula." + } + ], + "workflow": { + "entry": "guard-testnet", + "nodes": { + "guard-testnet": { + "action": "switch", + "value": "{{params.network}}", + "equals": "testnet", + "cases": { "match": "ensure-clean-market" }, + "default": "not-testnet", + "intent": "Refuse to run anywhere but testnet before any mutation" + }, + "not-testnet": { "action": "end", "status": "fail" }, + "ensure-clean-market": { + "action": "call", + "ref": "perps.ensure-market-state", + "params": { + "market": "{{params.market}}", + "network": "{{params.network}}", + "side": "long", + "position_state": "none", + "order_state": "none" + }, + "intent": "Start flat on {{params.market}} so the margin available to this order, and the state asserted after it, are attributable to this proof", + "next": "compute-max" + }, + "compute-max": { + "action": "command", + "cmd": "packages/perps-controller/e2e/tat-3344/run-compute.sh --network={{params.network}} --market={{params.market}} --leverage={{params.leverage}} --mode={{params.mode}} --out={{params.compute_out}}", + "timeout_ms": 180000, + "intent": "Read live spendableBalance/price/szDecimals through the controller and size the max with getMaxAllowedAmount ({{params.mode}}); stdout is the amount", + "proves": ["max-sizing"], + "next": "submit-max-order" + }, + "submit-max-order": { + "action": "metamask.perps.place_order", + "market": "{{params.market}}", + "side": "long", + "order_type": "market", + "network": "{{params.network}}", + "amount": "{{outputs.compute-max.stdout}}", + "leverage": "{{params.leverage}}", + "expect_error": "{{params.expect_error}}", + "timeout_ms": 120000, + "intent": "Submit the computed maximum as a real market buy. The amount becomes the provider's usdAmount, so size = usdAmount / mid while the order is priced at mid * (1 + slippage) — the mismatch TAT-3344 is about.", + "proves": ["venue-verdict"], + "next": "choose-state-assertion" + }, + "choose-state-assertion": { + "action": "switch", + "value": "{{params.expected_state}}", + "equals": "open_position", + "cases": { "match": "assert-open-position" }, + "default": "assert-no-position", + "intent": "Assert the state this submission was expected to produce" + }, + "assert-open-position": { + "action": "metamask.perps.assert_positions", + "market": "{{params.market}}", + "side": "long", + "network": "{{params.network}}", + "state": "open", + "timeout_ms": 60000, + "intent": "The max-size buy actually opened a position, so the venue took it", + "proves": ["venue-verdict"], + "next": "restore-market" + }, + "assert-no-position": { + "action": "metamask.perps.assert_positions", + "market": "{{params.market}}", + "network": "{{params.network}}", + "state": "none", + "timeout_ms": 60000, + "intent": "The refused order left no position behind", + "proves": ["venue-verdict"], + "next": "restore-market" + }, + "restore-market": { + "action": "call", + "ref": "perps.ensure-market-state", + "params": { + "market": "{{params.market}}", + "network": "{{params.network}}", + "side": "long", + "position_state": "none", + "order_state": "none" + }, + "intent": "Close or cancel whatever this proof placed, leaving the account flat", + "next": "done" + }, + "done": { "action": "end", "status": "pass" } + } + } +} diff --git a/packages/perps-controller/e2e/tat-3344/run-compute.sh b/packages/perps-controller/e2e/tat-3344/run-compute.sh new file mode 100755 index 00000000000..186b0bc95c7 --- /dev/null +++ b/packages/perps-controller/e2e/tat-3344/run-compute.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# TAT-3344 — run compute-max-order-amount.mjs against an UNBUILT core checkout. +# +# The workspace packages the perps controller imports at runtime resolve only to +# dist/ via their package "exports", which does not exist in a fresh checkout. +# The harness solves this for its own live adapters by pointing tsx at each +# package's src through a generated tsconfig paths map +# (harness src/live-adapter-contract.ts, platformAdapterEnv). This mirrors that +# so the proof's compute step runs on the same terms, without building or +# modifying the checkout. +set -euo pipefail + +PROJECT_ROOT="${PROJECT_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)}" +TSCONFIG="$(mktemp -t tat-3344-tsconfig.XXXXXX)" + +python3 - "$PROJECT_ROOT" "$TSCONFIG" <<'PY' +import json, os, sys +root, out = sys.argv[1], sys.argv[2] +paths = {} +for pkg in ['base-controller', 'messenger', 'controller-utils', 'keyring-controller']: + name = f'@metamask/{pkg}' + pkg_dir = os.path.join(root, 'packages', pkg) + if os.path.exists(os.path.join(pkg_dir, 'dist/index.cjs')): + continue + src = os.path.join(pkg_dir, 'src') + if not os.path.exists(os.path.join(src, 'index.ts')): + continue + paths[name] = [os.path.join(src, 'index.ts')] + paths[f'{name}/*'] = [os.path.join(src, '*')] +json.dump({'compilerOptions': {'baseUrl': root, 'paths': paths}}, open(out, 'w'), indent=2) +PY + +trap 'rm -f "$TSCONFIG"' EXIT + +TSX_TSCONFIG_PATH="$TSCONFIG" \ + "$PROJECT_ROOT/node_modules/.bin/tsx" \ + "$PROJECT_ROOT/packages/perps-controller/e2e/tat-3344/compute-max-order-amount.mjs" \ + --project-root="$PROJECT_ROOT" \ + "$@" From 13352504fa46fb536ba255dd054d1f3a91ec7bf4 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Thu, 30 Jul 2026 13:33:46 +0800 Subject: [PATCH 4/6] style(perps): format TAT-3344 proof artifacts with oxfmt --- .../e2e/tat-3344/compute-max-order-amount.mjs | 32 +++++++++++++------ .../tat-3344/prove-max-order.core.recipe.json | 12 +++++-- 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/packages/perps-controller/e2e/tat-3344/compute-max-order-amount.mjs b/packages/perps-controller/e2e/tat-3344/compute-max-order-amount.mjs index 07c7cc6109a..ceb6b4cf17f 100644 --- a/packages/perps-controller/e2e/tat-3344/compute-max-order-amount.mjs +++ b/packages/perps-controller/e2e/tat-3344/compute-max-order-amount.mjs @@ -59,7 +59,8 @@ if (!['fixed', 'prefix'].includes(mode)) { // The headless controller bootstrap lives in the harness, keyed off the // provisioned MM_HARNESS_BIN so this stays pinned to the same runtime the // recipe uses. -const harnessBin = process.env.MM_HARNESS_BIN ?? process.env.METAMASK_HARNESS_BIN; +const harnessBin = + process.env.MM_HARNESS_BIN ?? process.env.METAMASK_HARNESS_BIN; if (!harnessBin) { throw new Error('MM_HARNESS_BIN (or METAMASK_HARNESS_BIN) must be set.'); } @@ -71,17 +72,23 @@ const controllerHelpers = pathToFileURL( // The signer-bound controller, exactly as metamask.perps.place_order builds it: // getMarketDataWithPrices goes through the provider's client init, which needs a // wallet adapter even for a read. -const { getCoreControllerWithSigner, currentMarketPrice } = - await import(controllerHelpers); +const { getCoreControllerWithSigner, currentMarketPrice } = await import( + controllerHelpers +); // The changed function, imported from the checkout entrypoint the harness's own // core adapter imports — same source tree, same module instance. const { getMaxAllowedAmount } = await import( - pathToFileURL(path.join(projectRoot, 'packages/perps-controller/src/index.ts')) - .href + pathToFileURL( + path.join(projectRoot, 'packages/perps-controller/src/index.ts'), + ).href ); -const input = { action: 'tat-3344.compute-max', context: { projectRoot }, node: { network } }; +const input = { + action: 'tat-3344.compute-max', + context: { projectRoot }, + node: { network }, +}; const { controller, accountAddress } = await getCoreControllerWithSigner(input); const accountState = await controller.getAccountState({ @@ -119,7 +126,9 @@ const fixedMax = getMaxAllowedAmount({ const MAX_ORDER_MARGIN_BUFFER = 0.005; const prefixMax = Math.max( 0, - Math.floor(Math.floor(spendableBalance * leverage) * (1 - MAX_ORDER_MARGIN_BUFFER)), + Math.floor( + Math.floor(spendableBalance * leverage) * (1 - MAX_ORDER_MARGIN_BUFFER), + ), ); const amount = mode === 'fixed' ? fixedMax : prefixMax; @@ -141,12 +150,17 @@ const record = { prefix: (prefixMax * 1.03) / leverage, spendableBalance, }, - computedVia: 'packages/perps-controller/src/utils/orderCalculations.ts#getMaxAllowedAmount', + computedVia: + 'packages/perps-controller/src/utils/orderCalculations.ts#getMaxAllowedAmount', }; process.stderr.write(`[tat-3344] ${JSON.stringify(record)}\n`); if (outPath) { - await writeFile(path.resolve(outPath), `${JSON.stringify(record, null, 2)}\n`, 'utf-8'); + await writeFile( + path.resolve(outPath), + `${JSON.stringify(record, null, 2)}\n`, + 'utf-8', + ); } process.stdout.write(String(amount)); diff --git a/packages/perps-controller/e2e/tat-3344/prove-max-order.core.recipe.json b/packages/perps-controller/e2e/tat-3344/prove-max-order.core.recipe.json index 24f6028d5b9..ab28a429907 100644 --- a/packages/perps-controller/e2e/tat-3344/prove-max-order.core.recipe.json +++ b/packages/perps-controller/e2e/tat-3344/prove-max-order.core.recipe.json @@ -12,8 +12,16 @@ "default": "testnet", "description": "Testnet only. This proof spends the entire spendable balance as margin and must never touch real funds." }, - "market": { "type": "string", "default": "BTC", "description": "Perps market symbol." }, - "leverage": { "type": "number", "default": 3, "description": "Order leverage; also fed to getMaxAllowedAmount." }, + "market": { + "type": "string", + "default": "BTC", + "description": "Perps market symbol." + }, + "leverage": { + "type": "number", + "default": 3, + "description": "Order leverage; also fed to getMaxAllowedAmount." + }, "mode": { "type": "string", "enum": ["fixed", "prefix"], From 008dc22a82b29b2f3902ee82ae966bbc389031f2 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Thu, 30 Jul 2026 15:14:54 +0800 Subject: [PATCH 5/6] chore(perps): remove TAT-3344 proof scaffolding from the PR The live reproduction harness is task-local validation tooling, not package source. Kept out of the shipped diff; the two commits above retain it in branch history. --- .../e2e/tat-3344/compute-max-order-amount.mjs | 170 ------------------ .../tat-3344/prove-max-order.core.recipe.json | 151 ---------------- .../e2e/tat-3344/run-compute.sh | 39 ---- 3 files changed, 360 deletions(-) delete mode 100644 packages/perps-controller/e2e/tat-3344/compute-max-order-amount.mjs delete mode 100644 packages/perps-controller/e2e/tat-3344/prove-max-order.core.recipe.json delete mode 100755 packages/perps-controller/e2e/tat-3344/run-compute.sh diff --git a/packages/perps-controller/e2e/tat-3344/compute-max-order-amount.mjs b/packages/perps-controller/e2e/tat-3344/compute-max-order-amount.mjs deleted file mode 100644 index ceb6b4cf17f..00000000000 --- a/packages/perps-controller/e2e/tat-3344/compute-max-order-amount.mjs +++ /dev/null @@ -1,170 +0,0 @@ -// TAT-3344 — compute a max order amount through the CHANGED code path. -// -// This is the first half of the live proof for -// `getMaxAllowedAmount` (packages/perps-controller/src/utils/orderCalculations.ts). -// A fixed-notional live order proves nothing about max sizing, so this step -// derives the notional the way the client does: -// -// live spendableBalance + live mid + live szDecimals -// -> getMaxAllowedAmount(...) <-- the changed function -// -> stdout -// -// The recipe feeds that stdout straight into metamask.perps.place_order as -// `amount`, which the provider uses as `usdAmount`: -// -// calculateFinalPositionSize -> finalPositionSize = usdAmount / currentPrice -// calculateOrderPriceAndSize -> orderPrice = currentPrice * (1 + slippage) -// -// so the exchange charges initial margin against `orderPrice` — the exact -// mismatch TAT-3344 is about. -// -// mode=fixed emits the current (fixed) getMaxAllowedAmount result. -// mode=prefix emits the pre-fix formula (no slippage haircut) recomputed from -// the SAME live inputs, so the counterfactual is a controlled A/B -// rather than a remembered number. -// -// Only the amount goes to stdout. Diagnostics go to stderr and the full record -// is written to --out as JSON evidence. -// -// This is a standalone proof script run by the harness's `command` action, not -// library code: it reads its runtime location from the environment and must exit -// explicitly because the controller holds a HyperLiquid WebSocket open. -/* eslint-disable n/no-process-env, n/no-process-exit */ - -import { writeFile } from 'node:fs/promises'; -import path from 'node:path'; -import { pathToFileURL } from 'node:url'; - -function arg(name, fallback) { - const hit = process.argv.find((entry) => entry.startsWith(`--${name}=`)); - return hit === undefined ? fallback : hit.slice(name.length + 3); -} - -const projectRoot = path.resolve(arg('project-root', process.cwd())); -const network = arg('network', 'testnet'); -const market = arg('market', 'BTC'); -const leverage = Number(arg('leverage', '3')); -const mode = arg('mode', 'fixed'); -const outPath = arg('out', ''); - -if (network !== 'testnet') { - throw new Error( - `TAT-3344 proof is testnet-only; refusing network=${network}.`, - ); -} -if (!['fixed', 'prefix'].includes(mode)) { - throw new Error(`mode must be fixed | prefix, got ${mode}.`); -} - -// The headless controller bootstrap lives in the harness, keyed off the -// provisioned MM_HARNESS_BIN so this stays pinned to the same runtime the -// recipe uses. -const harnessBin = - process.env.MM_HARNESS_BIN ?? process.env.METAMASK_HARNESS_BIN; -if (!harnessBin) { - throw new Error('MM_HARNESS_BIN (or METAMASK_HARNESS_BIN) must be set.'); -} -const harnessRoot = path.dirname(path.dirname(harnessBin)); -const controllerHelpers = pathToFileURL( - path.join(harnessRoot, 'library/actions/core/perps/_controller.mjs'), -).href; - -// The signer-bound controller, exactly as metamask.perps.place_order builds it: -// getMarketDataWithPrices goes through the provider's client init, which needs a -// wallet adapter even for a read. -const { getCoreControllerWithSigner, currentMarketPrice } = await import( - controllerHelpers -); - -// The changed function, imported from the checkout entrypoint the harness's own -// core adapter imports — same source tree, same module instance. -const { getMaxAllowedAmount } = await import( - pathToFileURL( - path.join(projectRoot, 'packages/perps-controller/src/index.ts'), - ).href -); - -const input = { - action: 'tat-3344.compute-max', - context: { projectRoot }, - node: { network }, -}; -const { controller, accountAddress } = await getCoreControllerWithSigner(input); - -const accountState = await controller.getAccountState({ - standalone: true, - userAddress: accountAddress, -}); -const spendableBalance = Number(accountState?.spendableBalance); -if (!Number.isFinite(spendableBalance) || spendableBalance <= 0) { - throw new Error( - `Unusable spendableBalance from the controller: ${accountState?.spendableBalance}.`, - ); -} - -const assetPrice = await currentMarketPrice(controller, market); - -const markets = await controller.getMarkets({ standalone: true }); -const meta = (Array.isArray(markets) ? markets : []).find( - (item) => String(item?.name ?? '').toUpperCase() === market.toUpperCase(), -); -if (!meta || typeof meta.szDecimals !== 'number') { - throw new Error(`No szDecimals for ${market} on ${network}.`); -} -const assetSzDecimals = meta.szDecimals; - -const fixedMax = getMaxAllowedAmount({ - spendableBalance, - assetPrice, - assetSzDecimals, - leverage, -}); - -// Pre-fix formula, verbatim from the parent commit of the fix: theoretical max -// off the MID price with only MAX_ORDER_MARGIN_BUFFER (0.5%) shaved, and no -// slippage haircut. -const MAX_ORDER_MARGIN_BUFFER = 0.005; -const prefixMax = Math.max( - 0, - Math.floor( - Math.floor(spendableBalance * leverage) * (1 - MAX_ORDER_MARGIN_BUFFER), - ), -); - -const amount = mode === 'fixed' ? fixedMax : prefixMax; - -const record = { - ticket: 'TAT-3344', - mode, - network, - market, - account: accountAddress, - leverage, - inputs: { spendableBalance, assetPrice, assetSzDecimals }, - fixedMax, - prefixMax, - emittedAmount: amount, - // What the exchange will be asked for, at the default 300 bps market slippage. - projectedMarginAtOrderPrice: { - fixed: (fixedMax * 1.03) / leverage, - prefix: (prefixMax * 1.03) / leverage, - spendableBalance, - }, - computedVia: - 'packages/perps-controller/src/utils/orderCalculations.ts#getMaxAllowedAmount', -}; - -process.stderr.write(`[tat-3344] ${JSON.stringify(record)}\n`); -if (outPath) { - await writeFile( - path.resolve(outPath), - `${JSON.stringify(record, null, 2)}\n`, - 'utf-8', - ); -} - -process.stdout.write(String(amount)); - -// The controller opens a persistent HyperLiquid WebSocket; exit explicitly so -// the command node does not hang on an idle socket. -process.exit(0); diff --git a/packages/perps-controller/e2e/tat-3344/prove-max-order.core.recipe.json b/packages/perps-controller/e2e/tat-3344/prove-max-order.core.recipe.json deleted file mode 100644 index ab28a429907..00000000000 --- a/packages/perps-controller/e2e/tat-3344/prove-max-order.core.recipe.json +++ /dev/null @@ -1,151 +0,0 @@ -{ - "$schema": "https://farmslot.io/schemas/recipe-v1.schema.json", - "title": "TAT-3344 — prove the max order amount is placeable", - "description": "Derive a max order amount through the changed getMaxAllowedAmount against live account state, then submit that exact amount as a real market order on HyperLiquid testnet. A fixed-notional order proves nothing here: the defect is in how the maximum is SIZED, so the size under test must come from the changed function. mode=fixed expects the order to be accepted; mode=prefix recomputes the pre-fix formula from the same live inputs and expects the venue to refuse it, which is the TAT-3344 report reproduced live.", - "paramsSchema": { - "type": "object", - "additionalProperties": false, - "properties": { - "network": { - "type": "string", - "enum": ["testnet"], - "default": "testnet", - "description": "Testnet only. This proof spends the entire spendable balance as margin and must never touch real funds." - }, - "market": { - "type": "string", - "default": "BTC", - "description": "Perps market symbol." - }, - "leverage": { - "type": "number", - "default": 3, - "description": "Order leverage; also fed to getMaxAllowedAmount." - }, - "mode": { - "type": "string", - "enum": ["fixed", "prefix"], - "default": "fixed", - "description": "fixed = current getMaxAllowedAmount result. prefix = pre-fix formula on the same live inputs." - }, - "expected_state": { - "type": "string", - "enum": ["open_position", "none"], - "default": "open_position", - "description": "State the submission should produce. Use 'none' with expect_error." - }, - "expect_error": { - "type": "string", - "default": "", - "description": "Exact controller/venue error the submission must be refused with. Empty expects acceptance." - }, - "compute_out": { - "type": "string", - "default": "temp/tasks/fix/tat-3344-0729-223401/artifacts/proof/compute.json", - "description": "Where the compute step writes its inputs/outputs record as evidence." - } - } - }, - "proofTargets": [ - { - "id": "max-sizing", - "claim": "The order amount submitted is the value getMaxAllowedAmount returned for the live spendable balance, price and szDecimals — not a fixed notional." - }, - { - "id": "venue-verdict", - "claim": "HyperLiquid accepts a max-size market buy sized by the fixed function, and refuses the same order sized by the pre-fix formula." - } - ], - "workflow": { - "entry": "guard-testnet", - "nodes": { - "guard-testnet": { - "action": "switch", - "value": "{{params.network}}", - "equals": "testnet", - "cases": { "match": "ensure-clean-market" }, - "default": "not-testnet", - "intent": "Refuse to run anywhere but testnet before any mutation" - }, - "not-testnet": { "action": "end", "status": "fail" }, - "ensure-clean-market": { - "action": "call", - "ref": "perps.ensure-market-state", - "params": { - "market": "{{params.market}}", - "network": "{{params.network}}", - "side": "long", - "position_state": "none", - "order_state": "none" - }, - "intent": "Start flat on {{params.market}} so the margin available to this order, and the state asserted after it, are attributable to this proof", - "next": "compute-max" - }, - "compute-max": { - "action": "command", - "cmd": "packages/perps-controller/e2e/tat-3344/run-compute.sh --network={{params.network}} --market={{params.market}} --leverage={{params.leverage}} --mode={{params.mode}} --out={{params.compute_out}}", - "timeout_ms": 180000, - "intent": "Read live spendableBalance/price/szDecimals through the controller and size the max with getMaxAllowedAmount ({{params.mode}}); stdout is the amount", - "proves": ["max-sizing"], - "next": "submit-max-order" - }, - "submit-max-order": { - "action": "metamask.perps.place_order", - "market": "{{params.market}}", - "side": "long", - "order_type": "market", - "network": "{{params.network}}", - "amount": "{{outputs.compute-max.stdout}}", - "leverage": "{{params.leverage}}", - "expect_error": "{{params.expect_error}}", - "timeout_ms": 120000, - "intent": "Submit the computed maximum as a real market buy. The amount becomes the provider's usdAmount, so size = usdAmount / mid while the order is priced at mid * (1 + slippage) — the mismatch TAT-3344 is about.", - "proves": ["venue-verdict"], - "next": "choose-state-assertion" - }, - "choose-state-assertion": { - "action": "switch", - "value": "{{params.expected_state}}", - "equals": "open_position", - "cases": { "match": "assert-open-position" }, - "default": "assert-no-position", - "intent": "Assert the state this submission was expected to produce" - }, - "assert-open-position": { - "action": "metamask.perps.assert_positions", - "market": "{{params.market}}", - "side": "long", - "network": "{{params.network}}", - "state": "open", - "timeout_ms": 60000, - "intent": "The max-size buy actually opened a position, so the venue took it", - "proves": ["venue-verdict"], - "next": "restore-market" - }, - "assert-no-position": { - "action": "metamask.perps.assert_positions", - "market": "{{params.market}}", - "network": "{{params.network}}", - "state": "none", - "timeout_ms": 60000, - "intent": "The refused order left no position behind", - "proves": ["venue-verdict"], - "next": "restore-market" - }, - "restore-market": { - "action": "call", - "ref": "perps.ensure-market-state", - "params": { - "market": "{{params.market}}", - "network": "{{params.network}}", - "side": "long", - "position_state": "none", - "order_state": "none" - }, - "intent": "Close or cancel whatever this proof placed, leaving the account flat", - "next": "done" - }, - "done": { "action": "end", "status": "pass" } - } - } -} diff --git a/packages/perps-controller/e2e/tat-3344/run-compute.sh b/packages/perps-controller/e2e/tat-3344/run-compute.sh deleted file mode 100755 index 186b0bc95c7..00000000000 --- a/packages/perps-controller/e2e/tat-3344/run-compute.sh +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env bash -# TAT-3344 — run compute-max-order-amount.mjs against an UNBUILT core checkout. -# -# The workspace packages the perps controller imports at runtime resolve only to -# dist/ via their package "exports", which does not exist in a fresh checkout. -# The harness solves this for its own live adapters by pointing tsx at each -# package's src through a generated tsconfig paths map -# (harness src/live-adapter-contract.ts, platformAdapterEnv). This mirrors that -# so the proof's compute step runs on the same terms, without building or -# modifying the checkout. -set -euo pipefail - -PROJECT_ROOT="${PROJECT_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)}" -TSCONFIG="$(mktemp -t tat-3344-tsconfig.XXXXXX)" - -python3 - "$PROJECT_ROOT" "$TSCONFIG" <<'PY' -import json, os, sys -root, out = sys.argv[1], sys.argv[2] -paths = {} -for pkg in ['base-controller', 'messenger', 'controller-utils', 'keyring-controller']: - name = f'@metamask/{pkg}' - pkg_dir = os.path.join(root, 'packages', pkg) - if os.path.exists(os.path.join(pkg_dir, 'dist/index.cjs')): - continue - src = os.path.join(pkg_dir, 'src') - if not os.path.exists(os.path.join(src, 'index.ts')): - continue - paths[name] = [os.path.join(src, 'index.ts')] - paths[f'{name}/*'] = [os.path.join(src, '*')] -json.dump({'compilerOptions': {'baseUrl': root, 'paths': paths}}, open(out, 'w'), indent=2) -PY - -trap 'rm -f "$TSCONFIG"' EXIT - -TSX_TSCONFIG_PATH="$TSCONFIG" \ - "$PROJECT_ROOT/node_modules/.bin/tsx" \ - "$PROJECT_ROOT/packages/perps-controller/e2e/tat-3344/compute-max-order-amount.mjs" \ - --project-root="$PROJECT_ROOT" \ - "$@" From a0ffe2290d2bea7fc99e4189b5dd02aa986adc40 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Thu, 30 Jul 2026 15:31:33 +0800 Subject: [PATCH 6/6] fix(perps): size the max order amount off a resting limit price HyperLiquid reserves initial margin for a resting order against the price that order is submitted at, not the market price its size was derived from. A max-size limit order resting above the market price - typically a sell - therefore reserved more margin than the account had and the venue refused it with "order 0: insufficient margin to place order". getMaxAllowedAmount now takes optional orderType and limitPrice and scales the maximum by limitPrice / marketPrice when the order rests above the market. Orders at or below the market price, and market orders, are unchanged. Reproduced live on HyperLiquid testnet before the change and verified accepted after it. --- packages/perps-controller/CHANGELOG.md | 6 +- .../src/utils/orderCalculations.ts | 45 +++---- .../tests/src/utils/orderCalculations.test.ts | 125 ++++++++---------- 3 files changed, 75 insertions(+), 101 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index b7974ddb443..63af14321f7 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -9,9 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Size the max order amount off the price the order is actually submitted at, fixing `order 0: insufficient margin to place order` rejections on max-size market buys ([#9694](https://github.com/MetaMask/core/pull/9694)) - - `getMaxAllowedAmount` previously derived the maximum from the market price while market buys are submitted as limit orders priced at `market price * (1 + slippage)`; because HyperLiquid charges initial margin against the submitted price, a max-size buy asked for ~3% (up to 10% at the highest slippage setting) more margin than the account had and the exchange rejected it. - - `getMaxAllowedAmount` now accepts optional `orderType`, `isBuy`, and `maxSlippageBps` params. The slippage haircut applies to market buys only; sells and limit orders are unchanged. The params default to a market buy at the default slippage, so existing callers get the safe behavior without changes. +- 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. ## [10.0.0] diff --git a/packages/perps-controller/src/utils/orderCalculations.ts b/packages/perps-controller/src/utils/orderCalculations.ts index 60e70ad7f70..33aa69d89f7 100644 --- a/packages/perps-controller/src/utils/orderCalculations.ts +++ b/packages/perps-controller/src/utils/orderCalculations.ts @@ -35,16 +35,13 @@ type MaxAllowedAmountParams = { assetPrice: number; assetSzDecimals: number; leverage: number; - // Order type the max is being computed for. Market orders are submitted with a - // slippage-adjusted price, which is what the exchange charges margin against. - // Defaults to 'market' (the conservative case) when omitted. + // 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'; - // Direction of the order. Only buys are priced above the market price, so only - // buys need the slippage haircut. Defaults to a buy (the conservative case). - isBuy?: boolean; - // Max slippage in basis points (e.g. 300 = 3%). Falls back to - // ORDER_SLIPPAGE_CONFIG.DefaultMarketSlippageBps. - maxSlippageBps?: number; + // 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 @@ -166,31 +163,27 @@ export function getMaxAllowedAmount(params: MaxAllowedAmountParams): number { assetSzDecimals, leverage, orderType = 'market', - isBuy = true, - maxSlippageBps, + limitPrice, } = params; if (spendableBalance === 0 || !assetPrice || assetSzDecimals === undefined) { return 0; } - // Market buys are sent to HyperLiquid as limit orders priced at - // `assetPrice * (1 + slippage)` (see calculateOrderPriceAndSize), and the - // exchange charges initial margin against that submitted price - not against - // the market price the size was derived from. Sizing the max off the market - // price therefore requires ~slippage% more margin than the account has and the - // exchange rejects with "insufficient margin to place order". Price the max - // off the worst-case execution price instead. Sells are priced below the - // market price, so they need no haircut. - const slippageMultiplier = - orderType === 'market' && isBuy - ? 1 + - (maxSlippageBps ?? ORDER_SLIPPAGE_CONFIG.DefaultMarketSlippageBps) / - BASIS_POINTS_DIVISOR + // 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) / slippageMultiplier; + 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 @@ -204,7 +197,7 @@ export function getMaxAllowedAmount(params: MaxAllowedAmountParams): number { }); const actualNotionalValue = - parseFloat(testPositionSize) * assetPrice * slippageMultiplier; + 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 106c3edca93..404cb486d08 100644 --- a/packages/perps-controller/tests/src/utils/orderCalculations.test.ts +++ b/packages/perps-controller/tests/src/utils/orderCalculations.test.ts @@ -1,38 +1,32 @@ -import { BASIS_POINTS_DIVISOR } from '../../../src/constants/hyperLiquidConfig.js'; -import { ORDER_SLIPPAGE_CONFIG } from '../../../src/constants/perpsConfig.js'; import { calculateFinalPositionSize, - calculateOrderPriceAndSize, getMaxAllowedAmount, } from '../../../src/utils/orderCalculations.js'; /** - * Margin HyperLiquid charges for an order, which is based on the price the order - * is actually submitted at, not the market price the size was derived from. + * 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.isBuy - Whether the order is a buy. - * @param options.maxSlippageBps - Max slippage in basis points. - * @returns The margin the exchange requires for the resulting order. + * @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, - isBuy, - maxSlippageBps, + orderPrice, }: { usdAmount: number; assetPrice: number; assetSzDecimals: number; leverage: number; - isBuy: boolean; - maxSlippageBps?: number; + orderPrice: number; }): number { const { finalPositionSize } = calculateFinalPositionSize({ usdAmount: usdAmount.toString(), @@ -41,16 +35,7 @@ function exchangeRequiredMargin({ leverage, }); - const { orderPrice, formattedSize } = calculateOrderPriceAndSize({ - orderType: 'market', - isBuy, - finalPositionSize, - currentPrice: assetPrice, - maxSlippageBps, - szDecimals: assetSzDecimals, - }); - - return (parseFloat(formattedSize) * orderPrice) / leverage; + return (finalPositionSize * orderPrice) / leverage; } describe('getMaxAllowedAmount', () => { @@ -75,19 +60,22 @@ describe('getMaxAllowedAmount', () => { }); // Regression: TAT-3344 - "order 0: insufficient margin to place order". - // Market buys are submitted at market price * (1 + slippage) and the exchange - // charges margin against that price, so the max must be sized off it too. - it('keeps a max market buy within the spendable balance', () => { + // 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); @@ -97,72 +85,66 @@ describe('getMaxAllowedAmount', () => { assetPrice, assetSzDecimals, leverage, - isBuy: true, + orderPrice: limitPrice, }), ).toBeLessThanOrEqual(spendableBalance); }); - it('honors a custom max slippage for market buys', () => { - const spendableBalance = 500; - const assetPrice = 2500; - const assetSzDecimals = 4; - const leverage = 10; - const maxSlippageBps = 1000; - - const maxAmount = getMaxAllowedAmount({ - spendableBalance, - assetPrice, - assetSzDecimals, - leverage, - isBuy: true, - maxSlippageBps, - }); - - expect( - exchangeRequiredMargin({ - usdAmount: maxAmount, - assetPrice, - assetSzDecimals, - leverage, - isBuy: true, - maxSlippageBps, - }), - ).toBeLessThanOrEqual(spendableBalance); - }); - - it('applies the slippage haircut by default so unaware callers stay safe', () => { + 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 slippageMultiplier = - 1 + ORDER_SLIPPAGE_CONFIG.DefaultMarketSlippageBps / BASIS_POINTS_DIVISOR; - expect(getMaxAllowedAmount(params)).toBe( - getMaxAllowedAmount({ ...params, isBuy: true, orderType: 'market' }), - ); - expect(getMaxAllowedAmount(params)).toBeLessThan( - (params.spendableBalance * params.leverage) / slippageMultiplier, - ); + 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 apply the slippage haircut to sells or limit orders', () => { + 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); - const buyMax = getMaxAllowedAmount({ ...params, isBuy: true }); - const sellMax = getMaxAllowedAmount({ ...params, isBuy: false }); - const limitMax = getMaxAllowedAmount({ ...params, orderType: 'limit' }); + // 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(sellMax).toBeGreaterThan(buyMax); - expect(limitMax).toBe(sellMax); - expect(sellMax).toBeLessThanOrEqual( + expect(uncapped).toBeLessThanOrEqual( params.spendableBalance * params.leverage, ); }); @@ -180,7 +162,6 @@ describe('getMaxAllowedAmount', () => { assetPrice, assetSzDecimals, leverage, - isBuy: false, }); expect( @@ -189,7 +170,7 @@ describe('getMaxAllowedAmount', () => { assetPrice, assetSzDecimals, leverage, - isBuy: false, + orderPrice: assetPrice, }), ).toBeLessThanOrEqual(spendableBalance); });