Skip to content

feat(positions): isolated margin add/remove modal #101

Description

@vipineth

Background

For isolated positions, users currently can't add or remove margin from an open position. The position row should expose an "Edit margin" action (next to Close/Reverse) that opens an Adjust Margin modal — letting the user add USDC to reduce liquidation risk, or remove excess USDC (within leverage limits) to free it up for other positions.

Reference design (from Hyperliquid):

  • Title: Adjust Margin
  • Subtext: Decrease the chance of liquidation by adding more margin or remove excess margin to use for other positions.
  • Number input with MAX and an Add/Remove dropdown
  • Two info rows:
    • Current margin for {SYMBOL}{marginUsed} USDC
    • Margin available to add/remove — {maxAmount} USDC
  • Confirm button

API research

Endpoint

Hyperliquid's updateIsolatedMargin action handles both directions. It is already exposed by the SDK as ExchangeClient.updateIsolatedMargin and is therefore already wired through useExchange(\"updateIsolatedMargin\") (no registry change needed — it routes via the default "trading" client, no builder, no client key).

await updateIsolatedMargin({
  asset: number,        // perp asset index
  isBuy: boolean,       // currently no-op in HL, but required (will matter for hedge mode)
  ntli: number,         // amount in USDC scaled by 1e6, signed (positive = add, negative = remove)
});

Notes:

  • ntli uses the same 6-decimal USDC scaling pattern as elsewhere. Sign convention: positive adds, negative removes.
  • isBuy should mirror the position side (szi > 0) — it has no on-chain effect today but is forward-compatible.
  • Response is the standard { status: \"ok\" } / { status: \"err\", response: string } shape.

Where the max amounts come from

There is no dedicated "max isolated margin transfer" endpoint. Both numbers are derived client-side from data we already subscribe to:

Direction Source Formula
Max to add useUserPositions().withdrawable (also exposed on useDefaultDexBalances().perpSummary) withdrawable directly — Hyperliquid already accounts for cross margin used + maintenance buffer
Max to remove The position itself (marginUsed, positionValue, leverage.value) max(0, marginUsed - positionValue / leverage.value)

The remove formula is the slack between the position's current isolated margin and the initial margin requirement at the configured leverage. Removing more would push the position above the user's set leverage and the chain rejects it.

We can optionally tighten with a small buffer (e.g. floor to USDC's weiDecimals and subtract a few cents) to avoid rounding-related rejections — match the buffer pattern already used in getPerpAvailable.

Implementation plan

1. Domain helpers — apps/terminal/src/domain/trade/isolated-margin.ts (new)

Pure functions, no React, easy to unit test:

import Big from \"big.js\";
import type { Position } from \"@/lib/hyperliquid\";

export function getMaxIsolatedMarginToAdd(withdrawable: string | null | undefined): string;
export function getMaxIsolatedMarginToRemove(position: Position): string;
export function isIsolated(position: Position): boolean; // position.leverage.type === \"isolated\"
export function toNtli(amountUsd: string, mode: \"add\" | \"remove\"): number; // returns signed integer scaled * 1e6

toNtli should Big(amount).times(1e6).round(0, Big.roundDown).toNumber() then negate for remove.

2. Position row trigger

apps/terminal/src/components/trade/positions/position-row.tsx

  • Add an "Edit" pencil icon affordance in the Margin cell (only when position.leverage.type === \"isolated\"). Cross positions don't expose this control.
  • Wire onEditMargin(data) callback up to positions-tab.tsx, mirroring the existing TP/SL and limit-close flows.

3. New modal — apps/terminal/src/components/trade/positions/position-adjust-margin-modal.tsx

Mirrors the structure of position-tpsl-modal.tsx:

  • Modal + ModalPopup size=\"sm\"
  • State: mode: \"add\" | \"remove\", amount: string
  • useExchange(\"updateIsolatedMargin\") for the mutation
  • Read withdrawable from useUserPositions() for add max
  • Read live position from useUserPositions().getPosition(coin) so values stay fresh while modal is open (don't snapshot — user can leave it open across funding/PnL ticks)
  • Inputs:
    • NumberInput (existing component, see transfer-modal.tsx) with MAX clicking the displayed available value
    • Mode select — small Dropdown (existing UI primitive) on the right of the input
  • Info rows via InfoRow:
    • Current margin: formatUSD(position.marginUsed)
    • Available to {add|remove}: formatUSD(maxForCurrentMode)
  • Validation: clamp to [0, max], disable submit when amount is empty / 0 / exceeds max / not connected
  • On submit, call:
    updateIsolatedMargin({
      asset: position.assetId,
      isBuy: position.szi > 0,
      ntli: toNtli(amount, mode),
    })
  • Toast success/error using the same pattern as positions-tab.tsx
  • Close on success, reset amount

4. Type — apps/terminal/src/components/trade/positions/position-dialog-types.ts

Add AdjustMarginPositionData with: coin, assetId, isLong, marginUsed, positionValue, leverageValue, szDecimals.

5. Wire into positions-tab.tsx

Add modal-open state + handler alongside the existing TP/SL and limit-close modals. Render <PositionAdjustMarginModal /> next to the others.

6. Mobile

Add the same edit affordance to the mobile position card (apps/terminal/src/components/trade/mobile/... — locate the equivalent of position-row.tsx). Reuse the same modal — it's already size=\"sm\" and works on mobile.

Edge cases / tests

  • Cross positions: edit affordance hidden entirely.
  • Disconnected wallet: action button disabled with "Connect wallet" CTA (match transfer-modal.tsx pattern).
  • Position closed while modal is open: subscription updates → getPosition returns null → close modal automatically.
  • withdrawable === \"0\": Add disabled, switch to Remove preselected if removable > 0.
  • Removable === 0 (position is at exactly max leverage): Remove disabled.
  • Amount > max: red border + disabled submit (match the exceedsBalance pattern from transfer-modal.tsx).
  • Tiny amounts: ntli is an integer at 6 decimals → minimum step is 0.000001 USDC; limitDecimalInput(value, 6) on input change.
  • Server rejection (e.g. position state changed mid-submit): surface error via toast, keep modal open with amount preserved.

Acceptance criteria

  • "Edit" affordance visible on every isolated position row (desktop + mobile), hidden on cross.
  • Modal opens with current margin and available-to-add prefilled, Add mode by default.
  • MAX button populates the available amount (floored to 6 decimals).
  • Switching to Remove updates the available number and re-validates.
  • Confirm calls updateIsolatedMargin with the correctly signed ntli, toasts on success/failure, and closes on success.
  • No new fields requested from the API beyond what's already subscribed (allDexsClearinghouseState).

Metadata

Metadata

Assignees

No one assigned

    Labels

    SandcastleIssues for Sandcastle to work onscope:terminalAffects apps/terminal

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions