Skip to content

[UI/UX][HIGH] Portfolio live liquidation distance can suppress warnings and mark crossed positions as safe #2412

Description

@Bayyan16

Summary

The Portfolio live position card calculates liquidation distance using an absolute price delta divided by the current mark price:

Math.abs(Number(markE6) - Number(liquidationPriceE6))
  / Number(markE6)
  * 100

This calculation is not direction-aware and does not preserve the canonical denominator used for short positions.

As a result, the Portfolio UI can:

  • suppress a valid liquidation warning;
  • downgrade a crossed position from danger to warning;
  • mark a crossed short position as safe;
  • display risk severity inconsistent with the Portfolio snapshot.

This does not modify the protocol liquidation engine. However, it can present materially incorrect risk information to users when they are deciding whether to add margin or close a position.


Affected Code

app/app/portfolio/page.tsx
app/hooks/usePortfolio.ts

The snapshot calculation in usePortfolio.ts is direction-aware:

Long:
(mark - liquidation) / mark

Short:
(liquidation - mark) / liquidation

The live PositionCard instead uses:

abs(mark - liquidation) / mark

The two paths can therefore produce different liquidation-risk severity for the same position.


Severity Assessment

High user-impact UI risk; no direct protocol or on-chain state impact.

This issue is classified as High severity from a user-impact and trading-risk perspective, rather than as a direct smart-contract or protocol exploit.


User Impact

The calculated percentage is passed to the Portfolio liquidation-severity logic:

distance <= 10%  → danger
distance <= 30%  → warning
distance > 30%   → safe

An incorrect distance can affect:

  • liquidation warning labels;
  • card border and status styling;
  • the perceived urgency of a position;
  • the user's decision to deposit margin or close the position.

The strongest failure occurs after a short position's mark price crosses its liquidation price: the absolute delta starts increasing again, allowing the position to appear safe.


Root Cause

Math.abs() removes the position direction.

Liquidation boundaries are directional:

Long position:
The position approaches liquidation as mark decreases.

Short position:
The position approaches liquidation as mark increases.

After the mark crosses the boundary, liquidation distance must remain 0%.

The current live formula instead calculates a new positive distance after the boundary is crossed.

The short calculation also incorrectly uses the mark price as its denominator, while the canonical Portfolio calculation uses the liquidation price.


Proof of Concept

Case 1 — Short warning incorrectly displayed as safe

Input:

Position size:      Short
Mark price:         77
Liquidation price:  110

Expected canonical calculation:

(110 - 77) / 110 × 100
= 30.00%
= warning

Current live-card calculation:

abs(77 - 110) / 77 × 100
= 42.857142857%
= safe

Result:

Expected severity: warning
Actual severity:   safe

The user receives no warning even though the canonical distance is exactly at the warning threshold.


Case 2 — Crossed short position incorrectly displayed as safe

Input:

Position size:      Short
Mark price:         160
Liquidation price:  110

For a short position:

mark >= liquidation price

Therefore the position is already at or beyond the liquidation boundary.

Expected result:

Distance: 0%
Severity: danger

Current live-card calculation:

abs(160 - 110) / 160 × 100
= 31.25%
= safe

Result:

Expected severity: danger
Actual severity:   safe

This is the highest-impact case because a crossed short position can be presented as safe.


Case 3 — Crossed long position downgraded to warning

Input:

Position size:      Long
Mark price:         80
Liquidation price:  90

For a long position:

mark <= liquidation price

Expected result:

Distance: 0%
Severity: danger

Current live-card calculation:

abs(80 - 90) / 80 × 100
= 12.5%
= warning

Result:

Expected severity: danger
Actual severity:   warning

Regression Test

A minimal regression test reproduces the three incorrect results:

import { describe, expect, it } from "vitest";

import { computeLiquidationDistancePct } from "@/lib/liquidation-distance";

describe("computeLiquidationDistancePct", () => {
  it("preserves the canonical warning distance for a short position", () => {
    const distance = computeLiquidationDistancePct(
      -1n,
      77_000_000n,
      110_000_000n,
    );

    expect(distance).toBe(30);
  });

  it("returns zero after a short crosses its liquidation boundary", () => {
    const distance = computeLiquidationDistancePct(
      -1n,
      160_000_000n,
      110_000_000n,
    );

    expect(distance).toBe(0);
  });

  it("returns zero after a long crosses its liquidation boundary", () => {
    const distance = computeLiquidationDistancePct(
      1n,
      80_000_000n,
      90_000_000n,
    );

    expect(distance).toBe(0);
  });
});

Run:

cd app

pnpm vitest run \
  __tests__/lib/liquidation-distance.test.ts \
  --reporter=verbose

Before the fix, the reproduced failures are:

Expected: 30
Received: 42.857142857142854

Expected: 0
Received: 31.25

Expected: 0
Received: 12.5

These results match the incorrect live Portfolio formula.


Expected Behavior

Liquidation distance must preserve the position direction.

Long:
  mark <= liquidation
    → 0%

  otherwise
    → (mark - liquidation) / mark × 100

Short:
  mark >= liquidation
    → 0%

  otherwise
    → (liquidation - mark) / liquidation × 100

All Portfolio consumers should use one shared calculation so that:

  • live cards and snapshot data remain consistent;
  • exact and crossed boundaries always return 0%;
  • short positions use the correct denominator;
  • missing live prices continue using the existing snapshot fallback.

Proposed Fix

Introduce a shared direction-aware helper:

app/lib/liquidation-distance.ts

Use the helper in:

app/app/portfolio/page.tsx
app/hooks/usePortfolio.ts

The helper should:

  1. distinguish long and short positions;
  2. return 0% at or beyond the liquidation boundary;
  3. preserve the canonical long and short denominators;
  4. use BigInt arithmetic before converting the final percentage to number;
  5. preserve the existing fallback when position or price data is unavailable.

Regression Coverage

The proposed test suite covers:

  • canonical short warning distance;
  • healthy long distance;
  • healthy short distance;
  • crossed long boundary;
  • crossed short boundary;
  • exact long boundary;
  • exact short boundary;
  • zero-position fallback;
  • invalid mark-price fallback;
  • invalid liquidation-price fallback;
  • prices above Number.MAX_SAFE_INTEGER.

Validation

Targeted regression tests: 11 passed
Related Portfolio tests:   38 passed
TypeScript validation:     passed
Prettier check:             passed
Production build:           passed

The full application suite currently contains unrelated pre-existing failures.

A clean upstream/playground worktree produced:

101 failed tests
3 unhandled errors

The patched branch produced the same failed-test signatures, the same failure count, and the same unhandled-error count. No additional failure was introduced by this change.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions