feat(perps): persist the Pro margin-mode preference - #10464
Merged
Merged
Conversation
Store the Pro order form's Isolated/Cross pick in tradeConfigurations[network][symbol].marginMode, alongside leverage, and expose getMarginMode/saveMarginMode plus a selectMarginMode selector so clients can restore it across remounts and share it between Mobile and Extension. Refs: TAT-4015
abretonc7s
marked this pull request as ready for review
September 25, 2026 10:16
Link the perps-controller changelog entry to #10464 so the changelog check passes.
This was referenced Sep 27, 2026
Closed
michalconsensys
approved these changes
Sep 28, 2026
pull Bot
pushed a commit
to Reality2byte/core
that referenced
this pull request
Sep 29, 2026
…arket (MetaMask#10414) > Canonical Mobile consumer: MetaMask/metamask-mobile#36881 now contains Pro order entry, the temporary venue-lock backport and the locked picker. The former Mobile split PRs are superseded. Keep this upstream PR; remove the Mobile patch after adopting a released version and validating parity. Preference persistence remains separate in MetaMask#10464. ## Explanation HyperLiquid binds the margin mode to the asset. `OrderParams.marginMode` placement is rejected when an open position (`ORDER_MARGIN_MODE_POSITION_OPEN`) or a resting order / active TWAP (`ORDER_MARGIN_MODE_ORDER_OPEN`) already fixes the other mode. Clients could not see that current mode: `Order` and `TwapOrder` carry no margin mode, and `activeAssetData` is only read inside `#validateMarginMode`. So a client picker could offer a mode that placement will refuse. This adds `PerpsController.getMarginModeLock({ symbol, providerId? })` (plus the `PerpsController:getMarginModeLock` messenger action) and the optional `PerpsProvider.getMarginModeLock`. It returns `locked` (with `marginMode` and `reason: 'position' | 'open_order'`), `unlocked`, or a typed `unavailable` result, and never throws. - HyperLiquid: the lock detection moved out of `#validateMarginMode` into `#readMarginModeLock`, which both validation and the new method use, so the reported lock matches what placement enforces. Validation behavior is unchanged (same reads, same errors, same order). - Aggregated provider and controller routing mirror `getOrderCapabilities` (explicit/default provider, `provider_not_found`, `provider_not_routable`, `not_implemented`, `provider_unavailable`). - Lighter implements it from the open position's mode (`UpdateLeverage` carries the wire mode; a missing field means cross). The existing position-row read moved into `#readPositionMarginMode`; the leverage-update path keeps its fallback behavior. Only open positions lock the mode on Lighter; resting orders are not treated as a lock. It also adds an optional `supportedMarginModes` to ready order capabilities, so clients stop inferring margin-mode support from the provider (HyperLiquid, not HIP-3, not isolated-only). HyperLiquid reports `['isolated', 'cross']` for main-DEX markets and `['isolated']` for HIP-3 or isolated-only assets, from a single `#isCrossMarginSupported` predicate that `#validateMarginMode` also uses. Omitted means the provider does not report it and clients should not offer an explicit mode. ## References - Needed by MetaMask Mobile Perps Pro cross margin (TAT-3524): the Pro margin-mode picker locks to the venue mode while a resting order or TWAP exists. Mobile validated this against a backported patch on HyperLiquid testnet (resting Cross limit order → fresh form reads Cross, Isolated disabled). ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) - [ ] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Touches HyperLiquid pre-sign margin validation and multi-step venue reads; behavior is intended to stay equivalent while exposing new client-facing state. > > **Overview** > Clients can now query **which margin mode a market is locked to** (open position, resting order, or active TWAP) via `PerpsController.getMarginModeLock` and the matching messenger action, with optional `PerpsProvider.getMarginModeLock` on HyperLiquid and Lighter. Results are `locked` / `unlocked` / typed `unavailable` and never throw, using the same provider routing as order capabilities. > > HyperLiquid centralizes lock detection in `#readMarginModeLock` (shared with pre-sign `#validateMarginMode`) and adds account pinning so position/order reads cannot mix accounts. **Ready order capabilities** now optionally include `supportedMarginModes`; HyperLiquid derives isolated vs cross from `#isCrossMarginSupported` (main DEX vs HIP-3 / isolated-only metadata), so UIs need not guess from the provider id. > > Lighter locks **only on open positions** (resting orders ignored), reusing `#readPositionMarginMode` for leverage updates and the new API. Aggregated routing and tests cover the new surface end-to-end. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 1827be4. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
github-merge-queue Bot
pushed a commit
that referenced
this pull request
Sep 30, 2026
## Explanation - Release major perps-controller 19.0.0 Breaking changes (see changelog): - `OrderFill.liquidation.liquidatedUser` is now optional - `FrontendOrder.orderType` union adds `Twap Slice`, `Vault Close`, `Spot Dust Conversion` - Removed `LighterPersonalSigner` and the `personalSigner` / `l1Address` fields of `LighterAuthConfig` ## References - #10414, #10437, #10464, #10559, #10588, #10589, #10591 ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) - [ ] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Explanation
In the Pro order form, leverage and order-book grouping are saved per market in
tradeConfigurations[network][symbol]. The Isolated/Cross pick is the exception: Mobile keeps it in local React state, so it resets to Isolated whenever the Pro screen remounts, and Extension never sees it.This adds the pick to the same per-market, per-network entry:
tradeConfigurations[network][symbol].marginMode('isolated' | 'cross'), persisted with the rest oftradeConfigurations. No migration needed; the field is optional and absent means "no saved pick".getMarginMode(symbol)andsaveMarginMode(symbol, marginMode), exposed asPerpsController:getMarginMode/PerpsController:saveMarginMode. Saving merges into the existing entry, so leverage, grouping andpendingConfigare kept. Values other thanisolated/crossare ignored, so a bad call can't persist something that would later reachplaceOrder({ marginMode }).selectMarginMode(state, symbol)selector, plusmarginMode?onTradeConfiguration.Keying: network + market, same as leverage, not account. The mode is tied to leverage in the form, so keeping both on one key stops them drifting apart. The account-specific constraint (an open position or resting order fixes the mode) is the venue lock, which clients already apply on top of the saved pick.
placeOrderbehaviour is unchanged.Validation: new controller and selector unit tests; a headless run that saves picks on the real controller, rebuilds it from the persisted state slice, and checks the picks come back, leverage is kept, and mainnet/testnet stay separate. Root
yarn buildpasses and the new symbols are indist.References
usePerpsProOrderFormshould readselectMarginModeas the default and write withsaveMarginModeinstead of localmarginModeSelection, keepinggetMarginModeLockprecedence.Validation Recipe
recipe.json (0 steps — TAT-4015 persisted Pro margin mode)
{ "$schema": "https://farmslot.io/schemas/recipe-v1.schema.json", "title": "TAT-4015 persisted Pro margin mode", "description": "Proves PerpsController saves and restores the Isolated/Cross pick per market and network through its messenger actions, across a rebuild from persisted state, without disturbing leverage.", "workflow": { "entry": "run-driver", "nodes": { "run-driver": { "action": "command", "timeout_ms": 180000, "intent": "Drive the real PerpsController from this checkout: save/read margin mode, rebuild from the persisted slice, then switch network", "next": "driver-ok" }, "driver-ok": { "action": "assert_exit_code", "source": "run-driver", "expected": 0, "intent": "The driver completed; it throws if saveMarginMode/getMarginMode are missing", "next": "c1-btc-cross" }, "c1-btc-cross": { "action": "assert_json", "assert": { "path": "$.sameSession.btc", "operator": "eq", "value": "cross" }, "intent": "C1: BTC pick saved as cross is read back on mainnet", "next": "c1-eth-isolated" }, "c1-eth-isolated": { "action": "assert_json", "assert": { "path": "$.sameSession.eth", "operator": "eq", "value": "isolated" }, "intent": "C1+C5: ETH keeps isolated after an invalid 'portfolio' save is ignored", "next": "c3-unset-market" }, "c3-unset-market": { "action": "assert_json", "assert": { "path": "$.sameSession.sol", "operator": "eq", "value": "unset" }, "intent": "C3: a market with no saved pick returns undefined (client falls back to Isolated)", "next": "c4-leverage-kept" }, "c4-leverage-kept": { "action": "assert_json", "assert": { "path": "$.sameSession.btcLeverage", "operator": "eq", "value": 7 }, "intent": "C4: saving margin mode does not drop the market's saved leverage", "next": "c2-restart-btc" }, "c2-restart-btc": { "action": "assert_json", "assert": { "path": "$.afterRestart.btc", "operator": "eq", "value": "cross" }, "intent": "C2: BTC cross pick survives rebuilding the controller from persisted state", "next": "c2-restart-eth" }, "c2-restart-eth": { "action": "assert_json", "assert": { "path": "$.afterRestart.eth", "operator": "eq", "value": "isolated" }, "intent": "C2: ETH isolated pick survives rebuilding from persisted state", "next": "c2-restart-leverage" }, "c2-restart-leverage": { "action": "assert_json", "assert": { "path": "$.afterRestart.btcLeverage", "operator": "eq", "value": 7 }, "intent": "C2+C4: leverage persists alongside margin mode", "next": "c3-testnet-empty" }, "c3-testnet-empty": { "action": "assert_json", "assert": { "path": "$.networkScope.testnetBtcBeforeSave", "operator": "eq", "value": "unset" }, "intent": "C3: mainnet BTC pick does not leak to testnet", "next": "c3-testnet-saved" }, "c3-testnet-saved": { "action": "assert_json", "assert": { "path": "$.networkScope.testnetBtcAfterSave", "operator": "eq", "value": "isolated" }, "intent": "C3: testnet BTC pick is stored independently", "next": "c3-mainnet-untouched" }, "c3-mainnet-untouched": { "action": "assert_json", "assert": { "path": "$.networkScope.mainnetBtcInState", "operator": "eq", "value": "cross" }, "intent": "C3: testnet save leaves the mainnet BTC entry unchanged", "next": "unit-tests" }, "unit-tests": { "action": "command", "cmd": "yarn workspace @metamask/perps-controller run jest packages/perps-controller/tests/src/PerpsController.configuration.test.ts packages/perps-controller/tests/src/selectors.test.ts -t 'argin mode' --no-coverage --reporters=default --verbose", "timeout_ms": 600000, "intent": "C6: run the targeted margin-mode unit tests (controller methods, messenger exposure via init, selector)", "next": "unit-tests-ok" }, "unit-tests-ok": { "action": "assert_exit_code", "source": "unit-tests", "expected": 0, "intent": "C6: targeted unit tests pass", "next": "unit-tests-ran" }, "unit-tests-ran": { "action": "assert_output", "node": "unit-tests", "match": "Tests: +\\d+ skipped, 10 passed", "intent": "C6: exactly the 10 margin-mode tests ran and passed (not a zero-test pass)", "next": "done", "stream": "stderr" }, "done": { "action": "end", "status": "pass" } } } }Validation Logs
Full output (16/16 passed, pass)
Checklist
Screenshots/Recordings
Note
Low Risk
Additive persisted UI preference with input validation; no changes to order placement or margin enforcement on the venue.
Overview
Adds per-market, per-network persistence for the Pro order form’s Isolated/Cross margin pick, aligned with existing
tradeConfigurationsentries for leverage and order-book grouping.Clients can store and restore the choice in
tradeConfigurations[network][symbol].marginModeviagetMarginMode/saveMarginMode(messenger actionsPerpsController:getMarginModeandPerpsController:saveMarginMode) andselectMarginMode(state, symbol). Saves merge into the existing trade config so leverage, grouping, and pending drafts are unchanged; only'isolated'and'cross'are accepted—other values are ignored.TradeConfigurationgains an optionalmarginModefield.placeOrderbehavior is unchanged; venue locks still override the saved pick on the client.Changelog, exports, and unit tests cover controller methods, messenger registration, and the selector (mainnet vs testnet isolation).
Reviewed by Cursor Bugbot for commit 241c210. Bugbot is set up for automated code reviews on this repo. Configure here.