Skip to content
markpaper

src/ws/types.ts

v0.3.0 · 13 KB

Download file
// Subscription and frame types for the Hyperliquid WebSocket API.
//
// Every number inside a payload (prices, sizes, accountValue, szi, pnl, ...)
// arrives as a STRING. Keep them as strings until you convert them with
// decimal-safe code; never feed them straight into float arithmetic.
//
// Spot balances are NOT delivered over the WebSocket: `allDexsClearinghouseState`
// carries perp state only. Poll REST `spotClearinghouseState` (a cache TTL of
// ~5 minutes is enough) and add it to every WS update, otherwise the
// spot part of equity silently "disappears" on each push.

/** `0x`-prefixed hex address. The client lowercases it before sending. */
export type HexAddress = `0x${string}`;

export type CandleInterval =
  | '1m' | '3m' | '5m' | '15m' | '30m' | '1h' | '2h' | '4h' | '8h' | '12h' | '1d' | '3d' | '1w' | '1M';

// ---------------------------------------------------------------------------
// Subscriptions (the `subscription` object of `{method:"subscribe"}`)
// ---------------------------------------------------------------------------

/**
 * Trade tape of one coin. HIP-3 coins use the dex prefix (`xyz:TSLA`), spot coins `@107` / `PURR/USDC`.
 * Coin names are case-sensitive: an unknown or wrong-case coin makes the server drop the whole socket
 * (live, 2026-09-15). The first frame right after subscribe contains recent trades, so frames repeat
 * after every (re)subscribe: dedupe by `tid` (see {@link createTidDeduper}).
 * @experimental The frame shape was only spot-checked on a live socket (main, HIP-3) on 2026-09-15.
 */
export interface TradesSubscription { type: 'trades'; coin: string }

/**
 * Order book of one coin. Frames are pushed ONLY when the book changes, so silence on a quiet
 * market is normal: detect a dead socket by the silence of all frames (pong included), not by `l2Book`.
 * `nSigFigs` / `mantissa` aggregation is documented but was not verified live. The server
 * echoes the subscription with extra fields (`nSigFigs:null, mantissa:null, fast:false`); ack matching
 * handles that. An unknown or wrong-case coin makes the server drop the socket (live, 2026-09-15).
 */
export interface L2BookSubscription {
  type: 'l2Book';
  coin: string;
  /** @experimental Level aggregation by significant figures (documented, unverified). */
  nSigFigs?: 2 | 3 | 4 | 5 | null;
  /** @experimental Only valid together with `nSigFigs: 5` (documented, unverified). */
  mantissa?: 2 | 5 | null;
}

/**
 * Live candles; the last bar is updated until it closes. Minimum interval is `1m`.
 * @experimental Frame shape follows the public docs; not checked against a live socket.
 */
export interface CandleSubscription { type: 'candle'; coin: string; interval: CandleInterval }

/**
 * Mid prices of every coin of one dex in one frame. `dex: ""` is the main dex and is dropped from the
 * canonical form. Main-dex frames carry no `dex` field, HIP-3 frames carry it (`dex: "xyz"`, keys
 * `xyz:COIN`). An unknown dex is acknowledged but never pushes.
 * @experimental Only spot-checked on a live socket on 2026-09-15.
 */
export interface AllMidsSubscription { type: 'allMids'; dex?: string }

/**
 * Cheap top of book (best bid / best ask).
 * @experimental Frame shape follows the public docs; not checked against a live socket.
 */
export interface BboSubscription { type: 'bbo'; coin: string }

/**
 * Mark / oracle price and current funding of one coin. Spot coins (`@107`) answer on the
 * `activeSpotAssetCtx` channel with a spot context (no funding / oracle); the client routes both
 * channels to this subscription. An unknown coin gets `Invalid subscription {...}` on the error channel.
 * @experimental Only spot-checked on a live socket on 2026-09-15.
 */
export interface ActiveAssetCtxSubscription { type: 'activeAssetCtx'; coin: string }

/**
 * Asset contexts of every perp dex.
 * @experimental Not described in the knowledge base; shape taken from the SDK type declarations.
 */
export interface AllDexsAssetCtxsSubscription { type: 'allDexsAssetCtxs' }

/**
 * Fills of one user. The FIRST frame after every (re)subscribe has `isSnapshot: true` and contains
 * HISTORY, not new fills: applying it as new fills doubles positions/PnL.
 * Uses one slot of the per-IP unique-user limit.
 */
export interface UserFillsSubscription {
  type: 'userFills';
  user: string;
  /** @experimental Aggregates partial fills by time, like REST `userFillsByTime` (declared, unverified). */
  aggregateByTime?: boolean;
}

