diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index f27bb0297e0..d7064e7f221 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] +### Added + +- Persist the Isolated/Cross margin-mode pick per market and network in `tradeConfigurations[network][symbol].marginMode`, so clients can restore it after the order form remounts and share it across Mobile and Extension ([#10464](https://github.com/MetaMask/core/pull/10464)) + - Add `getMarginMode(symbol)` and `saveMarginMode(symbol, marginMode)` methods, exposed as the `PerpsController:getMarginMode` and `PerpsController:saveMarginMode` messenger actions (`PerpsControllerGetMarginModeAction`, `PerpsControllerSaveMarginModeAction`). `saveMarginMode` ignores values other than `isolated` or `cross`. + - Add the `selectMarginMode(state, symbol)` selector and an optional `marginMode` field on `TradeConfiguration`. + ## [18.0.1] ### Fixed diff --git a/packages/perps-controller/src/PerpsController-method-action-types.ts b/packages/perps-controller/src/PerpsController-method-action-types.ts index 7c0e6daaa3e..1271dd001d6 100644 --- a/packages/perps-controller/src/PerpsController-method-action-types.ts +++ b/packages/perps-controller/src/PerpsController-method-action-types.ts @@ -1271,6 +1271,30 @@ export type PerpsControllerSaveOrderBookGroupingAction = { handler: PerpsController['saveOrderBookGrouping']; }; +/** + * Get the saved margin mode (Isolated/Cross) for a market on the current + * network. Clients should still let a venue-enforced mode take priority. + * + * @param symbol - Market symbol + * @returns The saved margin mode or undefined if not set + */ +export type PerpsControllerGetMarginModeAction = { + type: `PerpsController:getMarginMode`; + handler: PerpsController['getMarginMode']; +}; + +/** + * Save the margin mode (Isolated/Cross) picked for a market on the current + * network. Values other than `isolated` or `cross` are ignored. + * + * @param symbol - Market symbol + * @param marginMode - Margin mode to persist + */ +export type PerpsControllerSaveMarginModeAction = { + type: `PerpsController:saveMarginMode`; + handler: PerpsController['saveMarginMode']; +}; + /** * Toggle watchlist status for a market. * @@ -1463,6 +1487,8 @@ export type PerpsControllerMethodActions = | PerpsControllerResetSelectedPaymentTokenAction | PerpsControllerGetOrderBookGroupingAction | PerpsControllerSaveOrderBookGroupingAction + | PerpsControllerGetMarginModeAction + | PerpsControllerSaveMarginModeAction | PerpsControllerToggleWatchlistMarketAction | PerpsControllerIsWatchlistMarketAction | PerpsControllerGetWatchlistMarketsAction diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index 641d05742c4..c53e72205bc 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -105,6 +105,7 @@ import type { MaintenanceMarginParams, PositionModifyPreviewParams, PositionModifyPreviewResult, + MarginMode, MarginResult, MarketInfo, Order, @@ -420,6 +421,7 @@ export type PerpsControllerState = { [marketSymbol: string]: { leverage?: number; // Last used leverage for this market orderBookGrouping?: number; // Persisted price grouping for order book + marginMode?: MarginMode; // Last Isolated/Cross pick for this market // Pending trade configuration (temporary, expires after 30 seconds) pendingConfig?: { amount?: string; // Order size in USD @@ -438,6 +440,7 @@ export type PerpsControllerState = { [marketSymbol: string]: { leverage?: number; orderBookGrouping?: number; // Persisted price grouping for order book + marginMode?: MarginMode; // Last Isolated/Cross pick for this market // Pending trade configuration (temporary, expires after 30 seconds) pendingConfig?: { amount?: string; // Order size in USD @@ -925,6 +928,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'getMarketDataWithPrices', 'getMarketFilterPreferences', 'getMarkets', + 'getMarginMode', 'getMaxLeverage', 'getOpenOrders', 'getOrderBookGrouping', @@ -967,6 +971,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'setProLayoutPreferences', 'setPerpsMode', 'setSelectedOrderType', + 'saveMarginMode', 'saveMarketFilterPreferences', 'saveOrderBookGrouping', 'savePendingTradeConfiguration', @@ -6727,6 +6732,51 @@ export class PerpsController extends BaseController< }); } + /** + * Get the saved margin mode (Isolated/Cross) for a market on the current + * network. Clients should still let a venue-enforced mode take priority. + * + * @param symbol - Market symbol + * @returns The saved margin mode or undefined if not set + */ + getMarginMode(symbol: string): MarginMode | undefined { + const network = this.state.isTestnet ? 'testnet' : 'mainnet'; + return this.state.tradeConfigurations[network]?.[symbol]?.marginMode; + } + + /** + * Save the margin mode (Isolated/Cross) picked for a market on the current + * network. Values other than `isolated` or `cross` are ignored. + * + * @param symbol - Market symbol + * @param marginMode - Margin mode to persist + */ + saveMarginMode(symbol: string, marginMode: MarginMode): void { + if (marginMode !== 'isolated' && marginMode !== 'cross') { + return; + } + + const network = this.state.isTestnet ? 'testnet' : 'mainnet'; + + this.#debugLog('PerpsController: Saving margin mode', { + symbol, + network, + marginMode, + }); + + this.update((state) => { + if (!state.tradeConfigurations[network]) { + state.tradeConfigurations[network] = {}; + } + + const existingConfig = state.tradeConfigurations[network][symbol] || {}; + state.tradeConfigurations[network][symbol] = { + ...existingConfig, + marginMode, + }; + }); + } + /** * Toggle watchlist status for a market. * diff --git a/packages/perps-controller/src/index.ts b/packages/perps-controller/src/index.ts index e97b7f34822..75afd7d8060 100644 --- a/packages/perps-controller/src/index.ts +++ b/packages/perps-controller/src/index.ts @@ -97,6 +97,7 @@ export type { PerpsControllerGetMarketFilterPreferencesAction, PerpsControllerGetMarketCategoriesAction, PerpsControllerGetMarketsAction, + PerpsControllerGetMarginModeAction, PerpsControllerGetMaxLeverageAction, PerpsControllerGetOpenOrdersAction, PerpsControllerGetOrderBookGroupingAction, @@ -131,6 +132,7 @@ export type { PerpsControllerRefreshEligibilityAction, PerpsControllerResetFirstTimeUserStateAction, PerpsControllerResetSelectedPaymentTokenAction, + PerpsControllerSaveMarginModeAction, PerpsControllerSaveMarketFilterPreferencesAction, PerpsControllerGetProLayoutPreferencesAction, PerpsControllerSetProLayoutPreferencesAction, @@ -705,6 +707,7 @@ export { selectPendingTradeConfiguration, selectMarketFilterPreferences, selectOrderBookGrouping, + selectMarginMode, selectOrderBookPreferences, selectProLayoutPreferences, selectSelectedOrderType, diff --git a/packages/perps-controller/src/selectors.ts b/packages/perps-controller/src/selectors.ts index 3677988fb1d..2566d56983f 100644 --- a/packages/perps-controller/src/selectors.ts +++ b/packages/perps-controller/src/selectors.ts @@ -17,6 +17,7 @@ import type { } from './constants/perpsConfig.js'; import type { PerpsControllerState } from './PerpsController.js'; import type { + MarginMode, OrderDirection, OrderType, PerpsSelectedPaymentToken, @@ -363,3 +364,28 @@ export const selectOrderBookGrouping = createSelector( return configs?.[network]?.[coin]?.orderBookGrouping; }, ); + +/** + * Select the saved margin mode for a specific market on the current network. + * + * Usage: selectMarginMode(state, coin) + * + * @param state - The perps controller state. + * @param coin - The market coin symbol. + * @returns The saved margin mode, or undefined. + */ +export const selectMarginMode = createSelector( + [ + (state: PerpsControllerState): boolean | undefined => state?.isTestnet, + ( + state: PerpsControllerState, + _coin: string, + ): PerpsControllerState['tradeConfigurations'] | undefined => + state?.tradeConfigurations, + (_state: PerpsControllerState, coin: string): string => coin, + ], + (isTestnet, configs, coin): MarginMode | undefined => { + const network = isTestnet ? 'testnet' : 'mainnet'; + return configs?.[network]?.[coin]?.marginMode; + }, +); diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index e253b8bfa8a..0a4aecc4f1f 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -70,6 +70,7 @@ export type GetUserHistoryParams = { // Trade configuration saved per market per network export type TradeConfiguration = { leverage?: number; // Last used leverage for this market + marginMode?: MarginMode; // Last Isolated/Cross pick for this market // Pending trade configuration (temporary, expires after 30 seconds) pendingConfig?: { amount?: string; // Order size in USD diff --git a/packages/perps-controller/tests/src/PerpsController.configuration.test.ts b/packages/perps-controller/tests/src/PerpsController.configuration.test.ts index 34826c15fa5..c6161a7dbc1 100644 --- a/packages/perps-controller/tests/src/PerpsController.configuration.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.configuration.test.ts @@ -43,6 +43,7 @@ import { HyperLiquidProvider } from '../../src/providers/HyperLiquidProvider.js' import type { AccountState, GetAvailableDexsParams, + MarginMode, PerpsProvider, PerpsPlatformDependencies, PerpsProviderType, @@ -1503,6 +1504,81 @@ describe('PerpsController', () => { }); }); + describe('margin mode', () => { + it('saves margin mode for mainnet', () => { + controller.testUpdate((state) => { + state.isTestnet = false; + }); + + controller.saveMarginMode('BTC', 'cross'); + + expect(controller.getMarginMode('BTC')).toBe('cross'); + expect(controller.state.tradeConfigurations.mainnet.BTC).toStrictEqual({ + marginMode: 'cross', + }); + }); + + it('saves margin mode for testnet without touching mainnet', () => { + controller.testUpdate((state) => { + state.isTestnet = true; + }); + + controller.saveMarginMode('ETH', 'isolated'); + + expect(controller.getMarginMode('ETH')).toBe('isolated'); + expect(controller.state.tradeConfigurations.mainnet.ETH).toBeUndefined(); + }); + + it('returns undefined when no margin mode is saved', () => { + expect(controller.getMarginMode('SOL')).toBeUndefined(); + }); + + it('overwrites the previous margin mode for the same market', () => { + controller.saveMarginMode('BTC', 'cross'); + controller.saveMarginMode('BTC', 'isolated'); + + expect(controller.getMarginMode('BTC')).toBe('isolated'); + }); + + it('preserves existing config when saving margin mode', () => { + controller.testUpdate((state) => { + state.isTestnet = false; + }); + controller.saveTradeConfiguration('BTC', 5); + controller.saveOrderBookGrouping('BTC', 100); + + controller.saveMarginMode('BTC', 'cross'); + + expect(controller.getTradeConfiguration('BTC')?.leverage).toBe(5); + expect(controller.getOrderBookGrouping('BTC')).toBe(100); + expect(controller.getMarginMode('BTC')).toBe('cross'); + }); + + it('ignores values that are not a margin mode', () => { + controller.saveMarginMode('BTC', 'cross'); + + controller.saveMarginMode('BTC', 'portfolio' as MarginMode); + + expect(controller.getMarginMode('BTC')).toBe('cross'); + }); + + it('exposes margin mode methods as messenger actions', async () => { + const messenger = createMockMessenger(); + const localController = new TestablePerpsController({ + messenger, + state: getDefaultPerpsControllerState(), + infrastructure: mockInfrastructure, + }); + + await localController.init(); + + expect(messenger.registerMethodActionHandlers).toHaveBeenCalledWith( + localController, + expect.arrayContaining(['getMarginMode', 'saveMarginMode']), + ); + }); + }); + describe('standalone mode', () => { const mockUserAddress = '0xabcdef1234567890abcdef1234567890abcdef12'; const MockedHyperLiquidProvider = HyperLiquidProvider as jest.MockedClass< diff --git a/packages/perps-controller/tests/src/selectors.test.ts b/packages/perps-controller/tests/src/selectors.test.ts index 7f1da6193b4..0e4c784763f 100644 --- a/packages/perps-controller/tests/src/selectors.test.ts +++ b/packages/perps-controller/tests/src/selectors.test.ts @@ -13,6 +13,7 @@ import { selectHasPlacedFirstOrder, selectMarketFilterPreferences, selectOrderBookGrouping, + selectMarginMode, selectRecentlyViewedMarkets, selectProLayoutPreferences, selectOrderBookPreferences, @@ -612,6 +613,45 @@ describe('PerpsController selectors', () => { }); }); + describe('selectMarginMode', () => { + it('returns the mainnet margin mode when not on testnet', () => { + const state = { + isTestnet: false, + tradeConfigurations: { + mainnet: { BTC: { marginMode: 'cross' } }, + testnet: { BTC: { marginMode: 'isolated' } }, + }, + } as unknown as PerpsControllerState; + + expect(selectMarginMode(state, 'BTC')).toBe('cross'); + }); + + it('returns the testnet margin mode when on testnet', () => { + const state = { + isTestnet: true, + tradeConfigurations: { + mainnet: { BTC: { marginMode: 'cross' } }, + testnet: { BTC: { marginMode: 'isolated' } }, + }, + } as unknown as PerpsControllerState; + + expect(selectMarginMode(state, 'BTC')).toBe('isolated'); + }); + + it('returns undefined when margin mode is not set', () => { + const state = { + isTestnet: false, + tradeConfigurations: { + mainnet: { BTC: { leverage: 10 } }, + testnet: {}, + }, + } as unknown as PerpsControllerState; + + expect(selectMarginMode(state, 'BTC')).toBeUndefined(); + expect(selectMarginMode(state, 'SOL')).toBeUndefined(); + }); + }); + describe('selectRecentlyViewedMarkets', () => { const now = Date.now(); const withinTtl = now - 60 * 60 * 1000; // 1 hour ago — within 24h TTL