Skip to content

Commit 268e164

Browse files
authored
fix(perps): Perp order failure: Order 0: insufficient margin to place order (#29800)
## **Description** When placing a limit order, position size, margin, and max order size were calculated using the current market price instead of the user-set limit price. This caused "insufficient margin" errors for Short orders with limit prices above market at 100% slider, and silent order downsizing for Long orders with low limit prices. Introduced `effectivePrice` that uses the limit price for limit orders and market price for market orders. Applied consistently across position size, margin calculation, max order amount, and order submission params. ## **Changelog** CHANGELOG entry: Fixed limit order margin calculation to use limit price instead of market price, preventing "insufficient margin" errors ## **Related issues** Fixes: [TAT-3086](https://consensyssoftware.atlassian.net/browse/TAT-3086) ## **Manual testing steps** ```gherkin Feature: Limit order margin calculation Scenario: Short limit order at 100% slider with price above market Given wallet is unlocked and on BTC market When user taps Short, switches to Limit, sets limit price 5% above market And sets slider to 100% and taps Open Order Then order is placed without "insufficient margin" error Scenario: Long limit order at 100% slider with price below market Given wallet is unlocked and on BTC market When user taps Long, switches to Limit, sets limit price below market And sets slider to 100% and taps Open Order Then order is placed without error ``` ## **Screenshots/Recordings** Before: Short limit order at $83k (+5% above market) with 100% slider fails with 'Order failed: Insufficient margin' toast. After: Same flow — order placed successfully. **Video** <table> <tr><td align="center" width="50%"><em>Before</em><br/><a href="https://raw.githubusercontent.com/abretonc7s/mm-mobile-farm-artifacts/main/fixes/29800/before.mp4">before.mp4</a></td> <td align="center" width="50%"><em>After</em><br/><a href="https://raw.githubusercontent.com/abretonc7s/mm-mobile-farm-artifacts/main/fixes/29800/after.mp4">after.mp4</a></td></tr> </table> ## **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. #### Performance checks (if applicable) - [x] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [x] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [x] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **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. ## **Validation Recipe** <details> <summary>recipe.json — 60-node workflow validating limit order margin fix</summary> ```json { "pr": "29800", "title": "Verify limit order margin calculation uses limit price instead of market price", "jira": "TAT-3086", "acceptance_criteria": [ "AC1: Max USD order size at 100% slider accounts for limit price, not spot", "AC2: Changing limit price recalculates max order size and margin in real time", "AC3: Submitting limit order at 100% slider with diverging price produces no insufficient margin error", "AC4: UNTESTABLE — Extension-only (slot runs iOS Mobile)", "AC5: Behavior consistent across Long and Short on Mobile" ], "validate": { "workflow": { "pre_conditions": ["wallet.unlocked", "perps.ready_to_trade"], "entry": "setup-nav-short", "nodes": "... (60 nodes — see artifacts/recipe.json for full graph)" } } } ``` </details> ## **Recipe Workflow** <details> <summary>workflow.mmd — Mermaid diagram of the validation workflow</summary> ```mermaid flowchart TD %% Verify limit order margin calculation uses limit price instead of market price __entry__(["ENTRY"]) --> node_setup_nav_short node_setup_nav_short["setup-nav-short<br/>navigate"] node_setup_wait_short_btn["setup-wait-short-btn<br/>wait_for"] node_setup_press_short["setup-press-short<br/>press"] node_setup_wait_order_type["setup-wait-order-type<br/>wait_for"] node_setup_press_order_type["setup-press-order-type<br/>press"] node_setup_wait_limit_option["setup-wait-limit-option<br/>wait_for"] node_setup_press_limit["setup-press-limit<br/>press"] node_setup_wait_limit_form["setup-wait-limit-form<br/>wait_for"] node_setup_press_limit_price_row["setup-press-limit-price-row<br/>press"] node_setup_wait_limit_keypad["setup-wait-limit-keypad<br/>wait_for"] node_setup_clear_limit_keypad["setup-clear-limit-keypad<br/>clear_keypad"] node_setup_type_limit_price["setup-type-limit-price<br/>type_keypad"] node_setup_confirm_limit_price["setup-confirm-limit-price<br/>press"] node_setup_wait_amount_display["setup-wait-amount-display<br/>wait_for"] node_ac1_read_max_amount["ac1-read-max-amount<br/>eval_sync"] node_ac1_screenshot_short_limit["ac1-screenshot-short-limit<br/>screenshot"] node_ac2_change_limit_price["ac2-change-limit-price<br/>press"] node_ac2_wait_limit_keypad["ac2-wait-limit-keypad<br/>wait_for"] node_ac2_clear_limit_keypad["ac2-clear-limit-keypad<br/>clear_keypad"] node_ac2_type_new_limit_price["ac2-type-new-limit-price<br/>type_keypad"] node_ac2_confirm_new_price["ac2-confirm-new-price<br/>press"] node_ac2_wait_recalc["ac2-wait-recalc<br/>wait"] node_ac2_read_new_max["ac2-read-new-max<br/>eval_sync"] node_ac2_screenshot_recalc["ac2-screenshot-recalc<br/>screenshot"] node_ac3_press_amount["ac3-press-amount<br/>press"] node_ac3_wait_keypad["ac3-wait-keypad<br/>wait_for"] node_ac3_press_max["ac3-press-max<br/>press"] node_ac3_press_done["ac3-press-done<br/>press"] node_ac3_wait_place_order["ac3-wait-place-order<br/>wait_for"] node_ac3_read_margin_before_submit["ac3-read-margin-before-submit<br/>eval_sync"] node_ac3_press_place_order["ac3-press-place-order<br/>press"] node_ac3_wait_for_result["ac3-wait-for-result<br/>wait"] node_ac3_check_no_error["ac3-check-no-error<br/>log_watch"] node_ac3_screenshot_no_error["ac3-screenshot-no-error<br/>screenshot"] node_teardown_cancel_short[["teardown-cancel-short<br/>perps/order-limit-cancel"]] node_ac5_nav_long["ac5-nav-long<br/>navigate"] node_ac5_wait_long_btn["ac5-wait-long-btn<br/>wait_for"] node_ac5_press_long["ac5-press-long<br/>press"] node_ac5_wait_order_type["ac5-wait-order-type<br/>wait_for"] node_ac5_press_order_type["ac5-press-order-type<br/>press"] node_ac5_wait_limit_option["ac5-wait-limit-option<br/>wait_for"] node_ac5_press_limit["ac5-press-limit<br/>press"] node_ac5_wait_limit_form["ac5-wait-limit-form<br/>wait_for"] node_ac5_press_limit_price["ac5-press-limit-price<br/>press"] node_ac5_wait_keypad["ac5-wait-keypad<br/>wait_for"] node_ac5_clear_keypad["ac5-clear-keypad<br/>clear_keypad"] node_ac5_type_limit_price["ac5-type-limit-price<br/>type_keypad"] node_ac5_confirm_limit_price["ac5-confirm-limit-price<br/>press"] node_ac5_wait_amount["ac5-wait-amount<br/>wait_for"] node_ac5_press_amount["ac5-press-amount<br/>press"] node_ac5_wait_amount_keypad["ac5-wait-amount-keypad<br/>wait_for"] node_ac5_press_max["ac5-press-max<br/>press"] node_ac5_press_done["ac5-press-done<br/>press"] node_ac5_wait_place_order["ac5-wait-place-order<br/>wait_for"] node_ac5_read_margin["ac5-read-margin<br/>eval_sync"] node_ac5_press_place_order["ac5-press-place-order<br/>press"] node_ac5_wait_result["ac5-wait-result<br/>wait"] node_ac5_check_no_error["ac5-check-no-error<br/>log_watch"] node_ac5_screenshot_long_no_error["ac5-screenshot-long-no-error<br/>screenshot"] node_teardown_cancel_long[["teardown-cancel-long<br/>perps/order-limit-cancel"]] node_setup_done(["setup-done<br/>PASS"]) node_setup_nav_short --> node_setup_wait_short_btn node_setup_wait_short_btn --> node_setup_press_short node_setup_press_short --> node_setup_wait_order_type node_setup_wait_order_type --> node_setup_press_order_type node_setup_press_order_type --> node_setup_wait_limit_option node_setup_wait_limit_option --> node_setup_press_limit node_setup_press_limit --> node_setup_wait_limit_form node_setup_wait_limit_form --> node_setup_press_limit_price_row node_setup_press_limit_price_row --> node_setup_wait_limit_keypad node_setup_wait_limit_keypad --> node_setup_clear_limit_keypad node_setup_clear_limit_keypad --> node_setup_type_limit_price node_setup_type_limit_price --> node_setup_confirm_limit_price node_setup_confirm_limit_price --> node_setup_wait_amount_display node_setup_wait_amount_display --> node_ac1_read_max_amount node_ac1_read_max_amount --> node_ac1_screenshot_short_limit node_ac1_screenshot_short_limit --> node_ac2_change_limit_price node_ac2_change_limit_price --> node_ac2_wait_limit_keypad node_ac2_wait_limit_keypad --> node_ac2_clear_limit_keypad node_ac2_clear_limit_keypad --> node_ac2_type_new_limit_price node_ac2_type_new_limit_price --> node_ac2_confirm_new_price node_ac2_confirm_new_price --> node_ac2_wait_recalc node_ac2_wait_recalc --> node_ac2_read_new_max node_ac2_read_new_max --> node_ac2_screenshot_recalc node_ac2_screenshot_recalc --> node_ac3_press_amount node_ac3_press_amount --> node_ac3_wait_keypad node_ac3_wait_keypad --> node_ac3_press_max node_ac3_press_max --> node_ac3_press_done node_ac3_press_done --> node_ac3_wait_place_order node_ac3_wait_place_order --> node_ac3_read_margin_before_submit node_ac3_read_margin_before_submit --> node_ac3_press_place_order node_ac3_press_place_order --> node_ac3_wait_for_result node_ac3_wait_for_result --> node_ac3_check_no_error node_ac3_check_no_error --> node_ac3_screenshot_no_error node_ac3_screenshot_no_error --> node_teardown_cancel_short node_teardown_cancel_short --> node_ac5_nav_long node_ac5_nav_long --> node_ac5_wait_long_btn node_ac5_wait_long_btn --> node_ac5_press_long node_ac5_press_long --> node_ac5_wait_order_type node_ac5_wait_order_type --> node_ac5_press_order_type node_ac5_press_order_type --> node_ac5_wait_limit_option node_ac5_wait_limit_option --> node_ac5_press_limit node_ac5_press_limit --> node_ac5_wait_limit_form node_ac5_wait_limit_form --> node_ac5_press_limit_price node_ac5_press_limit_price --> node_ac5_wait_keypad node_ac5_wait_keypad --> node_ac5_clear_keypad node_ac5_clear_keypad --> node_ac5_type_limit_price node_ac5_type_limit_price --> node_ac5_confirm_limit_price node_ac5_confirm_limit_price --> node_ac5_wait_amount node_ac5_wait_amount --> node_ac5_press_amount node_ac5_press_amount --> node_ac5_wait_amount_keypad node_ac5_wait_amount_keypad --> node_ac5_press_max node_ac5_press_max --> node_ac5_press_done node_ac5_press_done --> node_ac5_wait_place_order node_ac5_wait_place_order --> node_ac5_read_margin node_ac5_read_margin --> node_ac5_press_place_order node_ac5_press_place_order --> node_ac5_wait_result node_ac5_wait_result --> node_ac5_check_no_error node_ac5_check_no_error --> node_ac5_screenshot_long_no_error node_ac5_screenshot_long_no_error --> node_teardown_cancel_long node_teardown_cancel_long --> node_setup_done ``` </details> [TAT-3086]: https://consensyssoftware.atlassian.net/browse/TAT-3086?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Touches core perps order sizing and margin math; mistakes could cause incorrect max slider values or order rejections for limit orders, but scope is localized to client-side calculations and covered by new tests. > > **Overview** > Fixes limit-order calculations to **use the user-set limit price as the effective entry price** across the perps order flow, so the 100% slider, position size, margin required, liquidation price inputs, and submission params (`currentPrice`/`priceAtCalculation`) all align with the limit price rather than spot/mark. > > Updates `usePerpsOrderForm` to compute `maxPossibleAmount` using `limitPrice` when `orderForm.type === 'limit'`, and adds focused unit tests ensuring max amount recalculates/falls back correctly as limit price/type changes. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 916a5b4. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 721934e commit 268e164

3 files changed

Lines changed: 236 additions & 66 deletions

File tree

app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx

Lines changed: 37 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -536,6 +536,19 @@ const PerpsOrderViewContentBase: React.FC<PerpsOrderViewContentProps> = ({
536536
},
537537
});
538538

539+
// For limit orders, use the user-set limit price for calculations instead of the market price.
540+
// This ensures position size, margin, and max order size correctly reflect the limit price.
541+
const effectivePrice = useMemo(() => {
542+
if (
543+
orderForm.type === 'limit' &&
544+
orderForm.limitPrice &&
545+
parseFloat(orderForm.limitPrice) > 0
546+
) {
547+
return parseFloat(orderForm.limitPrice);
548+
}
549+
return assetData.price;
550+
}, [orderForm.type, orderForm.limitPrice, assetData.price]);
551+
539552
// Real-time position size calculation - memoized to prevent recalculation
540553
const positionSize = useMemo(() => {
541554
// During loading, show '--' placeholder (consistent with other unavailable data displays)
@@ -545,22 +558,34 @@ const PerpsOrderViewContentBase: React.FC<PerpsOrderViewContentProps> = ({
545558

546559
return calculatePositionSize({
547560
amount: orderForm.amount,
548-
price: assetData.price,
561+
price: effectivePrice,
549562
// Defensive fallback if market data fails to load - prevents crashes
550563
// Real szDecimals should come from market data (varies by asset)
551564
szDecimals: szDecimals ?? DECIMAL_PRECISION_CONFIG.FallbackSizeDecimals,
552565
});
553-
}, [orderForm.amount, assetData.price, szDecimals, isLoadingMarketData]);
566+
}, [orderForm.amount, effectivePrice, szDecimals, isLoadingMarketData]);
554567