/**
 * Order status changes of one user. `open` arrives BEFORE the HTTP response of the order placement,
 * and an Alo rejection arrives twice (placement response + `badAloPxRejected` here): dedupe by oid.
 * Frames carry no `user` field, so with several `orderUpdates` users on one client every handler
 * receives every `orderUpdates` frame. Uses one unique-user slot.
 */
export interface OrderUpdatesSubscription { type: 'orderUpdates'; user: string }

/**
 * Fills / funding / liquidation / non-user-cancel events of one user; frames arrive on channel `user`
 * and carry no `user` field (same routing caveat as `orderUpdates`). Uses one unique-user slot.
 * @experimental Not verified live; shape taken from the SDK type declarations.
 */
export interface UserEventsSubscription { type: 'userEvents'; user: string }

/**
 * Aggregated user state blob. After a position closes the snapshot can lag for seconds.
 * Uses one unique-user slot. Does not carry spot balances in a trustworthy way: use REST.
 * @experimental Only mentioned in the knowledge base; the frame shape is not documented there.
 */
export interface WebData2Subscription { type: 'webData2'; user: string }

/**
 * Aggregated user blob. The ONLY place with the exact collateral mode string
 * (`data.userState.abstraction`). Uses one unique-user slot.
 */
export interface WebData3Subscription { type: 'webData3'; user: string }

/**
 * Full perp state of one user across all dexes (HIP-3 included). A snapshot arrives 0.5-2 s after
 * subscribe - EVEN for addresses the server then refuses to track - and afterwards only on changes.
 * An empty account stays silent after the first snapshot. No spot balances. Uses one unique-user slot.
 */
export interface AllDexsClearinghouseStateSubscription { type: 'allDexsClearinghouseState'; user: string }

/** Escape hatch for subscription types this module does not model. A string `user` field is counted as a tracked user. */
export interface GenericSubscription { type: string; [key: string]: unknown }

export type KnownSubscription =
  | TradesSubscription
  | L2BookSubscription
  | CandleSubscription
  | AllMidsSubscription
  | BboSubscription
  | ActiveAssetCtxSubscription
  | AllDexsAssetCtxsSubscription
  | UserFillsSubscription
  | OrderUpdatesSubscription
  | UserEventsSubscription
  | WebData2Subscription
  | WebData3Subscription
  | AllDexsClearinghouseStateSubscription;

export type WsSubscription = KnownSubscription | GenericSubscription;

// ---------------------------------------------------------------------------
// Frame payloads (`data` of `{channel, data}`)
// ---------------------------------------------------------------------------

export interface WsTrade {
  coin: string;
  /** Aggressor (taker) side: `B` = buy, `A` = sell. */
  side: 'B' | 'A';
  px: string;
  sz: string;
  time: number;
  hash: string;
  tid: number;
  /** `[buyer, seller]` */
  users: [string, string];
}

export interface WsBookLevel { px: string; sz: string; n: number }

export interface WsL2Book {
  coin: string;
  time: number;
  levels: [bids: WsBookLevel[], asks: WsBookLevel[]];
}

export interface WsCandle {
  /** Open time, ms. */ t: number;
  /** Close time, ms. */ T: number;
  /** Coin. */ s: string;
  /** Interval. */ i: CandleInterval;
  o: string; c: string; h: string; l: string;
  /** Volume in base units. */ v: string;
  /** Number of trades. */ n: number;
}

export interface WsAllMids { mids: Record<string, string>; dex?: string }

export interface WsBbo { coin: string; time: number; bbo: [bid: WsBookLevel | null, ask: WsBookLevel | null] }

export interface WsPerpAssetCtx {
  markPx: string;
  oraclePx: string;
  midPx?: string | null;
  funding: string;
  openInterest: string;
  dayNtlVlm: string;
  prevDayPx: string;
  premium?: string | null;
  impactPxs?: [string, string] | null;
  dayBaseVlm?: string;
}

/** Context of a spot coin (channel `activeSpotAssetCtx`). */
export interface WsSpotAssetCtx {
  markPx: string;
  midPx?: string | null;
  prevDayPx: string;
  dayNtlVlm: string;
  dayBaseVlm?: string;
  circulatingSupply?: string;
  totalSupply?: string;
  coin?: string;
  [key: string]: unknown;
}

/** Perp coins deliver {@link WsPerpAssetCtx}; spot coins (`@107`) deliver {@link WsSpotAssetCtx}. */
export interface WsActiveAssetCtx { coin: string; ctx: WsPerpAssetCtx | WsSpotAssetCtx }

/** @experimental Shape from the SDK type declarations. */
export interface WsAllDexsAssetCtxs { ctxs: Array<[dex: string, ctxs: WsPerpAssetCtx[]]> }

export interface WsFill {
  coin: string;
  px: string;
  sz: string;
  side: 'B' | 'A';
  time: number;
  startPosition: string;
  dir: string;
  closedPnl: string;
  hash: string;
  oid: number;
  crossed: boolean;
  fee: string;
  tid: number;
  feeToken: string;
  builderFee?: string;
  cloid?: string;
  twapId?: number | null;
  [key: string]: unknown;
}

export interface WsUserFills {
  user: string;
  fills: WsFill[];
  /** `true` on the first frame after each (re)subscribe: history, not new fills. */
  isSnapshot?: boolean;
}

/**
 * Statuses confirmed on a live socket: `open`, `badAloPxRejected`; `filled` / `canceled` are observed
 * too. The rest match `historicalOrders` snapshots; any `...Rejected` / `...Canceled` may appear.
 */
export type WsOrderStatus =
  | 'open' | 'filled' | 'canceled' | 'triggered' | 'rejected' | 'badAloPxRejected'
  | 'marginCanceled' | 'reduceOnlyCanceled' | 'selfTradeCanceled' | 'siblingFilledCanceled'
  | 'scheduledCancel' | 'liquidatedCanceled' | 'delistedCanceled'
  | (string & {});

export interface WsOrderUpdate {
  order: {
    coin: string;
    side: 'B' | 'A';
    limitPx: string;
    /** REMAINING size. */
    sz: string;
    oid: number;
    origSz: string;
    timestamp: number;
    cloid?: string;
    reduceOnly?: boolean;
  };
  status: WsOrderStatus;
  /** Event time; fall back to `order.timestamp` when missing. */
  statusTimestamp: number;
}

/** @experimental Shape from the SDK type declarations, not verified live. */
export type WsUserEvent =
  | { fills: WsFill[] }
  | { funding: { time: number; coin: string; usdc: string; szi: string; fundingRate: string; nSamples: number | null } }
  | { liquidation: { lid: number; liquidator: string; liquidated_user: string; liquidated_ntl_pos: string; liquidated_account_value: string } }
  | { nonUserCancel: Array<{ coin: string; oid: number }> }
  | Record<string, unknown>;

/** @experimental The knowledge base does not document the webData2 frame shape. */
export interface WsWebData2 { user?: string; [key: string]: unknown }

export interface WsWebData3 {
  userState: {
    user: string;
    /** `disabled` (manual), `unifiedAccount`, `dexAbstractionEnabled` (legacy); other values may appear. */
    abstraction?: string;
    dexAbstractionEnabled?: boolean;
    [key: string]: unknown;
  };
  perpDexStates: unknown[];
  [key: string]: unknown;
}

export interface WsAssetPosition {
  position?: {
    /** May be missing: resolve the name from `meta.universe[asset].name` of the SAME dex. */
    coin?: string;
    asset?: number;
    szi: string;
    unrealizedPnl: string;
    marginUsed: string;
    entryPx: string;
    /** `value` can transiently be 0 or missing: do not compute ROE stops from it on that tick. */
    leverage?: { value: number | string };
    [key: string]: unknown;
  };
  [key: string]: unknown;
}

export interface WsClearinghouseState {
  marginSummary?: { accountValue?: string; totalMarginUsed?: string };
  crossMarginSummary?: { accountValue?: string };
  assetPositions?: WsAssetPosition[];
  time?: number;
  [key: string]: unknown;
}

/**
 * Every frame is an authoritative full snapshot; a dex missing from the array has no state.
 * `dex` `''` (or `null`) is the main HL perp dex. No spot balances here - use REST.
 * Sanity rule before money decisions: positions present but `totalMarginUsed == 0` means an
 * incomplete payload - fall back to REST.
 */
export interface WsAllDexsClearinghouseState {
  user: string;
  clearinghouseStates: Array<[dex: string | null, state: WsClearinghouseState]>;
}

/** Maps a subscription `type` to the `data` of its frames. */
export interface WsEventMap {
  trades: WsTrade[];
  l2Book: WsL2Book;
  candle: WsCandle;
  allMids: WsAllMids;
  bbo: WsBbo;
  activeAssetCtx: WsActiveAssetCtx;
  allDexsAssetCtxs: WsAllDexsAssetCtxs;
  userFills: WsUserFills;
  orderUpdates: WsOrderUpdate[];
  userEvents: WsUserEvent;
  webData2: WsWebData2;
  webData3: WsWebData3;
  allDexsClearinghouseState: WsAllDexsClearinghouseState;
}

/** `data` type delivered to a handler of subscription `S`. */
export type WsEventFor<S> = S extends { type: infer T }
  ? T extends keyof WsEventMap ? WsEventMap[T] : unknown
  : unknown;

/** Raw server frame envelope. */
export interface WsFrame { channel: string; data?: unknown }
All files