Repository navigation
feat(perps): update to perps-controller 22.3.0 and use its shared calculations instead of Extension copies - #47777
abretonc7s wants to merge 4 commits into
Conversation
…culations Bump @metamask/perps-controller to ^22.3.0 and replace Extension's local copies with Core's shared calculations: - Close and close-all use estimateCloseReceive: cross margin keeps its PnL, PnL is priced at the close price, cents round half away from zero, receive stays signed, and an unquoted fee shows as unavailable with Close disabled until it is quoted. - Activity and transaction details use calculateNetClosedPnl; an unknown fee stays unknown through fill aggregation and details show the same amount as activity. - TP/SL RoE, trigger prices and validation use Core's helpers. The LavaMoat policies follow the hoisted @metamask/abi-utils edge, and the Jest stub re-exports Core's real calculation modules.
|
CLA Signature Action: All authors have signed the CLA. You may need to manually re-run the blocking PR check if it doesn't pass in a few minutes. |
✨ Files requiring CODEOWNER review ✨👨🔧 @MetaMask/perps (21 files, +1289 -970)
📜 @MetaMask/policy-reviewers (8 files, +8 -56)
Tip Follow the policy review process outlined in the LavaMoat Policy Review Process doc before expecting an approval from Policy Reviewers. |
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
| isSubmitting || | ||
| positions.length === 0 || | ||
| hasInvalidNotional || | ||
| totalFees === undefined; |
There was a problem hiding this comment.
ClosePositionModal blocks confirmation when youWillReceive < 0 (shows perpsInsufficientMargin and keeps submit disabled). After this change, Close all can display a signed negative receive (e.g. the new cross-loss fixture) but isSubmitDisabled still only keys off fee quotes / notional — submit stays enabled.
Consider aligning the two flows: disable Close all (and surface the same insufficient-margin copy if you show inline errors) when totalReceive !== undefined && totalReceive < 0, or document why bulk close is allowed in that state.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit f54a74f. Configure here.
Use formatPnl and formatPerpsFiat from @metamask/perps-controller instead of pre-rounding with toFixed before a local formatter copy. Rendered values are unchanged; the Jest mock exposes the real Core formatters.
Builds ready [c5754dc]
⚡ Performance Benchmarks (Total: 🟢 8 pass · 🟡 3 warn · 🔴 2 fail)
Bundle Size Diffs [🚨 Warning! Bundle size has increased!]
|

Description
Extension computed its own Perps close and TP/SL figures, so the same position showed different numbers than Mobile and Terminal. This bumps
@metamask/perps-controllerto^22.3.0and uses Core's shared calculations instead:estimateCloseReceive. Cross margin keeps its PnL (it used to be subtracted), PnL is priced at the live or limit close price, cents round half away from zero (1.005 → 1.01), and close-all shows a signed receive instead of clamping at 0. Amounts keep their cents ($2,114.49, not $2,114.5). A partial close is quoted at the size it submits, rounded to the market's size step. While the fee rate is unquoted, fee and receive show--and Close (and Close all) stays disabled, so no fee is reported as 0.calculateNetClosedPnl. A fill with an unknown fee keeps its net PnL unknown, including when split fills are aggregated. Details show the same amount as the activity row (cents rounded half up, thousands grouped), zero included, and do not subtract the fee again.validateTPSLPricesandformatRoePercent. A stop loss is capped at 99% loss per 1x, trigger prices keep Core's precision after the field loses focus, and an unknown leverage shows no RoE instead of assuming 1x.calculateAccountSummary(TAT-4480) has nothing to replace. Add/remove margin and new-order calculations are unchanged, per the ticket.The dependency bump hoists
@metamask/abi-utilsfor the controller, so the LavaMoat policies change only that edge. The Jest stub now re-exports Core's real calculation modules, so the tests check Core's figures.Changelog
CHANGELOG entry: Fixed Perps close amounts, closed PnL and TP/SL prices so they match the MetaMask mobile app
Related issues
Fixes: https://consensyssoftware.atlassian.net/browse/TAT-4534
Manual testing steps
--until the fee loads, Close is disabled until then, and Margin − Fees = You'll receive to the cent.Screenshots/Recordings
AC-2: Close review rows

AC-3: Market activity net PnL

AC-1: Stop loss at -5000% RoE gets Core's capped price

