Skip to content

Commit 6fa43af

Browse files
fix(perps): reduce max order amount by 0.5% buffer to avoid insufficient margin rejections (MetaMask#27417)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until the template has been completely filled out, and PR status checks have passed at least once. --> ## **Description** When users place a long/short Perps order with the amount slider at 100% (or tap "Max"), the app was sending the full theoretical maximum (available balance × leverage). The HyperLiquid API sometimes rejects these with "Order 0: Insufficient margin to place order" due to fees, rounding, and exchange-side margin checks. **Solution:** Introduce a 0.5% margin buffer on the maximum order amount so that "100%" uses 99.5% of the theoretical max. This is applied in a single place (`getMaxAllowedAmount`), and the order form uses this buffered value for the slider max, Max button, and 100% quick button so all paths stay consistent. The buffer is configurable via `MAX_ORDER_MARGIN_BUFFER` in perps config for future tuning or smarter logic (e.g. fee-based). **Changes:** - **perpsConfig**: Added `MAX_ORDER_MARGIN_BUFFER = 0.005` (0.5%). - **getMaxAllowedAmount**: After existing rounding logic, multiply max by `(1 - MAX_ORDER_MARGIN_BUFFER)` and return `floor(bufferedMax)`. - **usePerpsOrderForm**: `handleMaxAmount` and `handlePercentageAmount(1)` now set amount to `maxPossibleAmount` (the buffered max) instead of computing `balance × leverage` directly. - **Tests**: Updated expectations for low-balance scenarios (e.g. $2 @ 3x → max 5 instead of 6); added test that max is below theoretical after buffer. ## **Changelog** CHANGELOG entry: Fixed Perps orders at 100% margin sometimes failing with "Insufficient margin" by applying a small buffer to the maximum order amount. ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/TAT-2502 ## **Manual testing steps** ```gherkin Feature: Perps order placement at maximum margin Scenario: user places order at 100% (slider or Max) without insufficient margin error Given user is on Perps order view with available balance and an asset selected When user sets amount to 100% via the slider or taps the Max / 100% button Then the amount field shows the buffered max (slightly below theoretical max) And placing the order does not result in "Insufficient margin" rejection from the exchange ``` ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** N/A (behavioral fix; no UI change) ### **After** N/A ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Adjusts core order-sizing calculations for max/percentage selections, which can affect how much users trade and may surface edge cases around rounding and balance updates. Changes are localized and covered by updated/additional tests. > > **Overview** > Reduces Perps *maximum order amount* by applying a configurable **0.5% margin buffer** so “Max”/100% selections are less likely to be rejected as *Insufficient margin*. > > This adds `MAX_ORDER_MARGIN_BUFFER` and applies it in `getMaxAllowedAmount`, then updates `usePerpsOrderForm` handlers to clamp percentage-based amounts and set Max to `maxPossibleAmount` (the buffered max). Tests are updated to reflect new buffered expectations and add coverage for near-100% clamping and buffered-max behavior. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 03168c3. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent f09974b commit 6fa43af

5 files changed

Lines changed: 80 additions & 20 deletions

File tree

app/components/UI/Perps/hooks/usePerpsOrderForm.test.ts

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -435,9 +435,9 @@ describe('usePerpsOrderForm', () => {
435435
});
436436

437437
// Assert
438-
// With $2 balance and 3x leverage = $6 max amount, which is less than $10 default
439-
// Should use the max possible amount ($6) instead of the default ($10)
440-
expect(result.current.orderForm.amount).toBe('6');
438+
// With $2 balance and 3x leverage, max is floor(6 * (1 - 0.5% buffer)) = 5 (less than $10 default)
439+
// Should use the max possible amount (5) instead of the default ($10)
440+
expect(result.current.orderForm.amount).toBe('5');
441441
});
442442

