Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/perps-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -1463,6 +1487,8 @@ export type PerpsControllerMethodActions =
| PerpsControllerResetSelectedPaymentTokenAction
| PerpsControllerGetOrderBookGroupingAction
| PerpsControllerSaveOrderBookGroupingAction
| PerpsControllerGetMarginModeAction
| PerpsControllerSaveMarginModeAction
| PerpsControllerToggleWatchlistMarketAction
| PerpsControllerIsWatchlistMarketAction
| PerpsControllerGetWatchlistMarketsAction
Expand Down
50 changes: 50 additions & 0 deletions packages/perps-controller/src/PerpsController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ import type {
MaintenanceMarginParams,
PositionModifyPreviewParams,
PositionModifyPreviewResult,
MarginMode,
MarginResult,
MarketInfo,
Order,
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -925,6 +928,7 @@ const MESSENGER_EXPOSED_METHODS = [
'getMarketDataWithPrices',
'getMarketFilterPreferences',
'getMarkets',
'getMarginMode',
'getMaxLeverage',
'getOpenOrders',
'getOrderBookGrouping',
Expand Down Expand Up @@ -967,6 +971,7 @@ const MESSENGER_EXPOSED_METHODS = [
'setProLayoutPreferences',
'setPerpsMode',
'setSelectedOrderType',
'saveMarginMode',
'saveMarketFilterPreferences',
'saveOrderBookGrouping',
'savePendingTradeConfiguration',
Expand Down Expand Up @@ -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.
*
Expand Down
3 changes: 3 additions & 0 deletions packages/perps-controller/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ export type {
PerpsControllerGetMarketFilterPreferencesAction,
PerpsControllerGetMarketCategoriesAction,
PerpsControllerGetMarketsAction,
PerpsControllerGetMarginModeAction,
PerpsControllerGetMaxLeverageAction,
PerpsControllerGetOpenOrdersAction,
PerpsControllerGetOrderBookGroupingAction,
Expand Down Expand Up @@ -131,6 +132,7 @@ export type {
PerpsControllerRefreshEligibilityAction,
PerpsControllerResetFirstTimeUserStateAction,
PerpsControllerResetSelectedPaymentTokenAction,
PerpsControllerSaveMarginModeAction,
PerpsControllerSaveMarketFilterPreferencesAction,
PerpsControllerGetProLayoutPreferencesAction,
PerpsControllerSetProLayoutPreferencesAction,
Expand Down Expand Up @@ -705,6 +707,7 @@ export {
selectPendingTradeConfiguration,
selectMarketFilterPreferences,
selectOrderBookGrouping,
selectMarginMode,
selectOrderBookPreferences,
selectProLayoutPreferences,
selectSelectedOrderType,
Expand Down
26 changes: 26 additions & 0 deletions packages/perps-controller/src/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type {
} from './constants/perpsConfig.js';
import type { PerpsControllerState } from './PerpsController.js';
import type {
MarginMode,
OrderDirection,
OrderType,
PerpsSelectedPaymentToken,
Expand Down Expand Up @@ -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;
},
);
1 change: 1 addition & 0 deletions packages/perps-controller/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import { HyperLiquidProvider } from '../../src/providers/HyperLiquidProvider.js'
import type {
AccountState,
GetAvailableDexsParams,
MarginMode,
PerpsProvider,
PerpsPlatformDependencies,
PerpsProviderType,
Expand Down Expand Up @@ -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<
Expand Down
40 changes: 40 additions & 0 deletions packages/perps-controller/tests/src/selectors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
selectHasPlacedFirstOrder,
selectMarketFilterPreferences,
selectOrderBookGrouping,
selectMarginMode,
selectRecentlyViewedMarkets,
selectProLayoutPreferences,
selectOrderBookPreferences,
Expand Down Expand Up @@ -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
Expand Down
Loading