Skip to content

Commit 32eabc9

Browse files
abretonc7sgambinishjaviergarciaverametamaskbotsethkfman
authored
feat(perps): margin adjustment matching hyperliquid computation (MetaMask#23467)
## **Description** This PR adds margin adjustment UI improvements and fixes the max removable margin calculation to match Hyperliquid's official documentation. **What changed:** ### UI Improvements - Added two new i18n strings: `margin_available_to_add` and `margin_available_to_remove` - Added a new info row in the adjust margin view that displays the maximum amount of margin that can be added or removed - Updated the first row to always show "Current margin" (previously showed "Perps balance" for add mode) - Re-enabled "Reduce Margin" action in the action sheet (was previously disabled pending calculation fix) ### Max Removable Margin Calculation Fix - Fixed `calculateMaxRemovableMargin` to use position's actual leverage instead of asset's max leverage - Formula now matches Hyperliquid's official documentation exactly: `transfer_margin_required = max(notional/leverage, notional*0.1)` - Removed excessive 3x safety buffer that was causing incorrect calculations - Added `unrealizedPnl` parameter for more accurate effective margin calculation **Root cause:** The calculation was using `maxLeverage` (asset's max, e.g., 50x) instead of `position.leverage.value` (actual position leverage, e.g., 10x). This caused significant discrepancies - a position at 10x leverage requires 10% initial margin, not 2% that 50x would imply. The info section now displays four rows: 1. Current margin (always shows the margin in position) 2. Margin available to add/remove (shows max amount based on mode) 3. Liquidation price (with transition arrow when adjusting) 4. Liquidation distance (with transition arrow when adjusting) ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/TAT-2167 ## **Manual testing steps** ```gherkin Feature: Margin adjustment with correct calculations Scenario: User views margin available to add Given user has an open perpetual position And user navigates to the position details When user taps "Adjust Margin" And user selects "Add Margin" Then user sees "Current margin" row with current margin value And user sees "Margin available to add" row with available balance Scenario: User views margin available to remove Given user has an open perpetual position And user navigates to the position details When user taps "Adjust Margin" And user selects "Reduce Margin" Then user sees "Current margin" row with current margin value And user sees "Margin available to remove" row with max removable margin Scenario: Max removable margin matches Hyperliquid Given user has an isolated position with known values And user compares max removable in MetaMask vs Hyperliquid UI When the values match (or are very close) And user attempts to remove the displayed max amount Then the transaction succeeds without rejection ``` ## **Screenshots/Recordings** ### **Before** The info section only showed: - Perps balance (add mode) / Margin in position (remove mode) - Liquidation price - Liquidation distance <img width="420" height="876" alt="image" src="https://github.com/user-attachments/assets/96364845-73bd-4dff-8712-db21afbffde5" /> ### **After** The info section now shows: - Current margin (both modes) - Margin available to add (add mode) / Margin available to remove (remove mode) - Liquidation price - Liquidation distance <img width="414" height="830" alt="image" src="https://github.com/user-attachments/assets/4ce0decd-7a88-45ee-ab65-3a1d1f4cb67f" /> <img width="425" height="822" alt="image" src="https://github.com/user-attachments/assets/e5596180-37d5-4340-a984-222c9af958c9" /> ## **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. ## **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. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Refactors adjust margin to a new live-data hook and fixes max removable margin per Hyperliquid, updates UI (current/max rows), re-enables reduce action, and adds tests/docs. > > - **Logic/Calculations**: > - **New hook** `usePerpsAdjustMarginData`: centralizes live position/account/price data and computes `maxAmount`, liquidation price/distance, and mode; exported via `hooks/index`. > - **Fix** `calculateMaxRemovableMargin`: use `positionLeverage` (not asset max), accept optional `notionalValue`, rely on mark price, remove inflated safety buffer; update related tests. > - **UI**: > - **PerpsAdjustMarginView**: refactor to use new hook; remove manual/live hooks and inline calcs; pass loading state; floor values to 2 decimals; clamp inputs; slider `maximumValue` uses floored `maxAmount`. > - **Info rows**: always show `margin_in_position`; add `margin_available_to_add/remove`; keep liquidation price/distance with transition. > - **Action Sheet**: re-enable `reduce_margin` option. > - **Tests/Docs**: > - Add tests for new hook and update view/utils tests to new behavior. > - Add doc `docs/perps/hyperliquid/margining.md` describing margin rules. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit bf29e8d. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Signed-off-by: dan437 <80175477+dan437@users.noreply.github.com> Co-authored-by: Nick Gambino <35090461+gambinish@users.noreply.github.com> Co-authored-by: javiergarciavera <76975121+javiergarciavera@users.noreply.github.com> Co-authored-by: metamaskbot <metamaskbot@users.noreply.github.com> Co-authored-by: sethkfman <10342624+sethkfman@users.noreply.github.com> Co-authored-by: Jorge Carrasco <jorge.carrasco@consensys.net> Co-authored-by: Vinicius Stevam <45455812+vinistevam@users.noreply.github.com> Co-authored-by: Alejandro Garcia Anglada <aganglada@gmail.com> Co-authored-by: Daniel <80175477+dan437@users.noreply.github.com> Co-authored-by: Juanmi <95381763+juanmigdr@users.noreply.github.com> Co-authored-by: Matthew Grainger <46547583+Matt561@users.noreply.github.com> Co-authored-by: Salim TOUBAL <salim.toubal@outlook.com> Co-authored-by: CW <chris.wilcox@consensys.net> Co-authored-by: George Marshall <george.marshall@consensys.net> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Kevin Bluer <kevin@bluer.com> Co-authored-by: Michele Esposito <34438276+mikesposito@users.noreply.github.com> Co-authored-by: Pedro Pablo Aste Kompen <wachunei@gmail.com> Co-authored-by: Kylan Hurt <6249205+smilingkylan@users.noreply.github.com> Co-authored-by: Brian August Nguyen <brianacnguyen@gmail.com> Co-authored-by: Vince Howard <vincenguyenhoward@gmail.com> Co-authored-by: Curtis David <Curtis.David7@gmail.com> Co-authored-by: Bryan Fullam <bryan.fullam@consensys.net> Co-authored-by: Monte Lai <monte.lai@consensys.net> Co-authored-by: Charly Chevalier <charlyy.chevalier@gmail.com> Co-authored-by: Patryk Łucka <5708018+PatrykLucka@users.noreply.github.com> Co-authored-by: Goktug Poyraz <omergoktugpoyraz@gmail.com> Co-authored-by: Michal Szorad <michal.szorad@consensys.net> Co-authored-by: Nicholas Smith <nick.smith@consensys.net> Co-authored-by: Matthew Walsh <matthew.walsh@consensys.net> Co-authored-by: Bruno Nascimento <brunonascimentodev@gmail.com> Co-authored-by: cmd-ob <ola.bale@consensys.net> Co-authored-by: AxelGes <34173844+AxelGes@users.noreply.github.com> Co-authored-by: George Weiler <georgejweiler@gmail.com> Co-authored-by: Owen Craston <owen.craston@consensys.net> Co-authored-by: Prithpal Sooriya <prithpal.sooriya@consensys.net> Co-authored-by: Caainã Jeronimo <caainaje@gmail.com> Co-authored-by: Nicholas Gambino <nicholas.gambino@consensys.net> Co-authored-by: VGR <VanGulckRik@gmail.com> Co-authored-by: Daniel Suchý <suchydan@gmail.com> Co-authored-by: Nicholas Ellul <15018469+NicholasEllul@users.noreply.github.com> Co-authored-by: Harika <153644847+hjetpoluru@users.noreply.github.com> Co-authored-by: Maarten Zuidhoorn <maarten@zuidhoorn.com> Co-authored-by: jvbriones <1674192+jvbriones@users.noreply.github.com> Co-authored-by: Christopher Ferreira <104831203+christopherferreira9@users.noreply.github.com> Co-authored-by: Mark Stacey <markjstacey@gmail.com> Co-authored-by: António Regadas <antonio.regadas@consensys.net> Co-authored-by: Baptiste Marchand <75846779+baptiste-marchand@users.noreply.github.com> Co-authored-by: Cal Leung <cal.leung@consensys.net> Co-authored-by: sophieqgu <37032128+sophieqgu@users.noreply.github.com> Co-authored-by: George Gkasdrogkas <georgegkas@gmail.com> Co-authored-by: ffmcgee <51971598+ffmcgee725@users.noreply.github.com> Co-authored-by: Luis Taniça <matallui@gmail.com> Co-authored-by: Guillaume Roux <guillaumeroux123@gmail.com> Co-authored-by: Priya <priya.narayanaswamy@consensys.net> Co-authored-by: sahar-fehri <sahar.fehri@consensys.net> Co-authored-by: Ulisses Ferreira <ulisses@hey.com> Co-authored-by: imblue <106779544+imblue-dabadee@users.noreply.github.com> Co-authored-by: Amélie <amelie.chan@gmail.com> Co-authored-by: asalsys <sallem.ahmed@consensys.net> Co-authored-by: SteP-n-s <stylianos.panagakos@consensys.net> Co-authored-by: tommasini <46944231+tommasini@users.noreply.github.com> Co-authored-by: Bernardo Garces Chapero <bernardo.chapero@consensys.net> Co-authored-by: Gaurav Goel <grvgoel19@gmail.com> Co-authored-by: Frederik Bolding <frederik.bolding@gmail.com> Co-authored-by: jiexi <jiexiluan@gmail.com> Co-authored-by: khanti42 <florin.dzeladini@consensys.net> Co-authored-by: Ramon AC <36987446+racitores@users.noreply.github.com> Co-authored-by: ieow <4881057+ieow@users.noreply.github.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Andre Pimenta <andrepimenta7@gmail.com> Co-authored-by: infiniteflower <139582705+infiniteflower@users.noreply.github.com>
1 parent e545385 commit 32eabc9

9 files changed

Lines changed: 952 additions & 300 deletions

File tree

app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.test.tsx

Lines changed: 79 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -43,29 +43,17 @@ jest.mock('../../hooks/usePerpsMarginAdjustment', () => ({
4343
mockUsePerpsMarginAdjustment(opts),
4444
}));
4545

46-
const mockUsePerpsLiveAccount = jest.fn();
47-
const mockUsePerpsLivePrices = jest.fn();
46+
const mockUsePerpsAdjustMarginData = jest.fn();
4847

49-
jest.mock('../../hooks/stream', () => ({
50-
usePerpsLiveAccount: () => mockUsePerpsLiveAccount(),
51-
usePerpsLivePrices: () => mockUsePerpsLivePrices(),
52-
}));
53-
54-
const mockUsePerpsMarkets = jest.fn();
55-
56-
jest.mock('../../hooks/usePerpsMarkets', () => ({
57-
usePerpsMarkets: () => mockUsePerpsMarkets(),
48+
jest.mock('../../hooks/usePerpsAdjustMarginData', () => ({
49+
usePerpsAdjustMarginData: (opts: unknown) =>
50+
mockUsePerpsAdjustMarginData(opts),
5851
}));
5952

6053
jest.mock('../../hooks/usePerpsMeasurement', () => ({
6154
usePerpsMeasurement: jest.fn(),
6255
}));
6356

64-
jest.mock('../../utils/marginUtils', () => ({
65-
calculateMaxRemovableMargin: jest.fn(() => 200),
66-
calculateNewLiquidationPrice: jest.fn(() => 1800),
67-
}));
68-
6957
jest.mock('../../../../../util/Logger', () => ({
7058
error: jest.fn(),
7159
}));
@@ -167,16 +155,21 @@ describe('PerpsAdjustMarginView', () => {
167155
isAdjusting: false,
168156
});
169157

170-
mockUsePerpsLiveAccount.mockReturnValue({
171-
account: { availableBalance: '1000' },
172-
});
173-
174-
mockUsePerpsLivePrices.mockReturnValue({
175-
ETH: { price: '2000', markPrice: '2000', percentChange24h: '2.5' },
176-
});
177-
178-
mockUsePerpsMarkets.mockReturnValue({
179-
markets: [{ symbol: 'ETH', maxLeverage: '50x' }],
158+
// Default mock for add mode - will be overridden in specific tests
159+
mockUsePerpsAdjustMarginData.mockReturnValue({
160+
position: mockPosition,
161+
isLoading: false,
162+
currentMargin: 500,
163+
positionValue: 5000,
164+
maxAmount: 1000, // Available balance for add mode
165+
currentLiquidationPrice: 1900,
166+
newLiquidationPrice: 1900,
167+
currentLiquidationDistance: 5,
168+
newLiquidationDistance: 5,
169+
availableBalance: 1000,
170+
currentPrice: 2000,
171+
isAddMode: true,
172+
positionLeverage: 10,
180173
});
181174
});
182175

@@ -200,16 +193,17 @@ describe('PerpsAdjustMarginView', () => {
200193
).toBeOnTheScreen();
201194
});
202195

203-
it('displays perps balance available to add', () => {
196+
it('displays current margin and margin available to add', () => {
204197
render(<PerpsAdjustMarginView />);
205198

206199
expect(
207-
screen.getByText('perps.adjust_margin.perps_balance'),
200+
screen.getByText('perps.adjust_margin.margin_in_position'),
208201
).toBeOnTheScreen();
202+
expect(screen.getByText('$500.00')).toBeOnTheScreen();
209203
expect(
210204
screen.getByText('perps.adjust_margin.margin_available_to_add'),
211205
).toBeOnTheScreen();
212-
expect(screen.getAllByText('$1000.00')).toHaveLength(2);
206+
expect(screen.getByText('$1000.00')).toBeOnTheScreen();
213207
});
214208

215209
it('displays liquidation price label', () => {
@@ -243,6 +237,23 @@ describe('PerpsAdjustMarginView', () => {
243237
position: mockPosition,
244238
mode: 'remove',
245239
};
240+
241+
// Override mock for remove mode
242+
mockUsePerpsAdjustMarginData.mockReturnValue({
243+
position: mockPosition,
244+
isLoading: false,
245+
currentMargin: 500,
246+
positionValue: 5000,
247+
maxAmount: 200, // Max removable margin
248+
currentLiquidationPrice: 1900,
249+
newLiquidationPrice: 1900,
250+
currentLiquidationDistance: 5,
251+
newLiquidationDistance: 5,
252+
availableBalance: 1000,
253+
currentPrice: 2000,
254+
isAddMode: false,
255+
positionLeverage: 10,
256+
});
246257
});
247258

248259
it('renders remove margin title', () => {
@@ -253,13 +264,17 @@ describe('PerpsAdjustMarginView', () => {
253264
).toBeOnTheScreen();
254265
});
255266

256-
it('displays current position margin', () => {
267+
it('displays current margin and margin available to remove', () => {
257268
render(<PerpsAdjustMarginView />);
258269

259270
expect(
260271
screen.getByText('perps.adjust_margin.margin_in_position'),
261272
).toBeOnTheScreen();
262273
expect(screen.getByText('$500.00')).toBeOnTheScreen();
274+
expect(
275+
screen.getByText('perps.adjust_margin.margin_available_to_remove'),
276+
).toBeOnTheScreen();
277+
expect(screen.getByText('$200.00')).toBeOnTheScreen();
263278
});
264279

265280
it('displays reduce margin button label', () => {
@@ -291,6 +306,23 @@ describe('PerpsAdjustMarginView', () => {
291306
mode: 'add',
292307
};
293308

309+
// Hook returns null position when position not found
310+
mockUsePerpsAdjustMarginData.mockReturnValue({
311+
position: null,
312+
isLoading: false,
313+
currentMargin: 0,
314+
positionValue: 0,
315+
maxAmount: 0,
316+
currentLiquidationPrice: 0,
317+
newLiquidationPrice: 0,
318+
currentLiquidationDistance: 0,
319+
newLiquidationDistance: 0,
320+
availableBalance: 0,
321+
currentPrice: 0,
322+
isAddMode: true,
323+
positionLeverage: 10,
324+
});
325+
294326
render(<PerpsAdjustMarginView />);
295327

296328
expect(
@@ -358,6 +390,23 @@ describe('PerpsAdjustMarginView', () => {
358390
position: mockPosition,
359391
mode: 'remove',
360392
};
393+
394+
// Override mock for remove mode
395+
mockUsePerpsAdjustMarginData.mockReturnValue({
396+
position: mockPosition,
397+
isLoading: false,
398+
currentMargin: 500,
399+
positionValue: 5000,
400+
maxAmount: 200, // Max removable margin
401+
currentLiquidationPrice: 1900,
402+
newLiquidationPrice: 1900,
403+
currentLiquidationDistance: 5,
404+
newLiquidationDistance: 5,
405+
availableBalance: 1000,
406+
currentPrice: 2000,
407+
isAddMode: false,
408+
positionLeverage: 10,
409+
});
361410
});
362411

363412
it('displays margin available to remove', () => {

0 commit comments

Comments
 (0)