443443
it('should use default amount when available balance times leverage is greater than default amount', () => {
@@ -468,14 +468,14 @@ describe('usePerpsOrderForm', () => {
468468

469469
describe('useMemo and useEffect behavior', () => {
470470
it('should not overwrite user input when dependencies change', async () => {
471-
// Arrange - Start with balance high enough that max >= 999 (e.g. 334 * 3x = 1002)
471+
// Arrange - Start with balance high enough that max >= 999 after 0.5% buffer (e.g. 335 * 3x → floor(1005*0.995) = 999)
472472
const mockAccount = {
473473
account: {
474-
availableBalance: '334',
474+
availableBalance: '335',
475475
marginUsed: '0',
476476
unrealizedPnl: '0',
477477
returnOnEquity: '0',
478-
totalBalance: '334',
478+
totalBalance: '335',
479479
},
480480
isInitialLoading: false,
481481
};
@@ -490,7 +490,7 @@ describe('usePerpsOrderForm', () => {
490490
TRADING_DEFAULTS.amount.mainnet.toString(),
491491
);
492492

493-
// Act - User changes the amount (within current max)
493+
// Act - User changes the amount (within current max; 335*3*0.995 >= 999)
494494
act(() => {
495495
result.current.setAmount('999');
496496
});
@@ -515,7 +515,7 @@ describe('usePerpsOrderForm', () => {
515515
// Test 1: Low balance scenario
516516
mockUsePerpsLiveAccount.mockReturnValue({
517517
account: {
518-
availableBalance: '2', // $2 balance = $6 max with 3x leverage (less than $10 default)
518+
availableBalance: '2', // $2 balance, 3x leverage: max = floor(6 * 0.995) = 5 (less than $10 default)
519519
marginUsed: '0',
520520
unrealizedPnl: '0',
521521
returnOnEquity: '0',
@@ -528,7 +528,7 @@ describe('usePerpsOrderForm', () => {
528528
wrapper: createWrapper(),
529529
});
530530

531-
expect(result1.current.orderForm.amount).toBe('6'); // Should use maxPossibleAmount
531+
expect(result1.current.orderForm.amount).toBe('5'); // Should use maxPossibleAmount (with margin buffer)
532532

533533
// Test 2: High balance scenario
534534
mockUsePerpsLiveAccount.mockReturnValue({
@@ -690,7 +690,8 @@ describe('usePerpsOrderForm', () => {
690690
result.current.handleMaxAmount();
691691
});
692692

693-
expect(result.current.orderForm.amount).toBe('3000'); // 1000 * 3x leverage
693+
// 1000 * 3x leverage with 0.5% margin buffer = floor(3000 * 0.995) = 2985
694+
expect(result.current.orderForm.amount).toBe('2985');
694695
});
695696

696697
it('should handle min amount for mainnet', () => {
@@ -722,6 +723,27 @@ describe('usePerpsOrderForm', () => {
722723
);
723724
});
724725

726+
it('should clamp near-100% amounts to maxPossibleAmount', () => {
727+
const { result } = renderHook(() => usePerpsOrderForm(), {
728+
wrapper: createWrapper(),
729+
});
730+
731+
act(() => {
732+
result.current.handlePercentageAmount(0.999);
733+
});
734+
735+
const at999 = Number(result.current.orderForm.amount);
736+
737+
act(() => {
738+
result.current.handlePercentageAmount(1);
739+
});
740+
741+
const at100 = Number(result.current.orderForm.amount);
742+
743+
expect(at999).toBeLessThanOrEqual(at100);
744+
expect(at100).toBe(result.current.maxPossibleAmount);
745+
});
746+
725747
it('should not update amount when balance is 0', () => {
726748
mockUsePerpsLiveAccount.mockReturnValue({
727749
account: {

app/components/UI/Perps/hooks/usePerpsOrderForm.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -317,26 +317,28 @@ export function usePerpsOrderForm(
317317
setOrderForm((prev) => ({ ...prev, type }));
318318
};
319319

320-
// Handle percentage-based amount selection (respects custom token amount when set)
320+
// Handle percentage-based amount selection (respects custom token amount when set).
321+
// Clamp to maxPossibleAmount so near-100% values never exceed the buffered max.
321322
const handlePercentageAmount = useCallback(
322323
(percentage: number) => {
323324
if (balanceForMax === 0) return;
324-
const newAmount = Math.floor(
325-
balanceForMax * orderForm.leverage * percentage,
326-
).toString();
325+
const raw = balanceForMax * orderForm.leverage * percentage;
326+
const clamped = Math.min(raw, maxPossibleAmount);
327+
const newAmount = Math.floor(clamped).toString();
327328
setOrderForm((prev) => ({ ...prev, amount: newAmount }));
328329
},
329-
[balanceForMax, orderForm.leverage],
330+
[balanceForMax, orderForm.leverage, maxPossibleAmount],
330331
);
331332

332-
// Handle max amount selection (respects custom token amount when set)
333+
// Handle max amount selection (respects custom token amount when set).
334+
// Uses maxPossibleAmount (includes margin buffer) to avoid "Insufficient margin" rejections.
333335
const handleMaxAmount = useCallback(() => {
334336
if (balanceForMax === 0) return;
335337
setOrderForm((prev) => ({
336338
...prev,
337-
amount: Math.floor(balanceForMax * prev.leverage).toString(),
339+
amount: Math.floor(maxPossibleAmount).toString(),
338340
}));
339-
}, [balanceForMax]);
341+
}, [balanceForMax, maxPossibleAmount]);
340342

341343
// Handle min amount selection
342344
const handleMinAmount = useCallback(() => {

app/components/UI/Perps/utils/orderCalculations.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,27 @@ describe('orderCalculations', () => {
355355
expect(result).toBeGreaterThanOrEqual(0);
356356
expect(result).toBeLessThanOrEqual(50); // 10 * 5 leverage
357357
});
358+
359+
it('should apply margin buffer so result is below theoretical max', () => {
360+
// Arrange - case where theoretical max is 1000 (100 * 10)
361+
const params = {
362+
availableBalance: 100,
363+
assetPrice: 50000,
364+
assetSzDecimals: 6,
365+
leverage: 10,
366+
};
367+
368+
// Act
369+
const result = getMaxAllowedAmount(params);
370+
const theoreticalMax = params.availableBalance * params.leverage;
371+
372+
// Assert - buffer (0.5%) reduces max to avoid "Insufficient margin" rejections
373+
expect(result).toBeGreaterThan(0);
374+
expect(result).toBeLessThanOrEqual(theoreticalMax);
375+
expect(result).toBeLessThanOrEqual(
376+
Math.floor(theoreticalMax * (1 - 0.005)),
377+
);
378+
});
358379
});
359380

360381
describe('buildOrdersArray', () => {

app/controllers/perps/constants/perpsConfig.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,14 @@ export const ORDER_SLIPPAGE_CONFIG = {
105105
DefaultLimitSlippageBps: 100,
106106
} as const;
107107

108+
/**
109+
* Max order amount buffer to reduce "Insufficient margin" rejections from the exchange.
110+
* When the user selects 100% (slider or Max), we cap the order at (1 - this) of the
111+
* theoretical max so that fees, rounding, and exchange-side margin checks are covered.
112+
* Value as decimal (e.g. 0.005 = 0.5%).
113+
*/
114+
export const MAX_ORDER_MARGIN_BUFFER = 0.005; // 0.5%
115+
108116
/**
109117
* Performance optimization constants
110118
* These values control debouncing and throttling for better performance

app/controllers/perps/utils/orderCalculations.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import {
44
formatHyperLiquidPrice,
55
formatHyperLiquidSize,
66
} from './hyperLiquidAdapter';
7-
import { ORDER_SLIPPAGE_CONFIG } from '../constants/perpsConfig';
7+
import {
8+
MAX_ORDER_MARGIN_BUFFER,
9+
ORDER_SLIPPAGE_CONFIG,
10+
} from '../constants/perpsConfig';
811
import { PERPS_ERROR_CODES } from '../perpsErrorCodes';
912
import type { PerpsDebugLogger } from '../types';
1013
import type { SDKOrderParams } from '../types/hyperliquid-types';
@@ -174,7 +177,11 @@ export function getMaxAllowedAmount(params: MaxAllowedAmountParams): number {
174177
maxAmount -= positionSizeIncrementUsd;
175178
}
176179

177-
return Math.max(0, maxAmount);
180+
// Apply margin buffer to reduce "Insufficient margin" rejections from the exchange
181+
// (fees, rounding, and exchange-side checks can make 100% theoretical max fail)
182+
const bufferedMax = maxAmount * (1 - MAX_ORDER_MARGIN_BUFFER);
183+
184+
return Math.max(0, Math.floor(bufferedMax));
178185
}
179186

180187
/**

0 commit comments

Comments
 (0)