Full evidence gallery and publication receipt
caption confidence: LOW — generic filename — no state-specific suffix
caption confidence: LOW — generic filename — no state-specific suffix
caption confidence: LOW — generic filename — no state-specific suffix
Unassigned capture.
Unassigned capture.
Unassigned capture.
Unassigned capture.
Unassigned capture.
Unassigned capture.
Unassigned capture.
Unassigned capture.
Unassigned capture.
Unassigned capture.
Unassigned capture.
Unassigned capture.
Unassigned capture.
Unassigned capture.
Unassigned capture.
Unassigned capture.
Unassigned capture.
Unassigned capture.
Unassigned capture.
Unassigned capture.
Unassigned capture.
Unassigned capture.
Unassigned capture.
Publication approval
Arthur · person · arthur · 2026-11-10T01:04:17.445Z
Package:
c100f61b428837415bcd830fa6e8dc5ef9c2f9873476c5f2ef02e50a02729253. Visual receipts: none.Evidence policy attestation
Approved by Arthur (arthur) at 2026-11-10T01:04:17.444Z.
Package:
c100f61b428837415bcd830fa6e8dc5ef9c2f9873476c5f2ef02e50a02729253. Decision:73294e2c-77db-4981-8fea-5db6ee865705.Reason:
Waived claim-policy findings:
Original evidence and verdicts are retained. Integrity, source, CI and review checks remain required.
Validation Recipe
recipe.json (116 nodes — Perps close figures and TP/SL from Core 22.3.0 on Extension)
{ "$schema": "https://farmslot.io/schemas/recipe-v1.schema.json", "title": "Perps close figures and TP/SL from Core 22.3.0 on Extension", "description": "On Hyperliquid testnet, open one smallest owned position, show the close and close-all reviews with Core's margin-with-PnL, fee and signed receive, close it, show the same net closed PnL in market activity, recent activity and transaction details, then show Core's capped stop-loss price in the order entry. Other runs' positions on the shared account are only read.", "proofTargets": [ { "id": "AC-1", "claim": "Extension depends on `@metamask/perps-controller` 22.3.0; no local copy of a calculation Core exports remains in scope.", "proofMode": "mixed" }, { "id": "AC-2", "claim": "Close modal and close-all show Core's receive, margin-with-PnL and fee for Hyperliquid isolated and cross, gain and loss, partial and limit close, with discounts; values match Core's reference rows to the cent; signed receive shown.", "proofMode": "mixed" }, { "id": "AC-3", "claim": "Activity, recent activity and transaction details agree on net closed PnL from Core for split fills, zero, rebates and unknown fees.", "proofMode": "mixed" }, { "id": "AC-4", "claim": "Recipe proof in the browser for each user-visible change (testnet, smallest size), unit tests fail before and pass after, perps-review-pr passes.", "proofMode": "review" }, { "id": "AC-5", "claim": "Research with exact file paths and callers: client migration inventory drafted for Core #11378 (Extension section).", "proofMode": "review" } ], "paramsSchema": { "type": "object", "additionalProperties": false, "properties": { "market": { "type": "string", "default": "SOL", "description": "Market this run owns on the shared testnet account; must start flat." }, "amount": { "type": "string", "default": "11", "description": "Smallest USD order amount above the venue minimum." }, "leverage": { "type": "integer", "default": 1, "description": "Order leverage." }, "stop_loss_roe": { "type": "string", "default": "-5000", "description": "Stop-loss RoE beyond Core's 99% per 1x cap at any market leverage." }, "network": { "type": "string", "enum": [ "testnet" ], "default": "testnet", "description": "Perps network; this proof trades, so testnet only." }, "provider": { "type": "string", "enum": [ "hyperliquid" ], "default": "hyperliquid", "description": "Provider holding the owned position." }, "side": { "type": "string", "enum": [ "long", "short" ], "default": "long", "description": "Side of the owned position." }, "cross_amount": { "type": "string", "default": "24", "description": "USD size of the owned cross position: half of it still clears the venue minimum." } } }, "workflow": { "entry": "setup-unlock", "nodes": { "setup-unlock": { "action": "metamask.wallet.ensure_unlocked", "intent": "Start from the unlocked slot wallet", "next": "setup-perps" }, "setup-perps": { "action": "ui.navigate", "page": "perps", "intent": "Start from Perps home", "next": "setup-tutorial-clear" }, "setup-tutorial-clear": { "action": "ui.wait_for", "test_id": "perps-tutorial-skip-button", "expected": "absent", "timeout_ms": 5000, "intent": "The Perps tutorial is not covering the page", "next": "setup-testnet" }, "setup-testnet": { "action": "metamask.perps.start_state", "network": "{{params.network}}", "page": false, "positions": false, "orders": false, "intent": "Select Hyperliquid testnet and verify it before trading", "next": "setup-flat-positions" }, "setup-flat-positions": { "action": "metamask.perps.assert_positions", "market": "{{params.market}}", "state": "none", "intent": "The owned market starts without a position on either side", "next": "setup-flat-orders" }, "setup-flat-orders": { "action": "metamask.perps.assert_orders", "market": "{{params.market}}", "state": "none", "intent": "The owned market starts without open orders", "next": "setup-run-start" }, "setup-open": { "action": "metamask.perps.place_order", "market": "{{params.market}}", "side": "{{params.side}}", "amount": "{{params.amount}}", "leverage": "{{params.leverage}}", "order_type": "market", "provider": "{{params.provider}}", "network": "{{params.network}}", "confirm_live": true, "intent": "Open one smallest owned long on testnet", "next": "setup-position-open" }, "setup-position-open": { "action": "metamask.perps.assert_positions", "market": "{{params.market}}", "side": "{{params.side}}", "state": "open", "intent": "The owned long is open", "next": "ac2-open-market" }, "ac2-open-market": { "action": "ui.navigate", "page": "perps-market", "market": "{{params.market}}", "intent": "Show the owned position on its market page", "next": "ac2-venue-position" }, "ac2-open-close-modal": { "action": "ui.press", "test_id": "perps-close-cta-button", "intent": "Review closing the owned position", "next": "ac2-close-modal-receive" }, "ac2-close-modal-receive": { "action": "ui.wait_for", "test_id": "perps-close-summary-receive-value", "expected": "visible", "timeout_ms": 15000, "intent": "The close review shows a signed amount you will receive", "next": "ac2-close-modal-fees", "proves": [ "AC-2" ] }, "ac2-close-modal-fees": { "action": "ui.wait_for", "test_id": "perps-close-summary-fees-value", "expected": "visible", "timeout_ms": 15000, "intent": "The close review shows the quoted fee", "next": "ac2-close-modal-figures", "proves": [ "AC-2" ] }, "ac2-close-modal-capture": { "action": "ui.screenshot", "label": "Close review: margin with PnL, fee, receive", "intent": "Keep the close review rows for visual review", "next": "ac2-close-reconciles" }, "ac2-close-modal-dismiss": { "action": "ui.key_press", "key": "Escape", "intent": "Leave the close review without closing", "next": "ac2-close-modal-gone" }, "ac2-close-modal-gone": { "action": "ui.wait_for", "test_id": "perps-close-position-modal", "expected": "absent", "timeout_ms": 5000, "intent": "The close review is dismissed", "next": "ac2-close-all-home" }, "ac2-close-all-home": { "action": "ui.navigate", "page": "perps", "intent": "Show every open position on Perps home", "next": "ac2-close-all-open" }, "ac2-close-all-open": { "action": "ui.press", "test_id": "perps-close-all-positions", "intent": "Review closing all positions without confirming", "next": "ac2-close-all-receive" }, "ac2-close-all-receive": { "action": "ui.wait_for", "test_id": "perps-close-all-receive-value", "expected": "visible", "timeout_ms": 15000, "intent": "Close all shows a signed total you will receive", "next": "ac2-close-all-margin" }, "ac2-close-all-margin": { "action": "ui.wait_for", "test_id": "perps-close-all-total-margin-value", "expected": "visible", "timeout_ms": 5000, "intent": "Close all shows the total margin with PnL", "next": "ac2-close-all-figures", "proves": [ "AC-2" ] }, "ac2-close-all-capture": { "action": "ui.screenshot", "label": "Close all review: margin with PnL, fees, receive", "intent": "Keep the close-all review rows for visual review", "next": "ac2-close-all-reconciles" }, "ac2-close-all-keep": { "action": "ui.press", "test_id": "perps-close-all-positions-modal-cancel", "intent": "Keep every position open", "next": "ac2-close-all-gone" }, "ac2-close-all-gone": { "action": "ui.wait_for", "test_id": "perps-close-all-positions-modal", "expected": "absent", "timeout_ms": 5000, "intent": "Close all is dismissed and nothing was closed", "next": "ac2-reopen-market" }, "ac2-reopen-market": { "action": "ui.navigate", "page": "perps-market", "market": "{{params.market}}", "intent": "Return to the owned position", "next": "ac2-close-review" }, "ac2-venue-position": { "action": "metamask.perps.read_snapshot", "include": [ "positions" ], "market": "{{params.market}}", "intent": "Read the venue margin and PnL of the owned position before reviewing its close", "next": "ac2-open-close-modal" }, "ac2-close-reconciles": { "action": "assert_output", "source": "ac2-close-modal-figures", "stream": "stdout", "contains": "RECONCILED=true", "intent": "Margin with PnL minus fee equals what you receive, to the cent", "next": "ac2-close-modal-dismiss", "proves": [ "AC-2" ] }, "ac3-market-activity": { "action": "ui.navigate", "page": "perps-market", "market": "{{params.market}}", "intent": "Show the market's trade activity after the close", "next": "ac3-owned-close" }, "ac3-amount": { "action": "command", "intent": "Take the owned close's net PnL as shown in market activity", "next": "ac3-market-capture" }, "ac3-market-capture": { "action": "ui.screenshot", "label": "Market activity: owned close net PnL", "intent": "Keep the market activity row for visual review", "next": "ac3-recent-home" }, "ac3-recent-home": { "action": "ui.navigate", "page": "perps", "intent": "Show recent activity on Perps home", "next": "ac3-recent-row" }, "ac3-recent-row": { "action": "ui.wait_for", "selector": "[data-testid^=\"transaction-card-{{outputs.ac3-owned-close.stdout}}-\"]", "expected": "present", "timeout_ms": 30000, "intent": "The owned close appears in recent activity", "next": "ac3-recent-scroll" }, "ac3-recent-scroll": { "action": "ui.scroll", "selector": "[data-testid^=\"transaction-card-{{outputs.ac3-owned-close.stdout}}-\"]", "scroll_into_view": true, "intent": "The owned close row is on screen in recent activity", "next": "ac3-recent-visible" }, "ac3-recent-capture": { "action": "ui.screenshot", "label": "Recent activity: owned close net PnL", "intent": "Keep the recent activity row for visual review", "next": "ac3-recent-matches" }, "ac3-open-details": { "action": "ui.press", "selector": "[data-testid^=\"transaction-card-{{outputs.ac3-owned-close.stdout}}-\"]", "intent": "Open the owned close's transaction details", "next": "ac3-details-pnl" }, "ac3-details-pnl": { "action": "ui.wait_for", "selector": "[data-testid=\"perps-transaction-details-page\"]", "expected": "visible", "timeout_ms": 15000, "intent": "The owned close transaction details are showing", "next": "ac3-details-figures", "proves": [ "AC-3" ] }, "ac3-details-capture": { "action": "ui.screenshot", "label": "Transaction details: owned close net PnL", "intent": "Keep the transaction details for visual review", "next": "ac3-details-matches" }, "ac1-order-entry": { "action": "call", "ref": "perps.open-order-entry", "params": { "market": "{{params.market}}", "side": "{{params.side}}" }, "intent": "Open a new long order entry on the now flat market", "next": "ac1-auto-close-on" }, "ac1-auto-close-on": { "action": "ui.press", "selector": "label:has([data-testid=\"auto-close-toggle\"])", "intent": "Turn auto close on", "next": "ac1-sl-percent-ready" }, "ac1-sl-percent-ready": { "action": "ui.wait_for", "selector": "[data-testid=\"sl-percent-input\"] input", "expected": "visible", "timeout_ms": 5000, "intent": "The stop-loss RoE field is showing", "next": "ac1-sl-roe" }, "ac1-sl-roe": { "action": "ui.set_input", "selector": "[data-testid=\"sl-percent-input\"] input", "value": "{{params.stop_loss_roe}}", "intent": "Ask for a stop loss beyond the largest loss the venue allows", "next": "ac1-read-sl" }, "ac1-read-sl": { "action": "metamask.perps.read_visible_state", "surface": "order-entry", "market": "{{params.market}}", "auto_close": true, "max_items": 150, "intent": "Read the stop-loss price the form derived from the RoE", "next": "ac1-sl-scroll" }, "ac1-sl-capture": { "action": "ui.screenshot", "label": "Order entry: capped stop-loss price", "intent": "Keep the derived stop-loss price for visual review", "next": "ac1-auto-close-off" }, "ac1-auto-close-off": { "action": "call", "ref": "perps.disable-auto-close", "params": { "market": "{{params.market}}" }, "intent": "Leave the order entry without auto close prices in its draft", "next": "ac1-sl-roe-cap" }, "ac1-sl-roe-cap": { "action": "assert_output", "source": "ac1-read-sl", "assert": { "path": "$.orderEntry.stopLossPrice", "operator": "matches", "value": "^\\$?([1-9][0-9,]*(\\.[0-9]+)?|0\\.[0-9]*[1-9][0-9]*)$" }, "intent": "A stop loss beyond the cap gets Core's capped trigger price instead of none", "next": "ac2-cross-margin-includes-pnl", "proves": [ "AC-1" ] }, "ac2-cross-margin-includes-pnl": { "action": "assert_output", "source": "ac2-close-modal-figures", "stream": "stdout", "contains": "MARGIN_INCLUDES_PNL=true", "intent": "The close review margin matches the venue margin with its PnL for this margin mode", "next": "ac1-core-version-read", "proves": [ "AC-2" ] }, "ac1-core-version-read": { "action": "command", "cmd": "node -p \"require('@metamask/perps-controller/package.json').version\"", "intent": "Read the perps-controller version the build resolves", "next": "ac1-core-version" }, "ac1-core-version": { "action": "assert_output", "source": "ac1-core-version-read", "stream": "stdout", "match": "^22\\.3\\.", "intent": "The build depends on perps-controller 22.3", "next": "x-open", "proves": [ "AC-1" ] }, "done": { "action": "end", "status": "pass" }, "teardown-dismiss": { "action": "ui.key_press", "key": "Escape", "intent": "Dismiss any open review before cleanup", "next": "teardown-owned" }, "teardown-read": { "action": "metamask.perps.read_positions", "market": "{{params.market}}", "network": "{{params.network}}", "intent": "Inspect the owned market even after a failure", "next": "teardown-choose" }, "teardown-choose": { "action": "switch", "value": "positions:{{outputs.teardown-read.matchingCount}}", "equals": "positions:0", "cases": { "match": "teardown-flat" }, "default": "teardown-market", "intent": "Close only when the owned position is still open" }, "teardown-market": { "action": "ui.navigate", "page": "perps-market", "market": "{{params.market}}", "intent": "Show the owned position for cleanup", "next": "teardown-review" }, "teardown-flat": { "action": "metamask.perps.assert_positions", "market": "{{params.market}}", "state": "none", "intent": "The owned market ends flat", "next": "teardown-done", "timeout_ms": 45000 }, "teardown-done": { "action": "end", "status": "pass" }, "ac2-close-modal-figures": { "action": "command", "intent": "Read the close review rows once the fee quote has loaded", "next": "ac2-close-modal-capture" }, "ac2-close-all-figures": { "action": "command", "intent": "Read the close-all rows once every fee quote has loaded", "next": "ac2-close-all-capture" }, "ac2-close-all-reconciles": { "action": "assert_output", "source": "ac2-close-all-figures", "stream": "stdout", "contains": "RECONCILED=true", "intent": "Close all margin with PnL minus fees equals what you receive, to the cent", "next": "ac2-close-all-keep", "proves": [ "AC-2" ] }, "ac3-recent-figures": { "action": "command", "intent": "Read the owned close row in recent activity", "next": "ac3-recent-capture" }, "ac3-recent-matches": { "action": "assert_output", "source": "ac3-recent-figures", "stream": "stdout", "contains": "RECENT_MATCHES=true", "intent": "Recent activity shows the same net PnL as market activity", "next": "ac3-open-details", "proves": [ "AC-3" ] }, "ac3-details-figures": { "action": "command", "intent": "Read the net PnL the transaction details show", "next": "ac3-details-capture" }, "ac3-details-matches": { "action": "assert_output", "source": "ac3-details-figures", "stream": "stdout", "contains": "DETAILS_MATCHES=true", "intent": "Transaction details show the same net PnL as the activity rows", "next": "ac1-order-entry", "proves": [ "AC-3" ] }, "ac1-sl-scroll": { "action": "ui.scroll", "selector": "[data-testid=\"sl-price-input\"]", "scroll_into_view": true, "intent": "The stop-loss price and RoE fields are on screen for review", "next": "ac1-sl-visible" }, "setup-run-start": { "action": "command", "cmd": "node -e \"process.stdout.write(String(Date.now()))\"", "intent": "Stamp the run start so only this run's close counts as owned", "next": "setup-open" }, "ac2-close-review": { "action": "ui.press", "test_id": "perps-close-cta-button", "intent": "Review closing the owned position", "next": "ac2-close-ready" }, "ac2-close-ready": { "action": "ui.wait_for", "selector": "[data-testid=\"perps-close-position-modal-submit\"]:not([disabled])", "expected": "visible", "timeout_ms": 15000, "intent": "Close is enabled once the fee is quoted", "next": "ac2-close-submit" }, "ac2-close-submit": { "action": "ui.press", "test_id": "perps-close-position-modal-submit", "intent": "Close the owned position at market", "next": "ac2-closed" }, "ac2-closed": { "action": "metamask.perps.assert_positions", "market": "{{params.market}}", "state": "none", "timeout_ms": 45000, "intent": "The owned market is flat after the close", "next": "ac3-market-activity" }, "ac3-owned-close": { "action": "command", "intent": "Find this run's close at the top of the market activity", "next": "ac3-close-row" }, "ac3-close-row": { "action": "ui.wait_for", "selector": "[data-testid^=\"transaction-card-{{outputs.ac3-owned-close.stdout}}-\"]", "expected": "present", "timeout_ms": 10000, "intent": "The owned close shows in the market activity", "next": "ac3-close-row-scroll" }, "teardown-review": { "action": "ui.press", "test_id": "perps-close-cta-button", "intent": "Review closing the position this run opened", "next": "teardown-ready" }, "teardown-ready": { "action": "ui.wait_for", "selector": "[data-testid=\"perps-close-position-modal-submit\"]:not([disabled])", "expected": "visible", "timeout_ms": 15000, "intent": "Close is enabled once the fee is quoted", "next": "teardown-submit" }, "teardown-submit": { "action": "ui.press", "test_id": "perps-close-position-modal-submit", "intent": "Close only the position this run opened", "next": "teardown-flat" }, "teardown-owned": { "action": "switch", "value": "opened:{{outputs.setup-open.action}}", "equals": "opened:metamask.perps.place_order", "cases": { "match": "teardown-read" }, "default": "teardown-done", "intent": "Clean up only when this run placed its own order" }, "ac3-close-row-scroll": { "action": "ui.scroll", "selector": "[data-testid^=\"transaction-card-{{outputs.ac3-owned-close.stdout}}-\"]", "scroll_into_view": true, "intent": "The owned close row is on screen for review", "next": "ac3-close-row-visible" }, "ac3-close-row-visible": { "action": "ui.wait_for", "selector": "[data-testid^=\"transaction-card-{{outputs.ac3-owned-close.stdout}}-\"]", "visible": true, "timeout_ms": 5000, "intent": "The owned close row is visible before the market activity capture", "next": "ac3-amount", "proves": [ "AC-3" ] }, "ac3-recent-visible": { "action": "ui.wait_for", "selector": "[data-testid^=\"transaction-card-{{outputs.ac3-owned-close.stdout}}-\"]", "visible": true, "timeout_ms": 5000, "intent": "The owned close row is visible before the recent activity capture", "next": "ac3-recent-figures" }, "ac1-sl-visible": { "action": "ui.wait_for", "selector": "[data-testid=\"sl-price-input\"]", "visible": true, "timeout_ms": 5000, "intent": "The stop-loss price is visible before the capture", "next": "ac1-sl-capture" }, "x-open": { "action": "command", "intent": "Open one smallest owned cross long on testnet (the order form has no margin-mode control)", "next": "x-opened" }, "x-opened": { "action": "assert_output", "source": "x-open", "stream": "stdout", "contains": "OPENED=true", "intent": "The owned cross long was placed", "next": "x-position-open" }, "x-position-open": { "action": "metamask.perps.assert_positions", "market": "{{params.market}}", "side": "long", "state": "open", "timeout_ms": 30000, "intent": "The owned cross long is open", "next": "x-market" }, "x-market": { "action": "ui.navigate", "page": "perps-market", "market": "{{params.market}}", "intent": "Show the owned cross position on its market page", "next": "x-venue" }, "x-venue": { "action": "metamask.perps.read_snapshot", "include": [ "positions" ], "market": "{{params.market}}", "intent": "Read the venue margin, PnL and margin mode of the owned position", "next": "x-is-cross" }, "x-is-cross": { "action": "assert_output", "source": "x-venue", "assert": { "path": "$.sections.positions.items.0.leverage.type", "operator": "matches", "value": "^cross$" }, "intent": "The owned position is on cross margin", "proves": [ "AC-2" ], "next": "x-review" }, "x-review": { "action": "ui.press", "test_id": "perps-close-cta-button", "intent": "Review closing the cross position", "next": "x-receive" }, "x-receive": { "action": "ui.wait_for", "test_id": "perps-close-summary-receive-value", "expected": "visible", "timeout_ms": 15000, "intent": "The cross close review shows what you will receive", "proves": [ "AC-2" ], "next": "x-fees" }, "x-fees": { "action": "ui.wait_for", "test_id": "perps-close-summary-fees-value", "expected": "visible", "timeout_ms": 15000, "intent": "The cross close review shows the quoted fee", "next": "x-figures" }, "x-figures": { "action": "command", "intent": "Read the cross close review rows once the fee quote has loaded", "next": "x-capture" }, "x-capture": { "action": "ui.screenshot", "label": "Cross close review: margin with PnL, fee, receive", "intent": "Keep the cross close review for visual review", "next": "x-reconciles" }, "x-reconciles": { "action": "assert_output", "source": "x-figures", "stream": "stdout", "contains": "RECONCILED=true", "intent": "Cross: margin with PnL minus fee equals what you receive, to the cent", "proves": [ "AC-2" ], "next": "x-margin-pnl" }, "x-margin-pnl": { "action": "assert_output", "source": "x-figures", "stream": "stdout", "contains": "MARGIN_INCLUDES_PNL=true", "intent": "Cross: the margin row adds the PnL to the venue margin", "proves": [ "AC-2" ], "next": "p-percent-mode" }, "p-percent-mode": { "action": "ui.press", "test_id": "close-amount-mode-percent", "intent": "Enter the close amount as a percentage", "next": "p-percent" }, "p-percent": { "action": "ui.set_input", "selector": "[data-testid=\"close-amount-percent\"] input", "value": "50", "intent": "Close half of the cross position", "next": "p-percent-ready" }, "p-percent-ready": { "action": "ui.wait_for", "test_id": "close-amount-slider-pct-50", "expected": "present", "timeout_ms": 5000, "intent": "The slider reflects a 50% close", "next": "p-fraction" }, "p-figures": { "action": "command", "intent": "Read the partial close review rows", "next": "p-capture" }, "p-capture": { "action": "ui.screenshot", "label": "Partial close review (50%): margin with PnL, fee, receive", "intent": "Keep the partial close review for visual review", "next": "p-reconciles" }, "p-reconciles": { "action": "assert_output", "source": "p-figures", "stream": "stdout", "contains": "RECONCILED=true", "intent": "Partial: margin with PnL minus fee equals what you receive, to the cent", "proves": [ "AC-2" ], "next": "p-margin-half" }, "p-margin-half": { "action": "assert_output", "source": "p-figures", "stream": "stdout", "contains": "MARGIN_INCLUDES_PNL=true", "intent": "Partial: the margin row is the submitted share of the venue margin with PnL", "proves": [ "AC-2" ], "next": "p-ready" }, "p-ready": { "action": "ui.wait_for", "selector": "[data-testid=\"perps-close-position-modal-submit\"]:not([disabled])", "expected": "visible", "timeout_ms": 15000, "intent": "Close is enabled once the partial fee is quoted", "next": "p-submit" }, "p-submit": { "action": "ui.press", "test_id": "perps-close-position-modal-submit", "intent": "Close half of the cross position at market", "next": "p-half" }, "p-half": { "action": "command", "intent": "Wait for the venue to report the size the partial close leaves open", "next": "p-half-left" }, "p-half-left": { "action": "assert_output", "source": "p-half", "stream": "stdout", "contains": "SIZE_MATCHES=true", "intent": "The partial close submitted the size the review quoted", "proves": [ "AC-2" ], "next": "l-venue" }, "l-venue": { "action": "metamask.perps.read_snapshot", "include": [ "positions" ], "market": "{{params.market}}", "intent": "Read the remaining cross position before its limit close", "next": "l-review" }, "l-review": { "action": "ui.press", "test_id": "perps-close-cta-button", "intent": "Review closing the remaining position", "next": "l-type" }, "l-type": { "action": "ui.press", "test_id": "order-type-limit", "intent": "Close with a limit order", "next": "l-price-calc" }, "l-price-calc": { "action": "command", "intent": "Pick a sell limit 1% under the live price so the close fills", "next": "l-price" }, "l-price": { "action": "ui.set_input", "selector": "[data-testid=\"limit-price-input\"] input", "value": "{{outputs.l-price-calc.stdout}}", "intent": "Enter the limit close price", "next": "l-pnl" }, "l-pnl": { "action": "command", "intent": "PnL of the remaining long closed at the limit price", "next": "l-fees" }, "l-fees": { "action": "ui.wait_for", "test_id": "perps-close-summary-fees-value", "expected": "visible", "timeout_ms": 15000, "intent": "The limit close review shows the quoted fee", "next": "l-figures" }, "l-figures": { "action": "command", "intent": "Read the limit close review rows, priced at the limit", "next": "l-capture" }, "l-capture": { "action": "ui.screenshot", "label": "Limit close review: margin with PnL at the limit price, fee, receive", "intent": "Keep the limit close review for visual review", "next": "l-reconciles" }, "l-reconciles": { "action": "assert_output", "source": "l-figures", "stream": "stdout", "contains": "RECONCILED=true", "intent": "Limit: margin with PnL minus fee equals what you receive, to the cent", "proves": [ "AC-2" ], "next": "l-margin-pnl" }, "l-margin-pnl": { "action": "assert_output", "source": "l-figures", "stream": "stdout", "contains": "MARGIN_INCLUDES_PNL=true", "intent": "Limit: the margin row adds the PnL priced at the limit", "proves": [ "AC-2" ], "next": "l-ready" }, "l-ready": { "action": "ui.wait_for", "selector": "[data-testid=\"perps-close-position-modal-submit\"]:not([disabled])", "expected": "visible", "timeout_ms": 15000, "intent": "Close is enabled once the limit fee is quoted", "next": "l-submit" }, "l-submit": { "action": "ui.press", "test_id": "perps-close-position-modal-submit", "intent": "Place the limit close", "next": "l-flat" }, "l-flat": { "action": "metamask.perps.assert_positions", "market": "{{params.market}}", "state": "none", "timeout_ms": 45000, "intent": "The limit close filled and the owned market is flat", "next": "done" }, "p-fraction": { "action": "command", "intent": "Share of the position a 50% close submits after size-step rounding", "next": "p-remaining" }, "p-remaining": { "action": "command", "intent": "Size left open after the 50% close", "next": "p-figures" } }, "teardown": "teardown-dismiss" } }Validation Logs
Run: 3e1bf21c-67f9-45cf-8853-4f5c47d848ac
Runner: @metamask/recipe-runner
Runner revision: unknown
Recipe digest: sha256:1ce3b0e371fd75d7b04b22799d206c91a9262c905a68a22cab0074fae25cacf5
Full output (123/123 passed, pass)
Pre-merge author checklist
Pre-merge reviewer checklist
Note
Medium Risk
Touches user-visible Perps money figures and trading confirmations across close, activity, and TP/SL; behavior changes are intentional but regressions would misstate PnL or enable premature submits.
Overview
Bumps
@metamask/perps-controllerto 22.3.0 and routes Perps UI math through Core instead of Extension-local copies, so figures match mobile/terminal. LavaMoat policies only adjust@metamask/abi-utilsafter the dependency hoist.Close / close-all now use
estimateCloseReceive(viaclose-position-utils): margin-with-PnL at the live or limit price, cross vs isolated handling, cent rounding half away from zero, signed receive (including negatives), partial closes sized to the market step, and Close disabled until fees are quoted (--placeholders).Activity and transaction details use
calculateNetClosedPnland Core formatters; split-fill aggregation keeps net PnL unknown when any fee is missing, and details reuse the same display string as list rows (no double fee subtraction).TP/SL (auto-close, edit modal, order entry) delegates RoE ↔ price, validation, caps, and formatting to Core (
calculatePriceForRoE,validateTPSLPrices, etc.); localtpslValidationhelpers shrink to i18n direction strings only.The global Jest mock re-exports real Core calculation modules so unit tests assert against shared formulas.
Reviewed by Cursor Bugbot for commit c5754dc. Bugbot is set up for automated code reviews on this repo. Configure here.