555568
const marginRequired = useMemo(() => {
556569
if (!isLoadingMarketData && orderForm.amount) {
570+
// For limit orders with a valid limit price, use that price for margin calculation.
571+
// Otherwise use markPrice (oracle price) which is the standard margin basis.
572+
const hasValidLimitPrice =
573+
orderForm.type === 'limit' &&
574+
orderForm.limitPrice &&
575+
parseFloat(orderForm.limitPrice) > 0;
576+
const priceForMargin = hasValidLimitPrice
577+
? effectivePrice
578+
: assetData.markPrice;
557579
return calculateMarginRequired({
558-
amount: BigNumber(assetData.markPrice).times(positionSize).toString(),
580+
amount: BigNumber(priceForMargin).times(positionSize).toString(),
559581
leverage: orderForm.leverage,
560582
});
561583
}
562584
}, [
563585
orderForm.amount,
586+
orderForm.type,
587+
orderForm.limitPrice,
588+
effectivePrice,
564589
assetData.markPrice,
565590
orderForm.leverage,
566591
isLoadingMarketData,
@@ -652,27 +677,15 @@ const PerpsOrderViewContentBase: React.FC<PerpsOrderViewContentProps> = ({
652677
});
653678

654679
// Memoize liquidation price params to prevent infinite recalculation
655-
const liquidationPriceParams = useMemo(() => {
656-
// Use limit price for limit orders, market price for market orders
657-
const entryPrice =
658-
orderForm.type === 'limit' && orderForm.limitPrice
659-
? parseFloat(orderForm.limitPrice)
660-
: assetData.price;
661-
662-
return {
663-
entryPrice,
680+
const liquidationPriceParams = useMemo(
681+
() => ({
682+
entryPrice: effectivePrice,
664683
leverage: orderForm.leverage,
665684
direction: orderForm.direction,
666685
asset: orderForm.asset,
667-
};
668-
}, [
669-
assetData.price,
670-
orderForm.leverage,
671-
orderForm.direction,
672-
orderForm.asset,
673-
orderForm.type,
674-
orderForm.limitPrice,
675-
]);
686+
}),
687+
[effectivePrice, orderForm.leverage, orderForm.direction, orderForm.asset],
688+
);
676689

677690
const depositAmount = useMemo(() => {
678691
if (marginRequired !== undefined && marginRequired !== null) {
@@ -1060,11 +1073,11 @@ const PerpsOrderViewContentBase: React.FC<PerpsOrderViewContentProps> = ({
10601073
isBuy: orderForm.direction === 'long',
10611074
size: positionSize, // Kept for backward compatibility, provider recalculates from usdAmount
10621075
orderType: orderForm.type,
1063-
currentPrice: assetData.price,
1076+
currentPrice: effectivePrice,
10641077
leverage: orderForm.leverage,
10651078
// USD as source of truth (hybrid approach)
10661079
usdAmount: orderForm.amount, // USD amount (primary source of truth, provider calculates size from this)
1067-
priceAtCalculation: assetData.price, // Price snapshot when size was calculated (for slippage validation)
1080+
priceAtCalculation: effectivePrice, // Price snapshot when size was calculated (for slippage validation)
10681081
maxSlippageBps:
10691082
orderForm.type === 'limit'
10701083
? ORDER_SLIPPAGE_CONFIG.DefaultLimitSlippageBps // 1% for limit orders
@@ -1166,6 +1179,7 @@ const PerpsOrderViewContentBase: React.FC<PerpsOrderViewContentProps> = ({
11661179
orderForm.stopLossPrice,
11671180
orderForm.amount,
11681181
positionSize,
1182+
effectivePrice,
11691183
assetData.price,
11701184
navigation,
11711185
navigationMarketData,

0 commit comments

Comments
 (0)