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
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):
MAXand an Add/Remove dropdown{SYMBOL}—{marginUsed}USDC{maxAmount}USDCAPI research
Endpoint
Hyperliquid's
updateIsolatedMarginaction handles both directions. It is already exposed by the SDK asExchangeClient.updateIsolatedMarginand is therefore already wired throughuseExchange(\"updateIsolatedMargin\")(no registry change needed — it routes via the default "trading" client, no builder, no client key).Notes:
ntliuses the same 6-decimal USDC scaling pattern as elsewhere. Sign convention: positive adds, negative removes.isBuyshould mirror the position side (szi > 0) — it has no on-chain effect today but is forward-compatible.{ 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:
useUserPositions().withdrawable(also exposed onuseDefaultDexBalances().perpSummary)withdrawabledirectly — Hyperliquid already accounts for cross margin used + maintenance buffermarginUsed,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
weiDecimalsand subtract a few cents) to avoid rounding-related rejections — match the buffer pattern already used ingetPerpAvailable.Implementation plan
1. Domain helpers —
apps/terminal/src/domain/trade/isolated-margin.ts(new)Pure functions, no React, easy to unit test:
toNtlishouldBig(amount).times(1e6).round(0, Big.roundDown).toNumber()then negate for remove.2. Position row trigger
apps/terminal/src/components/trade/positions/position-row.tsxposition.leverage.type === \"isolated\"). Cross positions don't expose this control.onEditMargin(data)callback up topositions-tab.tsx, mirroring the existing TP/SL and limit-close flows.3. New modal —
apps/terminal/src/components/trade/positions/position-adjust-margin-modal.tsxMirrors the structure of
position-tpsl-modal.tsx:Modal+ModalPopup size=\"sm\"mode: \"add\" | \"remove\",amount: stringuseExchange(\"updateIsolatedMargin\")for the mutationwithdrawablefromuseUserPositions()for add maxuseUserPositions().getPosition(coin)so values stay fresh while modal is open (don't snapshot — user can leave it open across funding/PnL ticks)NumberInput(existing component, seetransfer-modal.tsx) with MAX clicking the displayed available valueDropdown(existing UI primitive) on the right of the inputInfoRow:formatUSD(position.marginUsed)formatUSD(maxForCurrentMode)[0, max], disable submit when amount is empty / 0 / exceeds max / not connectedpositions-tab.tsx4. Type —
apps/terminal/src/components/trade/positions/position-dialog-types.tsAdd
AdjustMarginPositionDatawith:coin,assetId,isLong,marginUsed,positionValue,leverageValue,szDecimals.5. Wire into
positions-tab.tsxAdd 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 ofposition-row.tsx). Reuse the same modal — it's alreadysize=\"sm\"and works on mobile.Edge cases / tests
transfer-modal.tsxpattern).getPositionreturnsnull→ close modal automatically.withdrawable === \"0\": Add disabled, switch to Remove preselected if removable > 0.exceedsBalancepattern fromtransfer-modal.tsx).ntliis an integer at 6 decimals → minimum step is0.000001USDC;limitDecimalInput(value, 6)on input change.Acceptance criteria
updateIsolatedMarginwith the correctly signedntli, toasts on success/failure, and closes on success.allDexsClearinghouseState).